mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-06-25 03:23:41 +08:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81a239d3d5 | ||
|
|
91d26bb92a | ||
|
|
1e386e1205 | ||
|
|
6e7a884c64 | ||
|
|
0b2263086f | ||
|
|
2f494818cf | ||
|
|
7dfd12254b | ||
|
|
2997e971a6 | ||
|
|
67a42b09ba | ||
|
|
bc8742977e | ||
|
|
1a699da8d6 | ||
|
|
1b73f19eb1 | ||
|
|
539e9e64c4 | ||
|
|
83bf5ec656 | ||
|
|
66373fa8e4 | ||
|
|
3a4c2edd9b | ||
|
|
a6dd8033ed | ||
|
|
81c9c042d6 | ||
|
|
3e90e0f8a8 | ||
|
|
827a5a2181 | ||
|
|
970eb154e1 |
63
.github/workflows/docs.yml
vendored
Normal file
63
.github/workflows/docs.yml
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
name: Deploy Docs
|
||||
|
||||
# 触发条件:
|
||||
# - 推送 main 时,如果 docs-site/ 或站点相关 README 有变化
|
||||
# - 手动触发(在 Actions 页面)
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'docs-site/**'
|
||||
- '.github/workflows/docs.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
# 允许写入 Pages,用于发布到 github.com/Awuqing/BackupX 的 Pages 站点
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
# 同时只保留一个部署任务
|
||||
concurrency:
|
||||
group: pages-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: docs-site
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: docs-site/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build site
|
||||
run: npm run build
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: docs-site/build
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
@@ -110,6 +110,11 @@ jobs:
|
||||
cp -r web/dist "${ARCHIVE_NAME}/web"
|
||||
cp server/config.example.yaml "${ARCHIVE_NAME}/"
|
||||
cp deploy/install.sh "${ARCHIVE_NAME}/" 2>/dev/null || true
|
||||
# v2.2+: 随发布包提供 Grafana dashboard 与 nginx.conf 模板
|
||||
if [ -d deploy/grafana ]; then
|
||||
cp -r deploy/grafana "${ARCHIVE_NAME}/grafana"
|
||||
fi
|
||||
cp deploy/nginx.conf "${ARCHIVE_NAME}/nginx.conf" 2>/dev/null || true
|
||||
tar czf "${ARCHIVE_NAME}.tar.gz" "${ARCHIVE_NAME}"
|
||||
|
||||
- name: Upload to GitHub Release
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1 +1,4 @@
|
||||
web/node_modules/
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
server/bin/
|
||||
.claude/
|
||||
321
README.md
321
README.md
@@ -1,11 +1,11 @@
|
||||
<p align="right">
|
||||
<a href="README_EN.md">English</a> | <strong>中文</strong>
|
||||
<strong>English</strong> | <a href="README.zh-CN.md">中文</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<h1 align="center">BackupX</h1>
|
||||
<p align="center">
|
||||
<strong>自托管服务器备份管理平台</strong><br>
|
||||
一个二进制,一条命令,管好你所有服务器的备份。
|
||||
<strong>Self-hosted server backup management</strong><br>
|
||||
One binary, one command — manage every backup of every server.
|
||||
</p>
|
||||
<p align="center">
|
||||
<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>
|
||||
@@ -15,307 +15,82 @@
|
||||
<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/BackupX?style=flat-square" alt="License"></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://awuqing.github.io/BackupX/"><strong>Docs</strong></a> ·
|
||||
<a href="https://github.com/Awuqing/BackupX/releases"><strong>Downloads</strong></a> ·
|
||||
<a href="https://hub.docker.com/r/awuqing/backupx"><strong>Docker Hub</strong></a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="screenshots/dashboard.png" alt="仪表盘"></td>
|
||||
<td width="50%"><img src="screenshots/backup-tasks.png" alt="备份任务"></td>
|
||||
<td width="50%"><img src="screenshots/dashboard.png" alt="Dashboard"></td>
|
||||
<td width="50%"><img src="screenshots/backup-tasks.png" alt="Backup Tasks"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="screenshots/storage-targets.png" alt="存储目标"></td>
|
||||
<td><img src="screenshots/backup-records.png" alt="备份记录"></td>
|
||||
<td><img src="screenshots/storage-targets.png" alt="Storage Targets"></td>
|
||||
<td><img src="screenshots/backup-records.png" alt="Backup Records"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 功能亮点
|
||||
## Highlights
|
||||
|
||||
| 能力 | 说明 |
|
||||
|------|------|
|
||||
| **备份类型** | 文件/目录(多源路径)、MySQL、PostgreSQL、SQLite、SAP HANA |
|
||||
| **存储后端** | 阿里云 OSS、腾讯云 COS、七牛云、S3 兼容(AWS/MinIO/R2)、Google Drive、WebDAV、FTP/FTPS、本地磁盘 |
|
||||
| **自动调度** | Cron 定时 + 可视化编辑器 + 自动保留策略(按天数/份数清理) |
|
||||
| **多节点** | Master-Agent 集群,统一管理多台服务器的备份 |
|
||||
| **安全** | JWT + bcrypt + AES-256-GCM 加密配置 + 可选备份文件加密 + 审计日志 |
|
||||
| **通知** | 邮件 / Webhook / Telegram,备份成功或失败时自动推送 |
|
||||
| **部署** | 单二进制 + 内嵌 SQLite,Docker 一键启动,零外部依赖 |
|
||||
| Capability | Details |
|
||||
|-----------|---------|
|
||||
| **Backup Types** | Files/directories (multi-source), MySQL, PostgreSQL, SQLite, SAP HANA (full / incremental / differential / log + parallel channels + retry) |
|
||||
| **SAP HANA Backint Agent** | Built-in Backint protocol — HANA's native interface routes data directly to any BackupX storage backend |
|
||||
| **70+ Storage Backends** | Alibaba OSS, Tencent COS, Qiniu, S3, Google Drive, WebDAV, FTP + SFTP, Azure Blob, Dropbox, OneDrive and dozens more via rclone |
|
||||
| **Scheduling** | Cron + visual editor + auto-retention (by days/count + empty-directory cleanup) |
|
||||
| **Multi-Node Cluster** | Master-Agent mode via HTTP long-polling — Agents run tasks locally, upload straight to storage, no reverse connectivity required |
|
||||
| **Security** | JWT + bcrypt + AES-256-GCM encrypted config + optional backup encryption + full audit log |
|
||||
| **Notifications** | Email / Webhook / Telegram on success or failure |
|
||||
| **Observability** | Prometheus `/metrics` endpoint + `/health` + `/ready` probes + SLA breach gauge |
|
||||
| **Audit Webhook** | HMAC-SHA256 signed forwarding to SIEM / WORM storage for compliance (SOC2 / GDPR) |
|
||||
| **Flow Control** | Per-node bandwidth cap + per-node concurrency limit — tune big/small nodes independently |
|
||||
| **Deployment** | Single binary + embedded SQLite, Docker one-click, zero external dependencies |
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装
|
||||
|
||||
**Docker(推荐,无需克隆仓库):**
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 创建 docker-compose.yml 后一键启动
|
||||
docker compose up -d
|
||||
|
||||
# 或直接运行
|
||||
# Docker (recommended)
|
||||
docker run -d --name backupx -p 8340:8340 -v backupx-data:/app/data awuqing/backupx:latest
|
||||
|
||||
# Or prebuilt archive
|
||||
curl -LO https://github.com/Awuqing/BackupX/releases/latest/download/backupx-linux-amd64.tar.gz
|
||||
tar xzf backupx-*.tar.gz && cd backupx-* && sudo ./install.sh
|
||||
```
|
||||
|
||||
> Docker Hub 镜像:[`awuqing/backupx`](https://hub.docker.com/r/awuqing/backupx),支持 linux/amd64 和 linux/arm64。
|
||||
Open `http://your-server:8340`, create the admin account, then follow the [5-minute Quick Start](https://awuqing.github.io/BackupX/docs/getting-started/quick-start).
|
||||
|
||||
<details>
|
||||
<summary>docker-compose.yml 参考</summary>
|
||||
## Documentation
|
||||
|
||||
```yaml
|
||||
services:
|
||||
backupx:
|
||||
image: awuqing/backupx:latest
|
||||
container_name: backupx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8340:8340"
|
||||
volumes:
|
||||
- backupx-data:/app/data
|
||||
# 挂载需要备份的宿主机目录(按需添加):
|
||||
# - /var/www:/mnt/www:ro
|
||||
# - /etc/nginx:/mnt/nginx-conf:ro
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
The full docs live at **https://awuqing.github.io/BackupX/** — Getting Started, Deployment, SAP HANA, Multi-Node Cluster, API reference, and more. Switch to Chinese via the language dropdown in the top-right nav.
|
||||
|
||||
volumes:
|
||||
backupx-data:
|
||||
```
|
||||
Quick links:
|
||||
|
||||
</details>
|
||||
- [Quick Start](https://awuqing.github.io/BackupX/docs/getting-started/quick-start) — first backup in five minutes
|
||||
- [Installation](https://awuqing.github.io/BackupX/docs/getting-started/installation) — Docker / bare metal / source
|
||||
- [Multi-Node Cluster](https://awuqing.github.io/BackupX/docs/features/multi-node) — deploy the Agent on remote servers
|
||||
- [SAP HANA Support](https://awuqing.github.io/BackupX/docs/features/sap-hana) — hdbsql Runner and native Backint
|
||||
- [API Reference](https://awuqing.github.io/BackupX/docs/reference/api) — REST endpoints
|
||||
|
||||
**预编译包(裸机部署):**
|
||||
|
||||
从 [Releases](https://github.com/Awuqing/BackupX/releases) 下载对应平台的压缩包:
|
||||
|
||||
```bash
|
||||
tar xzf backupx-v*-linux-amd64.tar.gz && cd backupx-*
|
||||
sudo ./install.sh # 自动配置 systemd + Nginx
|
||||
```
|
||||
|
||||
**从源码构建:**
|
||||
## Development
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
|
||||
make build # 构建前后端
|
||||
make docker-cn # 或用国内镜像构建 Docker(goproxy.cn / npmmirror / 阿里云 apk)
|
||||
make dev-server # Terminal 1 — backend (:8340)
|
||||
make dev-web # Terminal 2 — frontend (Vite HMR)
|
||||
make test # run all tests
|
||||
make build # produce server/bin/backupx + web/dist
|
||||
```
|
||||
|
||||
### 2. 打开控制台
|
||||
|
||||
浏览器访问 `http://your-server:8340`,首次打开会引导创建管理员账户。
|
||||
|
||||
### 3. 添加存储目标
|
||||
|
||||
进入 **存储目标** 页面,点击 **添加**,选择存储类型并填写凭证:
|
||||
|
||||
| 存储类型 | 需要填写 |
|
||||
|---------|---------|
|
||||
| 阿里云 OSS | Region + AccessKey ID/Secret + Bucket |
|
||||
| 腾讯云 COS | Region + SecretId/SecretKey + Bucket(格式 `name-appid`) |
|
||||
| 七牛云 Kodo | Region + AccessKey/SecretKey + Bucket |
|
||||
| S3 兼容 | Endpoint + AccessKey + Bucket |
|
||||
| Google Drive | Client ID/Secret → 点击授权完成 OAuth |
|
||||
| WebDAV | 服务器地址 + 用户名/密码 |
|
||||
| FTP | 主机 + 端口 + 用户名/密码 |
|
||||
| 本地磁盘 | 目标目录路径 |
|
||||
|
||||
> 国内云厂商只需填 Region 和 AccessKey,系统自动组装 Endpoint。
|
||||
|
||||
添加后点击 **测试连接** 确认配置正确。
|
||||
|
||||
### 4. 创建备份任务
|
||||
|
||||
进入 **备份任务** 页面,点击 **新建**,三步完成:
|
||||
|
||||
1. **基础信息** — 任务名称、备份类型、Cron 表达式(留空则仅手动执行)
|
||||
2. **源配置** — 文件备份选择源路径(支持多个)、数据库备份填写连接信息
|
||||
3. **存储与策略** — 选择存储目标、压缩策略、保留天数、是否加密
|
||||
|
||||
保存后可以点击 **立即执行** 测试,在 **备份记录** 页面实时查看执行日志。
|
||||
|
||||
### 5. 配置通知(可选)
|
||||
|
||||
进入 **通知配置** 页面,支持邮件、Webhook、Telegram 三种方式,可分别配置成功/失败时是否推送。
|
||||
|
||||
---
|
||||
|
||||
## 部署指南
|
||||
|
||||
### Docker 部署
|
||||
|
||||
```bash
|
||||
docker compose up -d # 使用上方的 docker-compose.yml
|
||||
```
|
||||
|
||||
备份宿主机目录时需要挂载路径(在 docker-compose.yml 的 `volumes` 中添加):
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- backupx-data:/app/data
|
||||
- /var/www:/mnt/www:ro # 挂载需要备份的目录
|
||||
- /etc/nginx:/mnt/nginx-conf:ro # 可以挂载多个
|
||||
```
|
||||
|
||||
通过环境变量调整配置:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
- BACKUPX_LOG_LEVEL=debug
|
||||
- BACKUPX_BACKUP_MAX_CONCURRENT=4
|
||||
```
|
||||
|
||||
### 裸机部署
|
||||
|
||||
```bash
|
||||
# 使用预编译包
|
||||
tar xzf backupx-v*-linux-amd64.tar.gz && cd backupx-*
|
||||
sudo ./install.sh
|
||||
|
||||
# 或从源码
|
||||
make build
|
||||
sudo ./deploy/install.sh
|
||||
```
|
||||
|
||||
安装脚本自动完成:创建系统用户 → 安装二进制到 `/opt/backupx/` → 配置 systemd → 配置 Nginx 反向代理。
|
||||
|
||||
### Nginx 反向代理(裸机部署时)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name backup.example.com;
|
||||
|
||||
location / {
|
||||
root /opt/backupx/web;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8340;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 配置文件
|
||||
|
||||
配置文件路径 `./config.yaml`,也可通过 `BACKUPX_` 前缀环境变量覆盖:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
port: 8340
|
||||
database:
|
||||
path: "./data/backupx.db"
|
||||
security:
|
||||
jwt_secret: "" # 留空自动生成并持久化到数据库
|
||||
encryption_key: "" # 留空自动生成
|
||||
backup:
|
||||
temp_dir: "/tmp/backupx"
|
||||
max_concurrent: 2
|
||||
log:
|
||||
level: "info" # debug | info | warn | error
|
||||
file: "./data/backupx.log"
|
||||
```
|
||||
|
||||
### 密码重置
|
||||
|
||||
忘记管理员密码时通过 CLI 重置:
|
||||
|
||||
```bash
|
||||
# 裸机
|
||||
./backupx reset-password --username admin --password newpass123
|
||||
|
||||
# Docker
|
||||
docker exec -it backupx /app/bin/backupx reset-password --username admin --password newpass123
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 多节点集群
|
||||
|
||||
BackupX 支持 Master-Agent 模式管理多台服务器:
|
||||
|
||||
1. Web 控制台 → **节点管理** → **添加节点**,系统生成 Token
|
||||
2. 在远程服务器部署 Agent 并使用 Token 连接 Master
|
||||
3. 创建备份任务时选择对应节点,Master 自动下发任务
|
||||
|
||||
创建文件备份任务时,可通过可视化目录浏览器远程选择 Agent 节点上的目录,无需手动输入路径。
|
||||
|
||||
---
|
||||
|
||||
## 开发指南
|
||||
|
||||
**环境要求:** Go >= 1.25 · Node.js >= 20 · npm
|
||||
|
||||
```bash
|
||||
# 开发模式
|
||||
make dev-server # 终端 1:后端(默认 :8340)
|
||||
make dev-web # 终端 2:前端(Vite HMR)
|
||||
|
||||
# 测试
|
||||
make test # 运行全部测试
|
||||
|
||||
# 构建
|
||||
make build # 前后端一起构建
|
||||
make docker # Docker 构建
|
||||
make docker-cn # 国内 Docker 构建(镜像加速)
|
||||
```
|
||||
|
||||
### 发版
|
||||
|
||||
```bash
|
||||
git tag v1.2.3 && git push --tags
|
||||
# GitHub Actions 自动:编译双架构二进制 → 发布 GitHub Release → 推送 Docker Hub 镜像
|
||||
```
|
||||
|
||||
也可在 GitHub Actions 页面手动触发 Release workflow。
|
||||
|
||||
---
|
||||
|
||||
## API 参考
|
||||
|
||||
所有接口以 `/api` 为前缀,使用 JWT Bearer Token 认证。
|
||||
|
||||
| 模块 | 端点 | 说明 |
|
||||
|------|------|------|
|
||||
| **认证** | `POST /auth/setup` | 初始化管理员 |
|
||||
| | `POST /auth/login` | 登录 |
|
||||
| | `PUT /auth/password` | 修改密码 |
|
||||
| **备份任务** | `GET\|POST /backup/tasks` | 列表 / 创建 |
|
||||
| | `GET\|PUT\|DELETE /backup/tasks/:id` | 详情 / 更新 / 删除 |
|
||||
| | `PUT /backup/tasks/:id/toggle` | 启用/禁用 |
|
||||
| | `POST /backup/tasks/:id/run` | 手动执行 |
|
||||
| **备份记录** | `GET /backup/records` | 列表(支持筛选) |
|
||||
| | `GET /backup/records/:id/logs/stream` | 实时日志 (SSE) |
|
||||
| | `GET /backup/records/:id/download` | 下载 |
|
||||
| | `POST /backup/records/:id/restore` | 恢复 |
|
||||
| **存储目标** | `GET\|POST /storage-targets` | 列表 / 添加 |
|
||||
| | `POST /storage-targets/test` | 测试连接 |
|
||||
| **节点** | `GET\|POST /nodes` | 列表 / 添加 |
|
||||
| | `GET /nodes/:id/fs/list` | 目录浏览 |
|
||||
| **通知** | `GET\|POST /notifications` | 列表 / 添加 |
|
||||
| **仪表盘** | `GET /dashboard/stats` | 概览统计 |
|
||||
| **审计日志** | `GET /audit-logs` | 操作审计 |
|
||||
| **系统** | `GET /system/info` | 系统信息 |
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 组件 | 技术 |
|
||||
|------|------|
|
||||
| **后端** | Go · Gin · GORM · SQLite · robfig/cron |
|
||||
| **前端** | React 18 · TypeScript · ArcoDesign · Vite · Zustand · ECharts |
|
||||
| **存储** | AWS SDK v2 · Google Drive API v3 · gowebdav · jlaffaye/ftp |
|
||||
| **安全** | JWT · bcrypt · AES-256-GCM |
|
||||
See the [development guide](https://awuqing.github.io/BackupX/docs/development/setup) for more.
|
||||
|
||||
## Contributing
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
Issues and pull requests welcome. Please read the [contributing guide](https://awuqing.github.io/BackupX/docs/development/contributing) before opening a PR — commit messages and PRs on this project are written in Chinese.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
97
README.zh-CN.md
Normal file
97
README.zh-CN.md
Normal file
@@ -0,0 +1,97 @@
|
||||
<p align="right">
|
||||
<a href="README.md">English</a> | <strong>中文</strong>
|
||||
</p>
|
||||
<p align="center">
|
||||
<h1 align="center">BackupX</h1>
|
||||
<p align="center">
|
||||
<strong>自托管服务器备份管理平台</strong><br>
|
||||
一个二进制,一条命令,管好你所有服务器的备份。
|
||||
</p>
|
||||
<p align="center">
|
||||
<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.25+-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/SQLite-embedded-003B57?style=flat-square&logo=sqlite" alt="SQLite">
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/Awuqing/BackupX?style=flat-square" alt="License"></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://awuqing.github.io/BackupX/zh-Hans/"><strong>文档</strong></a> ·
|
||||
<a href="https://github.com/Awuqing/BackupX/releases"><strong>下载</strong></a> ·
|
||||
<a href="https://hub.docker.com/r/awuqing/backupx"><strong>Docker Hub</strong></a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="screenshots/dashboard.png" alt="仪表盘"></td>
|
||||
<td width="50%"><img src="screenshots/backup-tasks.png" alt="备份任务"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="screenshots/storage-targets.png" alt="存储目标"></td>
|
||||
<td><img src="screenshots/backup-records.png" alt="备份记录"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 功能亮点
|
||||
|
||||
| 能力 | 说明 |
|
||||
|------|------|
|
||||
| **备份类型** | 文件/目录(多源路径)、MySQL、PostgreSQL、SQLite、SAP HANA(完整/增量/差异/日志备份 + 并行通道 + 失败重试) |
|
||||
| **SAP HANA Backint 代理** | 内置 SAP HANA Backint 协议代理,HANA 原生备份接口可直接把数据路由到 BackupX 支持的任意存储后端 |
|
||||
| **70+ 存储后端** | 内置阿里云 OSS / 腾讯云 COS / 七牛云 / S3 / Google Drive / WebDAV / FTP + 通过 rclone 集成 SFTP、Azure Blob、Dropbox、OneDrive 等 70+ 后端 |
|
||||
| **自动调度** | Cron 定时 + 可视化编辑器 + 自动保留策略(按天数/份数清理,自动回收空目录) |
|
||||
| **多节点集群** | Master-Agent 模式,基于 HTTP 长轮询跨多台服务器管理备份。Agent 本地执行任务并直接上传到存储,无需反向连通性 |
|
||||
| **安全** | JWT + bcrypt + AES-256-GCM 加密配置 + 可选备份文件加密 + 完整审计日志 |
|
||||
| **通知** | 邮件 / Webhook / Telegram,备份成功或失败时自动推送 |
|
||||
| **可观测性** | Prometheus `/metrics` 端点 + `/health` + `/ready` 探针 + SLA 违约监控 |
|
||||
| **审计外输** | HMAC-SHA256 签名 Webhook,对接 SIEM / WORM 存储满足 SOC2 / GDPR 合规 |
|
||||
| **流控** | 节点级带宽限速 + 节点级并发控制,大小节点分别配置,避免小内存 Agent 被挤爆 |
|
||||
| **部署** | 单二进制 + 内嵌 SQLite,Docker 一键启动,零外部依赖 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# Docker(推荐)
|
||||
docker run -d --name backupx -p 8340:8340 -v backupx-data:/app/data awuqing/backupx:latest
|
||||
|
||||
# 或使用预编译包
|
||||
curl -LO https://github.com/Awuqing/BackupX/releases/latest/download/backupx-linux-amd64.tar.gz
|
||||
tar xzf backupx-*.tar.gz && cd backupx-* && sudo ./install.sh
|
||||
```
|
||||
|
||||
打开 `http://your-server:8340`,创建管理员账户,按 [5 分钟快速开始](https://awuqing.github.io/BackupX/zh-Hans/docs/getting-started/quick-start) 完成首次备份。
|
||||
|
||||
## 文档
|
||||
|
||||
完整文档见 **https://awuqing.github.io/BackupX/zh-Hans/** — 快速开始、部署、SAP HANA、多节点集群、API 参考等。
|
||||
|
||||
快捷链接:
|
||||
|
||||
- [快速开始](https://awuqing.github.io/BackupX/zh-Hans/docs/getting-started/quick-start) — 五分钟跑通第一个备份
|
||||
- [安装](https://awuqing.github.io/BackupX/zh-Hans/docs/getting-started/installation) — Docker / 裸机 / 源码
|
||||
- [多节点集群](https://awuqing.github.io/BackupX/zh-Hans/docs/features/multi-node) — 远程服务器部署 Agent
|
||||
- [SAP HANA 支持](https://awuqing.github.io/BackupX/zh-Hans/docs/features/sap-hana) — hdbsql Runner 与原生 Backint
|
||||
- [API 参考](https://awuqing.github.io/BackupX/zh-Hans/docs/reference/api) — REST 端点
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
|
||||
make dev-server # 终端 1:后端(:8340)
|
||||
make dev-web # 终端 2:前端(Vite HMR)
|
||||
make test # 运行全部测试
|
||||
make build # 产出 server/bin/backupx + web/dist
|
||||
```
|
||||
|
||||
更多细节见 [开发指南](https://awuqing.github.io/BackupX/zh-Hans/docs/development/setup)。
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提交 Issue 与 Pull Request。提交 PR 前请先阅读 [贡献指南](https://awuqing.github.io/BackupX/zh-Hans/docs/development/contributing) — 本项目的 commit message 和 PR 正文均使用中文。
|
||||
|
||||
## License
|
||||
|
||||
[Apache License 2.0](LICENSE)
|
||||
318
README_EN.md
318
README_EN.md
@@ -1,318 +0,0 @@
|
||||
<p align="right">
|
||||
<strong>English</strong> | <a href="README.md">中文</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<h1 align="center">BackupX</h1>
|
||||
<p align="center">
|
||||
<strong>Self-hosted Server Backup Management Platform</strong><br>
|
||||
One binary, one command — manage all your server backups.
|
||||
</p>
|
||||
<p align="center">
|
||||
<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.25+-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/SQLite-embedded-003B57?style=flat-square&logo=sqlite" alt="SQLite">
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/Awuqing/BackupX?style=flat-square" alt="License"></a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="screenshots/dashboard.png" alt="Dashboard"></td>
|
||||
<td width="50%"><img src="screenshots/backup-tasks.png" alt="Backup Tasks"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="screenshots/storage-targets.png" alt="Storage Targets"></td>
|
||||
<td><img src="screenshots/backup-records.png" alt="Backup Records"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Highlights
|
||||
|
||||
| Capability | Details |
|
||||
|-----------|---------|
|
||||
| **Backup Types** | Files/Directories (multi-source), MySQL, PostgreSQL, SQLite, SAP HANA |
|
||||
| **Storage Backends** | Alibaba Cloud OSS, Tencent COS, Qiniu Kodo, S3-compatible (AWS/MinIO/R2), Google Drive, WebDAV, FTP/FTPS, Local Disk |
|
||||
| **Scheduling** | Cron-based scheduling + visual editor + auto-retention policy (by days/count) |
|
||||
| **Multi-Node** | Master-Agent cluster for managing backups across multiple servers |
|
||||
| **Security** | JWT + bcrypt + AES-256-GCM encrypted config + optional backup encryption + audit logs |
|
||||
| **Notifications** | Email / Webhook / Telegram — push on success or failure |
|
||||
| **Deployment** | Single binary + embedded SQLite, Docker one-click, zero external dependencies |
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install
|
||||
|
||||
**Docker (recommended, no clone needed):**
|
||||
|
||||
```bash
|
||||
# Create a docker-compose.yml then start
|
||||
docker compose up -d
|
||||
|
||||
# Or run directly
|
||||
docker run -d --name backupx -p 8340:8340 -v backupx-data:/app/data awuqing/backupx:latest
|
||||
```
|
||||
|
||||
> Docker Hub: [`awuqing/backupx`](https://hub.docker.com/r/awuqing/backupx) — supports linux/amd64 and linux/arm64.
|
||||
|
||||
<details>
|
||||
<summary>docker-compose.yml reference</summary>
|
||||
|
||||
```yaml
|
||||
services:
|
||||
backupx:
|
||||
image: awuqing/backupx:latest
|
||||
container_name: backupx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8340:8340"
|
||||
volumes:
|
||||
- backupx-data:/app/data
|
||||
# Mount host directories to back up (add as needed):
|
||||
# - /var/www:/mnt/www:ro
|
||||
# - /etc/nginx:/mnt/nginx-conf:ro
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
|
||||
volumes:
|
||||
backupx-data:
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Pre-built binaries (bare metal):**
|
||||
|
||||
Download from [Releases](https://github.com/Awuqing/BackupX/releases):
|
||||
|
||||
```bash
|
||||
tar xzf backupx-v*-linux-amd64.tar.gz && cd backupx-*
|
||||
sudo ./install.sh # Auto-configures systemd + Nginx
|
||||
```
|
||||
|
||||
**Build from source:**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
|
||||
make build # Build frontend + backend
|
||||
make docker-cn # Or Docker build with China mirrors (goproxy.cn / npmmirror / Aliyun apk)
|
||||
```
|
||||
|
||||
### 2. Open the Console
|
||||
|
||||
Visit `http://your-server:8340` in your browser. First-time access guides you through admin account creation.
|
||||
|
||||
### 3. Add a Storage Target
|
||||
|
||||
Go to **Storage Targets** → **Add**, choose a storage type and enter credentials:
|
||||
|
||||
| Storage Type | Required Fields |
|
||||
|-------------|----------------|
|
||||
| Alibaba Cloud OSS | Region + AccessKey ID/Secret + Bucket |
|
||||
| Tencent Cloud COS | Region + SecretId/SecretKey + Bucket (`name-appid`) |
|
||||
| Qiniu Cloud Kodo | Region + AccessKey/SecretKey + Bucket |
|
||||
| S3 Compatible | Endpoint + AccessKey + Bucket |
|
||||
| Google Drive | Client ID/Secret → click Authorize for OAuth |
|
||||
| WebDAV | Server URL + Username/Password |
|
||||
| FTP | Host + Port + Username/Password |
|
||||
| Local Disk | Target directory path |
|
||||
|
||||
Click **Test Connection** to verify.
|
||||
|
||||
### 4. Create a Backup Task
|
||||
|
||||
Go to **Backup Tasks** → **Create**, complete 3 steps:
|
||||
|
||||
1. **Basic Info** — Task name, backup type, Cron expression (leave empty for manual-only)
|
||||
2. **Source Config** — File backup: select source paths (supports multiple); Database: enter connection info
|
||||
3. **Storage & Policy** — Select storage target(s), compression, retention days, encryption toggle
|
||||
|
||||
Save, then click **Run Now** to test. View real-time logs in **Backup Records**.
|
||||
|
||||
### 5. Set Up Notifications (Optional)
|
||||
|
||||
Go to **Notifications** to configure Email, Webhook, or Telegram alerts for backup success/failure.
|
||||
|
||||
---
|
||||
|
||||
## Deployment Guide
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker compose up -d # Using the docker-compose.yml above
|
||||
```
|
||||
|
||||
Mount host directories for file backup (add to `volumes` in docker-compose.yml):
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- backupx-data:/app/data
|
||||
- /var/www:/mnt/www:ro
|
||||
- /etc/nginx:/mnt/nginx-conf:ro
|
||||
```
|
||||
|
||||
Override config via environment variables:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
- BACKUPX_LOG_LEVEL=debug
|
||||
- BACKUPX_BACKUP_MAX_CONCURRENT=4
|
||||
```
|
||||
|
||||
### Bare Metal
|
||||
|
||||
```bash
|
||||
# From pre-built package
|
||||
tar xzf backupx-v*-linux-amd64.tar.gz && cd backupx-*
|
||||
sudo ./install.sh
|
||||
|
||||
# Or from source
|
||||
make build
|
||||
sudo ./deploy/install.sh
|
||||
```
|
||||
|
||||
The install script creates a system user, installs to `/opt/backupx/`, configures systemd, and sets up Nginx reverse proxy.
|
||||
|
||||
### Nginx Reverse Proxy (bare metal)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name backup.example.com;
|
||||
|
||||
location / {
|
||||
root /opt/backupx/web;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8340;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Config file: `./config.yaml` (or override with `BACKUPX_` prefixed env vars):
|
||||
|
||||
```yaml
|
||||
server:
|
||||
port: 8340
|
||||
database:
|
||||
path: "./data/backupx.db"
|
||||
security:
|
||||
jwt_secret: "" # Auto-generated and persisted to DB
|
||||
encryption_key: "" # Auto-generated
|
||||
backup:
|
||||
temp_dir: "/tmp/backupx"
|
||||
max_concurrent: 2
|
||||
log:
|
||||
level: "info" # debug | info | warn | error
|
||||
file: "./data/backupx.log"
|
||||
```
|
||||
|
||||
### Password Reset
|
||||
|
||||
```bash
|
||||
# Bare metal
|
||||
./backupx reset-password --username admin --password newpass123
|
||||
|
||||
# Docker
|
||||
docker exec -it backupx /app/bin/backupx reset-password --username admin --password newpass123
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Multi-Node Cluster
|
||||
|
||||
BackupX supports Master-Agent mode for managing multiple servers:
|
||||
|
||||
1. Web Console → **Node Management** → **Add Node** — system generates a Token
|
||||
2. Deploy Agent on remote server, connect using the Token
|
||||
3. Create backup tasks and assign to specific nodes — Master dispatches automatically
|
||||
|
||||
The visual directory browser lets you pick directories on remote Agent nodes — no manual path typing.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
**Requirements:** Go >= 1.25 · Node.js >= 20 · npm
|
||||
|
||||
```bash
|
||||
# Dev mode
|
||||
make dev-server # Terminal 1: backend (:8340)
|
||||
make dev-web # Terminal 2: frontend (Vite HMR)
|
||||
|
||||
# Test
|
||||
make test # Run all tests
|
||||
|
||||
# Build
|
||||
make build # Build frontend + backend
|
||||
make docker # Docker build
|
||||
make docker-cn # Docker build with China mirrors
|
||||
```
|
||||
|
||||
### Release
|
||||
|
||||
```bash
|
||||
git tag v1.2.3 && git push --tags
|
||||
# GitHub Actions: compile dual-arch binaries → publish GitHub Release → push Docker Hub image
|
||||
```
|
||||
|
||||
Or manually trigger the Release workflow from GitHub Actions page.
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
All endpoints prefixed with `/api`, authenticated via JWT Bearer Token.
|
||||
|
||||
| Module | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| **Auth** | `POST /auth/setup` | Initialize admin |
|
||||
| | `POST /auth/login` | Login |
|
||||
| | `PUT /auth/password` | Change password |
|
||||
| **Backup Tasks** | `GET\|POST /backup/tasks` | List / Create |
|
||||
| | `GET\|PUT\|DELETE /backup/tasks/:id` | Detail / Update / Delete |
|
||||
| | `PUT /backup/tasks/:id/toggle` | Enable / Disable |
|
||||
| | `POST /backup/tasks/:id/run` | Manual run |
|
||||
| **Backup Records** | `GET /backup/records` | List (with filter) |
|
||||
| | `GET /backup/records/:id/logs/stream` | Real-time logs (SSE) |
|
||||
| | `GET /backup/records/:id/download` | Download |
|
||||
| | `POST /backup/records/:id/restore` | Restore |
|
||||
| **Storage Targets** | `GET\|POST /storage-targets` | List / Add |
|
||||
| | `POST /storage-targets/test` | Test connection |
|
||||
| **Nodes** | `GET\|POST /nodes` | List / Add |
|
||||
| | `GET /nodes/:id/fs/list` | Directory browser |
|
||||
| **Notifications** | `GET\|POST /notifications` | List / Add |
|
||||
| **Dashboard** | `GET /dashboard/stats` | Overview stats |
|
||||
| **Audit Logs** | `GET /audit-logs` | Operation audit |
|
||||
| **System** | `GET /system/info` | System info |
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|-----------|
|
||||
| **Backend** | Go · Gin · GORM · SQLite · robfig/cron |
|
||||
| **Frontend** | React 18 · TypeScript · ArcoDesign · Vite · Zustand · ECharts |
|
||||
| **Storage** | AWS SDK v2 · Google Drive API v3 · gowebdav · jlaffaye/ftp |
|
||||
| **Security** | JWT · bcrypt · AES-256-GCM |
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and Pull Requests are welcome!
|
||||
|
||||
## License
|
||||
|
||||
[Apache License 2.0](LICENSE)
|
||||
@@ -19,6 +19,25 @@ server {
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
|
||||
# Agent one-click install endpoints.
|
||||
# Some external reverse proxies strip the /api prefix before reaching this
|
||||
# container, so /install/ must be proxied here instead of falling through to
|
||||
# the SPA index.html.
|
||||
location /install/ {
|
||||
proxy_pass http://127.0.0.1:8341/install/;
|
||||
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_buffering off;
|
||||
proxy_cache off;
|
||||
}
|
||||
|
||||
location = /health { proxy_pass http://127.0.0.1:8341/health; }
|
||||
location = /ready { proxy_pass http://127.0.0.1:8341/ready; }
|
||||
location = /metrics { proxy_pass http://127.0.0.1:8341/metrics; }
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
34
deploy/grafana/README.md
Normal file
34
deploy/grafana/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# BackupX Grafana Dashboard
|
||||
|
||||
对接 BackupX v2.1+ 暴露的 Prometheus `/metrics` 端点。
|
||||
|
||||
## 导入步骤
|
||||
|
||||
1. 在 Grafana 配置 Prometheus 数据源指向你的 Prometheus(例如 `http://prometheus:9090`)
|
||||
2. 在 Prometheus 配置抓取 BackupX:
|
||||
|
||||
```yaml
|
||||
scrape_configs:
|
||||
- job_name: 'backupx'
|
||||
scrape_interval: 30s
|
||||
static_configs:
|
||||
- targets: ['backupx-master:8340']
|
||||
```
|
||||
|
||||
3. Grafana → Dashboards → Import → 上传 `backupx-dashboard.json` → 选 Prometheus 数据源 → Import
|
||||
|
||||
## 面板内容
|
||||
|
||||
- 当前运行任务数 / SLA 违约数 / 在线节点 / 24h 成功率 / 应用版本
|
||||
- 任务执行速率(按 success/failed 堆叠)
|
||||
- 任务耗时 P50/P95/P99(按任务类型)
|
||||
- 任务产出字节速率
|
||||
- 存储目标用量 TopN 柱状图
|
||||
- 节点在线状态表(红/绿标色)
|
||||
- 验证 / 恢复 / 复制的成功率时间线
|
||||
|
||||
## 自定义建议
|
||||
|
||||
- 将 `backupx_sla_breach_tasks > 0` 配为 AlertManager 告警
|
||||
- `sum(backupx_node_online) < N` 触发集群容量告警(N 为你集群的最少节点数)
|
||||
- P99 任务耗时突变可用于发现慢任务和资源压力
|
||||
193
deploy/grafana/backupx-dashboard.json
Normal file
193
deploy/grafana/backupx-dashboard.json
Normal file
@@ -0,0 +1,193 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": {"type": "grafana", "uid": "-- Grafana --"},
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "BackupX v2.1+ 核心指标面板。对接 /metrics 端点,抓取周期建议 30s(与服务端 Gauge collector 同步)。",
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 1,
|
||||
"id": null,
|
||||
"links": [
|
||||
{
|
||||
"title": "BackupX 文档",
|
||||
"url": "https://awuqing.github.io/BackupX/",
|
||||
"type": "link",
|
||||
"targetBlank": true
|
||||
}
|
||||
],
|
||||
"liveNow": false,
|
||||
"panels": [
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "正在运行的任务",
|
||||
"gridPos": {"h": 4, "w": 4, "x": 0, "y": 0},
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"targets": [{"expr": "backupx_task_running", "refId": "A"}],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short",
|
||||
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "yellow", "value": 5}]}
|
||||
}
|
||||
},
|
||||
"options": {"colorMode": "value", "graphMode": "area", "textMode": "auto"}
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "SLA 违约任务数",
|
||||
"gridPos": {"h": 4, "w": 4, "x": 4, "y": 0},
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"targets": [{"expr": "backupx_sla_breach_tasks", "refId": "A"}],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short",
|
||||
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "red", "value": 1}]}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "在线节点",
|
||||
"gridPos": {"h": 4, "w": 4, "x": 8, "y": 0},
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"targets": [{"expr": "sum(backupx_node_online)", "refId": "A"}],
|
||||
"fieldConfig": {
|
||||
"defaults": {"unit": "short", "color": {"mode": "thresholds"}, "thresholds": {"steps": [{"color": "red", "value": null}, {"color": "green", "value": 1}]}}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "24h 任务成功率",
|
||||
"gridPos": {"h": 4, "w": 6, "x": 12, "y": 0},
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"targets": [{
|
||||
"expr": "sum(rate(backupx_task_run_total{status=\"success\"}[24h])) / sum(rate(backupx_task_run_total[24h])) * 100",
|
||||
"refId": "A"
|
||||
}],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent", "decimals": 2,
|
||||
"thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": null}, {"color": "yellow", "value": 95}, {"color": "green", "value": 99}]}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "应用版本",
|
||||
"gridPos": {"h": 4, "w": 6, "x": 18, "y": 0},
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"targets": [{"expr": "backupx_app_info", "refId": "A", "format": "table", "instant": true}],
|
||||
"options": {"textMode": "value_and_name", "reduceOptions": {"calcs": ["last"], "fields": "/^version$/"}}
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "任务执行速率(按状态)",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 4},
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"targets": [{
|
||||
"expr": "sum by (status) (rate(backupx_task_run_total[5m]))",
|
||||
"refId": "A",
|
||||
"legendFormat": "{{status}}"
|
||||
}],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "ops",
|
||||
"custom": {"drawStyle": "line", "lineInterpolation": "smooth", "fillOpacity": 10, "stacking": {"mode": "normal"}}
|
||||
},
|
||||
"overrides": [
|
||||
{"matcher": {"id": "byName", "options": "success"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "green"}}]},
|
||||
{"matcher": {"id": "byName", "options": "failed"}, "properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": "red"}}]}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "任务耗时 P50 / P95 / P99",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 4},
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"targets": [
|
||||
{"expr": "histogram_quantile(0.50, sum(rate(backupx_task_run_duration_seconds_bucket[10m])) by (le, task_type))", "refId": "A", "legendFormat": "P50 {{task_type}}"},
|
||||
{"expr": "histogram_quantile(0.95, sum(rate(backupx_task_run_duration_seconds_bucket[10m])) by (le, task_type))", "refId": "B", "legendFormat": "P95 {{task_type}}"},
|
||||
{"expr": "histogram_quantile(0.99, sum(rate(backupx_task_run_duration_seconds_bucket[10m])) by (le, task_type))", "refId": "C", "legendFormat": "P99 {{task_type}}"}
|
||||
],
|
||||
"fieldConfig": {"defaults": {"unit": "s"}}
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "任务产出字节速率",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 12},
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"targets": [{"expr": "sum by (task_type) (rate(backupx_task_bytes_total[5m]))", "refId": "A", "legendFormat": "{{task_type}}"}],
|
||||
"fieldConfig": {"defaults": {"unit": "Bps"}}
|
||||
},
|
||||
{
|
||||
"type": "bargauge",
|
||||
"title": "存储目标用量 TopN",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 12},
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"targets": [{"expr": "topk(10, backupx_storage_used_bytes)", "refId": "A", "legendFormat": "{{target_name}} ({{target_type}})"}],
|
||||
"fieldConfig": {"defaults": {"unit": "bytes"}},
|
||||
"options": {"orientation": "horizontal", "displayMode": "gradient"}
|
||||
},
|
||||
{
|
||||
"type": "table",
|
||||
"title": "节点在线状态",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 20},
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"targets": [{"expr": "backupx_node_online", "refId": "A", "format": "table", "instant": true}],
|
||||
"transformations": [
|
||||
{"id": "organize", "options": {"excludeByName": {"Time": true, "__name__": true, "job": true, "instance": true}, "indexByName": {"node_name": 0, "role": 1, "Value": 2}, "renameByName": {"Value": "online"}}}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"overrides": [{
|
||||
"matcher": {"id": "byName", "options": "online"},
|
||||
"properties": [{"id": "mappings", "value": [{"type": "value", "options": {"0": {"text": "离线", "color": "red"}, "1": {"text": "在线", "color": "green"}}}]}]
|
||||
}]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "验证 / 恢复 / 复制成功率",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 20},
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"targets": [
|
||||
{"expr": "sum by (status) (rate(backupx_verify_run_total[15m]))", "refId": "A", "legendFormat": "verify {{status}}"},
|
||||
{"expr": "sum by (status) (rate(backupx_restore_run_total[15m]))", "refId": "B", "legendFormat": "restore {{status}}"},
|
||||
{"expr": "sum by (status) (rate(backupx_replication_run_total[15m]))", "refId": "C", "legendFormat": "replication {{status}}"}
|
||||
],
|
||||
"fieldConfig": {"defaults": {"unit": "ops"}}
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 39,
|
||||
"tags": ["backupx", "backup", "sre"],
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"current": {"selected": false, "text": "Prometheus", "value": "Prometheus"},
|
||||
"label": "Datasource",
|
||||
"name": "DS_PROMETHEUS",
|
||||
"query": "prometheus",
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"type": "datasource"
|
||||
}
|
||||
]
|
||||
},
|
||||
"time": {"from": "now-6h", "to": "now"},
|
||||
"timepicker": {},
|
||||
"timezone": "",
|
||||
"title": "BackupX Overview",
|
||||
"uid": "backupx-overview",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
@@ -18,6 +18,22 @@ server {
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
|
||||
# Agent 一键安装脚本路径(兼容 v2.0 及之前生成的命令)。
|
||||
# v2.1+ 新生成的命令走 /api/install/... 自动命中上面的 /api/ 代理。
|
||||
location /install/ {
|
||||
proxy_pass http://127.0.0.1:8340/install/;
|
||||
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;
|
||||
}
|
||||
|
||||
# 健康检查端点同样不走 SPA fallback。
|
||||
location = /health { proxy_pass http://127.0.0.1:8340/health; }
|
||||
location = /ready { proxy_pass http://127.0.0.1:8340/ready; }
|
||||
location = /metrics { proxy_pass http://127.0.0.1:8340/metrics; }
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
20
docs-site/.gitignore
vendored
Normal file
20
docs-site/.gitignore
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
# Dependencies
|
||||
/node_modules
|
||||
|
||||
# Production
|
||||
/build
|
||||
|
||||
# Generated files
|
||||
.docusaurus
|
||||
.cache-loader
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
41
docs-site/README.md
Normal file
41
docs-site/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Website
|
||||
|
||||
This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
yarn
|
||||
```
|
||||
|
||||
## Local Development
|
||||
|
||||
```bash
|
||||
yarn start
|
||||
```
|
||||
|
||||
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
yarn build
|
||||
```
|
||||
|
||||
This command generates static content into the `build` directory and can be served using any static contents hosting service.
|
||||
|
||||
## Deployment
|
||||
|
||||
Using SSH:
|
||||
|
||||
```bash
|
||||
USE_SSH=true yarn deploy
|
||||
```
|
||||
|
||||
Not using SSH:
|
||||
|
||||
```bash
|
||||
GIT_USER=<Your GitHub username> yarn deploy
|
||||
```
|
||||
|
||||
If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.
|
||||
86
docs-site/docs/deployment/bare-metal.md
Normal file
86
docs-site/docs/deployment/bare-metal.md
Normal file
@@ -0,0 +1,86 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: Bare-metal Deployment
|
||||
description: systemd + Nginx deployment from the prebuilt release tarball or source.
|
||||
---
|
||||
|
||||
# Bare-metal Deployment
|
||||
|
||||
## From prebuilt release
|
||||
|
||||
```bash
|
||||
# Download the matching tarball
|
||||
curl -LO https://github.com/Awuqing/BackupX/releases/latest/download/backupx-v1.6.0-linux-amd64.tar.gz
|
||||
|
||||
# Extract and install
|
||||
tar xzf backupx-v*-linux-amd64.tar.gz && cd backupx-*
|
||||
sudo ./install.sh
|
||||
```
|
||||
|
||||
The installer performs these steps automatically:
|
||||
|
||||
1. Creates a system user `backupx`
|
||||
2. Copies the binary to `/opt/backupx/`
|
||||
3. Generates a default `config.yaml` with safe JWT/encryption secrets
|
||||
4. Installs `backupx.service` (systemd), enabled at boot
|
||||
5. (Optional) installs an Nginx site file — see [Nginx Reverse Proxy](./nginx)
|
||||
|
||||
## From source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
|
||||
make build
|
||||
sudo ./deploy/install.sh
|
||||
```
|
||||
|
||||
`make build` compiles:
|
||||
|
||||
- `server/bin/backupx` (Go backend, no CGO)
|
||||
- `web/dist/` (React frontend, `npm run build`)
|
||||
|
||||
## systemd
|
||||
|
||||
The installed unit:
|
||||
|
||||
```ini title="/etc/systemd/system/backupx.service"
|
||||
[Unit]
|
||||
Description=BackupX backup management service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=backupx
|
||||
WorkingDirectory=/opt/backupx
|
||||
ExecStart=/opt/backupx/backupx --config /opt/backupx/config.yaml
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
LimitNOFILE=65536
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Typical operations:
|
||||
|
||||
```bash
|
||||
sudo systemctl status backupx
|
||||
sudo journalctl -u backupx -f # live logs
|
||||
sudo systemctl restart backupx
|
||||
```
|
||||
|
||||
## Password reset
|
||||
|
||||
If the admin password is lost:
|
||||
|
||||
```bash
|
||||
/opt/backupx/backupx reset-password \
|
||||
--username admin \
|
||||
--password 'newpass123' \
|
||||
--config /opt/backupx/config.yaml
|
||||
```
|
||||
|
||||
Docker equivalent:
|
||||
|
||||
```bash
|
||||
docker exec -it backupx /app/bin/backupx reset-password --username admin --password 'newpass123'
|
||||
```
|
||||
52
docs-site/docs/deployment/configuration.md
Normal file
52
docs-site/docs/deployment/configuration.md
Normal file
@@ -0,0 +1,52 @@
|
||||
---
|
||||
sidebar_position: 4
|
||||
title: Configuration Reference
|
||||
description: All server.yaml configuration keys with defaults and matching environment variables.
|
||||
---
|
||||
|
||||
# Configuration Reference
|
||||
|
||||
BackupX loads `./config.yaml` from the working directory by default. You can override the path with `--config`. Every key can also be set via a `BACKUPX_` prefixed environment variable.
|
||||
|
||||
## Full config reference
|
||||
|
||||
```yaml title="config.yaml"
|
||||
server:
|
||||
host: "0.0.0.0" # BACKUPX_SERVER_HOST
|
||||
port: 8340 # BACKUPX_SERVER_PORT
|
||||
mode: "release" # release | debug
|
||||
|
||||
database:
|
||||
path: "./data/backupx.db" # BACKUPX_DATABASE_PATH — embedded SQLite
|
||||
|
||||
security:
|
||||
jwt_secret: "" # BACKUPX_SECURITY_JWT_SECRET — auto-generated if empty
|
||||
jwt_expires_in: "24h"
|
||||
encryption_key: "" # AES-256-GCM key for storage config encryption
|
||||
|
||||
backup:
|
||||
temp_dir: "/tmp/backupx" # BACKUPX_BACKUP_TEMP_DIR
|
||||
max_concurrent: 2 # BACKUPX_BACKUP_MAX_CONCURRENT
|
||||
retries: 3 # Per-upload rclone low-level retries
|
||||
bandwidth_limit: "" # e.g. "10M" to cap transfers at 10 MB/s
|
||||
|
||||
log:
|
||||
level: "info" # debug | info | warn | error
|
||||
file: "./data/backupx.log"
|
||||
```
|
||||
|
||||
## Secret generation
|
||||
|
||||
If `jwt_secret` or `encryption_key` is empty on first start, BackupX generates a random value and persists it to the `system_configs` table. Keep a backup of `data/backupx.db` — losing it invalidates all existing encrypted storage configurations.
|
||||
|
||||
## Environment variables
|
||||
|
||||
The environment wins when both file and env are set. All dot-paths become underscores and uppercase:
|
||||
|
||||
| Config key | Env variable |
|
||||
|------------|--------------|
|
||||
| `server.port` | `BACKUPX_SERVER_PORT` |
|
||||
| `log.level` | `BACKUPX_LOG_LEVEL` |
|
||||
| `backup.max_concurrent` | `BACKUPX_BACKUP_MAX_CONCURRENT` |
|
||||
| `backup.temp_dir` | `BACKUPX_BACKUP_TEMP_DIR` |
|
||||
| `backup.bandwidth_limit` | `BACKUPX_BACKUP_BANDWIDTH_LIMIT` |
|
||||
68
docs-site/docs/deployment/docker.md
Normal file
68
docs-site/docs/deployment/docker.md
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: Docker Deployment
|
||||
description: Production-style Docker deployment with docker compose, mounted source directories, and environment overrides.
|
||||
---
|
||||
|
||||
# Docker Deployment
|
||||
|
||||
BackupX's official Docker image [`awuqing/backupx`](https://hub.docker.com/r/awuqing/backupx) supports multi-architecture (linux/amd64 + linux/arm64).
|
||||
|
||||
## Compose file
|
||||
|
||||
```yaml title="docker-compose.yml"
|
||||
services:
|
||||
backupx:
|
||||
image: awuqing/backupx:latest
|
||||
container_name: backupx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8340:8340"
|
||||
volumes:
|
||||
- backupx-data:/app/data
|
||||
# Mount host directories you want to back up:
|
||||
- /var/www:/mnt/www:ro
|
||||
- /etc/nginx:/mnt/nginx-conf:ro
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
- BACKUPX_LOG_LEVEL=info
|
||||
- BACKUPX_BACKUP_MAX_CONCURRENT=2
|
||||
|
||||
volumes:
|
||||
backupx-data:
|
||||
```
|
||||
|
||||
Start with:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Host-directory backup
|
||||
|
||||
To back up files from the host, mount them into the container. When creating a file-type task in the web UI, point the source path at the mount location (e.g. `/mnt/www`). Make sure the directory is visible inside the container.
|
||||
|
||||
## Environment variables
|
||||
|
||||
All configuration keys can be overridden with the `BACKUPX_` prefix:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
- BACKUPX_SERVER_PORT=8340
|
||||
- BACKUPX_LOG_LEVEL=debug
|
||||
- BACKUPX_BACKUP_MAX_CONCURRENT=4
|
||||
- BACKUPX_BACKUP_TEMP_DIR=/tmp/backupx
|
||||
```
|
||||
|
||||
See the [Configuration](./configuration) page for the full list.
|
||||
|
||||
## Upgrades
|
||||
|
||||
Check **System Settings → Check Updates** in the UI to see if a new version is available, then on the host:
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
No migrations needed — BackupX auto-migrates the SQLite schema on startup.
|
||||
53
docs-site/docs/deployment/nginx.md
Normal file
53
docs-site/docs/deployment/nginx.md
Normal file
@@ -0,0 +1,53 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
title: Nginx Reverse Proxy
|
||||
description: Expose BackupX behind Nginx with HTTPS and SSE-friendly buffering disabled.
|
||||
---
|
||||
|
||||
# Nginx Reverse Proxy
|
||||
|
||||
A minimal production-ready Nginx site for BackupX:
|
||||
|
||||
```nginx title="/etc/nginx/sites-available/backupx"
|
||||
server {
|
||||
listen 80;
|
||||
server_name backup.example.com;
|
||||
|
||||
# Static UI (served from /opt/backupx/web)
|
||||
location / {
|
||||
root /opt/backupx/web;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API reverse proxy
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8340;
|
||||
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;
|
||||
|
||||
# Large uploads (restore flow)
|
||||
client_max_body_size 0;
|
||||
|
||||
# Live log stream uses SSE — buffering must be off
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## HTTPS with certbot
|
||||
|
||||
```bash
|
||||
sudo apt install certbot python3-certbot-nginx
|
||||
sudo certbot --nginx -d backup.example.com
|
||||
```
|
||||
|
||||
Certbot rewrites the config to listen on 443 with auto-renewal.
|
||||
|
||||
:::caution Agent needs a stable URL
|
||||
If Master is behind HTTPS, remote Agent deployments must use the public HTTPS URL for `--master`. Self-signed certs require `--insecure-tls` (testing only).
|
||||
:::
|
||||
41
docs-site/docs/development/contributing.md
Normal file
41
docs-site/docs/development/contributing.md
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: Contributing
|
||||
description: How to report issues, propose changes, and submit PRs.
|
||||
---
|
||||
|
||||
# Contributing
|
||||
|
||||
BackupX is open-source under Apache License 2.0. Issues and pull requests are welcome.
|
||||
|
||||
## Reporting bugs
|
||||
|
||||
Open an issue at [github.com/Awuqing/BackupX/issues](https://github.com/Awuqing/BackupX/issues). Please include:
|
||||
|
||||
- BackupX version (`backupx --version`)
|
||||
- Your deployment mode (Docker / bare metal / from source)
|
||||
- Relevant backup task type and storage backend
|
||||
- Steps to reproduce
|
||||
- Stdout / `backupx.log` excerpt for the window around the problem
|
||||
|
||||
## Proposing changes
|
||||
|
||||
For significant features or refactors, open an issue first to align on scope before investing in a PR.
|
||||
|
||||
## Pull requests
|
||||
|
||||
1. Fork and create a topic branch (e.g. `fix/windows-path-escape`)
|
||||
2. Run `make test` and make sure everything passes
|
||||
3. Keep changes focused — one concern per PR
|
||||
4. Write commit messages in Chinese following `类型: 简要描述` — examples:
|
||||
- `功能: 新增审计日志模块`
|
||||
- `修复: 目录浏览器无法进入子目录`
|
||||
- `重构: 简化存储目标解密逻辑`
|
||||
- Types: `功能` / `修复` / `重构` / `文档` / `构建` / `测试`
|
||||
5. PR title and body in Chinese too. Describe the why and how, not just the what.
|
||||
|
||||
## Coding guidelines
|
||||
|
||||
- **Go** — handle every error (no `_ = err`); use the existing logger (`zap`); no `fmt.Println` in production paths
|
||||
- **TypeScript** — strict mode, no implicit any, follow existing ESLint/Prettier configs
|
||||
- **Commit scope** — one logical change per commit; don't mix drive-by cleanups with feature work
|
||||
83
docs-site/docs/development/setup.md
Normal file
83
docs-site/docs/development/setup.md
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: Development Setup
|
||||
description: Get a BackupX dev environment running — backend, frontend, tests.
|
||||
---
|
||||
|
||||
# Development Setup
|
||||
|
||||
**Requirements:** Go ≥ 1.25, Node.js ≥ 20, npm.
|
||||
|
||||
## Clone & install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
|
||||
cd web && npm install && cd ..
|
||||
```
|
||||
|
||||
## Dev servers
|
||||
|
||||
Run the backend and the Vite dev server in two terminals:
|
||||
|
||||
```bash
|
||||
# Terminal 1: backend on :8340
|
||||
make dev-server
|
||||
|
||||
# Terminal 2: Vite with HMR on :5173
|
||||
make dev-web
|
||||
```
|
||||
|
||||
The Vite config proxies `/api` to `http://127.0.0.1:8340` so you can open the UI at `http://localhost:5173`.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
make test # runs Go + Web test suites
|
||||
make test-server # Go only
|
||||
make test-web # Vitest only
|
||||
```
|
||||
|
||||
## Production build
|
||||
|
||||
```bash
|
||||
make build # server/bin/backupx + web/dist
|
||||
make docker # Docker image
|
||||
make docker-cn # Docker image with mainland China mirrors
|
||||
```
|
||||
|
||||
## Tech stack
|
||||
|
||||
| Component | Stack |
|
||||
|-----------|-------|
|
||||
| **Backend** | Go · Gin · GORM · SQLite · robfig/cron · rclone |
|
||||
| **Frontend** | React 18 · TypeScript · ArcoDesign · Vite · Zustand · ECharts |
|
||||
| **Storage** | rclone (70+ backends) · AWS SDK v2 · Google Drive API v3 |
|
||||
| **Security** | JWT · bcrypt · AES-256-GCM |
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
BackupX/
|
||||
├── server/ # Go backend
|
||||
│ ├── cmd/backupx/ # Entry point + subcommands (agent, backint, reset-password)
|
||||
│ ├── internal/
|
||||
│ │ ├── agent/ # Agent CLI logic
|
||||
│ │ ├── app/ # Wiring (repositories → services → handlers)
|
||||
│ │ ├── backup/ # Backup runners (file / mysql / postgres / sqlite / saphana)
|
||||
│ │ ├── backint/ # SAP HANA Backint protocol
|
||||
│ │ ├── http/ # HTTP handlers + router
|
||||
│ │ ├── model/ # GORM models
|
||||
│ │ ├── repository/ # DB access
|
||||
│ │ ├── service/ # Business logic
|
||||
│ │ └── storage/ # Storage providers (rclone + direct SDKs)
|
||||
│ └── pkg/ # Generic utilities
|
||||
├── web/ # React frontend (Vite)
|
||||
│ └── src/
|
||||
│ ├── components/
|
||||
│ ├── pages/
|
||||
│ ├── services/
|
||||
│ └── types/
|
||||
├── docs-site/ # This documentation site (Docusaurus)
|
||||
├── deploy/ # install.sh, systemd unit, nginx config
|
||||
└── Makefile
|
||||
```
|
||||
42
docs-site/docs/features/backup-types.md
Normal file
42
docs-site/docs/features/backup-types.md
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: Backup Types
|
||||
description: File, MySQL, PostgreSQL, SQLite and SAP HANA — what they back up and what to configure.
|
||||
---
|
||||
|
||||
# Backup Types
|
||||
|
||||
BackupX supports five built-in backup types. Type determines which runner executes the job.
|
||||
|
||||
## File / Directory
|
||||
|
||||
Tars (and optionally gzips) one or more filesystem paths.
|
||||
|
||||
- **Source** accepts multiple paths — one per line in the UI
|
||||
- **Exclude patterns** accept gitignore-style globs
|
||||
- Supports following symlinks, preserving permissions
|
||||
- Output is a single `.tar` or `.tar.gz` artifact
|
||||
|
||||
## MySQL
|
||||
|
||||
Uses `mysqldump` under the hood. Requires `mysqldump` to be on `$PATH` of the host running the task (Master or Agent).
|
||||
|
||||
- **Host / port / user / password / database** — multi-database allowed (comma-separated)
|
||||
- Output: `.sql` or `.sql.gz`
|
||||
- Default flags: `--single-transaction --routines --triggers --events`
|
||||
|
||||
## PostgreSQL
|
||||
|
||||
Uses `pg_dump`. Same connection fields as MySQL plus database name.
|
||||
|
||||
## SQLite
|
||||
|
||||
Copies the database file directly (with a consistency snapshot). No external tool required.
|
||||
|
||||
## SAP HANA
|
||||
|
||||
Two modes are supported — see the dedicated [SAP HANA](./sap-hana) page.
|
||||
|
||||
## Deletion behavior
|
||||
|
||||
When a task is deleted, BackupX removes backup artifacts from every storage target but preserves backup records for audit. Task deletion also tears down the cron schedule entry.
|
||||
113
docs-site/docs/features/multi-node.md
Normal file
113
docs-site/docs/features/multi-node.md
Normal file
@@ -0,0 +1,113 @@
|
||||
---
|
||||
sidebar_position: 4
|
||||
title: Multi-Node Cluster
|
||||
description: Master-Agent mode — route backups to remote servers via HTTP long-polling.
|
||||
---
|
||||
|
||||
# Multi-Node Cluster
|
||||
|
||||
BackupX supports Master-Agent mode: backup tasks can be routed to specific nodes. The Agent runs the backup locally and uploads straight to storage. All connections are initiated by the Agent, so remote networks only need outbound HTTP access.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
[Web Console] ─── JWT ──→ [Master (backupx)]
|
||||
↑ ↓
|
||||
│ │ HTTP long-poll (token auth)
|
||||
│ ↓
|
||||
[Agent (backupx agent)] ← runs on remote host
|
||||
↓
|
||||
[70+ Storage Backends]
|
||||
```
|
||||
|
||||
- **Protocol** — HTTP long-polling; the Agent initiates every connection
|
||||
- **Heartbeat** — Agent reports every 15s; Master marks nodes offline after 45s of silence
|
||||
- **Dispatch** — Master persists `run_task` commands to a queue; Agent polls and claims them
|
||||
- **Execution** — Agent reuses the same BackupRunner (file / mysql / postgresql / sqlite / saphana) and uploads directly to storage
|
||||
- **Security** — Each node has its own token; the Agent never holds the Master's JWT secret or AES-256 key
|
||||
|
||||
## Walkthrough
|
||||
|
||||
### 1. Open the install wizard
|
||||
|
||||
In the Web Console → **Node Management** → **Add Node**. You'll see a three-step wizard.
|
||||
|
||||
- **Step 1 — Node info.** Give the node a name, or switch to batch mode and paste multiple names (one per line, max 50).
|
||||
- **Step 2 — Deploy options.** Pick install mode (`systemd` recommended, `docker`, or `foreground` for debugging), architecture (auto-detect by default), agent version (defaults to the master's version), TTL for the install link (5 min / 15 min / 1 h / 24 h), and download source (`github` direct, or the `ghproxy` mirror for mainland China).
|
||||
- **Step 3 — Copy the command.** A one-line install command is shown with a live countdown. Click copy, paste into the target machine, and run with root privileges. The default command embeds the rendered installer, so the target host does not need to fetch `/api/install/:token` through your reverse proxy. The public install URL is still available as a fallback.
|
||||
|
||||
### 2. One-line install on the target host
|
||||
|
||||
Use the command generated by the Web Console. It writes the installer to a temporary file, validates the `BACKUPX_AGENT_INSTALL_V1` marker, then runs it with root privileges.
|
||||
|
||||
The script runs automatically and:
|
||||
|
||||
1. Detects OS and architecture (`uname -m`)
|
||||
2. Downloads the matching `backupx` binary from GitHub Release (or the ghproxy mirror)
|
||||
3. Installs to `/opt/backupx-agent` and creates a `backupx` system user
|
||||
4. Writes `/etc/systemd/system/backupx-agent.service` with the token baked into environment variables
|
||||
5. Runs `systemctl enable --now backupx-agent`
|
||||
6. Polls `/api/v1/agent/self` until the master confirms `status: online` (up to 30 s)
|
||||
|
||||
If you choose the URL-based fallback command and `curl` prints HTML or the shell reports `Syntax error: newline unexpected`, the install URL is being served by the web console instead of the backend. Ensure either `/api/install/` or `/install/` is forwarded to the BackupX backend, or use the embedded command generated by the console.
|
||||
|
||||
Reruns are idempotent — to upgrade or re-provision, simply generate a new install command and run it again. The one-time install link expires after its TTL or after first consumption, whichever is sooner.
|
||||
|
||||
### 3. Rotate agent tokens at any time
|
||||
|
||||
Go to the node's action menu (︙) → **Rotate Token**. The new token is shown once and the old token remains valid for 24 h, allowing rolling restarts without downtime. After 24 h, the old token is rejected.
|
||||
|
||||
### 4. Batch deployment
|
||||
|
||||
In Step 1 choose "Batch" and paste node names (one per line, max 50). Step 3 shows a table with one command per node plus a **Download .sh** button that bundles all commands into a shell script, convenient for SSH loops or Ansible tasks.
|
||||
|
||||
### 5. Route a task to the node
|
||||
|
||||
In the **Backup Tasks** page, pick the target node when creating the task. When the task runs:
|
||||
|
||||
- Local (`nodeId=0`) → Master executes in-process
|
||||
- Remote node → Master enqueues the command → Agent claims → Agent runs locally → uploads → reports back
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **Encrypted backups don't work via Agent** — the Agent doesn't hold Master's AES-256 key. Tasks with `encrypt: true` will fail if routed to an Agent
|
||||
- **Directory browser timeout** — remote dir listing is a synchronous RPC through the queue (15s default)
|
||||
- **Dispatched command timeout** — claimed-but-unfinished commands are marked `timeout` after 10 minutes
|
||||
|
||||
## CLI reference
|
||||
|
||||
```
|
||||
backupx agent --help
|
||||
-master string Master URL
|
||||
-token string Agent auth token
|
||||
-config string YAML config path (takes precedence over env)
|
||||
-temp-dir string Local temp directory (default /tmp/backupx-agent)
|
||||
-insecure-tls Skip TLS verification (testing only)
|
||||
```
|
||||
|
||||
## systemd unit
|
||||
|
||||
```ini title="/etc/systemd/system/backupx-agent.service"
|
||||
[Unit]
|
||||
Description=BackupX Agent
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=backupx
|
||||
Environment="BACKUPX_AGENT_MASTER=https://master.example.com"
|
||||
Environment="BACKUPX_AGENT_TOKEN=your-token"
|
||||
ExecStart=/opt/backupx/backupx agent
|
||||
Restart=on-failure
|
||||
RestartSec=10s
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Enable and start:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable --now backupx-agent
|
||||
sudo journalctl -u backupx-agent -f
|
||||
```
|
||||
49
docs-site/docs/features/notifications.md
Normal file
49
docs-site/docs/features/notifications.md
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
sidebar_position: 5
|
||||
title: Notifications
|
||||
description: Email, webhook, and Telegram notifications on backup success or failure.
|
||||
---
|
||||
|
||||
# Notifications
|
||||
|
||||
BackupX supports three notification channels. Configure per-channel rules for success-only, failure-only, or both.
|
||||
|
||||
## Email (SMTP)
|
||||
|
||||
| Field | Notes |
|
||||
|-------|-------|
|
||||
| SMTP host / port | e.g. `smtp.gmail.com:587` |
|
||||
| Username / password | App-specific password recommended |
|
||||
| From address | Used in `From:` header |
|
||||
| Recipients | Comma-separated list |
|
||||
| Use TLS / StartTLS | Match your SMTP provider |
|
||||
|
||||
## Webhook
|
||||
|
||||
Send a JSON POST to an arbitrary URL. Body shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "backup_result",
|
||||
"task": {"id": 1, "name": "web-files", "type": "file"},
|
||||
"record": {"id": 42, "status": "success", "fileSize": 1048576, "durationSeconds": 12},
|
||||
"error": ""
|
||||
}
|
||||
```
|
||||
|
||||
Useful for custom workflows: Slack incoming webhook, PagerDuty, your own API, etc.
|
||||
|
||||
## Telegram
|
||||
|
||||
| Field | Notes |
|
||||
|-------|-------|
|
||||
| Bot token | From [@BotFather](https://t.me/BotFather) |
|
||||
| Chat ID | Numeric — obtain via `/start` + bot's `getUpdates` |
|
||||
|
||||
## Event rules
|
||||
|
||||
Each notification configuration can be scoped to:
|
||||
|
||||
- **Success only** — quiet during normal runs, pings on first failure
|
||||
- **Failure only** — recommended for loud channels
|
||||
- **Both** — useful during initial setup to verify notifications flow
|
||||
79
docs-site/docs/features/sap-hana.md
Normal file
79
docs-site/docs/features/sap-hana.md
Normal file
@@ -0,0 +1,79 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
title: SAP HANA Support
|
||||
description: Two SAP HANA backup modes — managed hdbsql runner and native Backint protocol agent.
|
||||
---
|
||||
|
||||
# SAP HANA Support
|
||||
|
||||
BackupX provides two SAP HANA backup modes. Pick whichever fits your operations workflow.
|
||||
|
||||
## Mode 1: hdbsql Runner (console-managed)
|
||||
|
||||
Create a SAP HANA backup task in the Web console. The backend invokes `hdbsql` to execute the backup. Use this when BackupX should own the schedule.
|
||||
|
||||
**Source configuration supports:**
|
||||
|
||||
| Field | Options | Description |
|
||||
|-------|---------|-------------|
|
||||
| Backup type | `data` / `log` | Data or log backup |
|
||||
| Backup level | `full` / `incremental` / `differential` | Auto-disabled for log backups |
|
||||
| Parallel channels | `1 ~ 32` | Multi-path SQL (`BACKUP DATA USING FILE ('c1', 'c2', ...)`) |
|
||||
| Retry count | `1 ~ 10` | Exponential backoff (`5s × attempt²`) |
|
||||
| Instance number | Optional | Inferred from port or specified manually |
|
||||
|
||||
## Mode 2: Backint Protocol Agent (HANA native)
|
||||
|
||||
BackupX ships a built-in Backint Agent. SAP HANA calls it via the native `BACKUP DATA USING BACKINT` syntax, and data is routed automatically to any BackupX storage target (S3 / OSS / COS / WebDAV / 70+ backends).
|
||||
|
||||
### 1. Parameter file
|
||||
|
||||
```ini title="/opt/backupx/backint_params.ini"
|
||||
#STORAGE_TYPE = s3
|
||||
#STORAGE_CONFIG_JSON = /opt/backupx/storage.json
|
||||
#PARALLEL_FACTOR = 4
|
||||
#COMPRESS = true
|
||||
#KEY_PREFIX = hana-backup
|
||||
#CATALOG_DB = /opt/backupx/backint_catalog.db
|
||||
#LOG_FILE = /var/log/backupx/backint.log
|
||||
```
|
||||
|
||||
### 2. Storage config (same schema as storage targets)
|
||||
|
||||
```json title="/opt/backupx/storage.json"
|
||||
{
|
||||
"endpoint": "https://s3.amazonaws.com",
|
||||
"region": "us-east-1",
|
||||
"bucket": "hana-prod",
|
||||
"accessKeyId": "AKIA...",
|
||||
"secretAccessKey": "..."
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Create the hdbbackint symlink
|
||||
|
||||
```bash
|
||||
ln -s /opt/backupx/backupx /usr/sap/<SID>/SYS/global/hdb/opt/hdbbackint
|
||||
```
|
||||
|
||||
### 4. Enable Backint in HANA `global.ini`
|
||||
|
||||
```ini
|
||||
[backup]
|
||||
data_backup_using_backint = true
|
||||
catalog_backup_using_backint = true
|
||||
log_backup_using_backint = true
|
||||
data_backup_parameter_file = /opt/backupx/backint_params.ini
|
||||
log_backup_parameter_file = /opt/backupx/backint_params.ini
|
||||
```
|
||||
|
||||
### 5. Manual CLI invocation (troubleshooting)
|
||||
|
||||
```bash
|
||||
backupx backint -f backup -i input.txt -o output.txt -p backint_params.ini
|
||||
backupx backint -f restore -i input.txt -o output.txt -p backint_params.ini
|
||||
backupx backint -f inquire -i input.txt -o output.txt -p backint_params.ini
|
||||
backupx backint -f delete -i input.txt -o output.txt -p backint_params.ini
|
||||
```
|
||||
|
||||
The Backint Agent maintains an `EBID ↔ object-key` catalog in a local SQLite DB. All operations follow the SAP HANA Backint protocol (`#PIPE` / `#SAVED` / `#RESTORED` / `#BACKUP` / `#NOTFOUND` / `#DELETED` / `#ERROR`).
|
||||
38
docs-site/docs/features/storage-backends.md
Normal file
38
docs-site/docs/features/storage-backends.md
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: Storage Backends
|
||||
description: 70+ storage backends — built-in cloud providers plus any rclone backend.
|
||||
---
|
||||
|
||||
# Storage Backends
|
||||
|
||||
BackupX aims to accept any place you'd want to drop a backup file.
|
||||
|
||||
## Built-in providers
|
||||
|
||||
| Type | Required fields |
|
||||
|------|-----------------|
|
||||
| **Alibaba OSS** | Region + AccessKey ID/Secret + Bucket (endpoint auto-assembled) |
|
||||
| **Tencent COS** | Region + SecretId/SecretKey + Bucket (format `name-appid`) |
|
||||
| **Qiniu Kodo** | Region + AccessKey/SecretKey + Bucket |
|
||||
| **S3-compatible** | Endpoint + AccessKey + Bucket |
|
||||
| **Google Drive** | Client ID/Secret + OAuth authorization |
|
||||
| **WebDAV** | URL + username/password |
|
||||
| **FTP / FTPS** | Host + port + username/password |
|
||||
| **Local disk** | Target directory (absolute path) |
|
||||
|
||||
## Rclone backends
|
||||
|
||||
Every [rclone backend](https://rclone.org/overview/) is exposed as a first-class storage type — SFTP, Azure Blob, Dropbox, OneDrive, Backblaze B2, Wasabi, pCloud, HDFS, and many more.
|
||||
|
||||
- The form groups fields into **required** and **advanced** (advanced collapsed by default)
|
||||
- Validation and connection tests reuse rclone's built-in probe
|
||||
|
||||
## Multiple targets per task
|
||||
|
||||
A backup task can fan out to multiple targets in parallel. All targets receive the same artifact; a per-target status is recorded:
|
||||
|
||||
- Success: storage path + size
|
||||
- Failed: error message
|
||||
|
||||
If any target fails after retries, the record status is `failed` but successful targets are preserved (no rollback).
|
||||
82
docs-site/docs/getting-started/installation.md
Normal file
82
docs-site/docs/getting-started/installation.md
Normal file
@@ -0,0 +1,82 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: Installation
|
||||
description: Install BackupX via Docker, prebuilt archive, or from source.
|
||||
---
|
||||
|
||||
# Installation
|
||||
|
||||
BackupX ships as a single static binary. Three ways to install, pick the one that matches your environment.
|
||||
|
||||
## Docker (recommended)
|
||||
|
||||
No cloning required.
|
||||
|
||||
```bash
|
||||
docker run -d --name backupx \
|
||||
-p 8340:8340 \
|
||||
-v backupx-data:/app/data \
|
||||
awuqing/backupx:latest
|
||||
```
|
||||
|
||||
Or use `docker compose`:
|
||||
|
||||
```yaml title="docker-compose.yml"
|
||||
services:
|
||||
backupx:
|
||||
image: awuqing/backupx:latest
|
||||
container_name: backupx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8340:8340"
|
||||
volumes:
|
||||
- backupx-data:/app/data
|
||||
# Mount host directories to back up (as needed):
|
||||
# - /var/www:/mnt/www:ro
|
||||
# - /etc/nginx:/mnt/nginx-conf:ro
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
|
||||
volumes:
|
||||
backupx-data:
|
||||
```
|
||||
|
||||
Images: [`awuqing/backupx`](https://hub.docker.com/r/awuqing/backupx) — supports `linux/amd64` and `linux/arm64`.
|
||||
|
||||
## Prebuilt archive (bare metal)
|
||||
|
||||
Download from the [Releases page](https://github.com/Awuqing/BackupX/releases) and run the installer:
|
||||
|
||||
```bash
|
||||
tar xzf backupx-v*-linux-amd64.tar.gz && cd backupx-*
|
||||
sudo ./install.sh # creates system user, installs to /opt/backupx, sets up systemd + nginx
|
||||
```
|
||||
|
||||
The installer:
|
||||
|
||||
1. Creates a `backupx` system user
|
||||
2. Installs binary to `/opt/backupx/backupx`
|
||||
3. Creates `/opt/backupx/config.yaml` with safe defaults
|
||||
4. Installs and enables the `backupx.service` systemd unit
|
||||
5. (Optional) Configures an Nginx reverse proxy
|
||||
|
||||
## From source
|
||||
|
||||
Requires Go ≥ 1.25 and Node.js ≥ 20.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
|
||||
make build
|
||||
# or, for builds behind the great firewall
|
||||
make docker-cn
|
||||
```
|
||||
|
||||
After `make build`, the binary is at `server/bin/backupx` and the built web UI is at `web/dist/`.
|
||||
|
||||
## Verify the install
|
||||
|
||||
```bash
|
||||
backupx --version # e.g. v1.6.0
|
||||
```
|
||||
|
||||
Then open `http://your-server:8340` to see the initial admin setup screen.
|
||||
59
docs-site/docs/getting-started/quick-start.md
Normal file
59
docs-site/docs/getting-started/quick-start.md
Normal file
@@ -0,0 +1,59 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: Quick Start
|
||||
description: Set up BackupX, add a storage target, create your first backup task.
|
||||
---
|
||||
|
||||
# Quick Start
|
||||
|
||||
After [installation](./installation), get a first backup running in five minutes.
|
||||
|
||||
## 1. Open the console
|
||||
|
||||
Browse to `http://your-server:8340`. The first time, you'll be guided through creating an admin account.
|
||||
|
||||
## 2. Add a storage target
|
||||
|
||||
Navigate to **Storage Targets → Add**. Pick a type and fill the required fields:
|
||||
|
||||
| Type | Fields |
|
||||
|------|--------|
|
||||
| Alibaba OSS | Region + AccessKey ID/Secret + Bucket |
|
||||
| Tencent COS | Region + SecretId/SecretKey + Bucket (format `name-appid`) |
|
||||
| Qiniu Kodo | Region + AccessKey/SecretKey + Bucket |
|
||||
| S3-compatible | Endpoint + AccessKey + Bucket |
|
||||
| Google Drive | Client ID/Secret → click "Authorize" for OAuth flow |
|
||||
| WebDAV | URL + username/password |
|
||||
| FTP | Host + port + username/password |
|
||||
| Local disk | Target directory |
|
||||
| SFTP / Azure / Dropbox / OneDrive | Type-specific required fields; advanced options collapsed |
|
||||
|
||||
:::tip
|
||||
For mainland China cloud vendors you only fill Region and AccessKey — BackupX assembles the endpoint automatically. Rclone-style providers separate required fields from advanced ones, with advanced collapsed by default.
|
||||
:::
|
||||
|
||||
Click **Test Connection** to verify.
|
||||
|
||||
## 3. Create a backup task
|
||||
|
||||
Go to **Backup Tasks → New**. Three steps:
|
||||
|
||||
1. **Basic info** — name, type, cron expression (leave empty for manual-only)
|
||||
2. **Source** — paths for file backup (multi-source supported), or connection info for databases
|
||||
3. **Storage & policy** — pick target(s), compression, retention days, encryption on/off
|
||||
|
||||
Save, then click **Run Now** to trigger a test. Live logs stream on the **Backup Records** page.
|
||||
|
||||
:::note
|
||||
Deleting a task also removes remote backup files to prevent orphans, but records are kept for audit.
|
||||
:::
|
||||
|
||||
## 4. Configure notifications (optional)
|
||||
|
||||
**Notifications** page supports email, webhook, and Telegram. Configure per-channel rules for success/failure events.
|
||||
|
||||
## Next up
|
||||
|
||||
- Explore [backup types](/docs/features/backup-types) and [storage backends](/docs/features/storage-backends)
|
||||
- Running SAP HANA? See [SAP HANA Support](/docs/features/sap-hana)
|
||||
- Managing many servers? See [Multi-Node Cluster](/docs/features/multi-node)
|
||||
40
docs-site/docs/intro.md
Normal file
40
docs-site/docs/intro.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
id: intro
|
||||
slug: /intro
|
||||
sidebar_position: 1
|
||||
title: Introduction
|
||||
description: Overview of BackupX — a self-hosted server backup management platform.
|
||||
---
|
||||
|
||||
# BackupX
|
||||
|
||||
**BackupX** is a self-hosted server backup management platform. One static binary, one command, and every backup job for every server is under control.
|
||||
|
||||
- **Single binary + embedded SQLite** — no external database or orchestrator required
|
||||
- **Files, databases, SAP HANA** — in one place, with a visual scheduler
|
||||
- **70+ storage backends** — Alibaba OSS, Tencent COS, Qiniu, S3, Google Drive, WebDAV, FTP, plus SFTP / Azure Blob / Dropbox / OneDrive and dozens more via rclone
|
||||
- **Multi-node cluster** — Master-Agent mode manages backups across servers, agents run tasks locally and upload straight to storage
|
||||
- **Secure by default** — JWT auth, bcrypt, AES-256-GCM encrypted config, optional backup encryption, full audit log
|
||||
|
||||
## Architecture at a Glance
|
||||
|
||||
```
|
||||
[Web Console] ─── JWT ──→ [Master (backupx)]
|
||||
│
|
||||
│ HTTP long-poll (token auth)
|
||||
▼
|
||||
[Agent (backupx agent)]
|
||||
│
|
||||
▼
|
||||
[70+ Storage Backends]
|
||||
```
|
||||
|
||||
Tasks routed to the local Master run in-process; tasks assigned to remote nodes are dispatched through a command queue and executed by the Agent locally. Agents only ever initiate outbound HTTP — no reverse connectivity required.
|
||||
|
||||
## Where to Next
|
||||
|
||||
- **New to BackupX?** Read the [Quick Start](/docs/getting-started/quick-start) first.
|
||||
- **Deploying to production?** See the [Deployment Guide](/docs/deployment/docker).
|
||||
- **SAP HANA operator?** Both `hdbsql` Runner and native Backint are supported — see [SAP HANA](/docs/features/sap-hana).
|
||||
- **Managing multiple servers?** See [Multi-Node Cluster](/docs/features/multi-node).
|
||||
- **Integrating programmatically?** See the [API Reference](/docs/reference/api).
|
||||
135
docs-site/docs/reference/api.md
Normal file
135
docs-site/docs/reference/api.md
Normal file
@@ -0,0 +1,135 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: API Reference
|
||||
description: REST API endpoints — all under /api with JWT Bearer authentication.
|
||||
---
|
||||
|
||||
# API Reference
|
||||
|
||||
All endpoints are prefixed with `/api` and authenticated with a JWT Bearer token, obtained via `POST /api/auth/login`. Agent endpoints use `X-Agent-Token` instead.
|
||||
|
||||
## Authentication
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/auth/setup/status` | Check whether admin initialization is needed |
|
||||
| `POST` | `/api/auth/setup` | Initialize the first admin (only when no user exists) |
|
||||
| `POST` | `/api/auth/login` | Log in and receive a JWT |
|
||||
| `POST` | `/api/auth/logout` | Log out (invalidate current token) |
|
||||
| `GET` | `/api/auth/profile` | Current user profile |
|
||||
| `PUT` | `/api/auth/password` | Change password |
|
||||
|
||||
## Backup Tasks
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/backup/tasks` | List tasks |
|
||||
| `POST` | `/api/backup/tasks` | Create |
|
||||
| `GET` | `/api/backup/tasks/:id` | Detail |
|
||||
| `PUT` | `/api/backup/tasks/:id` | Update |
|
||||
| `DELETE` | `/api/backup/tasks/:id` | Delete |
|
||||
| `PUT` | `/api/backup/tasks/:id/toggle` | Enable / disable |
|
||||
| `POST` | `/api/backup/tasks/:id/run` | Trigger a manual run |
|
||||
|
||||
## Backup Records
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/backup/records` | List records with filters |
|
||||
| `GET` | `/api/backup/records/:id` | Record detail |
|
||||
| `GET` | `/api/backup/records/:id/logs/stream` | Live logs (SSE) |
|
||||
| `GET` | `/api/backup/records/:id/download` | Download the artifact |
|
||||
| `POST` | `/api/backup/records/:id/restore` | Restore to the original source |
|
||||
| `DELETE` | `/api/backup/records/:id` | Delete a record |
|
||||
| `POST` | `/api/backup/records/batch-delete` | Bulk delete |
|
||||
|
||||
## Storage Targets
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/storage-targets` | List |
|
||||
| `POST` | `/api/storage-targets` | Create |
|
||||
| `GET` | `/api/storage-targets/:id` | Detail |
|
||||
| `PUT` | `/api/storage-targets/:id` | Update |
|
||||
| `DELETE` | `/api/storage-targets/:id` | Delete |
|
||||
| `POST` | `/api/storage-targets/test` | Test connection with pending config |
|
||||
| `POST` | `/api/storage-targets/:id/test` | Re-test a saved target |
|
||||
| `PUT` | `/api/storage-targets/:id/star` | Toggle favourite |
|
||||
| `GET` | `/api/storage-targets/:id/usage` | Query remote usage (where supported) |
|
||||
| `GET` | `/api/storage-targets/rclone/backends` | List all available rclone backends |
|
||||
| `POST` | `/api/storage-targets/google-drive/auth-url` | Start Google Drive OAuth |
|
||||
| `POST` | `/api/storage-targets/google-drive/complete` | Complete OAuth flow |
|
||||
|
||||
## Nodes (Cluster)
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/nodes` | List nodes |
|
||||
| `POST` | `/api/nodes` | Create a node and return its token |
|
||||
| `GET` | `/api/nodes/:id` | Node detail |
|
||||
| `PUT` | `/api/nodes/:id` | Rename |
|
||||
| `DELETE` | `/api/nodes/:id` | Delete (rejected if tasks are still attached) |
|
||||
| `GET` | `/api/nodes/:id/fs/list` | Browse a directory (remote nodes use an async RPC via Agent) |
|
||||
|
||||
## Agent Protocol (X-Agent-Token)
|
||||
|
||||
Dedicated endpoints for the Agent CLI. Authenticated via the `X-Agent-Token` header instead of JWT.
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `POST` | `/api/agent/heartbeat` | Report liveness; returns the node ID |
|
||||
| `POST` | `/api/agent/commands/poll` | Claim one pending command |
|
||||
| `POST` | `/api/agent/commands/:id/result` | Report command result |
|
||||
| `GET` | `/api/agent/tasks/:id` | Fetch task spec with decrypted storage configs |
|
||||
| `POST` | `/api/agent/records/:id` | Append logs / update record status |
|
||||
|
||||
## Notifications
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/notifications` | List |
|
||||
| `POST` | `/api/notifications` | Create |
|
||||
| `GET` | `/api/notifications/:id` | Detail |
|
||||
| `PUT` | `/api/notifications/:id` | Update |
|
||||
| `DELETE` | `/api/notifications/:id` | Delete |
|
||||
| `POST` | `/api/notifications/test` | Test with pending config |
|
||||
| `POST` | `/api/notifications/:id/test` | Re-test a saved notifier |
|
||||
|
||||
## Dashboard
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/dashboard/stats` | Overview statistics |
|
||||
| `GET` | `/api/dashboard/timeline` | Recent activity timeline |
|
||||
|
||||
## Audit / System / Settings
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/audit-logs` | Audit log list |
|
||||
| `GET` | `/api/system/info` | System information |
|
||||
| `GET` | `/api/system/update-check` | Check for a newer release |
|
||||
| `GET` | `/api/settings` | System-level settings |
|
||||
| `PUT` | `/api/settings` | Update system settings |
|
||||
|
||||
## Response Envelope
|
||||
|
||||
All successful responses follow the shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "",
|
||||
"data": { /* actual payload */ }
|
||||
}
|
||||
```
|
||||
|
||||
Errors return an HTTP 4xx/5xx plus:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "BACKUP_TASK_NOT_FOUND",
|
||||
"message": "备份任务不存在",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
69
docs-site/docs/reference/cli.md
Normal file
69
docs-site/docs/reference/cli.md
Normal file
@@ -0,0 +1,69 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: CLI Reference
|
||||
description: backupx subcommands — server, agent, backint, reset-password.
|
||||
---
|
||||
|
||||
# CLI Reference
|
||||
|
||||
The `backupx` binary ships several subcommands. Running `backupx` with no subcommand starts the main server process.
|
||||
|
||||
## `backupx` (default: server)
|
||||
|
||||
```bash
|
||||
backupx --config /opt/backupx/config.yaml
|
||||
backupx --version
|
||||
```
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--config <path>` | Path to config YAML (default: `./config.yaml`) |
|
||||
| `--version` | Print version and exit |
|
||||
|
||||
## `backupx agent`
|
||||
|
||||
Run in Agent mode, connecting to a Master. See [Multi-Node Cluster](../features/multi-node).
|
||||
|
||||
```bash
|
||||
backupx agent --master http://master:8340 --token <token>
|
||||
```
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--master <url>` | Master URL |
|
||||
| `--token <token>` | Agent auth token |
|
||||
| `--config <path>` | YAML config (takes precedence over env) |
|
||||
| `--temp-dir <path>` | Local temp directory (default `/tmp/backupx-agent`) |
|
||||
| `--insecure-tls` | Skip TLS verification (testing only) |
|
||||
|
||||
Environment variables: `BACKUPX_AGENT_MASTER`, `BACKUPX_AGENT_TOKEN`, `BACKUPX_AGENT_HEARTBEAT`, `BACKUPX_AGENT_POLL`, `BACKUPX_AGENT_TEMP_DIR`, `BACKUPX_AGENT_INSECURE_TLS`.
|
||||
|
||||
## `backupx backint`
|
||||
|
||||
SAP HANA Backint protocol agent. See [SAP HANA Support](../features/sap-hana).
|
||||
|
||||
```bash
|
||||
backupx backint -f <function> -i <input> -o <output> -p <params>
|
||||
```
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `-f <fn>` | `backup` / `restore` / `inquire` / `delete` |
|
||||
| `-i <path>` | Input file |
|
||||
| `-o <path>` | Output file |
|
||||
| `-p <path>` | Parameter file |
|
||||
| `-u / -c / -l / -v` | Accepted and ignored for SAP compatibility |
|
||||
|
||||
## `backupx reset-password`
|
||||
|
||||
Reset an admin password directly in the SQLite database. No server restart needed.
|
||||
|
||||
```bash
|
||||
backupx reset-password --username admin --password 'newpass123' [--config /path/to/config.yaml]
|
||||
```
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--username` | Target username (default: `admin`) |
|
||||
| `--password` | New password (min 8 chars, required) |
|
||||
| `--config` | Config path (used to locate the database file) |
|
||||
155
docs-site/docusaurus.config.ts
Normal file
155
docs-site/docusaurus.config.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import {themes as prismThemes} from 'prism-react-renderer';
|
||||
import type {Config} from '@docusaurus/types';
|
||||
import type * as Preset from '@docusaurus/preset-classic';
|
||||
|
||||
// BackupX 官方站点 — 托管在 GitHub Pages
|
||||
// https://awuqing.github.io/BackupX/
|
||||
const config: Config = {
|
||||
title: 'BackupX',
|
||||
tagline: 'Self-hosted backup orchestration for servers, databases, storage targets and remote agents',
|
||||
favicon: 'img/favicon.ico',
|
||||
|
||||
future: {
|
||||
v4: true,
|
||||
},
|
||||
|
||||
url: 'https://awuqing.github.io',
|
||||
baseUrl: '/BackupX/',
|
||||
|
||||
organizationName: 'Awuqing',
|
||||
projectName: 'BackupX',
|
||||
deploymentBranch: 'gh-pages',
|
||||
trailingSlash: false,
|
||||
|
||||
onBrokenLinks: 'warn',
|
||||
markdown: {
|
||||
hooks: {
|
||||
onBrokenMarkdownLinks: 'warn',
|
||||
},
|
||||
},
|
||||
|
||||
i18n: {
|
||||
defaultLocale: 'en',
|
||||
locales: ['en', 'zh-Hans'],
|
||||
localeConfigs: {
|
||||
en: {label: 'English', direction: 'ltr', htmlLang: 'en-US'},
|
||||
'zh-Hans': {label: '简体中文', direction: 'ltr', htmlLang: 'zh-CN'},
|
||||
},
|
||||
},
|
||||
|
||||
presets: [
|
||||
[
|
||||
'classic',
|
||||
{
|
||||
docs: {
|
||||
sidebarPath: './sidebars.ts',
|
||||
editUrl: 'https://github.com/Awuqing/BackupX/edit/main/docs-site/',
|
||||
},
|
||||
blog: false,
|
||||
theme: {
|
||||
customCss: './src/css/custom.css',
|
||||
},
|
||||
} satisfies Preset.Options,
|
||||
],
|
||||
],
|
||||
|
||||
themeConfig: {
|
||||
image: 'img/social-card.png',
|
||||
colorMode: {
|
||||
respectPrefersColorScheme: true,
|
||||
},
|
||||
navbar: {
|
||||
title: 'BackupX',
|
||||
logo: {
|
||||
alt: 'BackupX Logo',
|
||||
src: 'img/logo.svg',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
type: 'docSidebar',
|
||||
sidebarId: 'docs',
|
||||
position: 'left',
|
||||
label: 'Docs',
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/Awuqing/BackupX/releases',
|
||||
label: 'Downloads',
|
||||
position: 'left',
|
||||
},
|
||||
{
|
||||
to: '/community',
|
||||
label: 'Community',
|
||||
position: 'left',
|
||||
},
|
||||
{
|
||||
to: '/sponsors',
|
||||
label: 'Sponsors',
|
||||
position: 'left',
|
||||
},
|
||||
{
|
||||
type: 'localeDropdown',
|
||||
position: 'right',
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/Awuqing/BackupX',
|
||||
label: 'GitHub',
|
||||
position: 'right',
|
||||
},
|
||||
],
|
||||
},
|
||||
footer: {
|
||||
style: 'dark',
|
||||
links: [
|
||||
{
|
||||
title: 'Docs',
|
||||
items: [
|
||||
{label: 'Introduction', to: '/docs/intro'},
|
||||
{label: 'Quick Start', to: '/docs/getting-started/quick-start'},
|
||||
{label: 'Installation', to: '/docs/getting-started/installation'},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Features',
|
||||
items: [
|
||||
{label: 'SAP HANA', to: '/docs/features/sap-hana'},
|
||||
{label: 'Multi-Node Cluster', to: '/docs/features/multi-node'},
|
||||
{label: 'API Reference', to: '/docs/reference/api'},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'More',
|
||||
items: [
|
||||
{label: 'GitHub', href: 'https://github.com/Awuqing/BackupX'},
|
||||
{label: 'Releases', href: 'https://github.com/Awuqing/BackupX/releases'},
|
||||
{label: 'Docker Hub', href: 'https://hub.docker.com/r/awuqing/backupx'},
|
||||
{label: 'Issues', href: 'https://github.com/Awuqing/BackupX/issues'},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Community',
|
||||
items: [
|
||||
{label: 'Contributors', href: 'https://github.com/Awuqing/BackupX/graphs/contributors'},
|
||||
{label: 'Pull Requests', href: 'https://github.com/Awuqing/BackupX/pulls'},
|
||||
{label: 'Sponsor', to: '/sponsors'},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Sponsors',
|
||||
items: [
|
||||
{label: 'Sponsor BackupX', href: 'https://github.com/sponsors/Awuqing'},
|
||||
{label: 'Partnership', href: 'https://github.com/Awuqing/BackupX/issues/new/choose'},
|
||||
{label: 'Sponsor tiers', to: '/sponsors'},
|
||||
],
|
||||
},
|
||||
],
|
||||
copyright: `Copyright © ${new Date().getFullYear()} BackupX · Apache License 2.0`,
|
||||
},
|
||||
prism: {
|
||||
theme: prismThemes.github,
|
||||
darkTheme: prismThemes.dracula,
|
||||
additionalLanguages: ['bash', 'yaml', 'ini', 'json', 'go', 'sql', 'nginx'],
|
||||
},
|
||||
} satisfies Preset.ThemeConfig,
|
||||
};
|
||||
|
||||
export default config;
|
||||
160
docs-site/i18n/zh-CN/code.json
Normal file
160
docs-site/i18n/zh-CN/code.json
Normal file
@@ -0,0 +1,160 @@
|
||||
{
|
||||
"home.badge": {
|
||||
"message": "开源备份控制平面 · v2.2.1",
|
||||
"description": "Version badge on the hero"
|
||||
},
|
||||
"home.title.part1": {
|
||||
"message": "面向自托管服务器的",
|
||||
"description": "Hero title, first line"
|
||||
},
|
||||
"home.title.part2": {
|
||||
"message": "备份编排平台。",
|
||||
"description": "Hero title accent second line"
|
||||
},
|
||||
"home.tagline": {
|
||||
"message": "在一个清爽控制台中管理文件、数据库、SAP HANA 和远程节点备份。控制平面自己掌握,存储后端灵活选择。",
|
||||
"description": "Tagline on the home page"
|
||||
},
|
||||
"home.pageTitle": {
|
||||
"message": "面向自托管服务器的备份编排",
|
||||
"description": "Page <title> element on the home page"
|
||||
},
|
||||
"home.getStarted": {
|
||||
"message": "快速开始",
|
||||
"description": "Primary CTA on the home page"
|
||||
},
|
||||
"home.metric.backends": {
|
||||
"message": "存储后端",
|
||||
"description": "Hero metric label: storage backends"
|
||||
},
|
||||
"home.metric.backupTypes": {
|
||||
"message": "远程执行",
|
||||
"description": "Hero metric label: backup types"
|
||||
},
|
||||
"home.metric.license": {
|
||||
"message": "开源协议",
|
||||
"description": "Hero metric label: license"
|
||||
},
|
||||
"home.visual.eyebrow": {"message": "BackupX 控制台"},
|
||||
"home.visual.title": {"message": "运维概览"},
|
||||
"home.visual.status": {"message": "健康"},
|
||||
"home.visual.success": {"message": "成功率"},
|
||||
"home.visual.nodes": {"message": "活跃节点"},
|
||||
"home.visual.targets": {"message": "存储目标"},
|
||||
"home.visual.row1.title": {"message": "PostgreSQL 夜间备份"},
|
||||
"home.visual.row1.desc": {"message": "加密归档已上传至 S3"},
|
||||
"home.visual.row2.title": {"message": "SAP HANA 快照"},
|
||||
"home.visual.row2.desc": {"message": "正在 agent-shanghai-02 上运行"},
|
||||
"home.visual.row3.title": {"message": "保留策略清理"},
|
||||
"home.visual.row3.desc": {"message": "下一次执行在 4 小时后"},
|
||||
"home.command.title": {"message": "使用 Docker 启动"},
|
||||
|
||||
"section.features.tag": {
|
||||
"message": "核心能力",
|
||||
"description": "FEATURES section tag"
|
||||
},
|
||||
"section.features.title": {
|
||||
"message": "该有的都有,多余的没有",
|
||||
"description": "Features section title"
|
||||
},
|
||||
"section.features.subtitle": {
|
||||
"message": "备份 Runner、存储 Provider、调度、集群 — 每一块都经过打磨。",
|
||||
"description": "Features section subtitle"
|
||||
},
|
||||
|
||||
"feat.types.title": {"message": "多种备份类型"},
|
||||
"feat.types.desc": {"message": "文件与目录(支持多源路径),以及 MySQL、PostgreSQL、SQLite、SAP HANA 统一管理。"},
|
||||
"feat.storage.title": {"message": "70+ 存储后端"},
|
||||
"feat.storage.desc": {"message": "内置阿里云 OSS、腾讯云 COS、七牛、S3、Google Drive、WebDAV、FTP,以及 SFTP、Azure Blob、Dropbox 等 rclone 后端。"},
|
||||
"feat.scheduling.title": {"message": "调度与保留策略"},
|
||||
"feat.scheduling.desc": {"message": "基于 Cron 的可视化调度编辑器,支持按天数/份数自动保留和空目录清理。"},
|
||||
"feat.cluster.title": {"message": "多节点集群"},
|
||||
"feat.cluster.desc": {"message": "Master-Agent 基于 HTTP 长轮询。Agent 在本地执行任务并直接上传到存储 — 无需反向连通性。"},
|
||||
"feat.security.title": {"message": "默认安全"},
|
||||
"feat.security.desc": {"message": "JWT 认证、bcrypt、AES-256-GCM 加密配置、可选备份加密、完整审计日志。"},
|
||||
"feat.deploy.title": {"message": "部署轻量"},
|
||||
"feat.deploy.desc": {"message": "单个静态二进制 + 内嵌 SQLite。Docker 一键启动或裸机 — 零外部依赖。"},
|
||||
"feat.learnMore": {"message": "了解更多"},
|
||||
|
||||
"showcase.tag": {"message": "产品界面"},
|
||||
"showcase.title": {"message": "精心打磨的控制台,而非 DIY 脚本"},
|
||||
"showcase.subtitle": {"message": "每个页面都为运维而生 — 可观测优先,可配置次之。"},
|
||||
"showcase.tab.dashboard": {"message": "仪表盘"},
|
||||
"showcase.tab.tasks": {"message": "备份任务"},
|
||||
"showcase.tab.storage": {"message": "存储目标"},
|
||||
"showcase.tab.nodes": {"message": "多节点"},
|
||||
"showcase.dashboard.title": {"message": "一眼掌握全局"},
|
||||
"showcase.dashboard.desc": {"message": "备份成功率、存储使用量、最近执行记录、即将触发的计划 — 一页实时数据。"},
|
||||
"showcase.tasks.title": {"message": "可视化任务编辑器"},
|
||||
"showcase.tasks.desc": {"message": "文件、MySQL、PostgreSQL、SQLite、SAP HANA — 三步完成。Cron 编辑器、多目标分发、保留策略、压缩、加密 — 点击即用。"},
|
||||
"showcase.storage.title": {"message": "70+ 后端,统一体验"},
|
||||
"showcase.storage.desc": {"message": "阿里云 OSS、腾讯云 COS、S3、Google Drive、WebDAV — 加上每一种 rclone 后端。测试连接、收藏、查看实时容量。"},
|
||||
"showcase.nodes.title": {"message": "几分钟搭起 Master-Agent"},
|
||||
"showcase.nodes.desc": {"message": "创建节点、复制令牌、在任意远程主机启动 Agent。路由到节点的任务在本地执行并直接上传到存储 — 无需反向连通性。"},
|
||||
"showcase.cta": {"message": "开始阅读文档"},
|
||||
|
||||
"community.tag": {"message": "社区"},
|
||||
"community.pageTitle": {"message": "社区、赞助商与贡献者"},
|
||||
"community.pageDescription": {"message": "赞助 BackupX,了解贡献者,并找到务实的参与方式。"},
|
||||
"community.title": {"message": "开放协作,面向长期运维"},
|
||||
"community.subtitle": {"message": "备份软件的信任来自透明发布、真实部署反馈,以及足够务实的贡献路径。"},
|
||||
"community.sponsor.kicker": {"message": "赞助商"},
|
||||
"community.sponsor.wallTitle": {"message": "赞助商"},
|
||||
"community.sponsor.title": {"message": "支持你依赖的备份基础设施"},
|
||||
"community.sponsor.cta": {"message": "赞助 BackupX"},
|
||||
"community.sponsor.openSlot": {"message": "赞助席位开放"},
|
||||
"community.sponsor.logo.project": {"message": "项目赞助"},
|
||||
"community.sponsor.logo.cloud": {"message": "云服务伙伴"},
|
||||
"community.sponsor.logo.object": {"message": "对象存储"},
|
||||
"community.sponsor.logo.cdn": {"message": "CDN 伙伴"},
|
||||
"community.sponsor.logo.database": {"message": "数据库伙伴"},
|
||||
"community.sponsor.logo.security": {"message": "安全审计"},
|
||||
"community.sponsor.logo.agent": {"message": "远程节点实验室"},
|
||||
"community.sponsor.logo.docs": {"message": "文档赞助"},
|
||||
"community.sponsor.logo.release": {"message": "发布赞助"},
|
||||
"community.sponsor.logo.s3": {"message": "S3 兼容"},
|
||||
"community.sponsor.logo.webdav": {"message": "WebDAV 伙伴"},
|
||||
"community.sponsor.logo.sftp": {"message": "SFTP 伙伴"},
|
||||
"community.sponsor.logo.docker": {"message": "容器伙伴"},
|
||||
"community.sponsor.logo.mirror": {"message": "镜像伙伴"},
|
||||
"community.sponsor.logo.restore": {"message": "恢复演练"},
|
||||
"community.sponsor.logo.qa": {"message": "测试实验室"},
|
||||
"community.sponsor.logo.oss": {"message": "开源支持"},
|
||||
"community.sponsor.logo.open": {"message": "赞助席位开放"},
|
||||
"community.sponsor.infrastructure.label": {"message": "基础设施"},
|
||||
"community.sponsor.infrastructure.title": {"message": "云与存储生态伙伴"},
|
||||
"community.sponsor.infrastructure.desc": {"message": "帮助 BackupX 覆盖对象存储、WebDAV、SFTP 以及区域云平台的真实验证。"},
|
||||
"community.sponsor.security.label": {"message": "安全"},
|
||||
"community.sponsor.security.title": {"message": "审计与可靠性支持者"},
|
||||
"community.sponsor.security.desc": {"message": "支持加密、恢复演练、发布签名和运维检查等强化工作。"},
|
||||
"community.sponsor.community.label": {"message": "社区"},
|
||||
"community.sponsor.community.title": {"message": "开源支持者"},
|
||||
"community.sponsor.community.desc": {"message": "支持文档、示例、平台测试和贡献者引导。"},
|
||||
"community.sponsor.tier.backer.name": {"message": "Backer"},
|
||||
"community.sponsor.tier.backer.amount": {"message": "适合个人与小团队"},
|
||||
"community.sponsor.tier.backer.desc": {"message": "支持文档、Issue 分流、兼容性测试和小型体验改进。"},
|
||||
"community.sponsor.tier.partner.name": {"message": "Partner"},
|
||||
"community.sponsor.tier.partner.amount": {"message": "适合存储与基础设施厂商"},
|
||||
"community.sponsor.tier.partner.desc": {"message": "支持 Provider 验证、部署示例、基准说明和集成指南。"},
|
||||
"community.sponsor.tier.enterprise.name": {"message": "Enterprise"},
|
||||
"community.sponsor.tier.enterprise.amount": {"message": "适合生产环境使用方"},
|
||||
"community.sponsor.tier.enterprise.desc": {"message": "赞助恢复演练、发布加固、审计和长期维护等可靠性工作。"},
|
||||
"community.contributor.kicker": {"message": "贡献者"},
|
||||
"community.contributor.all": {"message": "查看全部"},
|
||||
"community.contributor.source": {"message": "浏览器端通过 GitHub contributors API 获取。"},
|
||||
"community.contributor.botRole": {"message": "自动化贡献者"},
|
||||
"community.contributor.githubRole": {"message": "GitHub 贡献者"},
|
||||
"community.contributor.contributions": {"message": "{count} 次贡献"},
|
||||
"community.path.kicker": {"message": "贡献路径"},
|
||||
"community.path.issues.title": {"message": "反馈生产问题"},
|
||||
"community.path.issues.desc": {"message": "提交日志、部署拓扑和恢复预期。"},
|
||||
"community.path.docs.title": {"message": "完善文档与示例"},
|
||||
"community.path.docs.desc": {"message": "贡献存储、Agent 和数据库部署指南。"},
|
||||
"community.path.code.title": {"message": "提交聚焦的 PR"},
|
||||
"community.path.code.desc": {"message": "保持改动小而可测,并贴合现有架构。"},
|
||||
"sponsors.pageTitle": {"message": "赞助商"},
|
||||
"sponsors.pageDescription": {"message": "赞助 BackupX 的可靠性、文档、存储兼容性和长期维护。"},
|
||||
"sponsors.tag": {"message": "赞助商"},
|
||||
"sponsors.title": {"message": "赞助 BackupX 生态"},
|
||||
"sponsors.subtitle": {"message": "赞助帮助 BackupX 更贴近真实运维:经过验证的存储 Provider、可靠发布、恢复信心和更完善的文档。"}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"version.label": {"message": "Next"},
|
||||
"sidebar.docs.category.Getting Started": {"message": "快速开始"},
|
||||
"sidebar.docs.category.Deployment": {"message": "部署"},
|
||||
"sidebar.docs.category.Features": {"message": "功能特性"},
|
||||
"sidebar.docs.category.Reference": {"message": "参考"},
|
||||
"sidebar.docs.category.Development": {"message": "开发"}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: 裸机部署
|
||||
description: 从预编译包或源码部署 BackupX(systemd + Nginx)。
|
||||
---
|
||||
|
||||
# 裸机部署
|
||||
|
||||
## 使用预编译包
|
||||
|
||||
```bash
|
||||
# 下载对应平台的压缩包
|
||||
curl -LO https://github.com/Awuqing/BackupX/releases/latest/download/backupx-v1.6.0-linux-amd64.tar.gz
|
||||
|
||||
# 解压并安装
|
||||
tar xzf backupx-v*-linux-amd64.tar.gz && cd backupx-*
|
||||
sudo ./install.sh
|
||||
```
|
||||
|
||||
安装脚本自动完成以下步骤:
|
||||
|
||||
1. 创建系统用户 `backupx`
|
||||
2. 复制二进制到 `/opt/backupx/`
|
||||
3. 生成默认 `config.yaml`(含安全的 JWT/加密密钥)
|
||||
4. 安装并启用 `backupx.service` systemd 单元
|
||||
5. (可选)生成 Nginx 站点配置 — 参见 [Nginx 反向代理](./nginx)
|
||||
|
||||
## 从源码构建
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
|
||||
make build
|
||||
sudo ./deploy/install.sh
|
||||
```
|
||||
|
||||
`make build` 会产出:
|
||||
|
||||
- `server/bin/backupx`(Go 后端,无 CGO)
|
||||
- `web/dist/`(React 前端,执行 `npm run build`)
|
||||
|
||||
## systemd
|
||||
|
||||
安装后的 service 文件:
|
||||
|
||||
```ini title="/etc/systemd/system/backupx.service"
|
||||
[Unit]
|
||||
Description=BackupX backup management service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=backupx
|
||||
WorkingDirectory=/opt/backupx
|
||||
ExecStart=/opt/backupx/backupx --config /opt/backupx/config.yaml
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
LimitNOFILE=65536
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
常用命令:
|
||||
|
||||
```bash
|
||||
sudo systemctl status backupx
|
||||
sudo journalctl -u backupx -f # 实时日志
|
||||
sudo systemctl restart backupx
|
||||
```
|
||||
|
||||
## 密码重置
|
||||
|
||||
忘记管理员密码时:
|
||||
|
||||
```bash
|
||||
/opt/backupx/backupx reset-password \
|
||||
--username admin \
|
||||
--password 'newpass123' \
|
||||
--config /opt/backupx/config.yaml
|
||||
```
|
||||
|
||||
Docker 等效命令:
|
||||
|
||||
```bash
|
||||
docker exec -it backupx /app/bin/backupx reset-password --username admin --password 'newpass123'
|
||||
```
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
sidebar_position: 4
|
||||
title: 配置参考
|
||||
description: server.yaml 所有配置项及对应的环境变量。
|
||||
---
|
||||
|
||||
# 配置参考
|
||||
|
||||
BackupX 默认从工作目录加载 `./config.yaml`,可通过 `--config` 指定其他路径。所有配置项都可通过 `BACKUPX_` 前缀环境变量覆盖。
|
||||
|
||||
## 完整配置
|
||||
|
||||
```yaml title="config.yaml"
|
||||
server:
|
||||
host: "0.0.0.0" # BACKUPX_SERVER_HOST
|
||||
port: 8340 # BACKUPX_SERVER_PORT
|
||||
mode: "release" # release | debug
|
||||
|
||||
database:
|
||||
path: "./data/backupx.db" # BACKUPX_DATABASE_PATH — 内嵌 SQLite
|
||||
|
||||
security:
|
||||
jwt_secret: "" # BACKUPX_SECURITY_JWT_SECRET — 留空自动生成
|
||||
jwt_expires_in: "24h"
|
||||
encryption_key: "" # 用于加密存储配置的 AES-256-GCM 密钥
|
||||
|
||||
backup:
|
||||
temp_dir: "/tmp/backupx" # BACKUPX_BACKUP_TEMP_DIR
|
||||
max_concurrent: 2 # BACKUPX_BACKUP_MAX_CONCURRENT
|
||||
retries: 3 # 单次上传的 rclone 底层重试次数
|
||||
bandwidth_limit: "" # 例如 "10M" 表示限速 10 MB/s
|
||||
|
||||
log:
|
||||
level: "info" # debug | info | warn | error
|
||||
file: "./data/backupx.log"
|
||||
```
|
||||
|
||||
## 密钥生成
|
||||
|
||||
如果首次启动时 `jwt_secret` 或 `encryption_key` 为空,BackupX 会自动生成随机值并写入 `system_configs` 表。请妥善备份 `data/backupx.db`,一旦丢失将导致所有已加密的存储配置失效。
|
||||
|
||||
## 环境变量
|
||||
|
||||
文件和环境变量同时存在时,环境变量优先。配置路径转换规则:小写字母下划线 → 大写字母下划线:
|
||||
|
||||
| 配置项 | 环境变量 |
|
||||
|--------|----------|
|
||||
| `server.port` | `BACKUPX_SERVER_PORT` |
|
||||
| `log.level` | `BACKUPX_LOG_LEVEL` |
|
||||
| `backup.max_concurrent` | `BACKUPX_BACKUP_MAX_CONCURRENT` |
|
||||
| `backup.temp_dir` | `BACKUPX_BACKUP_TEMP_DIR` |
|
||||
| `backup.bandwidth_limit` | `BACKUPX_BACKUP_BANDWIDTH_LIMIT` |
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: Docker 部署
|
||||
description: 生产级 Docker 部署方案,含 compose 配置、宿主目录挂载、环境变量覆盖。
|
||||
---
|
||||
|
||||
# Docker 部署
|
||||
|
||||
BackupX 官方 Docker 镜像 [`awuqing/backupx`](https://hub.docker.com/r/awuqing/backupx) 支持多架构(linux/amd64 + linux/arm64)。
|
||||
|
||||
## Compose 文件
|
||||
|
||||
```yaml title="docker-compose.yml"
|
||||
services:
|
||||
backupx:
|
||||
image: awuqing/backupx:latest
|
||||
container_name: backupx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8340:8340"
|
||||
volumes:
|
||||
- backupx-data:/app/data
|
||||
# 挂载需要备份的宿主机目录:
|
||||
- /var/www:/mnt/www:ro
|
||||
- /etc/nginx:/mnt/nginx-conf:ro
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
- BACKUPX_LOG_LEVEL=info
|
||||
- BACKUPX_BACKUP_MAX_CONCURRENT=2
|
||||
|
||||
volumes:
|
||||
backupx-data:
|
||||
```
|
||||
|
||||
启动:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## 备份宿主机目录
|
||||
|
||||
想备份宿主机上的文件,需要将对应路径挂载进容器。在 Web UI 创建文件类型任务时,把源路径指向挂载后的容器内路径(如 `/mnt/www`)。
|
||||
|
||||
## 环境变量
|
||||
|
||||
所有配置项都可以通过 `BACKUPX_` 前缀环境变量覆盖:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
- BACKUPX_SERVER_PORT=8340
|
||||
- BACKUPX_LOG_LEVEL=debug
|
||||
- BACKUPX_BACKUP_MAX_CONCURRENT=4
|
||||
- BACKUPX_BACKUP_TEMP_DIR=/tmp/backupx
|
||||
```
|
||||
|
||||
完整列表见 [配置参考](./configuration)。
|
||||
|
||||
## 升级
|
||||
|
||||
在 UI **系统设置 → 检查更新** 页面查看是否有新版,然后在宿主机上:
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
无需手工迁移:BackupX 启动时自动迁移 SQLite schema。
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
title: Nginx 反向代理
|
||||
description: 通过 Nginx 发布 BackupX(HTTPS + SSE 友好的缓冲配置)。
|
||||
---
|
||||
|
||||
# Nginx 反向代理
|
||||
|
||||
生产环境可用的 Nginx 站点模板:
|
||||
|
||||
```nginx title="/etc/nginx/sites-available/backupx"
|
||||
server {
|
||||
listen 80;
|
||||
server_name backup.example.com;
|
||||
|
||||
# 静态 UI(由 /opt/backupx/web 提供)
|
||||
location / {
|
||||
root /opt/backupx/web;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API 反向代理
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8340;
|
||||
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;
|
||||
|
||||
# 大文件上传(用于恢复流程)
|
||||
client_max_body_size 0;
|
||||
|
||||
# 实时日志使用 SSE,必须关闭缓冲
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## certbot 配置 HTTPS
|
||||
|
||||
```bash
|
||||
sudo apt install certbot python3-certbot-nginx
|
||||
sudo certbot --nginx -d backup.example.com
|
||||
```
|
||||
|
||||
certbot 会自动改写配置监听 443 并设置续期。
|
||||
|
||||
:::caution Agent 需要稳定的 URL
|
||||
如果 Master 部署在 HTTPS 后面,远程 Agent 的 `--master` 必须使用公网 HTTPS 地址。自签名证书需加 `--insecure-tls`(仅供测试)。
|
||||
:::
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: 贡献指南
|
||||
description: 如何反馈问题、提出改进、提交 PR。
|
||||
---
|
||||
|
||||
# 贡献指南
|
||||
|
||||
BackupX 使用 Apache License 2.0 开源,欢迎提交 Issue 与 Pull Request。
|
||||
|
||||
## 报告 Bug
|
||||
|
||||
在 [github.com/Awuqing/BackupX/issues](https://github.com/Awuqing/BackupX/issues) 提交 Issue,请附上:
|
||||
|
||||
- BackupX 版本(`backupx --version`)
|
||||
- 部署方式(Docker / 裸机 / 源码)
|
||||
- 相关的备份任务类型和存储后端
|
||||
- 复现步骤
|
||||
- 问题发生时段的 stdout / `backupx.log` 片段
|
||||
|
||||
## 提议改动
|
||||
|
||||
对于重要功能或重构,建议先开 Issue 对齐方案,避免 PR 大改动后被 Review 回退。
|
||||
|
||||
## 提交 PR
|
||||
|
||||
1. Fork 仓库,创建主题分支(如 `fix/windows-path-escape`)
|
||||
2. 执行 `make test` 确认本地全通过
|
||||
3. 保持每个 PR 只做一件事
|
||||
4. Commit message 使用中文,格式 `类型: 简要描述`:
|
||||
- `功能: 新增审计日志模块`
|
||||
- `修复: 目录浏览器无法进入子目录`
|
||||
- `重构: 简化存储目标解密逻辑`
|
||||
- 类型:`功能` / `修复` / `重构` / `文档` / `构建` / `测试`
|
||||
5. PR 标题和正文同样使用中文,描述"为什么"和"怎么做",而非仅仅"做了什么"
|
||||
|
||||
## 代码规范
|
||||
|
||||
- **Go** — 所有错误必须处理(禁止 `_ = err`),日志使用现有 `zap`,禁止生产路径中出现 `fmt.Println`
|
||||
- **TypeScript** — 严格模式,禁止隐式 any,遵循现有 ESLint/Prettier 配置
|
||||
- **Commit 粒度** — 每个 commit 一件事,不要把顺手的小修改和功能代码混在一起
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: 开发环境
|
||||
description: 搭建 BackupX 本地开发环境 — 后端、前端、测试。
|
||||
---
|
||||
|
||||
# 开发环境
|
||||
|
||||
**环境要求:** Go ≥ 1.25,Node.js ≥ 20,npm。
|
||||
|
||||
## 克隆与依赖
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
|
||||
cd web && npm install && cd ..
|
||||
```
|
||||
|
||||
## 开发服务
|
||||
|
||||
开两个终端分别跑后端和前端:
|
||||
|
||||
```bash
|
||||
# 终端 1:后端,监听 :8340
|
||||
make dev-server
|
||||
|
||||
# 终端 2:Vite HMR,监听 :5173
|
||||
make dev-web
|
||||
```
|
||||
|
||||
Vite 配置了 `/api` 代理到 `http://127.0.0.1:8340`,浏览器直接访问 `http://localhost:5173`。
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
make test # 运行 Go + Web 全部测试
|
||||
make test-server # 仅 Go
|
||||
make test-web # 仅 Vitest
|
||||
```
|
||||
|
||||
## 生产构建
|
||||
|
||||
```bash
|
||||
make build # server/bin/backupx + web/dist
|
||||
make docker # Docker 镜像
|
||||
make docker-cn # 国内镜像加速构建
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 组件 | 技术 |
|
||||
|------|------|
|
||||
| **后端** | Go · Gin · GORM · SQLite · robfig/cron · rclone |
|
||||
| **前端** | React 18 · TypeScript · ArcoDesign · Vite · Zustand · ECharts |
|
||||
| **存储** | rclone(70+ 后端)· AWS SDK v2 · Google Drive API v3 |
|
||||
| **安全** | JWT · bcrypt · AES-256-GCM |
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
BackupX/
|
||||
├── server/ # Go 后端
|
||||
│ ├── cmd/backupx/ # 入口 + 子命令(agent / backint / reset-password)
|
||||
│ ├── internal/
|
||||
│ │ ├── agent/ # Agent CLI 逻辑
|
||||
│ │ ├── app/ # 装配(repo → service → handler)
|
||||
│ │ ├── backup/ # 备份 runner(file / mysql / postgres / sqlite / saphana)
|
||||
│ │ ├── backint/ # SAP HANA Backint 协议
|
||||
│ │ ├── http/ # HTTP handler + router
|
||||
│ │ ├── model/ # GORM 模型
|
||||
│ │ ├── repository/ # 数据访问
|
||||
│ │ ├── service/ # 业务逻辑
|
||||
│ │ └── storage/ # 存储 provider(rclone + 直接 SDK)
|
||||
│ └── pkg/ # 通用工具
|
||||
├── web/ # React 前端(Vite)
|
||||
│ └── src/
|
||||
│ ├── components/
|
||||
│ ├── pages/
|
||||
│ ├── services/
|
||||
│ └── types/
|
||||
├── docs-site/ # 文档站(Docusaurus)
|
||||
├── deploy/ # install.sh / systemd unit / nginx config
|
||||
└── Makefile
|
||||
```
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: 备份类型
|
||||
description: 文件、MySQL、PostgreSQL、SQLite 和 SAP HANA — 各自的能力与配置说明。
|
||||
---
|
||||
|
||||
# 备份类型
|
||||
|
||||
BackupX 支持五种内置备份类型,类型决定了用哪个 runner 执行。
|
||||
|
||||
## 文件 / 目录
|
||||
|
||||
打包(可选 gzip)一个或多个文件系统路径。
|
||||
|
||||
- **源路径** 支持多个(UI 中每行一个)
|
||||
- **排除模式** 支持 gitignore 风格的通配符
|
||||
- 可选跟随符号链接、保留权限
|
||||
- 输出单个 `.tar` 或 `.tar.gz`
|
||||
|
||||
## MySQL
|
||||
|
||||
底层使用 `mysqldump`,需要执行任务的主机(Master 或 Agent)的 `$PATH` 中有 `mysqldump`。
|
||||
|
||||
- **主机 / 端口 / 用户 / 密码 / 数据库** — 支持多库(英文逗号分隔)
|
||||
- 输出:`.sql` 或 `.sql.gz`
|
||||
- 默认参数:`--single-transaction --routines --triggers --events`
|
||||
|
||||
## PostgreSQL
|
||||
|
||||
底层使用 `pg_dump`,连接字段与 MySQL 一致加数据库名。
|
||||
|
||||
## SQLite
|
||||
|
||||
直接复制数据库文件(使用一致性快照),无需外部工具。
|
||||
|
||||
## SAP HANA
|
||||
|
||||
支持两种模式 — 详见 [SAP HANA](./sap-hana) 专题页。
|
||||
|
||||
## 删除行为
|
||||
|
||||
删除备份任务时,BackupX 会从所有存储目标上移除备份产物,但保留备份记录以供审计。删除任务同时拆除其 Cron 定时调度。
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
sidebar_position: 4
|
||||
title: 多节点集群
|
||||
description: Master-Agent 模式 — 通过 HTTP 长轮询把备份路由到远程服务器。
|
||||
---
|
||||
|
||||
# 多节点集群
|
||||
|
||||
BackupX 支持 Master-Agent 模式:备份任务可以指定在哪个节点执行,Agent 在本地完成备份并直接上传到存储。所有连接都由 Agent 主动发起,所以远程服务器只需要出站 HTTP 访问权限。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
[Web 控制台] ─── JWT ──→ [Master (backupx)]
|
||||
↑ ↓
|
||||
│ │ HTTP 长轮询(Token 认证)
|
||||
│ ↓
|
||||
[Agent (backupx agent)] ← 运行在远程服务器
|
||||
↓
|
||||
[70+ 存储后端]
|
||||
```
|
||||
|
||||
- **协议** — HTTP 长轮询,Agent 主动发起所有连接
|
||||
- **心跳** — Agent 每 15s 上报一次;Master 超过 45s 未收到心跳即判为离线
|
||||
- **下发** — Master 把 `run_task` 命令写入队列,Agent 轮询拉取
|
||||
- **执行** — Agent 复用 BackupRunner(file / mysql / postgresql / sqlite / saphana)并直接上传到存储
|
||||
- **安全** — 每个节点独立 Token;Agent 不持有 Master 的 JWT 密钥或 AES-256 加密密钥
|
||||
|
||||
## 一键部署步骤
|
||||
|
||||
### 1. 打开安装向导
|
||||
|
||||
Web 控制台 → **节点管理** → **添加节点**,打开三步向导:
|
||||
|
||||
- **第一步 · 节点信息**:填写节点名称;或切换"批量创建"粘贴多行名称(每行一个,最多 50 个)
|
||||
- **第二步 · 部署参数**:选择安装模式(`systemd` 推荐、`Docker`、`前台运行` 调试用)、架构(默认自动检测)、Agent 版本(默认跟随 Master 版本)、有效期(5 分钟 / 15 分钟 / 1 小时 / 24 小时)、下载源(`GitHub` 直连或 `ghproxy` 镜像,国内服务器建议后者)
|
||||
- **第三步 · 安装命令**:一条一键安装命令 + 实时倒计时。点击复制,粘贴到目标机以 root 权限执行。默认命令会嵌入已渲染的安装脚本,目标机无需再通过反向代理访问 `/api/install/:token`;公开安装 URL 仍作为备用路径保留。
|
||||
|
||||
### 2. 目标机一条命令完成
|
||||
|
||||
请直接使用 Web 控制台生成的命令。该命令会把安装脚本写入临时文件,校验 `BACKUPX_AGENT_INSTALL_V1` 魔数,再以 root 权限执行。
|
||||
|
||||
脚本会自动:
|
||||
|
||||
1. 检测操作系统与架构(`uname -m`)
|
||||
2. 从 GitHub Release(或 ghproxy 镜像)下载匹配的 `backupx` 二进制
|
||||
3. 安装到 `/opt/backupx-agent`,创建系统用户 `backupx`
|
||||
4. 写入 `/etc/systemd/system/backupx-agent.service`(token 已烧入环境变量)
|
||||
5. 执行 `systemctl enable --now backupx-agent`
|
||||
6. 轮询 `/api/v1/agent/self`,直到 Master 确认 `status: online`(最多 30 秒)
|
||||
|
||||
如果使用 URL 备用命令时 `curl` 输出 HTML,或 shell 报 `Syntax error: newline unexpected`,说明安装 URL 被 Web 控制台接管而不是转发到后端。需要确保 `/api/install/` 或 `/install/` 至少一个路径能转发到 BackupX 后端,或改用控制台生成的嵌入式命令。
|
||||
|
||||
脚本是幂等的:升级或重装只需重新生成一条安装命令再跑一次。一次性安装链接在 TTL 到期或被首次消费后立即作废。
|
||||
|
||||
### 3. 随时轮换 Agent Token
|
||||
|
||||
节点操作列(︙)→ **重新生成 Token**。新 Token 一次性显示,旧 Token 24 小时内仍有效,便于滚动替换无需停机。24 小时后旧 Token 被拒绝。
|
||||
|
||||
### 4. 批量部署
|
||||
|
||||
第一步选"批量创建"粘贴节点名(每行一个,最多 50 个)。第三步显示每个节点对应的命令表格,底部「导出 .sh」可打包为单个 shell 文件,方便 SSH 循环或 Ansible 任务。
|
||||
|
||||
### 5. 把任务路由到该节点
|
||||
|
||||
在 **备份任务** 页面新建任务时选择对应节点。任务触发时:
|
||||
|
||||
- 本机 / 未指定(`nodeId=0`):Master 进程内直接执行
|
||||
- 远程节点:Master 写入命令队列 → Agent 拉取 → Agent 本地执行 → 上传 → 回报
|
||||
|
||||
## 已知限制
|
||||
|
||||
- **Agent 不支持加密备份**:Agent 不持有 Master 的 AES-256 密钥。`encrypt: true` 的任务路由到 Agent 时会直接上报失败
|
||||
- **目录浏览超时**:远程目录浏览通过命令队列做同步 RPC,默认 15s 超时
|
||||
- **派发命令超时**:Agent 领取但未完成的命令超过 10 分钟会被置 `timeout`
|
||||
|
||||
## CLI 参考
|
||||
|
||||
```
|
||||
backupx agent --help
|
||||
-master string Master URL
|
||||
-token string Agent 认证令牌
|
||||
-config string YAML 配置文件路径(优先级高于环境变量)
|
||||
-temp-dir string 本地临时目录(默认 /tmp/backupx-agent)
|
||||
-insecure-tls 跳过 TLS 证书校验(仅测试用)
|
||||
```
|
||||
|
||||
## systemd 单元
|
||||
|
||||
```ini title="/etc/systemd/system/backupx-agent.service"
|
||||
[Unit]
|
||||
Description=BackupX Agent
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=backupx
|
||||
Environment="BACKUPX_AGENT_MASTER=https://master.example.com"
|
||||
Environment="BACKUPX_AGENT_TOKEN=your-token"
|
||||
ExecStart=/opt/backupx/backupx agent
|
||||
Restart=on-failure
|
||||
RestartSec=10s
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
启用并启动:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable --now backupx-agent
|
||||
sudo journalctl -u backupx-agent -f
|
||||
```
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
sidebar_position: 5
|
||||
title: 通知
|
||||
description: 备份成功或失败时通过邮件、Webhook、Telegram 推送通知。
|
||||
---
|
||||
|
||||
# 通知
|
||||
|
||||
BackupX 支持三种通知渠道,可为每个渠道单独配置成功/失败事件是否推送。
|
||||
|
||||
## 邮件(SMTP)
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| SMTP 主机 / 端口 | 如 `smtp.gmail.com:587` |
|
||||
| 用户名 / 密码 | 建议使用专用应用密码 |
|
||||
| 发件人地址 | 邮件 `From:` 头 |
|
||||
| 收件人列表 | 英文逗号分隔 |
|
||||
| 使用 TLS / StartTLS | 按 SMTP 提供方要求选择 |
|
||||
|
||||
## Webhook
|
||||
|
||||
向任意 URL 发送 JSON POST,请求体结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "backup_result",
|
||||
"task": {"id": 1, "name": "web-files", "type": "file"},
|
||||
"record": {"id": 42, "status": "success", "fileSize": 1048576, "durationSeconds": 12},
|
||||
"error": ""
|
||||
}
|
||||
```
|
||||
|
||||
适合自定义场景:Slack incoming webhook、PagerDuty、自建 API 等。
|
||||
|
||||
## Telegram
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| Bot Token | 在 [@BotFather](https://t.me/BotFather) 创建 |
|
||||
| Chat ID | 数字型,可通过 `/start` 后调 Bot 的 `getUpdates` 获取 |
|
||||
|
||||
## 事件规则
|
||||
|
||||
每个通知配置可以指定触发范围:
|
||||
|
||||
- **仅成功** — 正常运行时静默
|
||||
- **仅失败** — 适合高噪敏感通道
|
||||
- **全部** — 初始化配置时用于验证链路
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
title: SAP HANA 支持
|
||||
description: 两种 SAP HANA 备份模式 — 控制台托管的 hdbsql Runner 和原生 Backint 协议代理。
|
||||
---
|
||||
|
||||
# SAP HANA 支持
|
||||
|
||||
BackupX 提供两种 SAP HANA 备份模式,按实际运维流程选择。
|
||||
|
||||
## 模式一:hdbsql Runner(控制台托管)
|
||||
|
||||
通过 Web 控制台创建 SAP HANA 备份任务,后端调用 `hdbsql` 执行备份。适合希望 BackupX 来管理调度的场景。
|
||||
|
||||
**源配置支持:**
|
||||
|
||||
| 字段 | 可选值 | 说明 |
|
||||
|------|--------|------|
|
||||
| 备份类型 | `data` / `log` | 数据备份或日志备份 |
|
||||
| 备份级别 | `full` / `incremental` / `differential` | 日志备份时自动禁用 |
|
||||
| 并行通道数 | `1 ~ 32` | 多路径 SQL(`BACKUP DATA USING FILE ('c1', 'c2', ...)`) |
|
||||
| 失败重试次数 | `1 ~ 10` | 指数退避(`5s × 尝试次数²`) |
|
||||
| 实例编号 | 可选 | 从端口推断或手动指定 |
|
||||
|
||||
## 模式二:Backint 协议代理(HANA 原生接口)
|
||||
|
||||
BackupX 内置 Backint Agent,SAP HANA 通过原生 `BACKUP DATA USING BACKINT` 语法调用,数据自动路由到任意 BackupX 存储目标(S3 / OSS / COS / WebDAV / 70+ 后端)。
|
||||
|
||||
### 1. 参数文件
|
||||
|
||||
```ini title="/opt/backupx/backint_params.ini"
|
||||
#STORAGE_TYPE = s3
|
||||
#STORAGE_CONFIG_JSON = /opt/backupx/storage.json
|
||||
#PARALLEL_FACTOR = 4
|
||||
#COMPRESS = true
|
||||
#KEY_PREFIX = hana-backup
|
||||
#CATALOG_DB = /opt/backupx/backint_catalog.db
|
||||
#LOG_FILE = /var/log/backupx/backint.log
|
||||
```
|
||||
|
||||
### 2. 存储配置(与存储目标 schema 相同)
|
||||
|
||||
```json title="/opt/backupx/storage.json"
|
||||
{
|
||||
"endpoint": "https://s3.amazonaws.com",
|
||||
"region": "us-east-1",
|
||||
"bucket": "hana-prod",
|
||||
"accessKeyId": "AKIA...",
|
||||
"secretAccessKey": "..."
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 创建 hdbbackint 软链接
|
||||
|
||||
```bash
|
||||
ln -s /opt/backupx/backupx /usr/sap/<SID>/SYS/global/hdb/opt/hdbbackint
|
||||
```
|
||||
|
||||
### 4. 在 HANA `global.ini` 中启用
|
||||
|
||||
```ini
|
||||
[backup]
|
||||
data_backup_using_backint = true
|
||||
catalog_backup_using_backint = true
|
||||
log_backup_using_backint = true
|
||||
data_backup_parameter_file = /opt/backupx/backint_params.ini
|
||||
log_backup_parameter_file = /opt/backupx/backint_params.ini
|
||||
```
|
||||
|
||||
### 5. CLI 手动调用(用于排查)
|
||||
|
||||
```bash
|
||||
backupx backint -f backup -i input.txt -o output.txt -p backint_params.ini
|
||||
backupx backint -f restore -i input.txt -o output.txt -p backint_params.ini
|
||||
backupx backint -f inquire -i input.txt -o output.txt -p backint_params.ini
|
||||
backupx backint -f delete -i input.txt -o output.txt -p backint_params.ini
|
||||
```
|
||||
|
||||
Backint Agent 使用本地 SQLite 维护 `EBID ↔ 对象键` 目录,所有操作遵循 SAP HANA Backint 协议(`#PIPE` / `#SAVED` / `#RESTORED` / `#BACKUP` / `#NOTFOUND` / `#DELETED` / `#ERROR`)。
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: 存储后端
|
||||
description: 70+ 存储后端 — 内置云服务商 + 任意 rclone 后端。
|
||||
---
|
||||
|
||||
# 存储后端
|
||||
|
||||
BackupX 的目标是接入任何你想放置备份文件的地方。
|
||||
|
||||
## 内置后端
|
||||
|
||||
| 类型 | 必填字段 |
|
||||
|------|---------|
|
||||
| **阿里云 OSS** | Region + AccessKey ID/Secret + Bucket(endpoint 自动组装) |
|
||||
| **腾讯云 COS** | Region + SecretId/SecretKey + Bucket(格式 `name-appid`) |
|
||||
| **七牛云 Kodo** | Region + AccessKey/SecretKey + Bucket |
|
||||
| **S3 兼容** | Endpoint + AccessKey + Bucket |
|
||||
| **Google Drive** | Client ID/Secret + OAuth 授权 |
|
||||
| **WebDAV** | 地址 + 用户名/密码 |
|
||||
| **FTP / FTPS** | 主机 + 端口 + 用户名/密码 |
|
||||
| **本地磁盘** | 目标目录(绝对路径) |
|
||||
|
||||
## Rclone 后端
|
||||
|
||||
每一种 [rclone 后端](https://rclone.org/overview/) 都作为一等公民暴露 — SFTP、Azure Blob、Dropbox、OneDrive、Backblaze B2、Wasabi、pCloud、HDFS 等。
|
||||
|
||||
- 表单字段分为 **必填** 和 **高级**(高级默认折叠)
|
||||
- 校验与连接测试复用 rclone 自带的探测
|
||||
|
||||
## 一个任务多个目标
|
||||
|
||||
一个备份任务可以并行上传到多个存储目标。每个目标获得相同的产物,每目标的状态会单独记录:
|
||||
|
||||
- 成功:storage_path + 文件大小
|
||||
- 失败:错误信息
|
||||
|
||||
如果任一目标在重试后仍失败,整条记录的状态为 `failed`,但已成功的目标产物会被保留(不回滚)。
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: 安装
|
||||
description: 通过 Docker、预编译包或源码安装 BackupX。
|
||||
---
|
||||
|
||||
# 安装
|
||||
|
||||
BackupX 以单个静态二进制发布。三种安装方式,按实际环境选一种。
|
||||
|
||||
## Docker(推荐)
|
||||
|
||||
无需克隆仓库:
|
||||
|
||||
```bash
|
||||
docker run -d --name backupx \
|
||||
-p 8340:8340 \
|
||||
-v backupx-data:/app/data \
|
||||
awuqing/backupx:latest
|
||||
```
|
||||
|
||||
或使用 `docker compose`:
|
||||
|
||||
```yaml title="docker-compose.yml"
|
||||
services:
|
||||
backupx:
|
||||
image: awuqing/backupx:latest
|
||||
container_name: backupx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8340:8340"
|
||||
volumes:
|
||||
- backupx-data:/app/data
|
||||
# 挂载需要备份的宿主机目录(按需添加):
|
||||
# - /var/www:/mnt/www:ro
|
||||
# - /etc/nginx:/mnt/nginx-conf:ro
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
|
||||
volumes:
|
||||
backupx-data:
|
||||
```
|
||||
|
||||
Docker Hub:[`awuqing/backupx`](https://hub.docker.com/r/awuqing/backupx),支持 linux/amd64 和 linux/arm64。
|
||||
|
||||
## 预编译包(裸机)
|
||||
|
||||
从 [Releases 页面](https://github.com/Awuqing/BackupX/releases) 下载对应平台的压缩包,执行安装脚本:
|
||||
|
||||
```bash
|
||||
tar xzf backupx-v*-linux-amd64.tar.gz && cd backupx-*
|
||||
sudo ./install.sh # 创建系统用户、安装到 /opt/backupx、配置 systemd + Nginx
|
||||
```
|
||||
|
||||
安装脚本会自动:
|
||||
|
||||
1. 创建 `backupx` 系统用户
|
||||
2. 安装二进制到 `/opt/backupx/backupx`
|
||||
3. 生成 `/opt/backupx/config.yaml`(含安全默认值)
|
||||
4. 注册并启用 `backupx.service` systemd 单元
|
||||
5. (可选)配置 Nginx 反向代理
|
||||
|
||||
## 从源码构建
|
||||
|
||||
依赖:Go ≥ 1.25,Node.js ≥ 20。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
|
||||
make build
|
||||
# 或使用国内镜像加速构建 Docker
|
||||
make docker-cn
|
||||
```
|
||||
|
||||
`make build` 完成后,二进制位于 `server/bin/backupx`,构建好的 Web UI 位于 `web/dist/`。
|
||||
|
||||
## 验证安装
|
||||
|
||||
```bash
|
||||
backupx --version # 输出如 v1.6.0
|
||||
```
|
||||
|
||||
打开浏览器访问 `http://your-server:8340`,会进入初始化管理员账户页面。
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: 快速开始
|
||||
description: 部署 BackupX、添加存储目标、创建第一个备份任务。
|
||||
---
|
||||
|
||||
# 快速开始
|
||||
|
||||
完成 [安装](./installation) 后,花五分钟跑通第一个备份。
|
||||
|
||||
## 1. 打开控制台
|
||||
|
||||
浏览器访问 `http://your-server:8340`。首次打开会引导创建管理员账户。
|
||||
|
||||
## 2. 添加存储目标
|
||||
|
||||
进入 **存储目标 → 添加**,选择类型并填写凭证:
|
||||
|
||||
| 类型 | 需要填写 |
|
||||
|------|---------|
|
||||
| 阿里云 OSS | Region + AccessKey ID/Secret + Bucket |
|
||||
| 腾讯云 COS | Region + SecretId/SecretKey + Bucket(格式 `name-appid`) |
|
||||
| 七牛云 Kodo | Region + AccessKey/SecretKey + Bucket |
|
||||
| S3 兼容 | Endpoint + AccessKey + Bucket |
|
||||
| Google Drive | Client ID/Secret → 点击「授权」完成 OAuth |
|
||||
| WebDAV | 服务器地址 + 用户名/密码 |
|
||||
| FTP | 主机 + 端口 + 用户名/密码 |
|
||||
| 本地磁盘 | 目标目录路径 |
|
||||
| SFTP / Azure / Dropbox / OneDrive 等 | 选择对应类型后填写必填项,高级配置默认折叠 |
|
||||
|
||||
:::tip
|
||||
国内云厂商只需填 Region 和 AccessKey,系统自动组装 Endpoint。Rclone 类型的配置项按"必填 / 可选"分层展示,高级选项默认折叠。
|
||||
:::
|
||||
|
||||
添加后点击 **测试连接** 确认配置正确。
|
||||
|
||||
## 3. 创建备份任务
|
||||
|
||||
进入 **备份任务 → 新建**,三步完成:
|
||||
|
||||
1. **基础信息** — 任务名称、备份类型、Cron 表达式(留空则仅手动执行)
|
||||
2. **源配置** — 文件备份选择源路径(支持多个),数据库备份填写连接信息
|
||||
3. **存储与策略** — 选择存储目标(支持多个)、压缩策略、保留天数、是否加密
|
||||
|
||||
保存后可点击 **立即执行** 测试,**备份记录** 页面实时查看执行日志。
|
||||
|
||||
:::note
|
||||
删除备份任务时会自动清理远端存储上的备份文件,但保留备份记录以供审计追溯。
|
||||
:::
|
||||
|
||||
## 4. 配置通知(可选)
|
||||
|
||||
**通知配置** 页面支持邮件、Webhook、Telegram 三种方式,可分别配置成功/失败时是否推送。
|
||||
|
||||
## 继续阅读
|
||||
|
||||
- 了解 [备份类型](/docs/features/backup-types) 和 [存储后端](/docs/features/storage-backends)
|
||||
- 使用 SAP HANA?参考 [SAP HANA 支持](/docs/features/sap-hana)
|
||||
- 管理多台服务器?参考 [多节点集群](/docs/features/multi-node)
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
id: intro
|
||||
slug: /intro
|
||||
sidebar_position: 1
|
||||
title: 项目简介
|
||||
description: BackupX——自托管服务器备份管理平台概览。
|
||||
---
|
||||
|
||||
# BackupX
|
||||
|
||||
**BackupX** 是一款自托管的服务器备份管理平台:一个二进制,一条命令,管好所有服务器的所有备份。
|
||||
|
||||
- **单二进制 + 内嵌 SQLite** — 不依赖外部数据库或编排器
|
||||
- **文件、数据库、SAP HANA** — 统一管理,可视化调度
|
||||
- **70+ 存储后端** — 阿里云 OSS、腾讯云 COS、七牛、S3、Google Drive、WebDAV、FTP,以及通过 rclone 接入的 SFTP / Azure Blob / Dropbox / OneDrive 等数十种
|
||||
- **多节点集群** — Master-Agent 模式跨服务器管理备份,Agent 在本地执行并直接上传到存储
|
||||
- **默认安全** — JWT 认证、bcrypt、AES-256-GCM 加密配置、可选备份加密、完整审计日志
|
||||
|
||||
## 架构概览
|
||||
|
||||
```
|
||||
[Web 控制台] ─── JWT ──→ [Master (backupx)]
|
||||
│
|
||||
│ HTTP 长轮询(Token 认证)
|
||||
▼
|
||||
[Agent (backupx agent)]
|
||||
│
|
||||
▼
|
||||
[70+ 存储后端]
|
||||
```
|
||||
|
||||
路由到本机的任务在 Master 进程内直接执行;派到远程节点的任务通过命令队列下发,由 Agent 在本地执行。Agent 只发起出站 HTTP 连接 — 不需要任何反向连通性。
|
||||
|
||||
## 下一步
|
||||
|
||||
- **第一次使用 BackupX?** 先看 [快速开始](/docs/getting-started/quick-start)
|
||||
- **生产部署?** 参考 [部署指南](/docs/deployment/docker)
|
||||
- **SAP HANA 用户?** 支持 `hdbsql` Runner 和原生 Backint 两种模式 — 详见 [SAP HANA](/docs/features/sap-hana)
|
||||
- **管理多台服务器?** 参考 [多节点集群](/docs/features/multi-node)
|
||||
- **程序化集成?** 参考 [API 参考](/docs/reference/api)
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: API 参考
|
||||
description: REST API 端点 — 统一以 /api 为前缀,使用 JWT Bearer 认证。
|
||||
---
|
||||
|
||||
# API 参考
|
||||
|
||||
所有端点都以 `/api` 为前缀,使用 JWT Bearer 令牌认证(通过 `POST /api/auth/login` 获取)。Agent 专用端点使用 `X-Agent-Token` 头认证。
|
||||
|
||||
## 认证
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET` | `/api/auth/setup/status` | 查询是否需要初始化管理员 |
|
||||
| `POST` | `/api/auth/setup` | 初始化首个管理员(仅当系统无任何用户时) |
|
||||
| `POST` | `/api/auth/login` | 登录,返回 JWT |
|
||||
| `POST` | `/api/auth/logout` | 登出(使当前 Token 失效) |
|
||||
| `GET` | `/api/auth/profile` | 当前用户信息 |
|
||||
| `PUT` | `/api/auth/password` | 修改密码 |
|
||||
|
||||
## 备份任务
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET` | `/api/backup/tasks` | 列表 |
|
||||
| `POST` | `/api/backup/tasks` | 创建 |
|
||||
| `GET` | `/api/backup/tasks/:id` | 详情 |
|
||||
| `PUT` | `/api/backup/tasks/:id` | 更新 |
|
||||
| `DELETE` | `/api/backup/tasks/:id` | 删除 |
|
||||
| `PUT` | `/api/backup/tasks/:id/toggle` | 启用 / 禁用 |
|
||||
| `POST` | `/api/backup/tasks/:id/run` | 手动触发一次执行 |
|
||||
|
||||
## 备份记录
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET` | `/api/backup/records` | 列表(支持筛选) |
|
||||
| `GET` | `/api/backup/records/:id` | 记录详情 |
|
||||
| `GET` | `/api/backup/records/:id/logs/stream` | 实时日志(SSE) |
|
||||
| `GET` | `/api/backup/records/:id/download` | 下载备份产物 |
|
||||
| `POST` | `/api/backup/records/:id/restore` | 恢复到原始源 |
|
||||
| `DELETE` | `/api/backup/records/:id` | 删除记录 |
|
||||
| `POST` | `/api/backup/records/batch-delete` | 批量删除 |
|
||||
|
||||
## 存储目标
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET` | `/api/storage-targets` | 列表 |
|
||||
| `POST` | `/api/storage-targets` | 创建 |
|
||||
| `GET` | `/api/storage-targets/:id` | 详情 |
|
||||
| `PUT` | `/api/storage-targets/:id` | 更新 |
|
||||
| `DELETE` | `/api/storage-targets/:id` | 删除 |
|
||||
| `POST` | `/api/storage-targets/test` | 用待审核配置测试连接 |
|
||||
| `POST` | `/api/storage-targets/:id/test` | 重测已保存的目标 |
|
||||
| `PUT` | `/api/storage-targets/:id/star` | 切换收藏状态 |
|
||||
| `GET` | `/api/storage-targets/:id/usage` | 查询远端存储用量(支持此能力的后端) |
|
||||
| `GET` | `/api/storage-targets/rclone/backends` | 列出可用的 rclone 后端 |
|
||||
| `POST` | `/api/storage-targets/google-drive/auth-url` | 启动 Google Drive OAuth |
|
||||
| `POST` | `/api/storage-targets/google-drive/complete` | 完成 OAuth 流程 |
|
||||
|
||||
## 节点(集群)
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET` | `/api/nodes` | 节点列表 |
|
||||
| `POST` | `/api/nodes` | 创建节点并返回 Token |
|
||||
| `GET` | `/api/nodes/:id` | 节点详情 |
|
||||
| `PUT` | `/api/nodes/:id` | 重命名 |
|
||||
| `DELETE` | `/api/nodes/:id` | 删除(有关联任务时会被拒绝) |
|
||||
| `GET` | `/api/nodes/:id/fs/list` | 浏览目录(远程节点走 Agent 异步 RPC) |
|
||||
|
||||
## Agent 协议(X-Agent-Token)
|
||||
|
||||
Agent CLI 专用端点,通过 `X-Agent-Token` 头认证而非 JWT。
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
|------|------|------|
|
||||
| `POST` | `/api/agent/heartbeat` | 上报心跳(返回节点 ID) |
|
||||
| `POST` | `/api/agent/commands/poll` | 领取一条待执行命令 |
|
||||
| `POST` | `/api/agent/commands/:id/result` | 上报命令结果 |
|
||||
| `GET` | `/api/agent/tasks/:id` | 拉取任务规格(含解密后的存储配置) |
|
||||
| `POST` | `/api/agent/records/:id` | 追加日志 / 更新记录状态 |
|
||||
|
||||
## 通知
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET` | `/api/notifications` | 列表 |
|
||||
| `POST` | `/api/notifications` | 创建 |
|
||||
| `GET` | `/api/notifications/:id` | 详情 |
|
||||
| `PUT` | `/api/notifications/:id` | 更新 |
|
||||
| `DELETE` | `/api/notifications/:id` | 删除 |
|
||||
| `POST` | `/api/notifications/test` | 用待审核配置测试 |
|
||||
| `POST` | `/api/notifications/:id/test` | 重测已保存的通知器 |
|
||||
|
||||
## 仪表盘
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET` | `/api/dashboard/stats` | 概览统计 |
|
||||
| `GET` | `/api/dashboard/timeline` | 最近活动时间线 |
|
||||
|
||||
## 审计 / 系统 / 设置
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET` | `/api/audit-logs` | 审计日志 |
|
||||
| `GET` | `/api/system/info` | 系统信息 |
|
||||
| `GET` | `/api/system/update-check` | 检查新版本 |
|
||||
| `GET` | `/api/settings` | 系统级设置 |
|
||||
| `PUT` | `/api/settings` | 更新系统设置 |
|
||||
|
||||
## 响应结构
|
||||
|
||||
成功响应统一为:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "",
|
||||
"data": { /* 实际数据 */ }
|
||||
}
|
||||
```
|
||||
|
||||
错误返回 HTTP 4xx/5xx,并带:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "BACKUP_TASK_NOT_FOUND",
|
||||
"message": "备份任务不存在",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: CLI 参考
|
||||
description: backupx 子命令 — server / agent / backint / reset-password。
|
||||
---
|
||||
|
||||
# CLI 参考
|
||||
|
||||
`backupx` 二进制内置多个子命令。无子命令时默认启动主服务进程。
|
||||
|
||||
## `backupx`(默认:服务进程)
|
||||
|
||||
```bash
|
||||
backupx --config /opt/backupx/config.yaml
|
||||
backupx --version
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `--config <path>` | 配置文件路径(默认 `./config.yaml`) |
|
||||
| `--version` | 打印版本后退出 |
|
||||
|
||||
## `backupx agent`
|
||||
|
||||
以 Agent 模式运行,连接到 Master。详见 [多节点集群](../features/multi-node)。
|
||||
|
||||
```bash
|
||||
backupx agent --master http://master:8340 --token <token>
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `--master <url>` | Master URL |
|
||||
| `--token <token>` | Agent 认证令牌 |
|
||||
| `--config <path>` | YAML 配置文件(优先级高于环境变量) |
|
||||
| `--temp-dir <path>` | 本地临时目录(默认 `/tmp/backupx-agent`) |
|
||||
| `--insecure-tls` | 跳过 TLS 校验(仅测试用) |
|
||||
|
||||
环境变量:`BACKUPX_AGENT_MASTER`、`BACKUPX_AGENT_TOKEN`、`BACKUPX_AGENT_HEARTBEAT`、`BACKUPX_AGENT_POLL`、`BACKUPX_AGENT_TEMP_DIR`、`BACKUPX_AGENT_INSECURE_TLS`。
|
||||
|
||||
## `backupx backint`
|
||||
|
||||
SAP HANA Backint 协议代理,详见 [SAP HANA 支持](../features/sap-hana)。
|
||||
|
||||
```bash
|
||||
backupx backint -f <function> -i <input> -o <output> -p <params>
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `-f <fn>` | `backup` / `restore` / `inquire` / `delete` |
|
||||
| `-i <path>` | 输入文件 |
|
||||
| `-o <path>` | 输出文件 |
|
||||
| `-p <path>` | 参数文件 |
|
||||
| `-u / -c / -l / -v` | 接收但忽略(兼容 SAP 约定) |
|
||||
|
||||
## `backupx reset-password`
|
||||
|
||||
直接在 SQLite 中重置管理员密码,无需重启服务。
|
||||
|
||||
```bash
|
||||
backupx reset-password --username admin --password 'newpass123' [--config /path/to/config.yaml]
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `--username` | 目标用户名(默认 `admin`) |
|
||||
| `--password` | 新密码(最少 8 字符,必填) |
|
||||
| `--config` | 配置文件路径(用于定位数据库文件) |
|
||||
23
docs-site/i18n/zh-CN/docusaurus-theme-classic/footer.json
Normal file
23
docs-site/i18n/zh-CN/docusaurus-theme-classic/footer.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"link.title.Docs": {"message": "文档"},
|
||||
"link.title.Features": {"message": "功能"},
|
||||
"link.title.More": {"message": "更多"},
|
||||
"link.title.Community": {"message": "社区"},
|
||||
"link.title.Sponsors": {"message": "赞助商"},
|
||||
"link.item.label.Introduction": {"message": "简介"},
|
||||
"link.item.label.Quick Start": {"message": "快速开始"},
|
||||
"link.item.label.Installation": {"message": "安装"},
|
||||
"link.item.label.SAP HANA": {"message": "SAP HANA"},
|
||||
"link.item.label.Multi-Node Cluster": {"message": "多节点集群"},
|
||||
"link.item.label.API Reference": {"message": "API 参考"},
|
||||
"link.item.label.GitHub": {"message": "GitHub"},
|
||||
"link.item.label.Releases": {"message": "Releases"},
|
||||
"link.item.label.Docker Hub": {"message": "Docker Hub"},
|
||||
"link.item.label.Issues": {"message": "Issues"},
|
||||
"link.item.label.Contributors": {"message": "贡献者"},
|
||||
"link.item.label.Pull Requests": {"message": "Pull Requests"},
|
||||
"link.item.label.Sponsor": {"message": "赞助"},
|
||||
"link.item.label.Sponsor BackupX": {"message": "赞助 BackupX"},
|
||||
"link.item.label.Partnership": {"message": "合作伙伴"},
|
||||
"link.item.label.Sponsor tiers": {"message": "赞助层级"}
|
||||
}
|
||||
22
docs-site/i18n/zh-CN/docusaurus-theme-classic/navbar.json
Normal file
22
docs-site/i18n/zh-CN/docusaurus-theme-classic/navbar.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"item.label.Docs": {
|
||||
"message": "文档",
|
||||
"description": "Navbar item: Docs"
|
||||
},
|
||||
"item.label.Downloads": {
|
||||
"message": "下载",
|
||||
"description": "Navbar item: Downloads"
|
||||
},
|
||||
"item.label.Community": {
|
||||
"message": "社区",
|
||||
"description": "Navbar item: Community"
|
||||
},
|
||||
"item.label.Sponsors": {
|
||||
"message": "赞助商",
|
||||
"description": "Navbar item: Sponsors"
|
||||
},
|
||||
"item.label.GitHub": {
|
||||
"message": "GitHub",
|
||||
"description": "Navbar item: GitHub"
|
||||
}
|
||||
}
|
||||
19538
docs-site/package-lock.json
generated
Normal file
19538
docs-site/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
docs-site/package.json
Normal file
49
docs-site/package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "backupx-docs",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"start": "docusaurus start",
|
||||
"build": "docusaurus build",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "docusaurus deploy",
|
||||
"clear": "docusaurus clear",
|
||||
"serve": "docusaurus serve",
|
||||
"write-translations": "docusaurus write-translations",
|
||||
"write-heading-ids": "docusaurus write-heading-ids",
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "3.10.0",
|
||||
"@docusaurus/faster": "3.10.0",
|
||||
"@docusaurus/preset-classic": "3.10.0",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"clsx": "^2.0.0",
|
||||
"prism-react-renderer": "^2.3.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "3.10.0",
|
||||
"@docusaurus/tsconfig": "3.10.0",
|
||||
"@docusaurus/types": "3.10.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"typescript": "~6.0.2"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.5%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 3 chrome version",
|
||||
"last 3 firefox version",
|
||||
"last 5 safari version"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0"
|
||||
}
|
||||
}
|
||||
55
docs-site/sidebars.ts
Normal file
55
docs-site/sidebars.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type {SidebarsConfig} from '@docusaurus/plugin-content-docs';
|
||||
|
||||
const sidebars: SidebarsConfig = {
|
||||
docs: [
|
||||
'intro',
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Getting Started',
|
||||
collapsed: false,
|
||||
items: [
|
||||
'getting-started/installation',
|
||||
'getting-started/quick-start',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Deployment',
|
||||
items: [
|
||||
'deployment/docker',
|
||||
'deployment/bare-metal',
|
||||
'deployment/nginx',
|
||||
'deployment/configuration',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Features',
|
||||
items: [
|
||||
'features/backup-types',
|
||||
'features/storage-backends',
|
||||
'features/sap-hana',
|
||||
'features/multi-node',
|
||||
'features/notifications',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Reference',
|
||||
items: [
|
||||
'reference/api',
|
||||
'reference/cli',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Development',
|
||||
items: [
|
||||
'development/setup',
|
||||
'development/contributing',
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default sidebars;
|
||||
329
docs-site/src/components/HomepageCommunity/index.tsx
Normal file
329
docs-site/src/components/HomepageCommunity/index.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import type {ReactNode} from 'react';
|
||||
import {useEffect, useState} from 'react';
|
||||
import Heading from '@theme/Heading';
|
||||
import Translate from '@docusaurus/Translate';
|
||||
import Link from '@docusaurus/Link';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
type SponsorSlot = {
|
||||
brand: ReactNode;
|
||||
name: ReactNode;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
type Contributor = {
|
||||
login: string;
|
||||
avatarUrl?: string;
|
||||
contributions: number;
|
||||
type: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
type GitHubContributor = {
|
||||
login: string;
|
||||
avatar_url?: string;
|
||||
contributions?: number;
|
||||
html_url?: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
type CommunityPath = {
|
||||
title: ReactNode;
|
||||
description: ReactNode;
|
||||
href: string;
|
||||
};
|
||||
|
||||
const SPONSOR_SLOTS: SponsorSlot[] = [
|
||||
{
|
||||
brand: 'BackupX',
|
||||
name: <Translate id="community.sponsor.logo.project">Project backer</Translate>,
|
||||
href: 'https://github.com/sponsors/Awuqing',
|
||||
},
|
||||
{
|
||||
brand: 'Cloud',
|
||||
name: <Translate id="community.sponsor.logo.cloud">Cloud partner</Translate>,
|
||||
},
|
||||
{
|
||||
brand: 'Object',
|
||||
name: <Translate id="community.sponsor.logo.object">Object storage</Translate>,
|
||||
},
|
||||
{
|
||||
brand: 'CDN',
|
||||
name: <Translate id="community.sponsor.logo.cdn">CDN partner</Translate>,
|
||||
},
|
||||
{
|
||||
brand: 'DB',
|
||||
name: <Translate id="community.sponsor.logo.database">Database partner</Translate>,
|
||||
},
|
||||
{
|
||||
brand: 'Security',
|
||||
name: <Translate id="community.sponsor.logo.security">Security audit</Translate>,
|
||||
},
|
||||
{
|
||||
brand: 'Agent',
|
||||
name: <Translate id="community.sponsor.logo.agent">Remote node lab</Translate>,
|
||||
},
|
||||
{
|
||||
brand: 'Docs',
|
||||
name: <Translate id="community.sponsor.logo.docs">Docs sponsor</Translate>,
|
||||
},
|
||||
{
|
||||
brand: 'Release',
|
||||
name: <Translate id="community.sponsor.logo.release">Release sponsor</Translate>,
|
||||
},
|
||||
{
|
||||
brand: 'S3',
|
||||
name: <Translate id="community.sponsor.logo.s3">S3 compatible</Translate>,
|
||||
},
|
||||
{
|
||||
brand: 'WebDAV',
|
||||
name: <Translate id="community.sponsor.logo.webdav">WebDAV partner</Translate>,
|
||||
},
|
||||
{
|
||||
brand: 'SFTP',
|
||||
name: <Translate id="community.sponsor.logo.sftp">SFTP partner</Translate>,
|
||||
},
|
||||
{
|
||||
brand: 'Docker',
|
||||
name: <Translate id="community.sponsor.logo.docker">Container partner</Translate>,
|
||||
},
|
||||
{
|
||||
brand: 'Mirror',
|
||||
name: <Translate id="community.sponsor.logo.mirror">Mirror partner</Translate>,
|
||||
},
|
||||
{
|
||||
brand: 'Restore',
|
||||
name: <Translate id="community.sponsor.logo.restore">Restore drill</Translate>,
|
||||
},
|
||||
{
|
||||
brand: 'QA',
|
||||
name: <Translate id="community.sponsor.logo.qa">Test lab</Translate>,
|
||||
},
|
||||
{
|
||||
brand: 'OSS',
|
||||
name: <Translate id="community.sponsor.logo.oss">Open source</Translate>,
|
||||
},
|
||||
{
|
||||
brand: 'Open Slot',
|
||||
name: <Translate id="community.sponsor.logo.open">Sponsor slot open</Translate>,
|
||||
},
|
||||
];
|
||||
|
||||
const FALLBACK_CONTRIBUTORS: Contributor[] = [
|
||||
{
|
||||
login: 'Awuqing',
|
||||
contributions: 0,
|
||||
type: 'User',
|
||||
href: 'https://github.com/Awuqing',
|
||||
},
|
||||
{
|
||||
login: 'dependabot[bot]',
|
||||
contributions: 0,
|
||||
type: 'Bot',
|
||||
href: 'https://github.com/dependabot',
|
||||
},
|
||||
];
|
||||
|
||||
const COMMUNITY_PATHS: CommunityPath[] = [
|
||||
{
|
||||
title: <Translate id="community.path.issues.title">Report production issues</Translate>,
|
||||
description: <Translate id="community.path.issues.desc">Share logs, deployment topology and restore expectations.</Translate>,
|
||||
href: 'https://github.com/Awuqing/BackupX/issues',
|
||||
},
|
||||
{
|
||||
title: <Translate id="community.path.docs.title">Improve docs and examples</Translate>,
|
||||
description: <Translate id="community.path.docs.desc">Contribute deployment guides for storage, agents and databases.</Translate>,
|
||||
href: '/docs/development/contributing',
|
||||
},
|
||||
{
|
||||
title: <Translate id="community.path.code.title">Ship focused PRs</Translate>,
|
||||
description: <Translate id="community.path.code.desc">Keep changes small, tested and aligned with the existing architecture.</Translate>,
|
||||
href: 'https://github.com/Awuqing/BackupX/pulls',
|
||||
},
|
||||
];
|
||||
|
||||
function SponsorLogoCard({brand, name, href}: SponsorSlot) {
|
||||
return (
|
||||
<Link className={styles.sponsorLogoTile} to={href ?? 'https://github.com/sponsors/Awuqing'}>
|
||||
<span className={styles.sponsorLogoMark}>{brand}</span>
|
||||
<span className={styles.sponsorLogoName}>{name}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function getInitials(login: string): string {
|
||||
return login
|
||||
.replace(/\[bot\]$/i, '')
|
||||
.split(/[-_\s]/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map(part => part[0]?.toUpperCase())
|
||||
.join('') || login.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function normalizeContributor(contributor: GitHubContributor): Contributor | null {
|
||||
if (!contributor.login) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
login: contributor.login,
|
||||
avatarUrl: contributor.avatar_url,
|
||||
contributions: contributor.contributions ?? 0,
|
||||
type: contributor.type ?? 'User',
|
||||
href: contributor.html_url ?? `https://github.com/${contributor.login}`,
|
||||
};
|
||||
}
|
||||
|
||||
function useGitHubContributors(): Contributor[] {
|
||||
const [contributors, setContributors] = useState<Contributor[]>(FALLBACK_CONTRIBUTORS);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
fetch('https://api.github.com/repos/Awuqing/BackupX/contributors?per_page=12', {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
},
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub contributors request failed: ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<GitHubContributor[]>;
|
||||
})
|
||||
.then(payload => {
|
||||
const nextContributors = payload
|
||||
.map(normalizeContributor)
|
||||
.filter((contributor): contributor is Contributor => Boolean(contributor));
|
||||
|
||||
if (nextContributors.length > 0) {
|
||||
setContributors(nextContributors);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
if (error instanceof Error && error.name !== 'AbortError') {
|
||||
console.warn(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
return contributors;
|
||||
}
|
||||
|
||||
function ContributorCard({login, avatarUrl, contributions, type, href}: Contributor) {
|
||||
return (
|
||||
<Link className={styles.contributorCard} to={href}>
|
||||
{avatarUrl ? (
|
||||
<img className={styles.avatarImage} src={avatarUrl} alt="" loading="lazy" />
|
||||
) : (
|
||||
<span className={styles.avatar} aria-hidden="true">{getInitials(login)}</span>
|
||||
)}
|
||||
<span className={styles.contributorBody}>
|
||||
<strong>{login}</strong>
|
||||
<span>
|
||||
{type === 'Bot' ? (
|
||||
<Translate id="community.contributor.botRole">Automation contributor</Translate>
|
||||
) : (
|
||||
<Translate id="community.contributor.githubRole">GitHub contributor</Translate>
|
||||
)}
|
||||
</span>
|
||||
<em>
|
||||
<Translate id="community.contributor.contributions" values={{count: contributions}}>
|
||||
{'{count} contributions'}
|
||||
</Translate>
|
||||
</em>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomepageSponsors(): ReactNode {
|
||||
return (
|
||||
<div className={styles.sponsorWall}>
|
||||
<div className={styles.sponsorWallHeader}>
|
||||
<Heading as="h3" className={styles.sponsorWallTitle}>
|
||||
<Translate id="community.sponsor.wallTitle">Sponsors</Translate>
|
||||
</Heading>
|
||||
<Link className={styles.sponsorWallAction} to="https://github.com/sponsors/Awuqing">
|
||||
<Translate id="community.sponsor.cta">Sponsor BackupX</Translate>
|
||||
<span aria-hidden="true">-></span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className={styles.sponsorLogoGrid}>
|
||||
{SPONSOR_SLOTS.map((slot, index) => (
|
||||
<SponsorLogoCard key={index} {...slot} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomepageCommunity(): ReactNode {
|
||||
const contributors = useGitHubContributors();
|
||||
|
||||
return (
|
||||
<section id="community" className={styles.section}>
|
||||
<div className="container">
|
||||
<div className={styles.sectionHead}>
|
||||
<div className={styles.sectionTag}>
|
||||
<Translate id="community.tag">COMMUNITY</Translate>
|
||||
</div>
|
||||
<Heading as="h2" className={styles.sectionTitle}>
|
||||
<Translate id="community.title">Built in the open, ready for long-term operators</Translate>
|
||||
</Heading>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
<Translate id="community.subtitle">
|
||||
Backup software earns trust through transparent releases, real deployment feedback and a contributor path that stays practical.
|
||||
</Translate>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<HomepageSponsors />
|
||||
|
||||
<div className={styles.communityGrid}>
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.panelHeader}>
|
||||
<span>
|
||||
<Translate id="community.contributor.kicker">Contributors</Translate>
|
||||
</span>
|
||||
<Link to="https://github.com/Awuqing/BackupX/graphs/contributors">
|
||||
<Translate id="community.contributor.all">View all</Translate>
|
||||
</Link>
|
||||
</div>
|
||||
<div className={styles.panelNote}>
|
||||
<Translate id="community.contributor.source">Loaded from GitHub contributors API in the browser.</Translate>
|
||||
</div>
|
||||
<div className={styles.contributorList}>
|
||||
{contributors.map(contributor => (
|
||||
<ContributorCard key={contributor.login} {...contributor} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.panelHeader}>
|
||||
<span>
|
||||
<Translate id="community.path.kicker">Contributor paths</Translate>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.pathList}>
|
||||
{COMMUNITY_PATHS.map((path, index) => (
|
||||
<Link key={index} className={styles.pathItem} to={path.href}>
|
||||
<span className={styles.pathIndex}>{String(index + 1).padStart(2, '0')}</span>
|
||||
<span>
|
||||
<strong>{path.title}</strong>
|
||||
<em>{path.description}</em>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
429
docs-site/src/components/HomepageCommunity/styles.module.css
Normal file
429
docs-site/src/components/HomepageCommunity/styles.module.css
Normal file
@@ -0,0 +1,429 @@
|
||||
.section {
|
||||
padding: 5.5rem 0 6rem;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(245, 247, 250, 0) 0%, rgba(245, 247, 250, 0.86) 100%),
|
||||
var(--ifm-background-color);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .section {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(15, 17, 21, 0) 0%, rgba(255, 255, 255, 0.03) 100%),
|
||||
var(--ifm-background-color);
|
||||
}
|
||||
|
||||
.sectionHead {
|
||||
max-width: 760px;
|
||||
margin: 0 auto 2.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sectionTag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
margin-bottom: 1rem;
|
||||
padding: 4px 10px;
|
||||
color: #00a870;
|
||||
background: rgba(0, 180, 42, 0.1);
|
||||
border: 1px solid rgba(0, 180, 42, 0.18);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--ifm-heading-color);
|
||||
font-size: 2.35rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.sectionSubtitle {
|
||||
margin: 0;
|
||||
color: var(--ifm-color-content-secondary);
|
||||
font-size: 1.04rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.sponsorWall {
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
background: var(--ifm-background-color);
|
||||
border: 1px solid var(--ifm-color-emphasis-200);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 12px 28px rgba(29, 33, 41, 0.06);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .sponsorWall {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.sponsorWallHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
min-height: 60px;
|
||||
padding: 0 1.25rem;
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-200);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .sponsorWallHeader {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.sponsorWallTitle {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
padding-left: 14px;
|
||||
color: var(--ifm-heading-color);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.sponsorWallTitle::before {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
height: 18px;
|
||||
content: "";
|
||||
background: #52c41a;
|
||||
border-radius: 3px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.sponsorWallAction {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 36px;
|
||||
padding: 0 12px;
|
||||
color: #52c41a;
|
||||
background: rgba(82, 196, 26, 0.08);
|
||||
border: 1px solid rgba(82, 196, 26, 0.2);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-decoration: none !important;
|
||||
white-space: nowrap;
|
||||
transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.sponsorWallAction:hover,
|
||||
.sponsorWallAction:focus-visible {
|
||||
color: #389e0d;
|
||||
background: rgba(82, 196, 26, 0.14);
|
||||
border-color: #52c41a;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.sponsorLogoGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
background: var(--ifm-color-emphasis-200);
|
||||
gap: 1px;
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .sponsorLogoGrid {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.sponsorLogoTile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
min-height: 106px;
|
||||
padding: 14px 10px;
|
||||
flex-direction: column;
|
||||
color: inherit;
|
||||
background: var(--ifm-background-color);
|
||||
text-align: center;
|
||||
text-decoration: none !important;
|
||||
transition: background 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .sponsorLogoTile {
|
||||
background: rgba(15, 17, 21, 0.78);
|
||||
}
|
||||
|
||||
.sponsorLogoTile:hover,
|
||||
.sponsorLogoTile:focus-visible {
|
||||
z-index: 1;
|
||||
color: inherit;
|
||||
background: rgba(82, 196, 26, 0.04);
|
||||
box-shadow: inset 0 0 0 1px rgba(82, 196, 26, 0.5);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.sponsorLogoMark {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--ifm-color-primary);
|
||||
font-size: 1.45rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.sponsorLogoTile:nth-child(2n) .sponsorLogoMark {
|
||||
color: #ff7d00;
|
||||
}
|
||||
|
||||
.sponsorLogoTile:nth-child(3n) .sponsorLogoMark {
|
||||
color: #14c9c9;
|
||||
}
|
||||
|
||||
.sponsorLogoTile:nth-child(4n) .sponsorLogoMark {
|
||||
color: #722ed1;
|
||||
}
|
||||
|
||||
.sponsorLogoTile:nth-child(5n) .sponsorLogoMark {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.sponsorLogoName {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin-top: 10px;
|
||||
color: var(--ifm-color-content-secondary);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--ifm-background-color);
|
||||
border: 1px solid var(--ifm-color-emphasis-200);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 12px 28px rgba(29, 33, 41, 0.06);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .panel {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.communityGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
min-width: 0;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.panelHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--ifm-color-content-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.panelHeader a {
|
||||
color: var(--ifm-color-primary);
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.panelNote {
|
||||
margin: -0.35rem 0 1rem;
|
||||
color: var(--ifm-color-content-secondary);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.contributorList,
|
||||
.pathList {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.contributorCard,
|
||||
.pathItem {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
color: inherit;
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
border: 1px solid var(--ifm-color-emphasis-200);
|
||||
border-radius: 8px;
|
||||
text-decoration: none !important;
|
||||
transition: border-color 0.2s ease, transform 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.contributorCard:hover,
|
||||
.contributorCard:focus-visible,
|
||||
.pathItem:hover,
|
||||
.pathItem:focus-visible {
|
||||
color: inherit;
|
||||
background: var(--ifm-background-color);
|
||||
border-color: var(--ifm-color-primary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .contributorCard,
|
||||
[data-theme='dark'] .pathItem {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.contributorCard {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
color: #fff;
|
||||
background: #165dff;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.avatarImage {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 1px solid var(--ifm-color-emphasis-200);
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.contributorCard:nth-child(2) .avatar {
|
||||
background: #00a870;
|
||||
}
|
||||
|
||||
.contributorCard:nth-child(3) .avatar {
|
||||
background: #ff7d00;
|
||||
}
|
||||
|
||||
.contributorBody {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.contributorBody strong {
|
||||
color: var(--ifm-heading-color);
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.contributorBody span {
|
||||
color: var(--ifm-color-content);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.contributorBody em,
|
||||
.pathItem em {
|
||||
color: var(--ifm-color-content-secondary);
|
||||
font-size: 0.82rem;
|
||||
font-style: normal;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.pathItem {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.pathIndex {
|
||||
color: var(--ifm-color-primary);
|
||||
font-family: var(--ifm-font-family-monospace);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.pathItem strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: var(--ifm-heading-color);
|
||||
font-size: 0.96rem;
|
||||
}
|
||||
|
||||
@media (max-width: 996px) {
|
||||
.section {
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.communityGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sponsorLogoGrid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.sponsorLogoTile {
|
||||
min-height: 96px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.section {
|
||||
padding: 3.25rem 0;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.sponsorWallHeader {
|
||||
display: grid;
|
||||
min-height: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.sponsorWallAction {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sponsorLogoGrid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.sponsorLogoMark {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sponsorWallAction,
|
||||
.sponsorLogoTile,
|
||||
.contributorCard,
|
||||
.pathItem {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
172
docs-site/src/components/HomepageFeatures/index.tsx
Normal file
172
docs-site/src/components/HomepageFeatures/index.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import type {ReactNode} from 'react';
|
||||
import Heading from '@theme/Heading';
|
||||
import Translate from '@docusaurus/Translate';
|
||||
import Link from '@docusaurus/Link';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
type FeatureItem = {
|
||||
title: ReactNode;
|
||||
description: ReactNode;
|
||||
icon: ReactNode;
|
||||
link?: string;
|
||||
};
|
||||
|
||||
const DatabaseIcon = () => (
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
||||
<path d="M3 5v6c0 1.66 4 3 9 3s9-1.34 9-3V5" />
|
||||
<path d="M3 11v6c0 1.66 4 3 9 3s9-1.34 9-3v-6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CloudIcon = () => (
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M18 10h-1.26A8 8 0 109 20h9a5 5 0 000-10z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ClockIcon = () => (
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const NetworkIcon = () => (
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<rect x="9" y="2" width="6" height="6" rx="1" />
|
||||
<rect x="2" y="16" width="6" height="6" rx="1" />
|
||||
<rect x="16" y="16" width="6" height="6" rx="1" />
|
||||
<path d="M12 8v4" />
|
||||
<path d="M12 12H5v4" />
|
||||
<path d="M12 12h7v4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ShieldIcon = () => (
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M12 2l9 4v6c0 5-3.5 9.5-9 10-5.5-.5-9-5-9-10V6l9-4z" />
|
||||
<polyline points="9 12 11 14 15 10" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const RocketIcon = () => (
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 00-2.91-.09z" />
|
||||
<path d="M12 15l-3-3a22 22 0 012-3.95A12.88 12.88 0 0122 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 01-4 2z" />
|
||||
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" />
|
||||
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const FEATURES: FeatureItem[] = [
|
||||
{
|
||||
title: <Translate id="feat.types.title">Many Backup Types</Translate>,
|
||||
description: (
|
||||
<Translate id="feat.types.desc">
|
||||
Files and directories with multi-path sources, plus MySQL, PostgreSQL, SQLite, and SAP HANA — all in one place.
|
||||
</Translate>
|
||||
),
|
||||
icon: <DatabaseIcon />,
|
||||
link: '/docs/features/backup-types',
|
||||
},
|
||||
{
|
||||
title: <Translate id="feat.storage.title">70+ Storage Backends</Translate>,
|
||||
description: (
|
||||
<Translate id="feat.storage.desc">
|
||||
Native Alibaba OSS, Tencent COS, Qiniu, S3, Google Drive, WebDAV, FTP — plus SFTP, Azure Blob, Dropbox and more via rclone.
|
||||
</Translate>
|
||||
),
|
||||
icon: <CloudIcon />,
|
||||
link: '/docs/features/storage-backends',
|
||||
},
|
||||
{
|
||||
title: <Translate id="feat.scheduling.title">Scheduling & Retention</Translate>,
|
||||
description: (
|
||||
<Translate id="feat.scheduling.desc">
|
||||
Cron-based schedules with a visual editor and auto-retention (by days or count), plus empty-directory cleanup.
|
||||
</Translate>
|
||||
),
|
||||
icon: <ClockIcon />,
|
||||
},
|
||||
{
|
||||
title: <Translate id="feat.cluster.title">Multi-Node Cluster</Translate>,
|
||||
description: (
|
||||
<Translate id="feat.cluster.desc">
|
||||
Master-Agent via HTTP long-polling. Agents run tasks locally and upload directly to storage — no reverse connectivity.
|
||||
</Translate>
|
||||
),
|
||||
icon: <NetworkIcon />,
|
||||
link: '/docs/features/multi-node',
|
||||
},
|
||||
{
|
||||
title: <Translate id="feat.security.title">Secure by Default</Translate>,
|
||||
description: (
|
||||
<Translate id="feat.security.desc">
|
||||
JWT auth, bcrypt passwords, AES-256-GCM encrypted config, optional backup encryption, and a full audit log.
|
||||
</Translate>
|
||||
),
|
||||
icon: <ShieldIcon />,
|
||||
},
|
||||
{
|
||||
title: <Translate id="feat.deploy.title">Painless Deployment</Translate>,
|
||||
description: (
|
||||
<Translate id="feat.deploy.desc">
|
||||
Single static binary with embedded SQLite. Docker one-click or bare-metal — zero external dependencies.
|
||||
</Translate>
|
||||
),
|
||||
icon: <RocketIcon />,
|
||||
link: '/docs/getting-started/installation',
|
||||
},
|
||||
];
|
||||
|
||||
function Feature({title, description, icon, link}: FeatureItem) {
|
||||
const content = (
|
||||
<>
|
||||
<div className={styles.iconWrap}>{icon}</div>
|
||||
<Heading as="h3" className={styles.featureTitle}>{title}</Heading>
|
||||
<p className={styles.featureDesc}>{description}</p>
|
||||
{link && (
|
||||
<span className={styles.featureLink}>
|
||||
<Translate id="feat.learnMore">Learn more</Translate>
|
||||
<span className={styles.featureArrow} aria-hidden="true">-></span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
if (link) {
|
||||
return (
|
||||
<Link to={link} className={styles.featureCardLink}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return <div className={styles.featureCard}>{content}</div>;
|
||||
}
|
||||
|
||||
export default function HomepageFeatures(): ReactNode {
|
||||
return (
|
||||
<section className={styles.section}>
|
||||
<div className="container">
|
||||
<div className={styles.sectionHead}>
|
||||
<div className={styles.sectionTag}>
|
||||
<Translate id="section.features.tag">FEATURES</Translate>
|
||||
</div>
|
||||
<Heading as="h2" className={styles.sectionTitle}>
|
||||
<Translate id="section.features.title">Everything you need, nothing you don't</Translate>
|
||||
</Heading>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
<Translate id="section.features.subtitle">
|
||||
Battle-tested building blocks — backup runners, storage providers, scheduling, and clustering.
|
||||
</Translate>
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.grid}>
|
||||
{FEATURES.map((feat, idx) => (
|
||||
<Feature key={idx} {...feat} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
169
docs-site/src/components/HomepageFeatures/styles.module.css
Normal file
169
docs-site/src/components/HomepageFeatures/styles.module.css
Normal file
@@ -0,0 +1,169 @@
|
||||
.section {
|
||||
padding: 5.5rem 0 4.25rem;
|
||||
background: var(--ifm-background-color);
|
||||
}
|
||||
|
||||
.sectionHead {
|
||||
text-align: center;
|
||||
max-width: 720px;
|
||||
margin: 0 auto 3rem;
|
||||
}
|
||||
|
||||
.sectionTag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0;
|
||||
color: var(--ifm-color-primary);
|
||||
padding: 4px 12px;
|
||||
background: rgba(22, 93, 255, 0.08);
|
||||
border: 1px solid rgba(22, 93, 255, 0.16);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .sectionTag {
|
||||
background: rgba(96, 126, 255, 0.18);
|
||||
color: var(--ifm-color-primary-lighter);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 2.35rem;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0;
|
||||
font-weight: 750;
|
||||
margin: 0 0 1rem;
|
||||
color: var(--ifm-heading-color);
|
||||
}
|
||||
|
||||
.sectionSubtitle {
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.65;
|
||||
color: var(--ifm-color-content-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 996px) {
|
||||
.section {
|
||||
padding: 3.5rem 0 2rem;
|
||||
}
|
||||
.sectionTitle {
|
||||
font-size: 2rem;
|
||||
}
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 997px) and (max-width: 1200px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.featureCard,
|
||||
.featureCardLink {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.75rem;
|
||||
background: var(--ifm-background-color);
|
||||
border: 1px solid var(--ifm-color-emphasis-200);
|
||||
border-radius: 8px;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
text-decoration: none !important;
|
||||
color: inherit;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.featureCardLink:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--ifm-color-primary);
|
||||
box-shadow: 0 12px 30px -8px rgba(22, 93, 255, 0.18);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .featureCard,
|
||||
[data-theme='dark'] .featureCardLink {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .featureCardLink:hover {
|
||||
background: rgba(64, 128, 255, 0.05);
|
||||
border-color: var(--ifm-color-primary);
|
||||
box-shadow: 0 12px 30px -8px rgba(64, 128, 255, 0.25);
|
||||
}
|
||||
|
||||
.iconWrap {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, rgba(22, 93, 255, 0.1) 0%, rgba(20, 201, 201, 0.12) 100%);
|
||||
color: var(--ifm-color-primary);
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .iconWrap {
|
||||
background: linear-gradient(135deg, rgba(96, 126, 255, 0.15) 0%, rgba(20, 201, 201, 0.12) 100%);
|
||||
color: var(--ifm-color-primary-lighter);
|
||||
}
|
||||
|
||||
.featureTitle {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.6rem;
|
||||
color: var(--ifm-heading-color);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.featureDesc {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.65;
|
||||
color: var(--ifm-color-content-secondary);
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.featureLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 1rem;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
.featureArrow {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.featureCardLink:hover .featureArrow {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.sectionTitle {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.featureCard,
|
||||
.featureCardLink,
|
||||
.featureArrow {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
120
docs-site/src/components/HomepageShowcase/index.tsx
Normal file
120
docs-site/src/components/HomepageShowcase/index.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import type {ReactNode} from 'react';
|
||||
import {useState} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import Heading from '@theme/Heading';
|
||||
import Translate from '@docusaurus/Translate';
|
||||
import useBaseUrl from '@docusaurus/useBaseUrl';
|
||||
import Link from '@docusaurus/Link';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
type Tab = {
|
||||
id: string;
|
||||
label: ReactNode;
|
||||
image: string;
|
||||
title: ReactNode;
|
||||
description: ReactNode;
|
||||
};
|
||||
|
||||
function useTabs(): Tab[] {
|
||||
return [
|
||||
{
|
||||
id: 'dashboard',
|
||||
label: <Translate id="showcase.tab.dashboard">Dashboard</Translate>,
|
||||
image: useBaseUrl('/img/screenshots/dashboard.png'),
|
||||
title: <Translate id="showcase.dashboard.title">Know at a glance</Translate>,
|
||||
description: (
|
||||
<Translate id="showcase.dashboard.desc">
|
||||
Backup success rates, storage usage, recent runs and upcoming schedules — all on one page with live data.
|
||||
</Translate>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'tasks',
|
||||
label: <Translate id="showcase.tab.tasks">Backup Tasks</Translate>,
|
||||
image: useBaseUrl('/img/screenshots/backup-tasks.png'),
|
||||
title: <Translate id="showcase.tasks.title">Visual task editor</Translate>,
|
||||
description: (
|
||||
<Translate id="showcase.tasks.desc">
|
||||
Files, MySQL, PostgreSQL, SQLite and SAP HANA with a three-step wizard. Cron editor, multi-target dispatch, retention, compression and encryption — point and click.
|
||||
</Translate>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'storage',
|
||||
label: <Translate id="showcase.tab.storage">Storage Targets</Translate>,
|
||||
image: useBaseUrl('/img/screenshots/storage-targets.png'),
|
||||
title: <Translate id="showcase.storage.title">70+ backends, one flow</Translate>,
|
||||
description: (
|
||||
<Translate id="showcase.storage.desc">
|
||||
Alibaba OSS, Tencent COS, S3, Google Drive, WebDAV — plus every rclone backend behind a uniform form. Test connection, favourite, and view live usage.
|
||||
</Translate>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'nodes',
|
||||
label: <Translate id="showcase.tab.nodes">Multi-Node</Translate>,
|
||||
image: useBaseUrl('/img/screenshots/nodes.png'),
|
||||
title: <Translate id="showcase.nodes.title">Master-Agent in minutes</Translate>,
|
||||
description: (
|
||||
<Translate id="showcase.nodes.desc">
|
||||
Create a node, copy the token, start the Agent on any remote host. Tasks routed to a node run locally there and upload directly to storage — no reverse connectivity required.
|
||||
</Translate>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default function HomepageShowcase(): ReactNode {
|
||||
const tabs = useTabs();
|
||||
const [active, setActive] = useState(tabs[0].id);
|
||||
const current = tabs.find(t => t.id === active) ?? tabs[0];
|
||||
return (
|
||||
<section className={styles.section}>
|
||||
<div className="container">
|
||||
<div className={styles.sectionHead}>
|
||||
<div className={styles.sectionTag}>
|
||||
<Translate id="showcase.tag">PRODUCT</Translate>
|
||||
</div>
|
||||
<Heading as="h2" className={styles.sectionTitle}>
|
||||
<Translate id="showcase.title">A polished console, not a DIY script</Translate>
|
||||
</Heading>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
<Translate id="showcase.subtitle">
|
||||
Every screen designed for day-2 operations — visibility first, configuration second.
|
||||
</Translate>
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.tabs}>
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
className={clsx(styles.tabBtn, active === tab.id && styles.tabBtnActive)}
|
||||
onClick={() => setActive(tab.id)}>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.stage}>
|
||||
<div className={styles.browser}>
|
||||
<div className={styles.browserBar}>
|
||||
<span className={clsx(styles.browserDot, styles.browserDotRed)} />
|
||||
<span className={clsx(styles.browserDot, styles.browserDotYellow)} />
|
||||
<span className={clsx(styles.browserDot, styles.browserDotGreen)} />
|
||||
<div className={styles.browserUrl}>backupx.local</div>
|
||||
</div>
|
||||
<img src={current.image} alt="" className={styles.screenshot} />
|
||||
</div>
|
||||
<div className={styles.caption}>
|
||||
<Heading as="h3" className={styles.captionTitle}>{current.title}</Heading>
|
||||
<p className={styles.captionDesc}>{current.description}</p>
|
||||
<Link to="/docs/getting-started/quick-start" className={styles.captionLink}>
|
||||
<Translate id="showcase.cta">Explore the docs</Translate>
|
||||
<span aria-hidden="true"> -></span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
247
docs-site/src/components/HomepageShowcase/styles.module.css
Normal file
247
docs-site/src/components/HomepageShowcase/styles.module.css
Normal file
@@ -0,0 +1,247 @@
|
||||
.section {
|
||||
padding: 4.5rem 0 5.5rem;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(245, 247, 250, 0) 0%, rgba(245, 247, 250, 0.72) 100%),
|
||||
var(--ifm-background-color);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .section {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(15, 17, 21, 0) 0%, rgba(255, 255, 255, 0.03) 100%),
|
||||
var(--ifm-background-color);
|
||||
}
|
||||
|
||||
.sectionHead {
|
||||
text-align: center;
|
||||
max-width: 720px;
|
||||
margin: 0 auto 2.5rem;
|
||||
}
|
||||
|
||||
.sectionTag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0;
|
||||
color: #0e7490;
|
||||
padding: 4px 12px;
|
||||
background: rgba(20, 201, 201, 0.1);
|
||||
border: 1px solid rgba(20, 201, 201, 0.2);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .sectionTag {
|
||||
background: rgba(20, 201, 201, 0.16);
|
||||
color: #67e8f9;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 2.35rem;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0;
|
||||
font-weight: 750;
|
||||
margin: 0 0 1rem;
|
||||
color: var(--ifm-heading-color);
|
||||
}
|
||||
|
||||
.sectionSubtitle {
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.65;
|
||||
color: var(--ifm-color-content-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Tab bar */
|
||||
.tabs {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 6px;
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
border: 1px solid var(--ifm-color-emphasis-200);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tabBtn {
|
||||
min-height: 40px;
|
||||
padding: 8px 18px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
color: var(--ifm-color-content-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.tabBtn:hover {
|
||||
color: var(--ifm-color-primary);
|
||||
background: var(--ifm-background-color);
|
||||
}
|
||||
|
||||
.tabBtnActive,
|
||||
.tabBtnActive:hover {
|
||||
background: var(--ifm-background-color);
|
||||
color: var(--ifm-color-primary) !important;
|
||||
border-color: rgba(22, 93, 255, 0.18);
|
||||
box-shadow: 0 6px 16px rgba(22, 93, 255, 0.12);
|
||||
}
|
||||
|
||||
/* Stage */
|
||||
.stage {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
gap: 3rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 996px) {
|
||||
.stage {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.browser {
|
||||
background: var(--ifm-background-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 24px 58px -22px rgba(22, 93, 255, 0.28),
|
||||
0 0 0 1px var(--ifm-color-emphasis-200);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .browser {
|
||||
box-shadow:
|
||||
0 30px 60px -20px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.browserBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-200);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .browserBar {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-bottom-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.browserDot {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.browserDotRed { background: #ff5f56; }
|
||||
.browserDotYellow { background: #ffbd2e; }
|
||||
.browserDotGreen { background: #27c93f; }
|
||||
|
||||
.browserUrl {
|
||||
margin: 0 auto;
|
||||
padding: 3px 14px;
|
||||
background: var(--ifm-background-color);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--ifm-color-content-secondary);
|
||||
font-family: 'SFMono-Regular', Menlo, monospace;
|
||||
border: 1px solid var(--ifm-color-emphasis-200);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .browserUrl {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.screenshot {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
}
|
||||
|
||||
.caption {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 996px) {
|
||||
.caption {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.captionTitle {
|
||||
font-size: 1.7rem;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0;
|
||||
font-weight: 750;
|
||||
margin: 0 0 1rem;
|
||||
color: var(--ifm-heading-color);
|
||||
}
|
||||
|
||||
.captionDesc {
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.7;
|
||||
color: var(--ifm-color-content-secondary);
|
||||
margin: 0 0 1.25rem;
|
||||
}
|
||||
|
||||
.captionLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 40px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgba(22, 93, 255, 0.18);
|
||||
border-radius: 8px;
|
||||
font-weight: 650;
|
||||
color: var(--ifm-color-primary);
|
||||
text-decoration: none !important;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.captionLink:hover {
|
||||
color: var(--ifm-color-primary-dark);
|
||||
background: rgba(22, 93, 255, 0.06);
|
||||
border-color: var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 996px) {
|
||||
.sectionTitle {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.section {
|
||||
padding: 3.25rem 0 4rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.tabBtn {
|
||||
flex: 1 1 130px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tabBtn,
|
||||
.captionLink {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
264
docs-site/src/css/custom.css
Normal file
264
docs-site/src/css/custom.css
Normal file
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* BackupX 官方文档站样式
|
||||
* 灵感:Ant Design / Arco Design
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Primary palette (Arco blue) */
|
||||
--ifm-color-primary: #165dff;
|
||||
--ifm-color-primary-dark: #0e4fe6;
|
||||
--ifm-color-primary-darker: #0b4bd9;
|
||||
--ifm-color-primary-darkest: #093eb3;
|
||||
--ifm-color-primary-light: #2f6cff;
|
||||
--ifm-color-primary-lighter: #3d75ff;
|
||||
--ifm-color-primary-lightest: #668eff;
|
||||
|
||||
/* Surfaces */
|
||||
--ifm-background-color: #ffffff;
|
||||
--ifm-background-surface-color: #ffffff;
|
||||
--ifm-color-emphasis-100: #f5f7fa;
|
||||
--ifm-color-emphasis-200: #e5e6eb;
|
||||
--ifm-color-emphasis-300: #c9cdd4;
|
||||
--ifm-color-emphasis-400: #a9aeb8;
|
||||
|
||||
/* Typography */
|
||||
--ifm-font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
--ifm-font-family-monospace: 'SFMono-Regular', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
--ifm-heading-font-weight: 700;
|
||||
--ifm-code-font-size: 92%;
|
||||
--ifm-h1-font-size: 2.25rem;
|
||||
--ifm-h2-font-size: 1.75rem;
|
||||
--ifm-h3-font-size: 1.35rem;
|
||||
--ifm-line-height-base: 1.7;
|
||||
|
||||
--ifm-color-content: #1d2129;
|
||||
--ifm-color-content-secondary: #4e5969;
|
||||
--ifm-heading-color: #1d2129;
|
||||
--ifm-global-radius: 8px;
|
||||
|
||||
/* Navbar */
|
||||
--ifm-navbar-height: 64px;
|
||||
--ifm-navbar-background-color: rgba(255, 255, 255, 0.9);
|
||||
--ifm-navbar-link-color: #4e5969;
|
||||
--ifm-navbar-link-hover-color: var(--ifm-color-primary);
|
||||
|
||||
/* Sidebar */
|
||||
--ifm-menu-color: #4e5969;
|
||||
--ifm-menu-color-background-active: rgba(22, 93, 255, 0.08);
|
||||
--ifm-menu-color-background-hover: var(--ifm-color-emphasis-100);
|
||||
|
||||
/* Code */
|
||||
--ifm-code-background: rgba(22, 93, 255, 0.06);
|
||||
--docusaurus-highlighted-code-line-bg: rgba(22, 93, 255, 0.08);
|
||||
|
||||
/* Hero background helper (consumed in index.module.css) */
|
||||
--bx-hero-bg: transparent;
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
--ifm-color-primary: #4080ff;
|
||||
--ifm-color-primary-dark: #3371f2;
|
||||
--ifm-color-primary-darker: #2c6ae6;
|
||||
--ifm-color-primary-darkest: #2359c7;
|
||||
--ifm-color-primary-light: #5a93ff;
|
||||
--ifm-color-primary-lighter: #74a5ff;
|
||||
--ifm-color-primary-lightest: #9dbfff;
|
||||
|
||||
--ifm-background-color: #0f1115;
|
||||
--ifm-background-surface-color: #16181d;
|
||||
--ifm-color-emphasis-100: #1d2129;
|
||||
--ifm-color-emphasis-200: #272e3b;
|
||||
--ifm-color-emphasis-300: #384252;
|
||||
--ifm-color-emphasis-400: #4e5969;
|
||||
|
||||
--ifm-color-content: #e6e9ef;
|
||||
--ifm-color-content-secondary: #9aa3b2;
|
||||
--ifm-heading-color: #f0f2f5;
|
||||
|
||||
--ifm-navbar-background-color: rgba(15, 17, 21, 0.9);
|
||||
--ifm-navbar-link-color: #c9d1db;
|
||||
|
||||
--ifm-menu-color: #c9d1db;
|
||||
--ifm-menu-color-background-active: rgba(64, 128, 255, 0.15);
|
||||
--ifm-menu-color-background-hover: rgba(255, 255, 255, 0.04);
|
||||
|
||||
--ifm-code-background: rgba(64, 128, 255, 0.14);
|
||||
--docusaurus-highlighted-code-line-bg: rgba(64, 128, 255, 0.18);
|
||||
}
|
||||
|
||||
/* Frosted-glass navbar */
|
||||
.navbar {
|
||||
backdrop-filter: saturate(180%) blur(10px);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(10px);
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-200);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .navbar {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.navbar__title {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.navbar__link {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.navbar__link,
|
||||
.button,
|
||||
a {
|
||||
transition: color 0.2s ease, background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.button {
|
||||
border-radius: 8px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--ifm-color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Sidebar tweaks */
|
||||
.menu__link {
|
||||
font-size: 14px;
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.menu__link--active,
|
||||
.menu__link--active:hover {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.theme-doc-sidebar-container {
|
||||
border-right: 1px solid var(--ifm-color-emphasis-200) !important;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .theme-doc-sidebar-container {
|
||||
border-right-color: rgba(255, 255, 255, 0.06) !important;
|
||||
}
|
||||
|
||||
/* Article: better heading rhythm */
|
||||
.markdown h2 {
|
||||
margin-top: 2.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--ifm-color-emphasis-200);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .markdown h2 {
|
||||
border-top-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.markdown h3 {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.markdown table {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 0 1px var(--ifm-color-emphasis-200);
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
.markdown table thead tr {
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
}
|
||||
|
||||
.markdown table th,
|
||||
.markdown table td {
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-200);
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.markdown table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
code {
|
||||
background: var(--ifm-code-background);
|
||||
border: none;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
/* Admonitions: softer */
|
||||
.theme-admonition {
|
||||
border-radius: 8px;
|
||||
border-width: 1px;
|
||||
border-left-width: 4px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
--ifm-footer-background-color: #141720;
|
||||
--ifm-footer-color: #9aa3b2;
|
||||
--ifm-footer-link-color: #c9d1db;
|
||||
--ifm-footer-link-hover-color: #ffffff;
|
||||
--ifm-footer-title-color: #f0f2f5;
|
||||
padding: 3.5rem 0 2.5rem;
|
||||
}
|
||||
|
||||
.footer__title {
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.footer__link-item {
|
||||
font-size: 14px;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.footer__bottom {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding-top: 2rem;
|
||||
margin-top: 2.5rem;
|
||||
}
|
||||
|
||||
.footer__copyright {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--ifm-color-emphasis-300);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--ifm-color-emphasis-400);
|
||||
}
|
||||
|
||||
[data-theme='dark'] ::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
19
docs-site/src/pages/community.tsx
Normal file
19
docs-site/src/pages/community.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type {ReactNode} from 'react';
|
||||
import {translate} from '@docusaurus/Translate';
|
||||
import Layout from '@theme/Layout';
|
||||
import HomepageCommunity from '@site/src/components/HomepageCommunity';
|
||||
|
||||
export default function Community(): ReactNode {
|
||||
return (
|
||||
<Layout
|
||||
title={translate({id: 'community.pageTitle', message: 'Community, sponsors and contributors'})}
|
||||
description={translate({
|
||||
id: 'community.pageDescription',
|
||||
message: 'Sponsor BackupX, meet contributors, and find practical ways to contribute.',
|
||||
})}>
|
||||
<main>
|
||||
<HomepageCommunity />
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
470
docs-site/src/pages/index.module.css
Normal file
470
docs-site/src/pages/index.module.css
Normal file
@@ -0,0 +1,470 @@
|
||||
/* Hero */
|
||||
.hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 7rem 0 5.5rem;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(22, 93, 255, 0.08) 0%, rgba(255, 255, 255, 0) 72%),
|
||||
linear-gradient(90deg, rgba(20, 201, 201, 0.08) 0%, rgba(250, 173, 20, 0.08) 100%),
|
||||
var(--ifm-background-color);
|
||||
}
|
||||
|
||||
.hero::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(rgba(22, 93, 255, 0.06) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(22, 93, 255, 0.06) 1px, transparent 1px);
|
||||
background-size: 44px 44px;
|
||||
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.75), transparent 82%);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .hero {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(64, 128, 255, 0.16) 0%, rgba(15, 17, 21, 0) 72%),
|
||||
linear-gradient(90deg, rgba(20, 201, 201, 0.1) 0%, rgba(250, 173, 20, 0.08) 100%),
|
||||
var(--ifm-background-color);
|
||||
}
|
||||
|
||||
.heroInner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(420px, 0.9fr);
|
||||
gap: 4rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.heroContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 32px;
|
||||
padding: 5px 12px;
|
||||
color: var(--ifm-color-primary);
|
||||
background: rgba(22, 93, 255, 0.09);
|
||||
border: 1px solid rgba(22, 93, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .badge {
|
||||
background: rgba(64, 128, 255, 0.16);
|
||||
border-color: rgba(64, 128, 255, 0.3);
|
||||
color: var(--ifm-color-primary-lighter);
|
||||
}
|
||||
|
||||
.badgeDot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background: #00b42a;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 4px rgba(0, 180, 42, 0.12);
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
margin: 0;
|
||||
color: var(--ifm-heading-color);
|
||||
font-size: 3.45rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
.heroTitleAccent {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
max-width: 640px;
|
||||
margin: 0;
|
||||
color: var(--ifm-color-content-secondary);
|
||||
font-size: 1.15rem;
|
||||
line-height: 1.72;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.primaryBtn,
|
||||
.secondaryBtn {
|
||||
min-height: 46px;
|
||||
border-radius: 8px;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.primaryBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #fff;
|
||||
background: #165dff;
|
||||
border: 1px solid #165dff;
|
||||
box-shadow: 0 10px 24px rgba(22, 93, 255, 0.24);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.primaryBtn:hover,
|
||||
.primaryBtn:focus-visible {
|
||||
color: #fff;
|
||||
background: #0e4fe6;
|
||||
border-color: #0e4fe6;
|
||||
box-shadow: 0 14px 30px rgba(22, 93, 255, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btnArrow {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.primaryBtn:hover .btnArrow,
|
||||
.primaryBtn:focus-visible .btnArrow {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
.secondaryBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--ifm-font-color-base);
|
||||
background: var(--ifm-background-color);
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.secondaryBtn:hover,
|
||||
.secondaryBtn:focus-visible {
|
||||
color: var(--ifm-color-primary);
|
||||
border-color: var(--ifm-color-primary);
|
||||
background: var(--ifm-background-color);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 1.25rem;
|
||||
}
|
||||
|
||||
.metric {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.metricValue {
|
||||
color: var(--ifm-heading-color);
|
||||
font-size: 1.35rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metricLabel {
|
||||
color: var(--ifm-color-content-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.35;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.metricDivider {
|
||||
width: 1px;
|
||||
height: 30px;
|
||||
background: var(--ifm-color-emphasis-300);
|
||||
}
|
||||
|
||||
/* Product visual */
|
||||
.heroVisual {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.consolePanel {
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid rgba(22, 93, 255, 0.16);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 24px 60px rgba(29, 33, 41, 0.12);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .consolePanel {
|
||||
background: rgba(22, 24, 29, 0.9);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.34);
|
||||
}
|
||||
|
||||
.consoleHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem;
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-200);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .consoleHeader {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.consoleHeader strong {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--ifm-heading-color);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.consoleEyebrow {
|
||||
color: var(--ifm-color-content-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.consoleStatus {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 4px 10px;
|
||||
color: #00a870;
|
||||
background: rgba(0, 180, 42, 0.1);
|
||||
border: 1px solid rgba(0, 180, 42, 0.2);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.consoleGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-200);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .consoleGrid {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.consoleGrid > div {
|
||||
min-width: 0;
|
||||
padding: 1.1rem 1.25rem;
|
||||
border-right: 1px solid var(--ifm-color-emphasis-200);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .consoleGrid > div {
|
||||
border-right-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.consoleGrid > div:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.consoleGrid strong {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: var(--ifm-heading-color);
|
||||
font-size: 1.45rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.consoleLabel {
|
||||
display: block;
|
||||
color: var(--ifm-color-content-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.timelineRow {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-200);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .timelineRow {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.timelineRow:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.timelineRow strong,
|
||||
.timelineRow span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.timelineRow strong {
|
||||
color: var(--ifm-heading-color);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.timelineRow span {
|
||||
color: var(--ifm-color-content-secondary);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.timelineRow em {
|
||||
color: var(--ifm-color-content-secondary);
|
||||
font-size: 0.8rem;
|
||||
font-style: normal;
|
||||
font-weight: 650;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timelineDotOk,
|
||||
.timelineDotInfo,
|
||||
.timelineDotWarn {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.timelineDotOk {
|
||||
background: #00b42a;
|
||||
}
|
||||
|
||||
.timelineDotInfo {
|
||||
background: #165dff;
|
||||
}
|
||||
|
||||
.timelineDotWarn {
|
||||
background: #ff7d00;
|
||||
}
|
||||
|
||||
.commandCard {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 1rem 1.1rem;
|
||||
background: #111827;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 16px 34px rgba(17, 24, 39, 0.18);
|
||||
}
|
||||
|
||||
.commandTitle {
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.commandCard code {
|
||||
overflow-x: auto;
|
||||
color: #e5e7eb;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 996px) {
|
||||
.hero {
|
||||
padding: 4.5rem 0 3.5rem;
|
||||
}
|
||||
|
||||
.heroInner {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2.25rem;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: 2.45rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hero {
|
||||
padding: 3.75rem 0 2.75rem;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: 2.05rem;
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.primaryBtn,
|
||||
.secondaryBtn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
width: 100%;
|
||||
align-items: stretch;
|
||||
gap: 0.85rem;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.metricDivider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.consoleHeader,
|
||||
.timelineRow {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.consoleGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.consoleGrid > div {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-200);
|
||||
}
|
||||
|
||||
.consoleGrid > div:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .consoleGrid > div {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.primaryBtn,
|
||||
.secondaryBtn,
|
||||
.btnArrow {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
169
docs-site/src/pages/index.tsx
Normal file
169
docs-site/src/pages/index.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import type {ReactNode} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import Link from '@docusaurus/Link';
|
||||
import Translate, {translate} from '@docusaurus/Translate';
|
||||
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
||||
import Layout from '@theme/Layout';
|
||||
import Heading from '@theme/Heading';
|
||||
import HomepageFeatures from '@site/src/components/HomepageFeatures';
|
||||
import HomepageShowcase from '@site/src/components/HomepageShowcase';
|
||||
import HomepageCommunity from '@site/src/components/HomepageCommunity';
|
||||
|
||||
import styles from './index.module.css';
|
||||
|
||||
function HomepageHeader() {
|
||||
return (
|
||||
<header className={styles.hero}>
|
||||
<div className={clsx('container', styles.heroInner)}>
|
||||
<div className={styles.heroContent}>
|
||||
<div className={styles.badge}>
|
||||
<span className={styles.badgeDot} />
|
||||
<Translate id="home.badge">Open-source backup control plane · v2.2.1</Translate>
|
||||
</div>
|
||||
<Heading as="h1" className={styles.heroTitle}>
|
||||
<Translate id="home.title.part1">Backup orchestration</Translate>
|
||||
<span className={styles.heroTitleAccent}>
|
||||
<Translate id="home.title.part2">for self-hosted servers.</Translate>
|
||||
</span>
|
||||
</Heading>
|
||||
<p className={styles.heroSubtitle}>
|
||||
<Translate id="home.tagline">
|
||||
Run file, database, SAP HANA and remote-node backups from one clean console. Keep the control plane yours, keep the storage flexible.
|
||||
</Translate>
|
||||
</p>
|
||||
<div className={styles.actions}>
|
||||
<Link className={clsx('button button--primary button--lg', styles.primaryBtn)} to="/docs/getting-started/quick-start">
|
||||
<Translate id="home.getStarted">Get Started</Translate>
|
||||
<span className={styles.btnArrow} aria-hidden="true">-></span>
|
||||
</Link>
|
||||
<Link className={clsx('button button--lg', styles.secondaryBtn)} to="https://github.com/Awuqing/BackupX">
|
||||
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" style={{marginRight: 6}}>
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 005.47 7.59c.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
GitHub
|
||||
</Link>
|
||||
</div>
|
||||
<div className={styles.metrics}>
|
||||
<div className={styles.metric}>
|
||||
<div className={styles.metricValue}>70+</div>
|
||||
<div className={styles.metricLabel}>
|
||||
<Translate id="home.metric.backends">Storage backends</Translate>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.metricDivider} />
|
||||
<div className={styles.metric}>
|
||||
<div className={styles.metricValue}>Agent</div>
|
||||
<div className={styles.metricLabel}>
|
||||
<Translate id="home.metric.backupTypes">Remote execution</Translate>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.metricDivider} />
|
||||
<div className={styles.metric}>
|
||||
<div className={styles.metricValue}>Apache 2.0</div>
|
||||
<div className={styles.metricLabel}>
|
||||
<Translate id="home.metric.license">License</Translate>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.heroVisual}>
|
||||
<div className={styles.consolePanel}>
|
||||
<div className={styles.consoleHeader}>
|
||||
<div>
|
||||
<span className={styles.consoleEyebrow}>
|
||||
<Translate id="home.visual.eyebrow">BackupX Console</Translate>
|
||||
</span>
|
||||
<strong>
|
||||
<Translate id="home.visual.title">Operations overview</Translate>
|
||||
</strong>
|
||||
</div>
|
||||
<span className={styles.consoleStatus}>
|
||||
<Translate id="home.visual.status">Healthy</Translate>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.consoleGrid}>
|
||||
<div>
|
||||
<span className={styles.consoleLabel}>
|
||||
<Translate id="home.visual.success">Success rate</Translate>
|
||||
</span>
|
||||
<strong>99.4%</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.consoleLabel}>
|
||||
<Translate id="home.visual.nodes">Active nodes</Translate>
|
||||
</span>
|
||||
<strong>12</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.consoleLabel}>
|
||||
<Translate id="home.visual.targets">Storage targets</Translate>
|
||||
</span>
|
||||
<strong>8</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.timeline}>
|
||||
<div className={styles.timelineRow}>
|
||||
<span className={styles.timelineDotOk} />
|
||||
<div>
|
||||
<strong>
|
||||
<Translate id="home.visual.row1.title">PostgreSQL nightly</Translate>
|
||||
</strong>
|
||||
<span>
|
||||
<Translate id="home.visual.row1.desc">Encrypted archive uploaded to S3</Translate>
|
||||
</span>
|
||||
</div>
|
||||
<em>02:10</em>
|
||||
</div>
|
||||
<div className={styles.timelineRow}>
|
||||
<span className={styles.timelineDotInfo} />
|
||||
<div>
|
||||
<strong>
|
||||
<Translate id="home.visual.row2.title">SAP HANA snapshot</Translate>
|
||||
</strong>
|
||||
<span>
|
||||
<Translate id="home.visual.row2.desc">Running on agent-shanghai-02</Translate>
|
||||
</span>
|
||||
</div>
|
||||
<em>68%</em>
|
||||
</div>
|
||||
<div className={styles.timelineRow}>
|
||||
<span className={styles.timelineDotWarn} />
|
||||
<div>
|
||||
<strong>
|
||||
<Translate id="home.visual.row3.title">Retention cleanup</Translate>
|
||||
</strong>
|
||||
<span>
|
||||
<Translate id="home.visual.row3.desc">Next run in 4 hours</Translate>
|
||||
</span>
|
||||
</div>
|
||||
<em>queued</em>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.commandCard}>
|
||||
<div className={styles.commandTitle}>
|
||||
<Translate id="home.command.title">Start with Docker</Translate>
|
||||
</div>
|
||||
<code>docker run -d -p 8340:8340 awuqing/backupx:v2.2.1</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home(): ReactNode {
|
||||
const {siteConfig} = useDocusaurusContext();
|
||||
return (
|
||||
<Layout
|
||||
title={translate({id: 'home.pageTitle', message: 'Backup orchestration for self-hosted servers'})}
|
||||
description={siteConfig.tagline}>
|
||||
<HomepageHeader />
|
||||
<main>
|
||||
<HomepageFeatures />
|
||||
<HomepageShowcase />
|
||||
<HomepageCommunity />
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
39
docs-site/src/pages/sponsors.tsx
Normal file
39
docs-site/src/pages/sponsors.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type {ReactNode} from 'react';
|
||||
import {translate} from '@docusaurus/Translate';
|
||||
import Translate from '@docusaurus/Translate';
|
||||
import Layout from '@theme/Layout';
|
||||
import Heading from '@theme/Heading';
|
||||
import {HomepageSponsors} from '@site/src/components/HomepageCommunity';
|
||||
import styles from '@site/src/components/HomepageCommunity/styles.module.css';
|
||||
|
||||
export default function Sponsors(): ReactNode {
|
||||
return (
|
||||
<Layout
|
||||
title={translate({id: 'sponsors.pageTitle', message: 'Sponsors'})}
|
||||
description={translate({
|
||||
id: 'sponsors.pageDescription',
|
||||
message: 'Sponsor BackupX reliability, documentation, storage compatibility and long-term maintenance.',
|
||||
})}>
|
||||
<main>
|
||||
<section className={styles.section}>
|
||||
<div className="container">
|
||||
<div className={styles.sectionHead}>
|
||||
<div className={styles.sectionTag}>
|
||||
<Translate id="sponsors.tag">SPONSORS</Translate>
|
||||
</div>
|
||||
<Heading as="h1" className={styles.sectionTitle}>
|
||||
<Translate id="sponsors.title">Sponsor the BackupX ecosystem</Translate>
|
||||
</Heading>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
<Translate id="sponsors.subtitle">
|
||||
Sponsorship helps keep BackupX practical for real operators: tested storage providers, reliable releases, restore confidence and better documentation.
|
||||
</Translate>
|
||||
</p>
|
||||
</div>
|
||||
<HomepageSponsors />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
0
docs-site/static/.nojekyll
Normal file
0
docs-site/static/.nojekyll
Normal file
BIN
docs-site/static/img/favicon.ico
Normal file
BIN
docs-site/static/img/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
11
docs-site/static/img/logo.svg
Normal file
11
docs-site/static/img/logo.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
|
||||
<defs>
|
||||
<linearGradient id="bxg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#2f6cff"/>
|
||||
<stop offset="100%" stop-color="#0b3eb3"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="4" y="4" width="40" height="40" rx="8" fill="url(#bxg)"/>
|
||||
<path d="M16 14h10a5 5 0 0 1 0 10H16V14z" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M16 24h12a5 5 0 0 1 0 10H16V24z" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 639 B |
BIN
docs-site/static/img/screenshots/backup-tasks.png
Normal file
BIN
docs-site/static/img/screenshots/backup-tasks.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
BIN
docs-site/static/img/screenshots/dashboard.png
Normal file
BIN
docs-site/static/img/screenshots/dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
BIN
docs-site/static/img/screenshots/nodes.png
Normal file
BIN
docs-site/static/img/screenshots/nodes.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
BIN
docs-site/static/img/screenshots/storage-targets.png
Normal file
BIN
docs-site/static/img/screenshots/storage-targets.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
12
docs-site/tsconfig.json
Normal file
12
docs-site/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
// This file is not used by "docusaurus start/build" commands.
|
||||
// It is here to improve your IDE experience (type-checking, autocompletion...),
|
||||
// and can also run the package.json "typecheck" script manually.
|
||||
{
|
||||
"extends": "@docusaurus/tsconfig",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"strict": true
|
||||
},
|
||||
"exclude": [".docusaurus", "build"]
|
||||
}
|
||||
70
server/cmd/backupx/agent.go
Normal file
70
server/cmd/backupx/agent.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"backupx/server/internal/agent"
|
||||
)
|
||||
|
||||
// runAgent 是 `backupx agent` 子命令入口。
|
||||
//
|
||||
// 用法:
|
||||
//
|
||||
// backupx agent --master http://master:8340 --token <token>
|
||||
// backupx agent --config /etc/backupx-agent.yaml
|
||||
//
|
||||
// 配置优先级:CLI 参数 > 配置文件 > 环境变量
|
||||
func runAgent(args []string) {
|
||||
fs := flag.NewFlagSet("agent", flag.ExitOnError)
|
||||
configPath := fs.String("config", "", "path to agent config YAML (optional)")
|
||||
master := fs.String("master", "", "master URL, e.g. http://master.example.com:8340")
|
||||
token := fs.String("token", "", "agent authentication token")
|
||||
tempDir := fs.String("temp-dir", "", "local temp directory for backup artifacts")
|
||||
insecureTLS := fs.Bool("insecure-tls", false, "skip TLS verification (testing only)")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
cfg, err := loadAgentConfig(*configPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "agent: load config: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
cfg.MergeWithFlags(*master, *token, *tempDir)
|
||||
if *insecureTLS {
|
||||
cfg.InsecureSkipTLSVerify = true
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "agent: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
a, err := agent.New(cfg, version)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "agent: init: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
fmt.Fprintf(os.Stderr, "backupx agent %s starting (master=%s)\n", version, cfg.Master)
|
||||
if err := a.Run(ctx); err != nil && err != context.Canceled {
|
||||
fmt.Fprintf(os.Stderr, "agent: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// loadAgentConfig 按优先级加载配置:如果提供了 --config 就用文件,否则走环境变量。
|
||||
func loadAgentConfig(configPath string) (*agent.Config, error) {
|
||||
if configPath != "" {
|
||||
return agent.LoadConfigFile(configPath)
|
||||
}
|
||||
return agent.LoadConfigFromEnv()
|
||||
}
|
||||
98
server/cmd/backupx/backint.go
Normal file
98
server/cmd/backupx/backint.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"backupx/server/internal/backint"
|
||||
)
|
||||
|
||||
// runBackint 是 `backupx backint` 子命令入口。
|
||||
//
|
||||
// CLI 参数遵循 SAP HANA Backint 规范:
|
||||
//
|
||||
// backupx backint -f <function> -i <input-file> -o <output-file> -p <param-file>
|
||||
// [-u <user>] [-c <config-prefix>] [-l <log-file>] [-v <version>]
|
||||
//
|
||||
// 除 -f / -i / -o / -p 外其余参数接受但忽略(兼容 SAP 调用约定)。
|
||||
func runBackint(args []string) {
|
||||
fs := flag.NewFlagSet("backint", flag.ExitOnError)
|
||||
fnStr := fs.String("f", "", "function: backup | restore | inquire | delete")
|
||||
inputPath := fs.String("i", "", "input file path")
|
||||
outputPath := fs.String("o", "", "output file path")
|
||||
paramFile := fs.String("p", "", "parameter file path")
|
||||
|
||||
// 以下参数仅为兼容 SAP 调用约定,当前未使用
|
||||
_ = fs.String("u", "", "user (ignored)")
|
||||
_ = fs.String("c", "", "config-prefix (ignored)")
|
||||
_ = fs.String("l", "", "log file override (ignored, use LOG_FILE in params)")
|
||||
_ = fs.String("v", "", "backint version (ignored)")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
if *fnStr == "" || *inputPath == "" || *outputPath == "" || *paramFile == "" {
|
||||
fmt.Fprintln(os.Stderr, "backint: -f, -i, -o, -p are required")
|
||||
fs.Usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
fn, err := backint.ParseFunction(*fnStr)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "backint: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
cfg, err := backint.LoadConfigFile(*paramFile)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "backint: load config: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
// 配置日志重定向(如果指定 LOG_FILE)
|
||||
restoreLog, err := redirectStderr(cfg.LogFile)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "backint: open log: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
defer restoreLog()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
agent, err := backint.NewAgent(ctx, cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "backint: init agent: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = agent.Close() }()
|
||||
|
||||
if err := agent.Run(ctx, fn, *inputPath, *outputPath); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "backint: run: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// redirectStderr 将 stderr 重定向到指定日志文件,返回恢复函数。
|
||||
// 空字符串表示保持原样。
|
||||
func redirectStderr(path string) (func(), error) {
|
||||
if path == "" {
|
||||
return func() {}, nil
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orig := os.Stderr
|
||||
os.Stderr = f
|
||||
return func() {
|
||||
os.Stderr = orig
|
||||
_ = f.Close()
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -24,6 +24,16 @@ func main() {
|
||||
runResetPassword(os.Args[2:])
|
||||
return
|
||||
}
|
||||
// 子命令分发:backint(SAP HANA Backint Agent 模式)
|
||||
if len(os.Args) > 1 && os.Args[1] == "backint" {
|
||||
runBackint(os.Args[2:])
|
||||
return
|
||||
}
|
||||
// 子命令分发:agent(远程节点 Agent 模式)
|
||||
if len(os.Args) > 1 && os.Args[1] == "agent" {
|
||||
runAgent(os.Args[2:])
|
||||
return
|
||||
}
|
||||
|
||||
var configPath string
|
||||
var showVersion bool
|
||||
|
||||
@@ -7,6 +7,8 @@ require (
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/natefinch/lumberjack v2.0.0+incompatible
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/rclone/rclone v1.73.3
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/spf13/viper v1.20.0
|
||||
@@ -14,6 +16,7 @@ require (
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
google.golang.org/api v0.255.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
|
||||
@@ -179,8 +182,6 @@ require (
|
||||
github.com/pkg/xattr v0.4.12 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/pquerna/otp v1.5.0 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.2 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
@@ -245,7 +246,6 @@ require (
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
gopkg.in/validator.v2 v2.0.1 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
|
||||
288
server/internal/agent/agent.go
Normal file
288
server/internal/agent/agent.go
Normal file
@@ -0,0 +1,288 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/backup"
|
||||
)
|
||||
|
||||
// Agent 是 Agent 进程的主控制器。
|
||||
type Agent struct {
|
||||
cfg *Config
|
||||
client *MasterClient
|
||||
executor *Executor
|
||||
version string
|
||||
|
||||
mu sync.Mutex
|
||||
started bool
|
||||
}
|
||||
|
||||
// New 构造 Agent。
|
||||
func New(cfg *Config, version string) (*Agent, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client := NewMasterClient(cfg.Master, cfg.Token, cfg.InsecureSkipTLSVerify)
|
||||
executor := NewExecutor(client, cfg.TempDir)
|
||||
return &Agent{
|
||||
cfg: cfg,
|
||||
client: client,
|
||||
executor: executor,
|
||||
version: version,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Run 启动 Agent 主循环,阻塞直到 ctx 被取消。
|
||||
func (a *Agent) Run(ctx context.Context) error {
|
||||
a.mu.Lock()
|
||||
if a.started {
|
||||
a.mu.Unlock()
|
||||
return fmt.Errorf("agent already started")
|
||||
}
|
||||
a.started = true
|
||||
a.mu.Unlock()
|
||||
|
||||
hbInterval := parseDuration(a.cfg.HeartbeatInterval, 15*time.Second)
|
||||
pollInterval := parseDuration(a.cfg.PollInterval, 5*time.Second)
|
||||
|
||||
// 首次握手:通过一次心跳确认 token 有效
|
||||
if err := a.heartbeatOnce(ctx); err != nil {
|
||||
return fmt.Errorf("initial heartbeat failed: %w", err)
|
||||
}
|
||||
log.Printf("[agent] connected to master %s", a.cfg.Master)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
a.heartbeatLoop(ctx, hbInterval)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
a.pollLoop(ctx, pollInterval)
|
||||
}()
|
||||
wg.Wait()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// heartbeatLoop 定期发送心跳。
|
||||
func (a *Agent) heartbeatLoop(ctx context.Context, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := a.heartbeatOnce(ctx); err != nil {
|
||||
log.Printf("[agent] heartbeat failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) heartbeatOnce(ctx context.Context) error {
|
||||
hostname, _ := os.Hostname()
|
||||
req := HeartbeatRequest{
|
||||
Token: a.cfg.Token,
|
||||
Hostname: hostname,
|
||||
IPAddress: detectLocalIP(),
|
||||
AgentVersion: a.version,
|
||||
OS: runtime.GOOS,
|
||||
Arch: runtime.GOARCH,
|
||||
}
|
||||
_, err := a.client.Heartbeat(ctx, req)
|
||||
return err
|
||||
}
|
||||
|
||||
// pollLoop 定期拉取并处理待执行命令。
|
||||
func (a *Agent) pollLoop(ctx context.Context, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
a.pollAndHandleOnce(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) pollAndHandleOnce(ctx context.Context) {
|
||||
cmd, err := a.client.PollCommand(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[agent] poll command failed: %v", err)
|
||||
return
|
||||
}
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
log.Printf("[agent] received command #%d type=%s", cmd.ID, cmd.Type)
|
||||
switch cmd.Type {
|
||||
case "run_task":
|
||||
a.handleRunTask(ctx, cmd)
|
||||
case "list_dir":
|
||||
a.handleListDir(ctx, cmd)
|
||||
case "restore_record":
|
||||
a.handleRestoreRecord(ctx, cmd)
|
||||
case "discover_db":
|
||||
a.handleDiscoverDB(ctx, cmd)
|
||||
case "delete_storage_object":
|
||||
a.handleDeleteStorageObject(ctx, cmd)
|
||||
default:
|
||||
msg := fmt.Sprintf("unknown command type: %s", cmd.Type)
|
||||
log.Printf("[agent] %s", msg)
|
||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, msg, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// handleRunTask 处理 run_task 命令
|
||||
func (a *Agent) handleRunTask(ctx context.Context, cmd *CommandPayload) {
|
||||
var payload struct {
|
||||
TaskID uint `json:"taskId"`
|
||||
RecordID uint `json:"recordId"`
|
||||
}
|
||||
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
|
||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil)
|
||||
return
|
||||
}
|
||||
if err := a.executor.ExecuteRunTask(ctx, payload.TaskID, payload.RecordID); err != nil {
|
||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, true, "", map[string]any{
|
||||
"taskId": payload.TaskID,
|
||||
"recordId": payload.RecordID,
|
||||
})
|
||||
}
|
||||
|
||||
// handleRestoreRecord 处理 restore_record 命令
|
||||
func (a *Agent) handleRestoreRecord(ctx context.Context, cmd *CommandPayload) {
|
||||
var payload struct {
|
||||
RestoreRecordID uint `json:"restoreRecordId"`
|
||||
}
|
||||
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
|
||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil)
|
||||
return
|
||||
}
|
||||
if payload.RestoreRecordID == 0 {
|
||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "restoreRecordId is required", nil)
|
||||
return
|
||||
}
|
||||
if err := a.executor.ExecuteRestore(ctx, payload.RestoreRecordID); err != nil {
|
||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, true, "", map[string]any{
|
||||
"restoreRecordId": payload.RestoreRecordID,
|
||||
})
|
||||
}
|
||||
|
||||
// handleDeleteStorageObject 处理 delete_storage_object 命令:在 Agent 侧删除指定存储对象。
|
||||
// 用于跨节点 local_disk 场景下的远程备份文件清理。
|
||||
func (a *Agent) handleDeleteStorageObject(ctx context.Context, cmd *CommandPayload) {
|
||||
var payload struct {
|
||||
TargetType string `json:"targetType"`
|
||||
TargetConfig map[string]any `json:"targetConfig"`
|
||||
StoragePath string `json:"storagePath"`
|
||||
}
|
||||
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
|
||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(payload.StoragePath) == "" {
|
||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "storagePath is required", nil)
|
||||
return
|
||||
}
|
||||
provider, err := a.executor.storageRegistry.Create(ctx, payload.TargetType, payload.TargetConfig)
|
||||
if err != nil {
|
||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "create provider: "+err.Error(), nil)
|
||||
return
|
||||
}
|
||||
if err := provider.Delete(ctx, payload.StoragePath); err != nil {
|
||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "delete object: "+err.Error(), nil)
|
||||
return
|
||||
}
|
||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, true, "", map[string]any{"deleted": true})
|
||||
}
|
||||
|
||||
// handleDiscoverDB 处理 discover_db 命令:在 Agent 本机执行 mysql/psql 列出数据库。
|
||||
func (a *Agent) handleDiscoverDB(ctx context.Context, cmd *CommandPayload) {
|
||||
var payload struct {
|
||||
Type string `json:"type"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
|
||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil)
|
||||
return
|
||||
}
|
||||
databases, err := backup.DiscoverDatabases(ctx, backup.NewOSCommandExecutor(), backup.DiscoverRequest{
|
||||
Type: payload.Type,
|
||||
Host: payload.Host,
|
||||
Port: payload.Port,
|
||||
User: payload.User,
|
||||
Password: payload.Password,
|
||||
})
|
||||
if err != nil {
|
||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, true, "", map[string]any{"databases": databases})
|
||||
}
|
||||
|
||||
// handleListDir 处理 list_dir 命令(阶段四实现)
|
||||
func (a *Agent) handleListDir(ctx context.Context, cmd *CommandPayload) {
|
||||
var payload struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := json.Unmarshal(cmd.Payload, &payload); err != nil {
|
||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil)
|
||||
return
|
||||
}
|
||||
entries, err := listLocalDir(payload.Path)
|
||||
if err != nil {
|
||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, false, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
_ = a.client.SubmitCommandResult(ctx, cmd.ID, true, "", map[string]any{"entries": entries})
|
||||
}
|
||||
|
||||
// 辅助函数
|
||||
|
||||
func parseDuration(s string, fallback time.Duration) time.Duration {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return fallback
|
||||
}
|
||||
if d, err := time.ParseDuration(s); err == nil {
|
||||
return d
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func detectLocalIP() string {
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
if ipNet, ok := addr.(*net.IPNet); ok && !ipNet.IP.IsLoopback() {
|
||||
if ipNet.IP.To4() != nil {
|
||||
return ipNet.IP.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
254
server/internal/agent/client.go
Normal file
254
server/internal/agent/client.go
Normal file
@@ -0,0 +1,254 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MasterClient 是 Agent 调用 Master HTTP API 的封装。
|
||||
type MasterClient struct {
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewMasterClient 构造 Master 客户端。
|
||||
func NewMasterClient(baseURL, token string, insecureTLS bool) *MasterClient {
|
||||
transport := &http.Transport{}
|
||||
if insecureTLS {
|
||||
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
}
|
||||
return &MasterClient{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
token: token,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 120 * time.Second,
|
||||
Transport: transport,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// HeartbeatRequest Agent 上报心跳的请求
|
||||
type HeartbeatRequest struct {
|
||||
Token string `json:"token"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
IPAddress string `json:"ipAddress,omitempty"`
|
||||
AgentVersion string `json:"agentVersion,omitempty"`
|
||||
OS string `json:"os,omitempty"`
|
||||
Arch string `json:"arch,omitempty"`
|
||||
}
|
||||
|
||||
// HeartbeatResponse Master 返回的心跳响应
|
||||
type HeartbeatResponse struct {
|
||||
Status string `json:"status"`
|
||||
NodeID uint `json:"nodeId"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// Heartbeat 上报心跳并获取节点元信息
|
||||
func (c *MasterClient) Heartbeat(ctx context.Context, req HeartbeatRequest) (*HeartbeatResponse, error) {
|
||||
var resp HeartbeatResponse
|
||||
if err := c.do(ctx, http.MethodPost, "/api/agent/heartbeat", req, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// CommandPayload 与 service.AgentCommandPayload 对齐
|
||||
type CommandPayload struct {
|
||||
ID uint `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Payload json.RawMessage `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
// PollCommandResponse 轮询响应:无命令时 Command 为 nil
|
||||
type PollCommandResponse struct {
|
||||
Command *CommandPayload `json:"command"`
|
||||
}
|
||||
|
||||
// PollCommand 拉取下一条待执行命令
|
||||
func (c *MasterClient) PollCommand(ctx context.Context) (*CommandPayload, error) {
|
||||
var resp PollCommandResponse
|
||||
if err := c.do(ctx, http.MethodPost, "/api/agent/commands/poll", nil, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Command, nil
|
||||
}
|
||||
|
||||
// SubmitCommandResult 上报命令执行结果
|
||||
func (c *MasterClient) SubmitCommandResult(ctx context.Context, cmdID uint, success bool, errorMsg string, result any) error {
|
||||
var resultJSON json.RawMessage
|
||||
if result != nil {
|
||||
data, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal result: %w", err)
|
||||
}
|
||||
resultJSON = data
|
||||
}
|
||||
payload := map[string]any{
|
||||
"success": success,
|
||||
"errorMessage": errorMsg,
|
||||
}
|
||||
if resultJSON != nil {
|
||||
payload["result"] = resultJSON
|
||||
}
|
||||
path := fmt.Sprintf("/api/agent/commands/%d/result", cmdID)
|
||||
return c.do(ctx, http.MethodPost, path, payload, nil)
|
||||
}
|
||||
|
||||
// TaskSpec 与 service.AgentTaskSpec 对齐
|
||||
type TaskSpec struct {
|
||||
TaskID uint `json:"taskId"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
SourcePath string `json:"sourcePath"`
|
||||
SourcePaths string `json:"sourcePaths"`
|
||||
ExcludePatterns string `json:"excludePatterns"`
|
||||
DBHost string `json:"dbHost"`
|
||||
DBPort int `json:"dbPort"`
|
||||
DBUser string `json:"dbUser"`
|
||||
DBPassword string `json:"dbPassword"`
|
||||
DBName string `json:"dbName"`
|
||||
DBPath string `json:"dbPath"`
|
||||
ExtraConfig string `json:"extraConfig"`
|
||||
Compression string `json:"compression"`
|
||||
Encrypt bool `json:"encrypt"`
|
||||
StorageTargets []StorageTargetConfig `json:"storageTargets"`
|
||||
}
|
||||
|
||||
// StorageTargetConfig 与 service.AgentStorageTargetConfig 对齐
|
||||
type StorageTargetConfig struct {
|
||||
ID uint `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
}
|
||||
|
||||
// GetTaskSpec 拉取任务规格
|
||||
func (c *MasterClient) GetTaskSpec(ctx context.Context, taskID uint) (*TaskSpec, error) {
|
||||
var spec TaskSpec
|
||||
path := fmt.Sprintf("/api/agent/tasks/%d", taskID)
|
||||
if err := c.do(ctx, http.MethodGet, path, nil, &spec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &spec, nil
|
||||
}
|
||||
|
||||
// RecordUpdate 与 service.AgentRecordUpdate 对齐
|
||||
type RecordUpdate struct {
|
||||
Status string `json:"status,omitempty"`
|
||||
FileName string `json:"fileName,omitempty"`
|
||||
FileSize int64 `json:"fileSize,omitempty"`
|
||||
Checksum string `json:"checksum,omitempty"`
|
||||
StoragePath string `json:"storagePath,omitempty"`
|
||||
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||
LogAppend string `json:"logAppend,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateRecord 上报备份记录的状态/日志
|
||||
func (c *MasterClient) UpdateRecord(ctx context.Context, recordID uint, update RecordUpdate) error {
|
||||
path := fmt.Sprintf("/api/agent/records/%d", recordID)
|
||||
return c.do(ctx, http.MethodPost, path, update, nil)
|
||||
}
|
||||
|
||||
// RestoreSpec 与 service.AgentRestoreSpec 对齐
|
||||
type RestoreSpec struct {
|
||||
RestoreRecordID uint `json:"restoreRecordId"`
|
||||
BackupRecordID uint `json:"backupRecordId"`
|
||||
TaskID uint `json:"taskId"`
|
||||
TaskName string `json:"taskName"`
|
||||
Type string `json:"type"`
|
||||
SourcePath string `json:"sourcePath,omitempty"`
|
||||
SourcePaths []string `json:"sourcePaths,omitempty"`
|
||||
DBHost string `json:"dbHost,omitempty"`
|
||||
DBPort int `json:"dbPort,omitempty"`
|
||||
DBUser string `json:"dbUser,omitempty"`
|
||||
DBPassword string `json:"dbPassword,omitempty"`
|
||||
DBName string `json:"dbName,omitempty"`
|
||||
DBPath string `json:"dbPath,omitempty"`
|
||||
ExtraConfig string `json:"extraConfig,omitempty"`
|
||||
Compression string `json:"compression"`
|
||||
Encrypt bool `json:"encrypt"`
|
||||
Storage StorageTargetConfig `json:"storage"`
|
||||
StoragePath string `json:"storagePath"`
|
||||
FileName string `json:"fileName"`
|
||||
}
|
||||
|
||||
// RestoreUpdate 与 service.AgentRestoreUpdate 对齐
|
||||
type RestoreUpdate struct {
|
||||
Status string `json:"status,omitempty"`
|
||||
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||
LogAppend string `json:"logAppend,omitempty"`
|
||||
}
|
||||
|
||||
// GetRestoreSpec 拉取恢复规格
|
||||
func (c *MasterClient) GetRestoreSpec(ctx context.Context, restoreRecordID uint) (*RestoreSpec, error) {
|
||||
var spec RestoreSpec
|
||||
path := fmt.Sprintf("/api/agent/restores/%d/spec", restoreRecordID)
|
||||
if err := c.do(ctx, http.MethodGet, path, nil, &spec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &spec, nil
|
||||
}
|
||||
|
||||
// UpdateRestore 上报恢复记录的状态/日志
|
||||
func (c *MasterClient) UpdateRestore(ctx context.Context, restoreRecordID uint, update RestoreUpdate) error {
|
||||
path := fmt.Sprintf("/api/agent/restores/%d", restoreRecordID)
|
||||
return c.do(ctx, http.MethodPost, path, update, nil)
|
||||
}
|
||||
|
||||
// do 是通用 HTTP 调用。所有 Agent API 都统一走 JSON + X-Agent-Token。
|
||||
func (c *MasterClient) do(ctx context.Context, method, path string, body any, out any) error {
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
reqBody = bytes.NewReader(data)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reqBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("X-Agent-Token", c.token)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s %s: %w", method, path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("%s %s: http %d: %s", method, path, resp.StatusCode, string(data))
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
// BackupX API 统一封装成 {code, data, message} 形式,需要解出 data 字段
|
||||
var envelope struct {
|
||||
Code string `json:"code"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &envelope); err == nil && envelope.Data != nil {
|
||||
if err := json.Unmarshal(envelope.Data, out); err != nil {
|
||||
return fmt.Errorf("decode data: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// 兼容直接返回对象的情况
|
||||
return json.Unmarshal(data, out)
|
||||
}
|
||||
105
server/internal/agent/config.go
Normal file
105
server/internal/agent/config.go
Normal file
@@ -0,0 +1,105 @@
|
||||
// Package agent 实现 BackupX 远程 Agent。
|
||||
//
|
||||
// Agent 是一个独立的 Go 进程,部署在远程服务器上,通过 HTTP 轮询的方式
|
||||
// 与 Master 通信:定期上报心跳、拉取 Master 下发的命令、本地执行备份、
|
||||
// 把执行结果和日志回报给 Master。
|
||||
//
|
||||
// 通信协议见 server/internal/http/agent_handler.go。
|
||||
package agent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config 是 Agent 的运行时配置。
|
||||
type Config struct {
|
||||
// Master BackupX Master 的 HTTP 基础地址,例如 http://master.example.com:8340
|
||||
Master string `yaml:"master"`
|
||||
// Token 节点认证令牌(在 Master 创建节点时生成)
|
||||
Token string `yaml:"token"`
|
||||
// HeartbeatInterval 心跳间隔,默认 15s
|
||||
HeartbeatInterval string `yaml:"heartbeatInterval"`
|
||||
// PollInterval 命令轮询间隔,默认 5s
|
||||
PollInterval string `yaml:"pollInterval"`
|
||||
// TempDir 备份临时目录,默认 /var/lib/backupx-agent/tmp
|
||||
TempDir string `yaml:"tempDir"`
|
||||
// InsecureSkipTLSVerify 测试环境允许跳过 TLS 证书校验
|
||||
InsecureSkipTLSVerify bool `yaml:"insecureSkipTlsVerify"`
|
||||
}
|
||||
|
||||
// LoadConfigFile 从 YAML 文件加载 Agent 配置。
|
||||
func LoadConfigFile(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read agent config: %w", err)
|
||||
}
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse agent config: %w", err)
|
||||
}
|
||||
return applyConfigDefaults(&cfg)
|
||||
}
|
||||
|
||||
// LoadConfigFromEnv 从环境变量加载 Agent 配置。优先级低于 --config 文件。
|
||||
//
|
||||
// 支持的环境变量:
|
||||
// - BACKUPX_AGENT_MASTER Master URL
|
||||
// - BACKUPX_AGENT_TOKEN 节点认证令牌
|
||||
// - BACKUPX_AGENT_HEARTBEAT 心跳间隔(如 15s)
|
||||
// - BACKUPX_AGENT_POLL 命令轮询间隔(如 5s)
|
||||
// - BACKUPX_AGENT_TEMP_DIR 临时目录
|
||||
// - BACKUPX_AGENT_INSECURE_TLS true / 1 跳过 TLS 校验
|
||||
func LoadConfigFromEnv() (*Config, error) {
|
||||
cfg := &Config{
|
||||
Master: strings.TrimSpace(os.Getenv("BACKUPX_AGENT_MASTER")),
|
||||
Token: strings.TrimSpace(os.Getenv("BACKUPX_AGENT_TOKEN")),
|
||||
HeartbeatInterval: strings.TrimSpace(os.Getenv("BACKUPX_AGENT_HEARTBEAT")),
|
||||
PollInterval: strings.TrimSpace(os.Getenv("BACKUPX_AGENT_POLL")),
|
||||
TempDir: strings.TrimSpace(os.Getenv("BACKUPX_AGENT_TEMP_DIR")),
|
||||
InsecureSkipTLSVerify: strings.EqualFold(os.Getenv("BACKUPX_AGENT_INSECURE_TLS"), "true") || os.Getenv("BACKUPX_AGENT_INSECURE_TLS") == "1",
|
||||
}
|
||||
return applyConfigDefaults(cfg)
|
||||
}
|
||||
|
||||
// MergeWithFlags 把命令行覆盖值合并入配置(非空覆盖)。
|
||||
func (c *Config) MergeWithFlags(master, token, tempDir string) {
|
||||
if strings.TrimSpace(master) != "" {
|
||||
c.Master = master
|
||||
}
|
||||
if strings.TrimSpace(token) != "" {
|
||||
c.Token = token
|
||||
}
|
||||
if strings.TrimSpace(tempDir) != "" {
|
||||
c.TempDir = tempDir
|
||||
}
|
||||
}
|
||||
|
||||
// Validate 校验必填字段。
|
||||
func (c *Config) Validate() error {
|
||||
if strings.TrimSpace(c.Master) == "" {
|
||||
return errors.New("master url is required (set via --master, BACKUPX_AGENT_MASTER or config file)")
|
||||
}
|
||||
if strings.TrimSpace(c.Token) == "" {
|
||||
return errors.New("token is required (set via --token, BACKUPX_AGENT_TOKEN or config file)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyConfigDefaults(cfg *Config) (*Config, error) {
|
||||
if cfg.HeartbeatInterval == "" {
|
||||
cfg.HeartbeatInterval = "15s"
|
||||
}
|
||||
if cfg.PollInterval == "" {
|
||||
cfg.PollInterval = "5s"
|
||||
}
|
||||
if cfg.TempDir == "" {
|
||||
cfg.TempDir = "/var/lib/backupx-agent/tmp"
|
||||
}
|
||||
cfg.Master = strings.TrimRight(strings.TrimSpace(cfg.Master), "/")
|
||||
return cfg, nil
|
||||
}
|
||||
101
server/internal/agent/config_test.go
Normal file
101
server/internal/agent/config_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadConfigFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "agent.yaml")
|
||||
content := `master: http://master.example.com:8340/
|
||||
token: abc123
|
||||
heartbeatInterval: 20s
|
||||
pollInterval: 3s
|
||||
tempDir: /var/backupx-agent
|
||||
insecureSkipTlsVerify: true
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := LoadConfigFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if cfg.Master != "http://master.example.com:8340" {
|
||||
t.Errorf("trailing slash should be trimmed: %q", cfg.Master)
|
||||
}
|
||||
if cfg.Token != "abc123" {
|
||||
t.Errorf("token: %q", cfg.Token)
|
||||
}
|
||||
if cfg.HeartbeatInterval != "20s" || cfg.PollInterval != "3s" {
|
||||
t.Errorf("intervals: %+v", cfg)
|
||||
}
|
||||
if !cfg.InsecureSkipTLSVerify {
|
||||
t.Errorf("insecure should be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigDefaults(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "agent.yaml")
|
||||
if err := os.WriteFile(path, []byte("master: http://m\ntoken: t\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := LoadConfigFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.HeartbeatInterval != "15s" || cfg.PollInterval != "5s" {
|
||||
t.Errorf("default intervals not applied: %+v", cfg)
|
||||
}
|
||||
if cfg.TempDir != "/var/lib/backupx-agent/tmp" {
|
||||
t.Errorf("default tempdir: %q", cfg.TempDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg Config
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid", Config{Master: "http://m", Token: "t"}, false},
|
||||
{"missing master", Config{Token: "t"}, true},
|
||||
{"missing token", Config{Master: "http://m"}, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
err := c.cfg.Validate()
|
||||
if (err != nil) != c.wantErr {
|
||||
t.Errorf("%s: err=%v wantErr=%v", c.name, err, c.wantErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeWithFlags(t *testing.T) {
|
||||
cfg := &Config{Master: "http://old", Token: "old"}
|
||||
cfg.MergeWithFlags("http://new", "", "/tmp/x")
|
||||
if cfg.Master != "http://new" {
|
||||
t.Errorf("master not overridden: %q", cfg.Master)
|
||||
}
|
||||
if cfg.Token != "old" {
|
||||
t.Errorf("empty flag should not override: %q", cfg.Token)
|
||||
}
|
||||
if cfg.TempDir != "/tmp/x" {
|
||||
t.Errorf("tempDir: %q", cfg.TempDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigFromEnv(t *testing.T) {
|
||||
t.Setenv("BACKUPX_AGENT_MASTER", "http://env-master")
|
||||
t.Setenv("BACKUPX_AGENT_TOKEN", "env-token")
|
||||
t.Setenv("BACKUPX_AGENT_INSECURE_TLS", "true")
|
||||
cfg, err := LoadConfigFromEnv()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Master != "http://env-master" || cfg.Token != "env-token" || !cfg.InsecureSkipTLSVerify {
|
||||
t.Errorf("env not picked up: %+v", cfg)
|
||||
}
|
||||
}
|
||||
460
server/internal/agent/executor.go
Normal file
460
server/internal/agent/executor.go
Normal file
@@ -0,0 +1,460 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/backup"
|
||||
"backupx/server/internal/storage"
|
||||
storageRclone "backupx/server/internal/storage/rclone"
|
||||
"backupx/server/pkg/compress"
|
||||
)
|
||||
|
||||
// Executor 负责在 Agent 本地执行命令。
|
||||
type Executor struct {
|
||||
client *MasterClient
|
||||
tempDir string
|
||||
backupRegistry *backup.Registry
|
||||
storageRegistry *storage.Registry
|
||||
}
|
||||
|
||||
// NewExecutor 构造执行器。预先初始化 backup runner 与 storage registry。
|
||||
func NewExecutor(client *MasterClient, tempDir string) *Executor {
|
||||
backupRegistry := backup.NewRegistry(
|
||||
backup.NewFileRunner(),
|
||||
backup.NewSQLiteRunner(),
|
||||
backup.NewMySQLRunner(nil),
|
||||
backup.NewPostgreSQLRunner(nil),
|
||||
backup.NewSAPHANARunner(nil),
|
||||
)
|
||||
storageRegistry := storage.NewRegistry(
|
||||
storageRclone.NewLocalDiskFactory(),
|
||||
storageRclone.NewS3Factory(),
|
||||
storageRclone.NewWebDAVFactory(),
|
||||
storageRclone.NewGoogleDriveFactory(),
|
||||
storageRclone.NewAliyunOSSFactory(),
|
||||
storageRclone.NewTencentCOSFactory(),
|
||||
storageRclone.NewQiniuKodoFactory(),
|
||||
storageRclone.NewFTPFactory(),
|
||||
storageRclone.NewRcloneFactory(),
|
||||
)
|
||||
storageRclone.RegisterAllBackends(storageRegistry)
|
||||
return &Executor{
|
||||
client: client,
|
||||
tempDir: tempDir,
|
||||
backupRegistry: backupRegistry,
|
||||
storageRegistry: storageRegistry,
|
||||
}
|
||||
}
|
||||
|
||||
// ExecuteRunTask 处理 run_task 命令:拉规格 → 执行 runner → 压缩 → 上传 → 上报记录。
|
||||
//
|
||||
// 注意:Agent 当前不支持 Encrypt=true(加密密钥不下发到 Agent,避免密钥扩散)。
|
||||
// 遇到启用加密的任务会向 Master 上报失败并返回错误。
|
||||
func (e *Executor) ExecuteRunTask(ctx context.Context, taskID, recordID uint) error {
|
||||
if err := e.ensureTempDir(); err != nil {
|
||||
e.reportRecordFailure(ctx, recordID, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
// 1) 拉取任务规格
|
||||
spec, err := e.client.GetTaskSpec(ctx, taskID)
|
||||
if err != nil {
|
||||
e.reportRecordFailure(ctx, recordID, fmt.Sprintf("拉取任务规格失败: %v", err))
|
||||
return err
|
||||
}
|
||||
if spec.Encrypt {
|
||||
msg := "Agent 不支持加密备份(加密密钥仅在 Master 端持有)"
|
||||
e.reportRecordFailure(ctx, recordID, msg)
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
e.appendLog(ctx, recordID, fmt.Sprintf("[agent] 开始执行任务 %s (type=%s)\n", spec.Name, spec.Type))
|
||||
|
||||
// 2) 构造 backup.TaskSpec 并找对应 runner
|
||||
startedAt := time.Now().UTC()
|
||||
backupSpec := buildBackupTaskSpec(spec, startedAt, e.tempDir)
|
||||
runner, err := e.backupRegistry.Runner(backupSpec.Type)
|
||||
if err != nil {
|
||||
e.reportRecordFailure(ctx, recordID, fmt.Sprintf("不支持的备份类型: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
// 3) 运行 runner
|
||||
logger := newRecordLogger(ctx, e.client, recordID)
|
||||
result, err := runner.Run(ctx, backupSpec, logger)
|
||||
if err != nil {
|
||||
e.reportRecordFailure(ctx, recordID, err.Error())
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(result.TempDir)
|
||||
|
||||
// 4) 可选 gzip 压缩
|
||||
finalPath := result.ArtifactPath
|
||||
if strings.EqualFold(spec.Compression, "gzip") && !strings.HasSuffix(strings.ToLower(finalPath), ".gz") {
|
||||
e.appendLog(ctx, recordID, "[agent] 开始压缩备份文件\n")
|
||||
compressedPath, compressErr := compress.GzipFile(finalPath)
|
||||
if compressErr != nil {
|
||||
e.reportRecordFailure(ctx, recordID, fmt.Sprintf("压缩失败: %v", compressErr))
|
||||
return compressErr
|
||||
}
|
||||
finalPath = compressedPath
|
||||
}
|
||||
info, err := os.Stat(finalPath)
|
||||
if err != nil {
|
||||
e.reportRecordFailure(ctx, recordID, fmt.Sprintf("获取文件信息失败: %v", err))
|
||||
return err
|
||||
}
|
||||
fileName := filepath.Base(finalPath)
|
||||
fileSize := info.Size()
|
||||
storagePath := backup.BuildStorageKey(spec.Type, startedAt, fileName)
|
||||
|
||||
// 5) 计算 checksum(一次读一次)并上传到所有目标
|
||||
checksum, err := computeFileSHA256(finalPath)
|
||||
if err != nil {
|
||||
e.reportRecordFailure(ctx, recordID, fmt.Sprintf("计算 checksum 失败: %v", err))
|
||||
return err
|
||||
}
|
||||
if len(spec.StorageTargets) == 0 {
|
||||
e.reportRecordFailure(ctx, recordID, "没有关联的存储目标")
|
||||
return fmt.Errorf("no storage targets")
|
||||
}
|
||||
for _, target := range spec.StorageTargets {
|
||||
if err := e.uploadToTarget(ctx, recordID, target, finalPath, storagePath, fileSize, spec.TaskID); err != nil {
|
||||
e.reportRecordFailure(ctx, recordID, fmt.Sprintf("上传到 %s 失败: %v", target.Name, err))
|
||||
return err
|
||||
}
|
||||
e.appendLog(ctx, recordID, fmt.Sprintf("[agent] 已上传到存储目标 %s\n", target.Name))
|
||||
}
|
||||
|
||||
// 6) 上报最终成功
|
||||
return e.client.UpdateRecord(ctx, recordID, RecordUpdate{
|
||||
Status: "success",
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
Checksum: checksum,
|
||||
StoragePath: storagePath,
|
||||
LogAppend: fmt.Sprintf("[agent] 任务完成,总计 %d 字节\n", fileSize),
|
||||
})
|
||||
}
|
||||
|
||||
// uploadToTarget 上传单个目标。为保持简化不做上传级重试(rclone 本身已有 low-level 重试)。
|
||||
func (e *Executor) uploadToTarget(ctx context.Context, recordID uint, target StorageTargetConfig, filePath, objectKey string, fileSize int64, taskID uint) error {
|
||||
var rawConfig map[string]any
|
||||
if len(target.Config) > 0 {
|
||||
// DecodeRawConfig 通过 json 解析
|
||||
if err := jsonUnmarshalMap(target.Config, &rawConfig); err != nil {
|
||||
return fmt.Errorf("parse storage config: %w", err)
|
||||
}
|
||||
}
|
||||
provider, err := e.storageRegistry.Create(ctx, target.Type, rawConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create provider: %w", err)
|
||||
}
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open artifact: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
meta := map[string]string{
|
||||
"taskId": fmt.Sprintf("%d", taskID),
|
||||
"recordId": fmt.Sprintf("%d", recordID),
|
||||
}
|
||||
return provider.Upload(ctx, objectKey, f, fileSize, meta)
|
||||
}
|
||||
|
||||
// appendLog 追加日志到 Master 记录(尽力而为,失败不中断主流程)
|
||||
func (e *Executor) appendLog(ctx context.Context, recordID uint, line string) {
|
||||
_ = e.client.UpdateRecord(ctx, recordID, RecordUpdate{LogAppend: line})
|
||||
}
|
||||
|
||||
// reportRecordFailure 上报失败状态
|
||||
func (e *Executor) reportRecordFailure(ctx context.Context, recordID uint, msg string) {
|
||||
_ = e.client.UpdateRecord(ctx, recordID, RecordUpdate{
|
||||
Status: "failed",
|
||||
ErrorMessage: msg,
|
||||
LogAppend: fmt.Sprintf("[agent] 错误: %s\n", msg),
|
||||
})
|
||||
}
|
||||
|
||||
// buildBackupTaskSpec 把 AgentTaskSpec 转换为 backup.TaskSpec。
|
||||
func buildBackupTaskSpec(spec *TaskSpec, startedAt time.Time, tempDir string) backup.TaskSpec {
|
||||
sourcePaths := parseStringListField(spec.SourcePaths)
|
||||
excludes := parseStringListField(spec.ExcludePatterns)
|
||||
return backup.TaskSpec{
|
||||
ID: spec.TaskID,
|
||||
Name: spec.Name,
|
||||
Type: spec.Type,
|
||||
SourcePath: spec.SourcePath,
|
||||
SourcePaths: sourcePaths,
|
||||
ExcludePatterns: excludes,
|
||||
Database: backup.DatabaseSpec{
|
||||
Host: spec.DBHost,
|
||||
Port: spec.DBPort,
|
||||
User: spec.DBUser,
|
||||
Password: spec.DBPassword,
|
||||
Path: spec.DBPath,
|
||||
Names: splitCommaOrNewline(spec.DBName),
|
||||
},
|
||||
Compression: spec.Compression,
|
||||
Encrypt: spec.Encrypt,
|
||||
StartedAt: startedAt,
|
||||
TempDir: tempDir,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Executor) ensureTempDir() error {
|
||||
if err := os.MkdirAll(e.tempDir, 0o755); err != nil {
|
||||
return fmt.Errorf("create agent temp dir: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseStringListField(value string) []string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" || trimmed == "[]" {
|
||||
return nil
|
||||
}
|
||||
var jsonItems []string
|
||||
if err := json.Unmarshal([]byte(trimmed), &jsonItems); err == nil {
|
||||
return compactStringList(jsonItems)
|
||||
}
|
||||
return compactStringList(strings.FieldsFunc(trimmed, func(r rune) bool {
|
||||
return r == '\n' || r == '\r'
|
||||
}))
|
||||
}
|
||||
|
||||
func compactStringList(items []string) []string {
|
||||
result := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if trimmed := strings.TrimSpace(item); trimmed != "" {
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// recordLogger 把 runner 日志回传到 Master 记录。
|
||||
// 实现 backup.LogWriter,每条日志追加到 record.log_content。
|
||||
type recordLogger struct {
|
||||
ctx context.Context
|
||||
client *MasterClient
|
||||
recordID uint
|
||||
}
|
||||
|
||||
func newRecordLogger(ctx context.Context, client *MasterClient, recordID uint) *recordLogger {
|
||||
return &recordLogger{ctx: ctx, client: client, recordID: recordID}
|
||||
}
|
||||
|
||||
func (l *recordLogger) WriteLine(message string) {
|
||||
_ = l.client.UpdateRecord(l.ctx, l.recordID, RecordUpdate{LogAppend: message + "\n"})
|
||||
}
|
||||
|
||||
// restoreLogger 把 runner 日志回传到 Master 恢复记录。
|
||||
type restoreLogger struct {
|
||||
ctx context.Context
|
||||
client *MasterClient
|
||||
restoreID uint
|
||||
}
|
||||
|
||||
func newRestoreLogger(ctx context.Context, client *MasterClient, restoreID uint) *restoreLogger {
|
||||
return &restoreLogger{ctx: ctx, client: client, restoreID: restoreID}
|
||||
}
|
||||
|
||||
func (l *restoreLogger) WriteLine(message string) {
|
||||
_ = l.client.UpdateRestore(l.ctx, l.restoreID, RestoreUpdate{LogAppend: message + "\n"})
|
||||
}
|
||||
|
||||
// DeleteStorageObject 在 Agent 本机上删除指定存储对象(供跨节点清理调用)。
|
||||
func (e *Executor) DeleteStorageObject(ctx context.Context, targetType string, targetConfig map[string]any, storagePath string) error {
|
||||
provider, err := e.storageRegistry.Create(ctx, targetType, targetConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create provider: %w", err)
|
||||
}
|
||||
return provider.Delete(ctx, storagePath)
|
||||
}
|
||||
|
||||
// ExecuteRestore 处理 restore_record 命令:拉规格 → 下载 → 解压 → 执行 runner.Restore → 上报结果。
|
||||
//
|
||||
// 与 ExecuteRunTask 对称,但方向相反:
|
||||
// - 下载:通过 spec.Storage 创建 provider → Download(spec.StoragePath)
|
||||
// - 解密:当前 Agent 不支持加密恢复(密钥未下发),spec.Encrypt=true 会直接失败
|
||||
// - 执行:backup.Registry.Runner(spec.Type).Restore
|
||||
// - 上报:通过 UpdateRestore(status/logAppend)
|
||||
func (e *Executor) ExecuteRestore(ctx context.Context, restoreRecordID uint) error {
|
||||
if err := e.ensureTempDir(); err != nil {
|
||||
e.reportRestoreFailure(ctx, restoreRecordID, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
spec, err := e.client.GetRestoreSpec(ctx, restoreRecordID)
|
||||
if err != nil {
|
||||
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("拉取恢复规格失败: %v", err))
|
||||
return err
|
||||
}
|
||||
if spec.Encrypt {
|
||||
msg := "Agent 不支持加密恢复(加密密钥仅在 Master 端持有)"
|
||||
e.reportRestoreFailure(ctx, restoreRecordID, msg)
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
e.appendRestoreLog(ctx, restoreRecordID, fmt.Sprintf("[agent] 开始恢复 %s (type=%s)\n", spec.TaskName, spec.Type))
|
||||
|
||||
tmpDir, err := os.MkdirTemp(e.tempDir, "restore-*")
|
||||
if err != nil {
|
||||
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("创建恢复临时目录失败: %v", err))
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// 1) 创建 storage provider
|
||||
var rawConfig map[string]any
|
||||
if len(spec.Storage.Config) > 0 {
|
||||
if err := jsonUnmarshalMap(spec.Storage.Config, &rawConfig); err != nil {
|
||||
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("解析存储配置失败: %v", err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
provider, err := e.storageRegistry.Create(ctx, spec.Storage.Type, rawConfig)
|
||||
if err != nil {
|
||||
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("创建存储客户端失败: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
// 2) 下载
|
||||
fileName := spec.FileName
|
||||
if strings.TrimSpace(fileName) == "" {
|
||||
fileName = filepath.Base(spec.StoragePath)
|
||||
}
|
||||
artifactPath := filepath.Join(tmpDir, filepath.Base(fileName))
|
||||
e.appendRestoreLog(ctx, restoreRecordID, fmt.Sprintf("[agent] 下载备份文件 %s\n", spec.StoragePath))
|
||||
reader, err := provider.Download(ctx, spec.StoragePath)
|
||||
if err != nil {
|
||||
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("下载备份失败: %v", err))
|
||||
return err
|
||||
}
|
||||
if err := writeReaderToLocal(artifactPath, reader); err != nil {
|
||||
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("写入备份文件失败: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
// 3) 解压(Agent 不支持加密,遇到 .enc 会直接失败)
|
||||
preparedPath := artifactPath
|
||||
if strings.HasSuffix(strings.ToLower(preparedPath), ".enc") {
|
||||
msg := "检测到加密后缀,Agent 不支持加密恢复"
|
||||
e.reportRestoreFailure(ctx, restoreRecordID, msg)
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
if strings.HasSuffix(strings.ToLower(preparedPath), ".gz") {
|
||||
e.appendRestoreLog(ctx, restoreRecordID, "[agent] 解压 gzip 压缩\n")
|
||||
decompressed, err := compress.GunzipFile(preparedPath)
|
||||
if err != nil {
|
||||
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("解压失败: %v", err))
|
||||
return err
|
||||
}
|
||||
preparedPath = decompressed
|
||||
}
|
||||
|
||||
// 4) 运行 runner.Restore
|
||||
taskSpec := buildRestoreBackupTaskSpec(spec, time.Now().UTC(), tmpDir)
|
||||
runner, err := e.backupRegistry.Runner(taskSpec.Type)
|
||||
if err != nil {
|
||||
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("不支持的备份类型: %v", err))
|
||||
return err
|
||||
}
|
||||
logger := newRestoreLogger(ctx, e.client, restoreRecordID)
|
||||
if err := runner.Restore(ctx, taskSpec, preparedPath, logger); err != nil {
|
||||
e.reportRestoreFailure(ctx, restoreRecordID, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
// 5) 上报成功
|
||||
return e.client.UpdateRestore(ctx, restoreRecordID, RestoreUpdate{
|
||||
Status: "success",
|
||||
LogAppend: "[agent] 恢复执行完成\n",
|
||||
})
|
||||
}
|
||||
|
||||
func (e *Executor) appendRestoreLog(ctx context.Context, restoreID uint, line string) {
|
||||
_ = e.client.UpdateRestore(ctx, restoreID, RestoreUpdate{LogAppend: line})
|
||||
}
|
||||
|
||||
func (e *Executor) reportRestoreFailure(ctx context.Context, restoreID uint, msg string) {
|
||||
_ = e.client.UpdateRestore(ctx, restoreID, RestoreUpdate{
|
||||
Status: "failed",
|
||||
ErrorMessage: msg,
|
||||
LogAppend: fmt.Sprintf("[agent] 错误: %s\n", msg),
|
||||
})
|
||||
}
|
||||
|
||||
// buildRestoreBackupTaskSpec 把 RestoreSpec 转成 backup.TaskSpec。
|
||||
func buildRestoreBackupTaskSpec(spec *RestoreSpec, startedAt time.Time, tempDir string) backup.TaskSpec {
|
||||
return backup.TaskSpec{
|
||||
ID: spec.TaskID,
|
||||
Name: spec.TaskName,
|
||||
Type: spec.Type,
|
||||
SourcePath: spec.SourcePath,
|
||||
SourcePaths: spec.SourcePaths,
|
||||
ExcludePatterns: nil,
|
||||
Database: backup.DatabaseSpec{
|
||||
Host: spec.DBHost,
|
||||
Port: spec.DBPort,
|
||||
User: spec.DBUser,
|
||||
Password: spec.DBPassword,
|
||||
Path: spec.DBPath,
|
||||
Names: splitCommaOrNewline(spec.DBName),
|
||||
},
|
||||
Compression: spec.Compression,
|
||||
Encrypt: spec.Encrypt,
|
||||
StartedAt: startedAt,
|
||||
TempDir: tempDir,
|
||||
}
|
||||
}
|
||||
|
||||
// writeReaderToLocal 把 reader 写到本地文件(Agent 侧工具函数)。
|
||||
func writeReaderToLocal(targetPath string, reader io.ReadCloser) error {
|
||||
defer reader.Close()
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.Create(targetPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
_, err = io.Copy(file, reader)
|
||||
return err
|
||||
}
|
||||
|
||||
// 辅助函数
|
||||
|
||||
func computeFileSHA256(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func splitCommaOrNewline(s string) []string {
|
||||
var result []string
|
||||
for _, part := range strings.FieldsFunc(s, func(r rune) bool {
|
||||
return r == ',' || r == '\n' || r == ';'
|
||||
}) {
|
||||
if p := strings.TrimSpace(part); p != "" {
|
||||
result = append(result, p)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
34
server/internal/agent/executor_test.go
Normal file
34
server/internal/agent/executor_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBuildBackupTaskSpecParsesJSONSourcePaths(t *testing.T) {
|
||||
spec := &TaskSpec{
|
||||
TaskID: 7,
|
||||
Name: "root-files",
|
||||
Type: "file",
|
||||
SourcePaths: `["/root","/etc"]`,
|
||||
ExcludePatterns: `["*.log","tmp"]`,
|
||||
}
|
||||
|
||||
got := buildBackupTaskSpec(spec, time.Unix(0, 0), "/var/lib/backupx-agent/tmp")
|
||||
|
||||
if !reflect.DeepEqual(got.SourcePaths, []string{"/root", "/etc"}) {
|
||||
t.Fatalf("source paths = %#v", got.SourcePaths)
|
||||
}
|
||||
if !reflect.DeepEqual(got.ExcludePatterns, []string{"*.log", "tmp"}) {
|
||||
t.Fatalf("exclude patterns = %#v", got.ExcludePatterns)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStringListFieldKeepsLegacyLineFormat(t *testing.T) {
|
||||
got := parseStringListField("/root\n /etc \n")
|
||||
want := []string{"/root", "/etc"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("paths = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
50
server/internal/agent/fs.go
Normal file
50
server/internal/agent/fs.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DirEntry Agent 返回给 Master 的目录项。
|
||||
type DirEntry struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"isDir"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// listLocalDir 列出 Agent 所在机器的指定路径。
|
||||
func listLocalDir(path string) ([]DirEntry, error) {
|
||||
cleaned := filepath.Clean(strings.TrimSpace(path))
|
||||
if strings.TrimSpace(path) == "" || cleaned == "." {
|
||||
cleaned = "/"
|
||||
}
|
||||
entries, err := os.ReadDir(cleaned)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read dir: %w", err)
|
||||
}
|
||||
result := make([]DirEntry, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
info, _ := entry.Info()
|
||||
size := int64(0)
|
||||
if info != nil && !entry.IsDir() {
|
||||
size = info.Size()
|
||||
}
|
||||
result = append(result, DirEntry{
|
||||
Name: entry.Name(),
|
||||
Path: filepath.Join(cleaned, entry.Name()),
|
||||
IsDir: entry.IsDir(),
|
||||
Size: size,
|
||||
})
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].IsDir != result[j].IsDir {
|
||||
return result[i].IsDir
|
||||
}
|
||||
return result[i].Name < result[j].Name
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
76
server/internal/agent/fs_test.go
Normal file
76
server/internal/agent/fs_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListLocalDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_ = os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hello"), 0644)
|
||||
_ = os.Mkdir(filepath.Join(dir, "sub"), 0755)
|
||||
_ = os.WriteFile(filepath.Join(dir, "b.txt"), []byte("world!"), 0644)
|
||||
|
||||
entries, err := listLocalDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(entries) != 3 {
|
||||
t.Fatalf("expected 3 entries, got %d", len(entries))
|
||||
}
|
||||
// 目录排序靠前
|
||||
if !entries[0].IsDir || entries[0].Name != "sub" {
|
||||
t.Errorf("directories should sort first: %+v", entries)
|
||||
}
|
||||
// 文件大小正确
|
||||
var a *DirEntry
|
||||
for i := range entries {
|
||||
if entries[i].Name == "a.txt" {
|
||||
a = &entries[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if a == nil || a.Size != 5 {
|
||||
t.Errorf("file size: %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListLocalDirEmptyPathUsesRoot(t *testing.T) {
|
||||
entries, err := listLocalDir("")
|
||||
if err != nil {
|
||||
t.Fatalf("list root: %v", err)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
t.Fatalf("expected root entries")
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !filepath.IsAbs(entry.Path) {
|
||||
t.Fatalf("entry path should be absolute: %+v", entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitCommaOrNewline(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
out []string
|
||||
}{
|
||||
{"", nil},
|
||||
{"a,b,c", []string{"a", "b", "c"}},
|
||||
{"a\nb\nc", []string{"a", "b", "c"}},
|
||||
{"a; b ,\nc\n", []string{"a", "b", "c"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := splitCommaOrNewline(c.in)
|
||||
if len(got) != len(c.out) {
|
||||
t.Errorf("%q: got %v want %v", c.in, got, c.out)
|
||||
continue
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != c.out[i] {
|
||||
t.Errorf("%q[%d]: %q vs %q", c.in, i, got[i], c.out[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
server/internal/agent/json_util.go
Normal file
12
server/internal/agent/json_util.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package agent
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// jsonUnmarshalMap 把 []byte 或 json.RawMessage 解为 map[string]any。
|
||||
func jsonUnmarshalMap(data []byte, out *map[string]any) error {
|
||||
if len(data) == 0 {
|
||||
*out = map[string]any{}
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(data, out)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"backupx/server/internal/database"
|
||||
aphttp "backupx/server/internal/http"
|
||||
"backupx/server/internal/logger"
|
||||
"backupx/server/internal/metrics"
|
||||
"backupx/server/internal/notify"
|
||||
"backupx/server/internal/repository"
|
||||
"backupx/server/internal/scheduler"
|
||||
@@ -59,9 +60,9 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
||||
|
||||
jwtManager := security.NewJWTManager(resolvedSecurity.JWTSecret, config.MustJWTDuration(cfg.Security))
|
||||
rateLimiter := security.NewLoginRateLimiter(5, time.Minute)
|
||||
authService := service.NewAuthService(userRepo, systemConfigRepo, jwtManager, rateLimiter)
|
||||
systemService := service.NewSystemService(cfg, version, time.Now().UTC())
|
||||
configCipher := codec.NewConfigCipher(resolvedSecurity.EncryptionKey)
|
||||
authService := service.NewAuthService(userRepo, systemConfigRepo, jwtManager, rateLimiter, configCipher)
|
||||
systemService := service.NewSystemService(cfg, version, time.Now().UTC())
|
||||
storageRegistry := storage.NewRegistry(
|
||||
storageRclone.NewLocalDiskFactory(),
|
||||
storageRclone.NewS3Factory(),
|
||||
@@ -79,11 +80,14 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
||||
storageTargetService.SetBackupTaskRepository(backupTaskRepo)
|
||||
storageTargetService.SetBackupRecordRepository(backupRecordRepo)
|
||||
backupTaskService := service.NewBackupTaskService(backupTaskRepo, storageTargetRepo, configCipher)
|
||||
backupTaskService.SetRecordsAndStorage(backupRecordRepo, storageRegistry)
|
||||
// nodeRepo 在下方 Cluster 节点管理区块才实例化,这里延后注入
|
||||
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())
|
||||
notificationService := service.NewNotificationService(notificationRepo, notifyRegistry, configCipher)
|
||||
authService.SetNotificationService(notificationService)
|
||||
// 初始化 rclone 传输配置(重试 + 带宽限制)
|
||||
rcloneCtx := storageRclone.ConfiguredContext(ctx, storageRclone.TransferConfig{
|
||||
LowLevelRetries: cfg.Backup.Retries,
|
||||
@@ -96,6 +100,9 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
||||
backupTaskService.SetScheduler(schedulerService)
|
||||
// 审计日志注入延迟到 auditService 创建后(见下方)
|
||||
backupRecordService := service.NewBackupRecordService(backupRecordRepo, backupExecutionService, logHub)
|
||||
// 恢复服务:使用独立 LogHub 避免恢复记录与备份记录 ID 命名空间冲突
|
||||
restoreRecordRepo := repository.NewRestoreRecordRepository(db)
|
||||
restoreLogHub := backup.NewLogHub()
|
||||
dashboardService := service.NewDashboardService(backupTaskRepo, backupRecordRepo, storageTargetRepo)
|
||||
settingsService := service.NewSettingsService(systemConfigRepo)
|
||||
|
||||
@@ -104,36 +111,174 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
||||
auditService := service.NewAuditService(auditLogRepo)
|
||||
authService.SetAuditService(auditService)
|
||||
schedulerService.SetAuditRecorder(auditService)
|
||||
// 审计日志外输:启动时用当前 settings 初始化 webhook,后续前端修改立即生效
|
||||
settingsService.SetAuditWebhookConfigurer(ctx, auditService)
|
||||
|
||||
// Database discovery
|
||||
// Database discovery(集群依赖在 agentService 创建后注入)
|
||||
databaseDiscoveryService := service.NewDatabaseDiscoveryService(backup.NewOSCommandExecutor())
|
||||
|
||||
// Cluster: Node management
|
||||
nodeRepo := repository.NewNodeRepository(db)
|
||||
nodeService := service.NewNodeService(nodeRepo)
|
||||
backupTaskService.SetNodeRepository(nodeRepo)
|
||||
schedulerService.SetNodeRepository(nodeRepo)
|
||||
nodeService := service.NewNodeService(nodeRepo, version)
|
||||
nodeService.SetTaskRepository(backupTaskRepo)
|
||||
if err := nodeService.EnsureLocalNode(ctx); err != nil {
|
||||
appLogger.Warn("failed to ensure local node", zap.Error(err))
|
||||
}
|
||||
// 启动离线检测:每 15s 扫描一次,超过 45s 未心跳的远程节点标记为离线
|
||||
nodeService.StartOfflineMonitor(ctx, 15*time.Second)
|
||||
|
||||
// Agent 协议服务:命令队列 + 任务下发 + 记录上报
|
||||
agentCmdRepo := repository.NewAgentCommandRepository(db)
|
||||
agentService := service.NewAgentService(nodeRepo, backupTaskRepo, backupRecordRepo, storageTargetRepo, agentCmdRepo, configCipher)
|
||||
agentService.SetRestoreRepository(restoreRecordRepo)
|
||||
agentService.StartCommandTimeoutMonitor(ctx, 30*time.Second, 10*time.Minute)
|
||||
|
||||
// 一键部署:install token service + 后台 GC
|
||||
installTokenRepo := repository.NewAgentInstallTokenRepository(db)
|
||||
installTokenService := service.NewInstallTokenService(installTokenRepo, nodeRepo)
|
||||
installTokenService.StartGC(ctx, time.Hour)
|
||||
|
||||
// 把 Agent 下发能力注入到备份执行服务,实现多节点路由
|
||||
backupExecutionService.SetClusterDependencies(nodeRepo, agentService)
|
||||
// 启用远程目录浏览:NodeService 通过 AgentService 做同步 RPC
|
||||
nodeService.SetAgentRPC(agentService)
|
||||
// 启用远程数据库发现:远程节点任务配置时 DatabasePicker 拿到的是节点视角的 DB 列表
|
||||
databaseDiscoveryService.SetClusterDependencies(nodeRepo, agentService)
|
||||
|
||||
// 恢复服务:集群感知(本地/远程路由),依赖 agentService 入队
|
||||
restoreService := service.NewRestoreService(
|
||||
restoreRecordRepo,
|
||||
backupRecordRepo,
|
||||
backupTaskRepo,
|
||||
storageTargetRepo,
|
||||
nodeRepo,
|
||||
storageRegistry,
|
||||
backupRunnerRegistry,
|
||||
restoreLogHub,
|
||||
configCipher,
|
||||
agentService,
|
||||
cfg.Backup.TempDir,
|
||||
cfg.Backup.MaxConcurrent,
|
||||
)
|
||||
|
||||
// 验证服务:定期校验备份可恢复性(企业合规刚需)
|
||||
verificationRecordRepo := repository.NewVerificationRecordRepository(db)
|
||||
verifyLogHub := backup.NewLogHub()
|
||||
verificationService := service.NewVerificationService(
|
||||
verificationRecordRepo,
|
||||
backupRecordRepo,
|
||||
backupTaskRepo,
|
||||
storageTargetRepo,
|
||||
nodeRepo,
|
||||
storageRegistry,
|
||||
verifyLogHub,
|
||||
configCipher,
|
||||
cfg.Backup.TempDir,
|
||||
cfg.Backup.MaxConcurrent,
|
||||
)
|
||||
// 验证失败通知:通过 NotificationService 的事件总线派发 verify_failed
|
||||
verificationService.SetNotifier(service.NewVerificationEventNotifier(notificationService))
|
||||
// 恢复完成/失败事件派发(restore_success / restore_failed)
|
||||
restoreService.SetEventDispatcher(notificationService)
|
||||
// 调度器接入验证演练 cron
|
||||
schedulerService.SetVerifyRunner(verificationService)
|
||||
|
||||
// 用户管理与 API Key 服务(企业级 RBAC)
|
||||
userService := service.NewUserService(userRepo)
|
||||
apiKeyRepo := repository.NewApiKeyRepository(db)
|
||||
apiKeyService := service.NewApiKeyService(apiKeyRepo)
|
||||
|
||||
// SLA 后台扫描:每 15 分钟扫描违约任务,同任务 6 小时内不重复派发
|
||||
dashboardService.StartSLAMonitor(ctx, notificationService, 15*time.Minute, 6*time.Hour)
|
||||
// 存储目标健康扫描:每 5 分钟测试启用目标,掉线即告警
|
||||
storageTargetService.StartHealthMonitor(ctx, notificationService, 5*time.Minute)
|
||||
|
||||
// 备份复制服务(3-2-1 规则核心)
|
||||
replicationRecordRepo := repository.NewReplicationRecordRepository(db)
|
||||
replicationService := service.NewReplicationService(
|
||||
replicationRecordRepo, backupRecordRepo, storageTargetRepo,
|
||||
nodeRepo, storageRegistry, configCipher,
|
||||
cfg.Backup.TempDir, cfg.Backup.MaxConcurrent,
|
||||
)
|
||||
replicationService.SetEventDispatcher(notificationService)
|
||||
backupExecutionService.SetReplicationTrigger(replicationService)
|
||||
// 备份成功后触发下游依赖任务(任务依赖链工作流)
|
||||
backupExecutionService.SetDependentsResolver(backupTaskService)
|
||||
|
||||
// 任务模板(批量创建)
|
||||
taskTemplateRepo := repository.NewTaskTemplateRepository(db)
|
||||
taskTemplateService := service.NewTaskTemplateService(taskTemplateRepo, backupTaskService)
|
||||
|
||||
// 任务配置导入/导出(JSON,集群迁移 & 灾备)
|
||||
taskExportService := service.NewTaskExportService(backupTaskService, backupTaskRepo, storageTargetRepo, nodeRepo)
|
||||
|
||||
// 全局搜索(跨任务/存储/节点/最近记录)
|
||||
searchService := service.NewSearchService(backupTaskRepo, backupRecordRepo, storageTargetRepo, nodeRepo)
|
||||
|
||||
// 实时事件广播器(SSE 推送给前端 Dashboard)
|
||||
// 注入 notification 后,每次 DispatchEvent 同时 broadcast 到所有 SSE 订阅者
|
||||
eventBroadcaster := service.NewEventBroadcaster()
|
||||
notificationService.SetBroadcaster(eventBroadcaster)
|
||||
|
||||
// 集群版本监控:每 30 分钟扫描,节点 24 小时内只告警一次
|
||||
clusterVersionMonitor := service.NewClusterVersionMonitor(nodeRepo, version)
|
||||
clusterVersionMonitor.SetEventDispatcher(notificationService)
|
||||
clusterVersionMonitor.Start(ctx, 30*time.Minute, 24*time.Hour)
|
||||
|
||||
// Dashboard 集群概览依赖注入
|
||||
dashboardService.SetClusterDependencies(nodeRepo, version)
|
||||
|
||||
// Prometheus 指标采集:Counter/Histogram 由业务服务实时写入;
|
||||
// Gauge 类(存储用量、节点在线、SLA 违约)由 Collector 每 30s 异步刷新,
|
||||
// 避免 /metrics 请求路径做慢 IO。
|
||||
appMetrics := metrics.New(version)
|
||||
backupExecutionService.SetMetrics(appMetrics)
|
||||
restoreService.SetMetrics(appMetrics)
|
||||
verificationService.SetMetrics(appMetrics)
|
||||
replicationService.SetMetrics(appMetrics)
|
||||
metricsCollector := metrics.NewCollector(
|
||||
appMetrics,
|
||||
metrics.NewRepoSource(storageTargetRepo, backupRecordRepo, nodeRepo, backupTaskRepo),
|
||||
30*time.Second,
|
||||
)
|
||||
metricsCollector.Start(ctx)
|
||||
|
||||
router := aphttp.NewRouter(aphttp.RouterDependencies{
|
||||
Config: cfg,
|
||||
Version: version,
|
||||
Logger: appLogger,
|
||||
AuthService: authService,
|
||||
SystemService: systemService,
|
||||
StorageTargetService: storageTargetService,
|
||||
BackupTaskService: backupTaskService,
|
||||
BackupExecutionService: backupExecutionService,
|
||||
BackupRecordService: backupRecordService,
|
||||
NotificationService: notificationService,
|
||||
DashboardService: dashboardService,
|
||||
SettingsService: settingsService,
|
||||
Context: ctx,
|
||||
Config: cfg,
|
||||
Version: version,
|
||||
Logger: appLogger,
|
||||
AuthService: authService,
|
||||
SystemService: systemService,
|
||||
StorageTargetService: storageTargetService,
|
||||
BackupTaskService: backupTaskService,
|
||||
BackupExecutionService: backupExecutionService,
|
||||
BackupRecordService: backupRecordService,
|
||||
RestoreService: restoreService,
|
||||
VerificationService: verificationService,
|
||||
ReplicationService: replicationService,
|
||||
TaskTemplateService: taskTemplateService,
|
||||
TaskExportService: taskExportService,
|
||||
SearchService: searchService,
|
||||
EventBroadcaster: eventBroadcaster,
|
||||
UserService: userService,
|
||||
ApiKeyService: apiKeyService,
|
||||
NotificationService: notificationService,
|
||||
DashboardService: dashboardService,
|
||||
SettingsService: settingsService,
|
||||
NodeService: nodeService,
|
||||
AgentService: agentService,
|
||||
DatabaseDiscoveryService: databaseDiscoveryService,
|
||||
AuditService: auditService,
|
||||
AuditService: auditService,
|
||||
JWTManager: jwtManager,
|
||||
UserRepository: userRepo,
|
||||
SystemConfigRepo: systemConfigRepo,
|
||||
UserRepository: userRepo,
|
||||
SystemConfigRepo: systemConfigRepo,
|
||||
InstallTokenService: installTokenService,
|
||||
MasterExternalURL: "", // 如需覆盖 URL,可扩展 cfg.Server 增字段;目前留空依赖 X-Forwarded-* / Request.Host
|
||||
DB: db,
|
||||
Metrics: appMetrics,
|
||||
})
|
||||
|
||||
httpServer := &stdhttp.Server{
|
||||
|
||||
360
server/internal/backint/agent.go
Normal file
360
server/internal/backint/agent.go
Normal file
@@ -0,0 +1,360 @@
|
||||
package backint
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/storage"
|
||||
storageRclone "backupx/server/internal/storage/rclone"
|
||||
)
|
||||
|
||||
// Agent 是 Backint 协议代理主入口。
|
||||
//
|
||||
// 职责:
|
||||
// 1. 根据 -f 指定的功能,从 -i 输入文件解析请求
|
||||
// 2. 把数据路由到 BackupX storage 后端
|
||||
// 3. 把结果写回 -o 输出文件(失败使用 #ERROR,不中断批次)
|
||||
type Agent struct {
|
||||
cfg *Config
|
||||
provider storage.StorageProvider
|
||||
catalog *Catalog
|
||||
}
|
||||
|
||||
// NewAgent 构造 Agent,初始化 storage provider 与 catalog。
|
||||
func NewAgent(ctx context.Context, cfg *Config) (*Agent, error) {
|
||||
registry := buildStorageRegistry()
|
||||
provider, err := registry.Create(ctx, cfg.StorageType, cfg.StorageConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create storage provider: %w", err)
|
||||
}
|
||||
if err := provider.TestConnection(ctx); err != nil {
|
||||
return nil, fmt.Errorf("storage provider connection failed: %w", err)
|
||||
}
|
||||
cat, err := OpenCatalog(cfg.CatalogDB)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Agent{cfg: cfg, provider: provider, catalog: cat}, nil
|
||||
}
|
||||
|
||||
// Close 释放资源。
|
||||
func (a *Agent) Close() error {
|
||||
if a.catalog != nil {
|
||||
return a.catalog.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run 执行一次 Backint 调用。
|
||||
//
|
||||
// HANA 针对 BACKUP 调用时:input 是 #PIPE 列表,output 需返回 #SAVED 或 #ERROR。
|
||||
// 批次中任一条目失败不应导致整个进程退出,因此错误被降级为 #ERROR 行。
|
||||
// 仅在极端错误(参数非法、I/O 失败)时返回 error,进程以非 0 退出。
|
||||
func (a *Agent) Run(ctx context.Context, fn Function, inputPath, outputPath string) error {
|
||||
in, err := os.Open(inputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open input: %w", err)
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create output: %w", err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
switch fn {
|
||||
case FunctionBackup:
|
||||
return a.runBackup(ctx, in, out)
|
||||
case FunctionRestore:
|
||||
return a.runRestore(ctx, in, out)
|
||||
case FunctionInquire:
|
||||
return a.runInquire(ctx, in, out)
|
||||
case FunctionDelete:
|
||||
return a.runDelete(ctx, in, out)
|
||||
default:
|
||||
return fmt.Errorf("unsupported function: %s", fn)
|
||||
}
|
||||
}
|
||||
|
||||
// runBackup 处理 BACKUP 操作:读取每条请求的管道/文件,上传到存储后端。
|
||||
func (a *Agent) runBackup(ctx context.Context, in io.Reader, out io.Writer) error {
|
||||
reqs, err := ParseBackupRequests(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, req := range reqs {
|
||||
ebid, perr := a.handleBackupOne(ctx, req)
|
||||
if perr != nil {
|
||||
fmt.Fprintf(os.Stderr, "backint: backup %q failed: %v\n", req.Path, perr)
|
||||
_ = WriteError(out, req.Path)
|
||||
continue
|
||||
}
|
||||
_ = WriteSaved(out, ebid, req.Path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleBackupOne 上传一条请求,返回分配的 EBID。
|
||||
func (a *Agent) handleBackupOne(ctx context.Context, req BackupRequest) (string, error) {
|
||||
src, size, err := openBackupSource(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
ebid := generateEBID()
|
||||
objectKey := a.objectKeyFor(ebid)
|
||||
|
||||
reader := io.Reader(src)
|
||||
// 可选 gzip 压缩
|
||||
if a.cfg.Compress {
|
||||
pr, pw := io.Pipe()
|
||||
go func() {
|
||||
gw := gzip.NewWriter(pw)
|
||||
if _, cerr := io.Copy(gw, src); cerr != nil {
|
||||
_ = gw.Close()
|
||||
_ = pw.CloseWithError(cerr)
|
||||
return
|
||||
}
|
||||
if cerr := gw.Close(); cerr != nil {
|
||||
_ = pw.CloseWithError(cerr)
|
||||
return
|
||||
}
|
||||
_ = pw.Close()
|
||||
}()
|
||||
reader = pr
|
||||
size = -1 // 压缩后大小未知
|
||||
objectKey += ".gz"
|
||||
}
|
||||
|
||||
meta := map[string]string{
|
||||
"source-path": req.Path,
|
||||
"ebid": ebid,
|
||||
"compress": boolStr(a.cfg.Compress),
|
||||
}
|
||||
if err := a.provider.Upload(ctx, objectKey, reader, size, meta); err != nil {
|
||||
return "", fmt.Errorf("upload: %w", err)
|
||||
}
|
||||
|
||||
if err := a.catalog.Put(CatalogEntry{
|
||||
EBID: ebid,
|
||||
ObjectKey: objectKey,
|
||||
SourcePath: req.Path,
|
||||
Size: size,
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("catalog put: %w", err)
|
||||
}
|
||||
return ebid, nil
|
||||
}
|
||||
|
||||
// runRestore 处理 RESTORE 操作:根据 EBID 从存储下载,写入 HANA 指定的管道/文件。
|
||||
func (a *Agent) runRestore(ctx context.Context, in io.Reader, out io.Writer) error {
|
||||
reqs, err := ParseRestoreRequests(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, req := range reqs {
|
||||
if perr := a.handleRestoreOne(ctx, req); perr != nil {
|
||||
fmt.Fprintf(os.Stderr, "backint: restore %q failed: %v\n", req.EBID, perr)
|
||||
_ = WriteError(out, req.Path)
|
||||
continue
|
||||
}
|
||||
_ = WriteRestored(out, req.EBID, req.Path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Agent) handleRestoreOne(ctx context.Context, req RestoreRequest) error {
|
||||
entry, err := a.catalog.Get(req.EBID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("catalog get: %w", err)
|
||||
}
|
||||
if entry == nil {
|
||||
return fmt.Errorf("ebid not found: %s", req.EBID)
|
||||
}
|
||||
rc, err := a.provider.Download(ctx, entry.ObjectKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download: %w", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
var src io.Reader = rc
|
||||
if strings.HasSuffix(entry.ObjectKey, ".gz") {
|
||||
gr, err := gzip.NewReader(rc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gzip reader: %w", err)
|
||||
}
|
||||
defer gr.Close()
|
||||
src = gr
|
||||
}
|
||||
|
||||
dst, err := openRestoreTarget(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
return fmt.Errorf("copy to target: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runInquire 处理 INQUIRE 操作:查询 EBID 是否存在,或列出全部备份。
|
||||
func (a *Agent) runInquire(ctx context.Context, in io.Reader, out io.Writer) error {
|
||||
reqs, err := ParseInquireRequests(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, req := range reqs {
|
||||
if req.All {
|
||||
entries, err := a.catalog.List()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "backint: inquire list failed: %v\n", err)
|
||||
_ = WriteError(out, "#NULL")
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
_ = WriteBackup(out, e.EBID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
entry, err := a.catalog.Get(req.EBID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "backint: inquire %q failed: %v\n", req.EBID, err)
|
||||
_ = WriteError(out, req.EBID)
|
||||
continue
|
||||
}
|
||||
if entry == nil {
|
||||
_ = WriteNotFound(out, req.EBID)
|
||||
continue
|
||||
}
|
||||
_ = WriteBackup(out, entry.EBID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runDelete 处理 DELETE 操作:从存储删除对象并移除目录条目。
|
||||
func (a *Agent) runDelete(ctx context.Context, in io.Reader, out io.Writer) error {
|
||||
reqs, err := ParseDeleteRequests(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, req := range reqs {
|
||||
if perr := a.handleDeleteOne(ctx, req); perr != nil {
|
||||
fmt.Fprintf(os.Stderr, "backint: delete %q failed: %v\n", req.EBID, perr)
|
||||
_ = WriteError(out, req.EBID)
|
||||
continue
|
||||
}
|
||||
_ = WriteDeleted(out, req.EBID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Agent) handleDeleteOne(ctx context.Context, req DeleteRequest) error {
|
||||
entry, err := a.catalog.Get(req.EBID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("catalog get: %w", err)
|
||||
}
|
||||
if entry == nil {
|
||||
return fmt.Errorf("ebid not found: %s", req.EBID)
|
||||
}
|
||||
if err := a.provider.Delete(ctx, entry.ObjectKey); err != nil {
|
||||
// 允许后端返回"不存在"类错误后继续删除目录条目,避免孤立条目
|
||||
fmt.Fprintf(os.Stderr, "backint: storage delete warning for %s: %v\n", entry.ObjectKey, err)
|
||||
}
|
||||
return a.catalog.Delete(req.EBID)
|
||||
}
|
||||
|
||||
// 辅助函数
|
||||
|
||||
func (a *Agent) objectKeyFor(ebid string) string {
|
||||
base := ebid + ".bin"
|
||||
if a.cfg.KeyPrefix == "" {
|
||||
return base
|
||||
}
|
||||
return path.Join(a.cfg.KeyPrefix, base)
|
||||
}
|
||||
|
||||
// openBackupSource 打开 HANA 提供的数据源。
|
||||
//
|
||||
// 对于 #PIPE 模式:HANA 写入命名管道,Agent 读取。管道是顺序流,size 未知 (-1)。
|
||||
// 对于文件模式:HANA 已在指定路径写好完整文件。
|
||||
func openBackupSource(req BackupRequest) (io.ReadCloser, int64, error) {
|
||||
if req.IsPipe {
|
||||
f, err := os.OpenFile(req.Path, os.O_RDONLY, 0)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("open pipe: %w", err)
|
||||
}
|
||||
return f, -1, nil
|
||||
}
|
||||
f, err := os.Open(req.Path)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("open file: %w", err)
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
return nil, 0, fmt.Errorf("stat: %w", err)
|
||||
}
|
||||
return f, info.Size(), nil
|
||||
}
|
||||
|
||||
// openRestoreTarget 打开 HANA 指定的恢复目标(管道或文件)。
|
||||
func openRestoreTarget(req RestoreRequest) (io.WriteCloser, error) {
|
||||
if req.IsPipe {
|
||||
return os.OpenFile(req.Path, os.O_WRONLY, 0)
|
||||
}
|
||||
return os.Create(req.Path)
|
||||
}
|
||||
|
||||
// generateEBID 生成 Backint 外部备份 ID。
|
||||
// 格式:backupx-<timestamp>-<16 hex chars>
|
||||
func generateEBID() string {
|
||||
var buf [8]byte
|
||||
if _, err := rand.Read(buf[:]); err != nil {
|
||||
// fallback:用纳秒时间戳作为熵
|
||||
now := time.Now().UnixNano()
|
||||
for i := 0; i < 8; i++ {
|
||||
buf[i] = byte(now >> (i * 8))
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("backupx-%d-%s", time.Now().Unix(), hex.EncodeToString(buf[:]))
|
||||
}
|
||||
|
||||
func boolStr(b bool) string {
|
||||
if b {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
|
||||
// buildStorageRegistry 构造与主程序一致的 storage registry。
|
||||
//
|
||||
// Backint Agent 作为独立 CLI 进程运行,不依赖 BackupX HTTP 服务,
|
||||
// 因此这里直接引用 storage/rclone 包注册所有后端。
|
||||
func buildStorageRegistry() *storage.Registry {
|
||||
registry := storage.NewRegistry(
|
||||
storageRclone.NewLocalDiskFactory(),
|
||||
storageRclone.NewS3Factory(),
|
||||
storageRclone.NewWebDAVFactory(),
|
||||
storageRclone.NewGoogleDriveFactory(),
|
||||
storageRclone.NewAliyunOSSFactory(),
|
||||
storageRclone.NewTencentCOSFactory(),
|
||||
storageRclone.NewQiniuKodoFactory(),
|
||||
storageRclone.NewFTPFactory(),
|
||||
storageRclone.NewRcloneFactory(),
|
||||
)
|
||||
storageRclone.RegisterAllBackends(registry)
|
||||
return registry
|
||||
}
|
||||
|
||||
217
server/internal/backint/agent_test.go
Normal file
217
server/internal/backint/agent_test.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package backint
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"backupx/server/internal/storage"
|
||||
storageRclone "backupx/server/internal/storage/rclone"
|
||||
)
|
||||
|
||||
// newTestAgent 构造一个使用本地磁盘后端的 Agent,便于集成测试。
|
||||
func newTestAgent(t *testing.T, compress bool) (*Agent, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
storageDir := filepath.Join(dir, "storage")
|
||||
if err := os.MkdirAll(storageDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
registry := storage.NewRegistry(storageRclone.NewLocalDiskFactory())
|
||||
provider, err := registry.Create(context.Background(), "local_disk", map[string]any{
|
||||
"basePath": storageDir,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create provider: %v", err)
|
||||
}
|
||||
cat, err := OpenCatalog(filepath.Join(dir, "catalog.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
agent := &Agent{
|
||||
cfg: &Config{StorageType: "local_disk", KeyPrefix: "backint", Compress: compress, CatalogDB: filepath.Join(dir, "catalog.db")},
|
||||
provider: provider,
|
||||
catalog: cat,
|
||||
}
|
||||
t.Cleanup(func() { _ = agent.Close() })
|
||||
return agent, dir
|
||||
}
|
||||
|
||||
func TestAgent_BackupAndRestore_File(t *testing.T) {
|
||||
agent, dir := newTestAgent(t, false)
|
||||
ctx := context.Background()
|
||||
|
||||
// 准备源文件
|
||||
src := filepath.Join(dir, "src.bak")
|
||||
content := []byte("hello backint world")
|
||||
if err := os.WriteFile(src, content, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// BACKUP
|
||||
inPath := filepath.Join(dir, "backup.in")
|
||||
outPath := filepath.Join(dir, "backup.out")
|
||||
if err := os.WriteFile(inPath, []byte(src+"\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := agent.Run(ctx, FunctionBackup, inPath, outPath); err != nil {
|
||||
t.Fatalf("backup: %v", err)
|
||||
}
|
||||
out, _ := os.ReadFile(outPath)
|
||||
if !bytes.HasPrefix(out, []byte("#SAVED ")) {
|
||||
t.Fatalf("expected #SAVED, got: %s", out)
|
||||
}
|
||||
// 提取 EBID:#SAVED <ebid> "<path>"
|
||||
parts := strings.Fields(string(out))
|
||||
if len(parts) < 3 {
|
||||
t.Fatalf("malformed output: %s", out)
|
||||
}
|
||||
ebid := parts[1]
|
||||
|
||||
// RESTORE
|
||||
restoreDst := filepath.Join(dir, "restored.bak")
|
||||
inPath2 := filepath.Join(dir, "restore.in")
|
||||
outPath2 := filepath.Join(dir, "restore.out")
|
||||
if err := os.WriteFile(inPath2, []byte(ebid+" \""+restoreDst+"\"\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := agent.Run(ctx, FunctionRestore, inPath2, outPath2); err != nil {
|
||||
t.Fatalf("restore: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(restoreDst)
|
||||
if err != nil {
|
||||
t.Fatalf("read restored: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, content) {
|
||||
t.Errorf("restored content mismatch: %q vs %q", got, content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_BackupWithCompression(t *testing.T) {
|
||||
agent, dir := newTestAgent(t, true)
|
||||
ctx := context.Background()
|
||||
|
||||
src := filepath.Join(dir, "src.bak")
|
||||
content := bytes.Repeat([]byte("ABCDEFGH"), 1024)
|
||||
if err := os.WriteFile(src, content, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
inPath := filepath.Join(dir, "backup.in")
|
||||
outPath := filepath.Join(dir, "backup.out")
|
||||
_ = os.WriteFile(inPath, []byte(src+"\n"), 0644)
|
||||
if err := agent.Run(ctx, FunctionBackup, inPath, outPath); err != nil {
|
||||
t.Fatalf("backup: %v", err)
|
||||
}
|
||||
parts := strings.Fields(string(mustRead(t, outPath)))
|
||||
ebid := parts[1]
|
||||
|
||||
// 验证 catalog 记录的对象键以 .gz 结尾
|
||||
entry, _ := agent.catalog.Get(ebid)
|
||||
if entry == nil || !strings.HasSuffix(entry.ObjectKey, ".gz") {
|
||||
t.Fatalf("expected .gz suffix: %+v", entry)
|
||||
}
|
||||
|
||||
// RESTORE 应能解压回原始内容
|
||||
dst := filepath.Join(dir, "restored.bak")
|
||||
in2 := filepath.Join(dir, "restore.in")
|
||||
out2 := filepath.Join(dir, "restore.out")
|
||||
_ = os.WriteFile(in2, []byte(ebid+" \""+dst+"\"\n"), 0644)
|
||||
if err := agent.Run(ctx, FunctionRestore, in2, out2); err != nil {
|
||||
t.Fatalf("restore: %v", err)
|
||||
}
|
||||
got := mustRead(t, dst)
|
||||
if !bytes.Equal(got, content) {
|
||||
t.Errorf("decompressed content mismatch (len=%d vs %d)", len(got), len(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_Inquire(t *testing.T) {
|
||||
agent, dir := newTestAgent(t, false)
|
||||
ctx := context.Background()
|
||||
|
||||
// 注入两条目录记录
|
||||
_ = agent.catalog.Put(CatalogEntry{EBID: "bid-a", ObjectKey: "k/a"})
|
||||
_ = agent.catalog.Put(CatalogEntry{EBID: "bid-b", ObjectKey: "k/b"})
|
||||
|
||||
// INQUIRE #NULL 应列出全部
|
||||
in := filepath.Join(dir, "inq.in")
|
||||
out := filepath.Join(dir, "inq.out")
|
||||
_ = os.WriteFile(in, []byte("#NULL\n"), 0644)
|
||||
if err := agent.Run(ctx, FunctionInquire, in, out); err != nil {
|
||||
t.Fatalf("inquire: %v", err)
|
||||
}
|
||||
text := string(mustRead(t, out))
|
||||
if !strings.Contains(text, "bid-a") || !strings.Contains(text, "bid-b") {
|
||||
t.Errorf("expected both ebids, got: %s", text)
|
||||
}
|
||||
|
||||
// INQUIRE 不存在的 ebid → #NOTFOUND
|
||||
_ = os.WriteFile(in, []byte("bid-missing\n"), 0644)
|
||||
if err := agent.Run(ctx, FunctionInquire, in, out); err != nil {
|
||||
t.Fatalf("inquire missing: %v", err)
|
||||
}
|
||||
text = string(mustRead(t, out))
|
||||
if !strings.Contains(text, "#NOTFOUND") {
|
||||
t.Errorf("expected #NOTFOUND, got: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_Delete(t *testing.T) {
|
||||
agent, dir := newTestAgent(t, false)
|
||||
ctx := context.Background()
|
||||
|
||||
// 先做一次 BACKUP
|
||||
src := filepath.Join(dir, "src.bak")
|
||||
_ = os.WriteFile(src, []byte("data"), 0644)
|
||||
inPath := filepath.Join(dir, "b.in")
|
||||
outPath := filepath.Join(dir, "b.out")
|
||||
_ = os.WriteFile(inPath, []byte(src+"\n"), 0644)
|
||||
if err := agent.Run(ctx, FunctionBackup, inPath, outPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ebid := strings.Fields(string(mustRead(t, outPath)))[1]
|
||||
|
||||
// DELETE
|
||||
delIn := filepath.Join(dir, "d.in")
|
||||
delOut := filepath.Join(dir, "d.out")
|
||||
_ = os.WriteFile(delIn, []byte(ebid+"\n"), 0644)
|
||||
if err := agent.Run(ctx, FunctionDelete, delIn, delOut); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(mustRead(t, delOut)), "#DELETED") {
|
||||
t.Errorf("expected #DELETED, got: %s", mustRead(t, delOut))
|
||||
}
|
||||
// catalog 条目应已删除
|
||||
if entry, _ := agent.catalog.Get(ebid); entry != nil {
|
||||
t.Errorf("catalog entry should be removed, got: %+v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_RestoreUnknownEBID(t *testing.T) {
|
||||
agent, dir := newTestAgent(t, false)
|
||||
ctx := context.Background()
|
||||
|
||||
in := filepath.Join(dir, "r.in")
|
||||
out := filepath.Join(dir, "r.out")
|
||||
_ = os.WriteFile(in, []byte("bid-unknown \""+filepath.Join(dir, "dst")+"\"\n"), 0644)
|
||||
if err := agent.Run(ctx, FunctionRestore, in, out); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(mustRead(t, out)), "#ERROR") {
|
||||
t.Errorf("expected #ERROR for unknown ebid, got: %s", mustRead(t, out))
|
||||
}
|
||||
}
|
||||
|
||||
func mustRead(t *testing.T, path string) []byte {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
102
server/internal/backint/catalog.go
Normal file
102
server/internal/backint/catalog.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package backint
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// CatalogEntry 是 Backint 目录条目,建立 BID (备份 ID) 与对象键的映射。
|
||||
//
|
||||
// BID 是 Backint Agent 返回给 SAP HANA 的唯一标识,HANA 后续用它作为 RESTORE/DELETE
|
||||
// 的句柄。Agent 用 catalog 查询该 BID 对应的实际存储对象键。
|
||||
type CatalogEntry struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
EBID string `gorm:"column:ebid;uniqueIndex;size:128;not null"`
|
||||
ObjectKey string `gorm:"column:object_key;size:512;not null"`
|
||||
SourcePath string `gorm:"column:source_path;size:1024"`
|
||||
Size int64 `gorm:"column:size"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
|
||||
// TableName 指定表名,避免 GORM 自动复数化。
|
||||
func (CatalogEntry) TableName() string { return "backint_catalog" }
|
||||
|
||||
// Catalog 是本地 Backint 目录(SQLite 后端)。
|
||||
type Catalog struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// OpenCatalog 打开或创建 catalog 数据库。
|
||||
func OpenCatalog(dbPath string) (*Catalog, error) {
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
Logger: gormlogger.Default.LogMode(gormlogger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open catalog: %w", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&CatalogEntry{}); err != nil {
|
||||
return nil, fmt.Errorf("migrate catalog: %w", err)
|
||||
}
|
||||
return &Catalog{db: db}, nil
|
||||
}
|
||||
|
||||
// Close 关闭底层连接。
|
||||
func (c *Catalog) Close() error {
|
||||
if c.db == nil {
|
||||
return nil
|
||||
}
|
||||
sqlDB, err := c.db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sqlDB.Close()
|
||||
}
|
||||
|
||||
// Put 插入或更新一条记录。
|
||||
func (c *Catalog) Put(entry CatalogEntry) error {
|
||||
if entry.EBID == "" {
|
||||
return fmt.Errorf("ebid is required")
|
||||
}
|
||||
if entry.CreatedAt.IsZero() {
|
||||
entry.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
// Upsert:EBID 冲突时更新 object_key/size/source_path
|
||||
return c.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "ebid"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"object_key", "source_path", "size", "created_at",
|
||||
}),
|
||||
}).Create(&entry).Error
|
||||
}
|
||||
|
||||
// Get 通过 EBID 查询条目。未找到返回 (nil, nil)。
|
||||
func (c *Catalog) Get(ebid string) (*CatalogEntry, error) {
|
||||
var entry CatalogEntry
|
||||
err := c.db.Where("ebid = ?", ebid).First(&entry).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &entry, nil
|
||||
}
|
||||
|
||||
// Delete 删除一条记录。
|
||||
func (c *Catalog) Delete(ebid string) error {
|
||||
return c.db.Where("ebid = ?", ebid).Delete(&CatalogEntry{}).Error
|
||||
}
|
||||
|
||||
// List 列出全部条目。
|
||||
func (c *Catalog) List() ([]CatalogEntry, error) {
|
||||
var entries []CatalogEntry
|
||||
if err := c.db.Order("created_at DESC").Find(&entries).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
74
server/internal/backint/catalog_test.go
Normal file
74
server/internal/backint/catalog_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package backint
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCatalog_CRUD(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cat, err := OpenCatalog(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer cat.Close()
|
||||
|
||||
if err := cat.Put(CatalogEntry{EBID: "bid-1", ObjectKey: "k/1.bin", SourcePath: "/tmp/a", Size: 100}); err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
if err := cat.Put(CatalogEntry{EBID: "bid-2", ObjectKey: "k/2.bin", Size: 200}); err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
|
||||
got, err := cat.Get("bid-1")
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("get: %v %v", got, err)
|
||||
}
|
||||
if got.ObjectKey != "k/1.bin" || got.Size != 100 {
|
||||
t.Errorf("mismatch: %+v", got)
|
||||
}
|
||||
|
||||
// 不存在的条目
|
||||
missing, err := cat.Get("bid-999")
|
||||
if err != nil {
|
||||
t.Fatalf("get missing: %v", err)
|
||||
}
|
||||
if missing != nil {
|
||||
t.Errorf("expected nil, got %+v", missing)
|
||||
}
|
||||
|
||||
// List
|
||||
all, err := cat.List()
|
||||
if err != nil || len(all) != 2 {
|
||||
t.Fatalf("list: %v %d", err, len(all))
|
||||
}
|
||||
|
||||
// Delete
|
||||
if err := cat.Delete("bid-1"); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
got, _ = cat.Get("bid-1")
|
||||
if got != nil {
|
||||
t.Errorf("bid-1 should be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalog_UpsertSameEBID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cat, err := OpenCatalog(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer cat.Close()
|
||||
|
||||
if err := cat.Put(CatalogEntry{EBID: "bid-x", ObjectKey: "v1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cat.Put(CatalogEntry{EBID: "bid-x", ObjectKey: "v2"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := cat.Get("bid-x")
|
||||
if got == nil || got.ObjectKey != "v2" {
|
||||
t.Errorf("upsert failed: %+v", got)
|
||||
}
|
||||
}
|
||||
140
server/internal/backint/config.go
Normal file
140
server/internal/backint/config.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package backint
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config 是 Backint Agent 的运行时配置。
|
||||
//
|
||||
// SAP HANA 通过 -p <paramfile> 传入一个参数文件。BackupX Backint Agent 复用 SAP
|
||||
// 的"#KEY = VALUE"风格(兼容原生 backint 参数文件习惯),不支持 section。
|
||||
//
|
||||
// 必填字段:
|
||||
// - STORAGE_TYPE:存储类型(s3/webdav/local_disk/...,与 BackupX storage registry 一致)
|
||||
// - STORAGE_CONFIG_JSON:存储配置 JSON 文件路径(或直接 STORAGE_CONFIG = <json>)
|
||||
//
|
||||
// 可选字段:
|
||||
// - PARALLEL_FACTOR:并行度(默认 1)
|
||||
// - COMPRESS:是否 gzip 压缩(true/false,默认 false)
|
||||
// - LOG_FILE:日志文件路径(默认 stderr)
|
||||
// - CATALOG_DB:本地目录数据库路径(默认 ./backint_catalog.db)
|
||||
// - KEY_PREFIX:对象键前缀(默认空,最终对象键 = <prefix>/<ebid>)
|
||||
type Config struct {
|
||||
StorageType string
|
||||
StorageConfigJSON string // 存储配置 JSON 文件路径
|
||||
StorageConfigRaw []byte // 也支持直接内联(STORAGE_CONFIG)
|
||||
StorageConfig map[string]any // 解析后的存储配置
|
||||
ParallelFactor int
|
||||
Compress bool
|
||||
LogFile string
|
||||
CatalogDB string
|
||||
KeyPrefix string
|
||||
}
|
||||
|
||||
// LoadConfigFile 从文件加载配置。
|
||||
func LoadConfigFile(path string) (*Config, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open backint config: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
return ParseConfig(f)
|
||||
}
|
||||
|
||||
// ParseConfig 从 reader 解析配置。
|
||||
func ParseConfig(r io.Reader) (*Config, error) {
|
||||
cfg := &Config{ParallelFactor: 1}
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, ";") {
|
||||
continue
|
||||
}
|
||||
// 兼容可选的 "#" 前缀(SAP 约定)
|
||||
line = strings.TrimPrefix(line, "#")
|
||||
eq := strings.Index(line, "=")
|
||||
if eq < 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(line[:eq])
|
||||
value := strings.TrimSpace(line[eq+1:])
|
||||
if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
|
||||
value = value[1 : len(value)-1]
|
||||
}
|
||||
switch strings.ToUpper(key) {
|
||||
case "STORAGE_TYPE":
|
||||
cfg.StorageType = value
|
||||
case "STORAGE_CONFIG_JSON":
|
||||
cfg.StorageConfigJSON = value
|
||||
case "STORAGE_CONFIG":
|
||||
cfg.StorageConfigRaw = []byte(value)
|
||||
case "PARALLEL_FACTOR":
|
||||
n, err := strconv.Atoi(value)
|
||||
if err != nil || n <= 0 {
|
||||
return nil, fmt.Errorf("invalid PARALLEL_FACTOR: %q", value)
|
||||
}
|
||||
cfg.ParallelFactor = n
|
||||
case "COMPRESS":
|
||||
cfg.Compress = parseBool(value)
|
||||
case "LOG_FILE":
|
||||
cfg.LogFile = value
|
||||
case "CATALOG_DB":
|
||||
cfg.CatalogDB = value
|
||||
case "KEY_PREFIX":
|
||||
cfg.KeyPrefix = strings.Trim(value, "/")
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cfg.finalize(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) finalize() error {
|
||||
if c.StorageType == "" {
|
||||
return errors.New("STORAGE_TYPE is required")
|
||||
}
|
||||
if c.CatalogDB == "" {
|
||||
c.CatalogDB = "./backint_catalog.db"
|
||||
}
|
||||
// 加载存储配置 JSON
|
||||
var raw []byte
|
||||
switch {
|
||||
case c.StorageConfigJSON != "":
|
||||
data, err := os.ReadFile(c.StorageConfigJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read STORAGE_CONFIG_JSON: %w", err)
|
||||
}
|
||||
raw = data
|
||||
case len(c.StorageConfigRaw) > 0:
|
||||
raw = c.StorageConfigRaw
|
||||
default:
|
||||
return errors.New("STORAGE_CONFIG_JSON or STORAGE_CONFIG is required")
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return fmt.Errorf("parse storage config JSON: %w", err)
|
||||
}
|
||||
c.StorageConfig = m
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseBool(v string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
74
server/internal/backint/config_test.go
Normal file
74
server/internal/backint/config_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package backint
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
storagePath := filepath.Join(dir, "storage.json")
|
||||
if err := os.WriteFile(storagePath, []byte(`{"basePath":"/tmp/backup"}`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input := `
|
||||
; 注释
|
||||
#STORAGE_TYPE = local_disk
|
||||
#STORAGE_CONFIG_JSON = ` + storagePath + `
|
||||
#PARALLEL_FACTOR = 4
|
||||
#COMPRESS = true
|
||||
#KEY_PREFIX = /hana/backups/
|
||||
#CATALOG_DB = ` + filepath.Join(dir, "catalog.db") + `
|
||||
`
|
||||
cfg, err := ParseConfig(strings.NewReader(input))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if cfg.StorageType != "local_disk" {
|
||||
t.Errorf("StorageType: %q", cfg.StorageType)
|
||||
}
|
||||
if cfg.ParallelFactor != 4 {
|
||||
t.Errorf("ParallelFactor: %d", cfg.ParallelFactor)
|
||||
}
|
||||
if !cfg.Compress {
|
||||
t.Errorf("Compress should be true")
|
||||
}
|
||||
if cfg.KeyPrefix != "hana/backups" {
|
||||
t.Errorf("KeyPrefix should be trimmed: %q", cfg.KeyPrefix)
|
||||
}
|
||||
if cfg.StorageConfig["basePath"] != "/tmp/backup" {
|
||||
t.Errorf("StorageConfig mismatch: %+v", cfg.StorageConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfig_MissingStorageType(t *testing.T) {
|
||||
input := `PARALLEL_FACTOR = 1`
|
||||
if _, err := ParseConfig(strings.NewReader(input)); err == nil {
|
||||
t.Fatal("expected error for missing STORAGE_TYPE")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfig_InlineStorageConfig(t *testing.T) {
|
||||
input := `STORAGE_TYPE = local_disk
|
||||
STORAGE_CONFIG = {"basePath":"/x"}
|
||||
`
|
||||
cfg, err := ParseConfig(strings.NewReader(input))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if cfg.StorageConfig["basePath"] != "/x" {
|
||||
t.Errorf("inline config not parsed: %+v", cfg.StorageConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfig_InvalidParallel(t *testing.T) {
|
||||
input := `STORAGE_TYPE = local_disk
|
||||
STORAGE_CONFIG = {}
|
||||
PARALLEL_FACTOR = oops
|
||||
`
|
||||
if _, err := ParseConfig(strings.NewReader(input)); err == nil {
|
||||
t.Fatal("expected error for invalid PARALLEL_FACTOR")
|
||||
}
|
||||
}
|
||||
267
server/internal/backint/protocol.go
Normal file
267
server/internal/backint/protocol.go
Normal file
@@ -0,0 +1,267 @@
|
||||
// Package backint 实现 SAP HANA Backint 协议代理。
|
||||
//
|
||||
// Backint 协议是 SAP HANA 与第三方备份工具之间的管道/文件协议。
|
||||
// SAP HANA 通过 CLI 调用 Backint Agent,传入参数文件、输入文件、输出文件,
|
||||
// Agent 根据输入文件中的 #PIPE / #EBID / #NULL 指令读取/写入数据,
|
||||
// 并在输出文件中返回 #SAVED / #RESTORED / #BACKUP / #NOTFOUND / #DELETED / #ERROR。
|
||||
//
|
||||
// 支持的功能:BACKUP / RESTORE / INQUIRE / DELETE
|
||||
// 参考规范:SAP HANA Backint Interface for Backup Tools (OSS 1642148)
|
||||
package backint
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Function 代表 Backint 操作类型,对应 CLI 的 -f 参数。
|
||||
type Function string
|
||||
|
||||
const (
|
||||
FunctionBackup Function = "backup"
|
||||
FunctionRestore Function = "restore"
|
||||
FunctionInquire Function = "inquire"
|
||||
FunctionDelete Function = "delete"
|
||||
)
|
||||
|
||||
// BackupRequest 是 BACKUP 操作的单条请求。
|
||||
//
|
||||
// 两种形态:
|
||||
// - Pipe: #PIPE <path> (HANA 通过命名管道传输数据)
|
||||
// - File: "<path>" (HANA 指向一个已完成的临时文件)
|
||||
type BackupRequest struct {
|
||||
IsPipe bool
|
||||
Path string
|
||||
}
|
||||
|
||||
// RestoreRequest 是 RESTORE 操作的单条请求。
|
||||
//
|
||||
// 形态:#PIPE <ebid> "<path>" 或 <ebid> "<path>"
|
||||
type RestoreRequest struct {
|
||||
IsPipe bool
|
||||
EBID string // 之前 BACKUP 返回的备份 ID
|
||||
Path string
|
||||
}
|
||||
|
||||
// InquireRequest 是 INQUIRE 操作的单条请求。
|
||||
//
|
||||
// 形态:
|
||||
// - #NULL (列出所有备份)
|
||||
// - "<ebid>" (查询指定 ID 是否存在)
|
||||
// - #EBID "<ebid>" (带前缀的变体)
|
||||
type InquireRequest struct {
|
||||
All bool
|
||||
EBID string
|
||||
}
|
||||
|
||||
// DeleteRequest 是 DELETE 操作的单条请求。
|
||||
//
|
||||
// 形态:<ebid> 或 #EBID <ebid>
|
||||
type DeleteRequest struct {
|
||||
EBID string
|
||||
}
|
||||
|
||||
// ParseBackupRequests 解析 BACKUP 输入文件。
|
||||
func ParseBackupRequests(r io.Reader) ([]BackupRequest, error) {
|
||||
var items []BackupRequest
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "#PIPE") {
|
||||
path := strings.TrimSpace(strings.TrimPrefix(line, "#PIPE"))
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("invalid #PIPE line: %q", line)
|
||||
}
|
||||
items = append(items, BackupRequest{IsPipe: true, Path: trimQuotes(path)})
|
||||
continue
|
||||
}
|
||||
items = append(items, BackupRequest{IsPipe: false, Path: trimQuotes(line)})
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// ParseRestoreRequests 解析 RESTORE 输入文件。
|
||||
func ParseRestoreRequests(r io.Reader) ([]RestoreRequest, error) {
|
||||
var items []RestoreRequest
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
isPipe := false
|
||||
if strings.HasPrefix(line, "#PIPE") {
|
||||
isPipe = true
|
||||
line = strings.TrimSpace(strings.TrimPrefix(line, "#PIPE"))
|
||||
}
|
||||
if strings.HasPrefix(line, "#EBID") {
|
||||
line = strings.TrimSpace(strings.TrimPrefix(line, "#EBID"))
|
||||
}
|
||||
ebid, rest := splitFirstField(line)
|
||||
if ebid == "" || rest == "" {
|
||||
return nil, fmt.Errorf("invalid restore line: %q", line)
|
||||
}
|
||||
items = append(items, RestoreRequest{
|
||||
IsPipe: isPipe,
|
||||
EBID: trimQuotes(ebid),
|
||||
Path: trimQuotes(rest),
|
||||
})
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// ParseInquireRequests 解析 INQUIRE 输入文件。
|
||||
func ParseInquireRequests(r io.Reader) ([]InquireRequest, error) {
|
||||
var items []InquireRequest
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if line == "#NULL" {
|
||||
items = append(items, InquireRequest{All: true})
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "#EBID") {
|
||||
line = strings.TrimSpace(strings.TrimPrefix(line, "#EBID"))
|
||||
}
|
||||
items = append(items, InquireRequest{EBID: trimQuotes(line)})
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// ParseDeleteRequests 解析 DELETE 输入文件。
|
||||
func ParseDeleteRequests(r io.Reader) ([]DeleteRequest, error) {
|
||||
var items []DeleteRequest
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "#EBID") {
|
||||
line = strings.TrimSpace(strings.TrimPrefix(line, "#EBID"))
|
||||
}
|
||||
ebid := trimQuotes(strings.TrimSpace(line))
|
||||
if ebid == "" {
|
||||
return nil, fmt.Errorf("invalid delete line: %q", line)
|
||||
}
|
||||
items = append(items, DeleteRequest{EBID: ebid})
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// 输出写入辅助
|
||||
|
||||
// WriteSaved 写入一条 BACKUP 成功响应:#SAVED <ebid> "<path>"
|
||||
func WriteSaved(w io.Writer, ebid, path string) error {
|
||||
_, err := fmt.Fprintf(w, "#SAVED %s %s\n", ebid, quote(path))
|
||||
return err
|
||||
}
|
||||
|
||||
// WriteRestored 写入一条 RESTORE 成功响应:#RESTORED "<ebid>" "<path>"
|
||||
func WriteRestored(w io.Writer, ebid, path string) error {
|
||||
_, err := fmt.Fprintf(w, "#RESTORED %s %s\n", quote(ebid), quote(path))
|
||||
return err
|
||||
}
|
||||
|
||||
// WriteBackup 写入一条 INQUIRE 命中响应:#BACKUP "<ebid>"
|
||||
func WriteBackup(w io.Writer, ebid string) error {
|
||||
_, err := fmt.Fprintf(w, "#BACKUP %s\n", quote(ebid))
|
||||
return err
|
||||
}
|
||||
|
||||
// WriteNotFound 写入一条 INQUIRE/RESTORE 未命中响应:#NOTFOUND "<path-or-ebid>"
|
||||
func WriteNotFound(w io.Writer, identifier string) error {
|
||||
_, err := fmt.Fprintf(w, "#NOTFOUND %s\n", quote(identifier))
|
||||
return err
|
||||
}
|
||||
|
||||
// WriteDeleted 写入一条 DELETE 成功响应:#DELETED "<ebid>"
|
||||
func WriteDeleted(w io.Writer, ebid string) error {
|
||||
_, err := fmt.Fprintf(w, "#DELETED %s\n", quote(ebid))
|
||||
return err
|
||||
}
|
||||
|
||||
// WriteError 写入一条错误响应:#ERROR "<path-or-ebid>"
|
||||
//
|
||||
// SAP HANA 会将 #ERROR 视为本条请求失败,但不会终止整个批次。
|
||||
// 在 stderr 输出错误详情便于排查。
|
||||
func WriteError(w io.Writer, identifier string) error {
|
||||
_, err := fmt.Fprintf(w, "#ERROR %s\n", quote(identifier))
|
||||
return err
|
||||
}
|
||||
|
||||
// 内部工具函数
|
||||
|
||||
func trimQuotes(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func quote(s string) string {
|
||||
return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"`
|
||||
}
|
||||
|
||||
// splitFirstField 把一行拆分为 "第一个字段" 和 "剩余部分"。
|
||||
// 支持带引号的字段:`"abc def" "path"` → `abc def` / `"path"`。
|
||||
func splitFirstField(line string) (first, rest string) {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
return "", ""
|
||||
}
|
||||
if line[0] == '"' {
|
||||
idx := strings.Index(line[1:], `"`)
|
||||
if idx < 0 {
|
||||
return line, ""
|
||||
}
|
||||
return line[1 : idx+1], strings.TrimSpace(line[idx+2:])
|
||||
}
|
||||
idx := strings.IndexAny(line, " \t")
|
||||
if idx < 0 {
|
||||
return line, ""
|
||||
}
|
||||
return line[:idx], strings.TrimSpace(line[idx+1:])
|
||||
}
|
||||
|
||||
// ParseFunction 将 CLI 的 -f 参数字符串规范化为 Function。
|
||||
func ParseFunction(s string) (Function, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "backup":
|
||||
return FunctionBackup, nil
|
||||
case "restore":
|
||||
return FunctionRestore, nil
|
||||
case "inquire":
|
||||
return FunctionInquire, nil
|
||||
case "delete":
|
||||
return FunctionDelete, nil
|
||||
default:
|
||||
return "", errors.New("unsupported backint function: " + s)
|
||||
}
|
||||
}
|
||||
142
server/internal/backint/protocol_test.go
Normal file
142
server/internal/backint/protocol_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package backint
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseBackupRequests(t *testing.T) {
|
||||
input := `#PIPE /tmp/pipe1
|
||||
#PIPE "/tmp/pipe two"
|
||||
/tmp/file.bak
|
||||
"/tmp/file two.bak"
|
||||
`
|
||||
reqs, err := ParseBackupRequests(strings.NewReader(input))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(reqs) != 4 {
|
||||
t.Fatalf("expected 4 requests, got %d", len(reqs))
|
||||
}
|
||||
if !reqs[0].IsPipe || reqs[0].Path != "/tmp/pipe1" {
|
||||
t.Errorf("req[0] mismatch: %+v", reqs[0])
|
||||
}
|
||||
if !reqs[1].IsPipe || reqs[1].Path != "/tmp/pipe two" {
|
||||
t.Errorf("req[1] mismatch: %+v", reqs[1])
|
||||
}
|
||||
if reqs[2].IsPipe || reqs[2].Path != "/tmp/file.bak" {
|
||||
t.Errorf("req[2] mismatch: %+v", reqs[2])
|
||||
}
|
||||
if reqs[3].Path != "/tmp/file two.bak" {
|
||||
t.Errorf("req[3] mismatch: %+v", reqs[3])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRestoreRequests(t *testing.T) {
|
||||
input := `#PIPE backupx-123 "/tmp/pipe1"
|
||||
#EBID "backupx-456" "/tmp/file.bak"
|
||||
backupx-789 /tmp/plain.bak
|
||||
`
|
||||
reqs, err := ParseRestoreRequests(strings.NewReader(input))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(reqs) != 3 {
|
||||
t.Fatalf("expected 3, got %d", len(reqs))
|
||||
}
|
||||
if !reqs[0].IsPipe || reqs[0].EBID != "backupx-123" || reqs[0].Path != "/tmp/pipe1" {
|
||||
t.Errorf("req[0] mismatch: %+v", reqs[0])
|
||||
}
|
||||
if reqs[1].IsPipe || reqs[1].EBID != "backupx-456" {
|
||||
t.Errorf("req[1] mismatch: %+v", reqs[1])
|
||||
}
|
||||
if reqs[2].EBID != "backupx-789" || reqs[2].Path != "/tmp/plain.bak" {
|
||||
t.Errorf("req[2] mismatch: %+v", reqs[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInquireRequests(t *testing.T) {
|
||||
input := "#NULL\nbackupx-abc\n#EBID \"backupx-xyz\"\n"
|
||||
reqs, err := ParseInquireRequests(strings.NewReader(input))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(reqs) != 3 {
|
||||
t.Fatalf("expected 3, got %d", len(reqs))
|
||||
}
|
||||
if !reqs[0].All {
|
||||
t.Errorf("req[0] should be All")
|
||||
}
|
||||
if reqs[1].EBID != "backupx-abc" {
|
||||
t.Errorf("req[1] mismatch: %+v", reqs[1])
|
||||
}
|
||||
if reqs[2].EBID != "backupx-xyz" {
|
||||
t.Errorf("req[2] mismatch: %+v", reqs[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDeleteRequests(t *testing.T) {
|
||||
input := "backupx-aaa\n#EBID \"backupx-bbb\"\n"
|
||||
reqs, err := ParseDeleteRequests(strings.NewReader(input))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(reqs) != 2 || reqs[0].EBID != "backupx-aaa" || reqs[1].EBID != "backupx-bbb" {
|
||||
t.Fatalf("unexpected: %+v", reqs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteResponses(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
_ = WriteSaved(&buf, "backupx-1", "/tmp/x")
|
||||
_ = WriteRestored(&buf, "backupx-2", "/tmp/y")
|
||||
_ = WriteBackup(&buf, "backupx-3")
|
||||
_ = WriteNotFound(&buf, "backupx-4")
|
||||
_ = WriteDeleted(&buf, "backupx-5")
|
||||
_ = WriteError(&buf, "/tmp/z")
|
||||
want := "#SAVED backupx-1 \"/tmp/x\"\n" +
|
||||
"#RESTORED \"backupx-2\" \"/tmp/y\"\n" +
|
||||
"#BACKUP \"backupx-3\"\n" +
|
||||
"#NOTFOUND \"backupx-4\"\n" +
|
||||
"#DELETED \"backupx-5\"\n" +
|
||||
"#ERROR \"/tmp/z\"\n"
|
||||
if buf.String() != want {
|
||||
t.Errorf("output mismatch:\n got: %q\nwant: %q", buf.String(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFunction(t *testing.T) {
|
||||
cases := map[string]Function{
|
||||
"backup": FunctionBackup,
|
||||
"BACKUP": FunctionBackup,
|
||||
"restore": FunctionRestore,
|
||||
"inquire": FunctionInquire,
|
||||
"delete": FunctionDelete,
|
||||
}
|
||||
for s, want := range cases {
|
||||
got, err := ParseFunction(s)
|
||||
if err != nil || got != want {
|
||||
t.Errorf("ParseFunction(%q) = %v, %v; want %v", s, got, err, want)
|
||||
}
|
||||
}
|
||||
if _, err := ParseFunction("bogus"); err == nil {
|
||||
t.Errorf("expected error for bogus function")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitFirstField(t *testing.T) {
|
||||
cases := []struct{ in, first, rest string }{
|
||||
{`abc def`, "abc", "def"},
|
||||
{`"abc def" ghi`, "abc def", "ghi"},
|
||||
{`"a b" "c d"`, "a b", `"c d"`},
|
||||
{`lone`, "lone", ""},
|
||||
{``, "", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
f, r := splitFirstField(c.in)
|
||||
if f != c.first || r != c.rest {
|
||||
t.Errorf("splitFirstField(%q) = (%q, %q); want (%q, %q)", c.in, f, r, c.first, c.rest)
|
||||
}
|
||||
}
|
||||
}
|
||||
119
server/internal/backup/discover.go
Normal file
119
server/internal/backup/discover.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DiscoverRequest 数据库发现请求参数。
|
||||
// Type 取 "mysql" 或 "postgresql"。
|
||||
type DiscoverRequest struct {
|
||||
Type string
|
||||
Host string
|
||||
Port int
|
||||
User string
|
||||
Password string
|
||||
}
|
||||
|
||||
// DiscoverDatabases 通过本机 mysql/psql 客户端连接目标数据库并列出非系统库。
|
||||
// 5 秒命令超时。调用方负责传入 CommandExecutor(Master 用 OSCommandExecutor,
|
||||
// Agent 同理)。此函数不依赖 service / apperror,便于在 agent 包复用。
|
||||
func DiscoverDatabases(ctx context.Context, executor CommandExecutor, req DiscoverRequest) ([]string, error) {
|
||||
switch strings.TrimSpace(strings.ToLower(req.Type)) {
|
||||
case "mysql":
|
||||
return discoverMySQLDatabases(ctx, executor, req)
|
||||
case "postgresql":
|
||||
return discoverPostgreSQLDatabases(ctx, executor, req)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported database type: %s", req.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func discoverMySQLDatabases(ctx context.Context, executor CommandExecutor, req DiscoverRequest) ([]string, error) {
|
||||
mysqlPath, err := executor.LookPath("mysql")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("系统未安装 mysql 客户端")
|
||||
}
|
||||
timeout, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
var stdout, stderr bytes.Buffer
|
||||
args := []string{
|
||||
fmt.Sprintf("--host=%s", req.Host),
|
||||
fmt.Sprintf("--port=%d", req.Port),
|
||||
fmt.Sprintf("--user=%s", req.User),
|
||||
"-e", "SHOW DATABASES",
|
||||
"--skip-column-names",
|
||||
}
|
||||
env := []string{fmt.Sprintf("MYSQL_PWD=%s", req.Password)}
|
||||
if err := executor.Run(timeout, mysqlPath, args, CommandOptions{
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Env: env,
|
||||
}); err != nil {
|
||||
errMsg := strings.TrimSpace(stderr.String())
|
||||
if errMsg == "" {
|
||||
errMsg = err.Error()
|
||||
}
|
||||
return nil, fmt.Errorf("连接 MySQL 失败:%s", errMsg)
|
||||
}
|
||||
systemDBs := map[string]bool{
|
||||
"information_schema": true,
|
||||
"performance_schema": true,
|
||||
"mysql": true,
|
||||
"sys": true,
|
||||
}
|
||||
var databases []string
|
||||
for _, line := range strings.Split(stdout.String(), "\n") {
|
||||
db := strings.TrimSpace(line)
|
||||
if db == "" || systemDBs[db] {
|
||||
continue
|
||||
}
|
||||
databases = append(databases, db)
|
||||
}
|
||||
return databases, nil
|
||||
}
|
||||
|
||||
func discoverPostgreSQLDatabases(ctx context.Context, executor CommandExecutor, req DiscoverRequest) ([]string, error) {
|
||||
psqlPath, err := executor.LookPath("psql")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("系统未安装 psql 客户端")
|
||||
}
|
||||
timeout, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
var stdout, stderr bytes.Buffer
|
||||
args := []string{
|
||||
"-h", req.Host,
|
||||
"-p", fmt.Sprintf("%d", req.Port),
|
||||
"-U", req.User,
|
||||
"-d", "postgres",
|
||||
"-t", "-A",
|
||||
"-c", "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname",
|
||||
}
|
||||
env := []string{fmt.Sprintf("PGPASSWORD=%s", req.Password)}
|
||||
if err := executor.Run(timeout, psqlPath, args, CommandOptions{
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Env: env,
|
||||
}); err != nil {
|
||||
errMsg := strings.TrimSpace(stderr.String())
|
||||
if errMsg == "" {
|
||||
errMsg = err.Error()
|
||||
}
|
||||
return nil, fmt.Errorf("连接 PostgreSQL 失败:%s", errMsg)
|
||||
}
|
||||
skipDBs := map[string]bool{
|
||||
"postgres": true,
|
||||
}
|
||||
var databases []string
|
||||
for _, line := range strings.Split(stdout.String(), "\n") {
|
||||
db := strings.TrimSpace(line)
|
||||
if db == "" || skipDBs[db] || strings.HasPrefix(db, "template") {
|
||||
continue
|
||||
}
|
||||
databases = append(databases, db)
|
||||
}
|
||||
return databases, nil
|
||||
}
|
||||
@@ -11,6 +11,28 @@ import (
|
||||
"backupx/server/internal/storage"
|
||||
)
|
||||
|
||||
// collectDirPrefixes 从待删除的记录中提取唯一的父目录前缀。
|
||||
func collectDirPrefixes(records []model.BackupRecord) []string {
|
||||
seen := make(map[string]struct{})
|
||||
var prefixes []string
|
||||
for _, record := range records {
|
||||
path := strings.TrimSpace(record.StoragePath)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
idx := strings.LastIndex(path, "/")
|
||||
if idx <= 0 {
|
||||
continue
|
||||
}
|
||||
dir := path[:idx]
|
||||
if _, ok := seen[dir]; !ok {
|
||||
seen[dir] = struct{}{}
|
||||
prefixes = append(prefixes, dir)
|
||||
}
|
||||
}
|
||||
return prefixes
|
||||
}
|
||||
|
||||
type CleanupResult struct {
|
||||
DeletedRecords int
|
||||
DeletedObjects int
|
||||
@@ -54,6 +76,17 @@ func (s *Service) Cleanup(ctx context.Context, task *model.BackupTask, provider
|
||||
}
|
||||
result.DeletedRecords++
|
||||
}
|
||||
|
||||
// 清理空目录:收集被删除文件的父目录,尝试移除空目录
|
||||
if dirCleaner, ok := provider.(storage.StorageDirCleaner); ok && result.DeletedObjects > 0 {
|
||||
prefixes := collectDirPrefixes(candidates)
|
||||
for _, prefix := range prefixes {
|
||||
if err := dirCleaner.RemoveEmptyDirs(ctx, prefix); err != nil {
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("cleanup empty dirs for %s: %v", prefix, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,9 @@ func (r *fakeRecordRepository) Delete(_ context.Context, id uint) error {
|
||||
func (r *fakeRecordRepository) ListRecent(context.Context, int) ([]model.BackupRecord, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *fakeRecordRepository) ListByTask(_ context.Context, _ uint) ([]model.BackupRecord, error) {
|
||||
return r.records, nil
|
||||
}
|
||||
func (r *fakeRecordRepository) ListSuccessfulByTask(_ context.Context, _ uint) ([]model.BackupRecord, error) {
|
||||
return r.records, nil
|
||||
}
|
||||
|
||||
@@ -35,6 +35,12 @@ func (r *SAPHANARunner) Type() string {
|
||||
// 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.
|
||||
//
|
||||
// 支持以下增强(通过 task.Database 字段配置):
|
||||
// - BackupLevel: full / incremental / differential
|
||||
// - BackupType: data / log
|
||||
// - BackupChannels: 并行通道数(>1 时生成多路径 SQL)
|
||||
// - MaxRetries: hdbsql 执行失败的重试次数
|
||||
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)")
|
||||
@@ -68,32 +74,46 @@ func (r *SAPHANARunner) Run(ctx context.Context, task TaskSpec, writer LogWriter
|
||||
port = 30015
|
||||
}
|
||||
|
||||
writer.WriteLine(fmt.Sprintf("连接到 SAP HANA: %s:%d", task.Database.Host, port))
|
||||
backupLevel := normalizeBackupLevel(task.Database.BackupLevel)
|
||||
backupType := normalizeBackupType(task.Database.BackupType)
|
||||
channels := task.Database.BackupChannels
|
||||
if channels < 1 {
|
||||
channels = 1
|
||||
}
|
||||
maxRetries := task.Database.MaxRetries
|
||||
if maxRetries < 1 {
|
||||
maxRetries = 3
|
||||
}
|
||||
instance := task.Database.InstanceNumber
|
||||
if strings.TrimSpace(instance) == "" {
|
||||
instance = hanaInstanceNumber(port)
|
||||
}
|
||||
|
||||
writer.WriteLine(fmt.Sprintf("连接到 SAP HANA: %s:%d (实例 %s)", task.Database.Host, port, instance))
|
||||
writer.WriteLine(fmt.Sprintf("备份数据库: %s", tenantDB))
|
||||
writer.WriteLine(fmt.Sprintf("备份配置: 类型=%s, 级别=%s, 通道数=%d, 最大重试=%d", backupType, backupLevel, channels, maxRetries))
|
||||
|
||||
// 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)
|
||||
prefixes, err := buildBackupPrefixes(backupDir, tenantDB, timestamp, channels)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build SQL based on backup type and level.
|
||||
backupSQL := buildBackupSQL(tenantDB, prefixes, backupType, backupLevel)
|
||||
writer.WriteLine(fmt.Sprintf("生成 SQL: %s", backupSQL))
|
||||
|
||||
// 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")
|
||||
writer.WriteLine("开始执行 SAP HANA 备份命令")
|
||||
|
||||
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())
|
||||
if err := r.runHdbsqlWithRetry(ctx, "hdbsql", args, maxRetries, writer); err != nil {
|
||||
return nil, fmt.Errorf("run hdbsql backup: %w", err)
|
||||
}
|
||||
|
||||
writer.WriteLine("SAP HANA BACKUP DATA 命令执行完成,开始打包备份文件")
|
||||
writer.WriteLine("SAP HANA 备份命令执行完成,开始打包备份文件")
|
||||
|
||||
// Package all generated backup files into a tar archive.
|
||||
if err := packageBackupFiles(backupDir, artifactPath, writer); err != nil {
|
||||
@@ -166,12 +186,12 @@ func (r *SAPHANARunner) Restore(ctx context.Context, task TaskSpec, artifactPath
|
||||
|
||||
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))
|
||||
maxRetries := task.Database.MaxRetries
|
||||
if maxRetries < 1 {
|
||||
maxRetries = 3
|
||||
}
|
||||
if err := r.runHdbsqlWithRetry(ctx, "hdbsql", args, maxRetries, writer); err != nil {
|
||||
return fmt.Errorf("run hdbsql RECOVER DATA: %w", err)
|
||||
}
|
||||
|
||||
writer.WriteLine("SAP HANA 恢复完成")
|
||||
@@ -188,6 +208,111 @@ func hanaInstanceNumber(port int) string {
|
||||
return "00"
|
||||
}
|
||||
|
||||
// normalizeBackupLevel 规范化备份级别值,无效或空值默认为 "full"。
|
||||
func normalizeBackupLevel(level string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(level)) {
|
||||
case "incremental":
|
||||
return "incremental"
|
||||
case "differential":
|
||||
return "differential"
|
||||
default:
|
||||
return "full"
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeBackupType 规范化备份类型,无效或空值默认为 "data"。
|
||||
func normalizeBackupType(t string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(t)) {
|
||||
case "log":
|
||||
return "log"
|
||||
default:
|
||||
return "data"
|
||||
}
|
||||
}
|
||||
|
||||
// buildBackupPrefixes 为每个并行通道生成独立子目录和路径前缀。
|
||||
// 当 channels=1 时返回单个直接位于 backupDir 下的前缀;
|
||||
// 当 channels>1 时为每个通道创建 chan_N/ 子目录。
|
||||
func buildBackupPrefixes(backupDir, tenantDB, timestamp string, channels int) ([]string, error) {
|
||||
tenantLower := strings.ToLower(tenantDB)
|
||||
if channels <= 1 {
|
||||
return []string{filepath.Join(backupDir, fmt.Sprintf("hana_%s_%s", tenantLower, timestamp))}, nil
|
||||
}
|
||||
prefixes := make([]string, 0, channels)
|
||||
for i := 0; i < channels; i++ {
|
||||
chanDir := filepath.Join(backupDir, fmt.Sprintf("chan_%d", i))
|
||||
if err := os.MkdirAll(chanDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create channel %d dir: %w", i, err)
|
||||
}
|
||||
prefixes = append(prefixes, filepath.Join(chanDir, fmt.Sprintf("hana_%s_%s", tenantLower, timestamp)))
|
||||
}
|
||||
return prefixes, nil
|
||||
}
|
||||
|
||||
// buildBackupSQL 根据备份类型和级别构建 SAP HANA BACKUP SQL 语句。
|
||||
//
|
||||
// 支持的语法:
|
||||
//
|
||||
// 全量数据备份: BACKUP DATA [FOR <tenant>] USING FILE ('p1' [, 'p2', ...])
|
||||
// 增量数据备份: BACKUP DATA [FOR <tenant>] INCREMENTAL USING FILE ('...')
|
||||
// 差异数据备份: BACKUP DATA [FOR <tenant>] DIFFERENTIAL USING FILE ('...')
|
||||
// 日志备份: BACKUP LOG [FOR <tenant>] USING FILE ('...')
|
||||
func buildBackupSQL(tenantDB string, prefixes []string, backupType, backupLevel string) string {
|
||||
tenantClause := ""
|
||||
if strings.ToUpper(tenantDB) != "SYSTEMDB" {
|
||||
tenantClause = fmt.Sprintf(" FOR %s", tenantDB)
|
||||
}
|
||||
|
||||
// 多路径以 'p1', 'p2', ... 拼接(HANA 多通道并行语法)
|
||||
quoted := make([]string, len(prefixes))
|
||||
for i, p := range prefixes {
|
||||
quoted[i] = fmt.Sprintf("'%s'", p)
|
||||
}
|
||||
pathClause := strings.Join(quoted, ", ")
|
||||
|
||||
if backupType == "log" {
|
||||
// LOG 备份不支持 INCREMENTAL/DIFFERENTIAL 关键字
|
||||
return fmt.Sprintf("BACKUP LOG%s USING FILE (%s)", tenantClause, pathClause)
|
||||
}
|
||||
|
||||
levelClause := ""
|
||||
switch backupLevel {
|
||||
case "incremental":
|
||||
levelClause = " INCREMENTAL"
|
||||
case "differential":
|
||||
levelClause = " DIFFERENTIAL"
|
||||
}
|
||||
return fmt.Sprintf("BACKUP DATA%s%s USING FILE (%s)", tenantClause, levelClause, pathClause)
|
||||
}
|
||||
|
||||
// runHdbsqlWithRetry 执行 hdbsql 命令并在失败时按指数退避重试。
|
||||
// 退避公式:5s × attempt²,并在 ctx 取消时立即返回。
|
||||
func (r *SAPHANARunner) runHdbsqlWithRetry(ctx context.Context, name string, args []string, maxAttempts int, writer LogWriter) error {
|
||||
if maxAttempts < 1 {
|
||||
maxAttempts = 1
|
||||
}
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
backoff := time.Duration(attempt*attempt) * 5 * time.Second
|
||||
writer.WriteLine(fmt.Sprintf("hdbsql 第 %d 次重试(等待 %s)", attempt, backoff))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
}
|
||||
stderrWriter := newLogLineWriter(writer, "hdbsql")
|
||||
err := r.executor.Run(ctx, name, args, CommandOptions{Stderr: stderrWriter})
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
lastErr = fmt.Errorf("%w: %s", err, strings.TrimSpace(stderrWriter.collected()))
|
||||
writer.WriteLine(fmt.Sprintf("hdbsql 执行失败(第 %d/%d 次): %v", attempt, maxAttempts, lastErr))
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// buildHdbsqlArgs constructs the common hdbsql CLI arguments.
|
||||
func buildHdbsqlArgs(host string, port int, user, password, database, sql string) []string {
|
||||
return []string{
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSAPHANARunnerRun_BackupDataCommand(t *testing.T) {
|
||||
@@ -273,6 +274,246 @@ func TestSAPHANARunnerRestore_TenantRecoverCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBackupSQL_FullSystemDB(t *testing.T) {
|
||||
sql := buildBackupSQL("SYSTEMDB", []string{"/tmp/p1"}, "data", "full")
|
||||
if sql != "BACKUP DATA USING FILE ('/tmp/p1')" {
|
||||
t.Fatalf("unexpected SQL: %s", sql)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBackupSQL_IncrementalTenant(t *testing.T) {
|
||||
sql := buildBackupSQL("HDB", []string{"/tmp/p1"}, "data", "incremental")
|
||||
expected := "BACKUP DATA FOR HDB INCREMENTAL USING FILE ('/tmp/p1')"
|
||||
if sql != expected {
|
||||
t.Fatalf("expected %q, got %q", expected, sql)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBackupSQL_DifferentialTenant(t *testing.T) {
|
||||
sql := buildBackupSQL("HDB", []string{"/tmp/p1"}, "data", "differential")
|
||||
expected := "BACKUP DATA FOR HDB DIFFERENTIAL USING FILE ('/tmp/p1')"
|
||||
if sql != expected {
|
||||
t.Fatalf("expected %q, got %q", expected, sql)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBackupSQL_LogBackup(t *testing.T) {
|
||||
sql := buildBackupSQL("HDB", []string{"/tmp/log"}, "log", "full")
|
||||
expected := "BACKUP LOG FOR HDB USING FILE ('/tmp/log')"
|
||||
if sql != expected {
|
||||
t.Fatalf("expected %q, got %q", expected, sql)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBackupSQL_ParallelChannels(t *testing.T) {
|
||||
sql := buildBackupSQL("SYSTEMDB", []string{"/tmp/c0/p", "/tmp/c1/p", "/tmp/c2/p"}, "data", "full")
|
||||
expected := "BACKUP DATA USING FILE ('/tmp/c0/p', '/tmp/c1/p', '/tmp/c2/p')"
|
||||
if sql != expected {
|
||||
t.Fatalf("expected %q, got %q", expected, sql)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeBackupLevel(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "full",
|
||||
"FULL": "full",
|
||||
"incremental": "incremental",
|
||||
"DIFFERENTIAL": "differential",
|
||||
"unknown": "full",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeBackupLevel(in); got != want {
|
||||
t.Errorf("normalizeBackupLevel(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeBackupType(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "data",
|
||||
"DATA": "data",
|
||||
"log": "log",
|
||||
"LOG": "log",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeBackupType(in); got != want {
|
||||
t.Errorf("normalizeBackupType(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSAPHANARunnerRun_IncrementalBackup(t *testing.T) {
|
||||
var capturedSQL string
|
||||
executor := &fakeCommandExecutor{
|
||||
runFunc: func(name string, args []string, options CommandOptions) error {
|
||||
capturedSQL = args[len(args)-1]
|
||||
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("incremental data"), 0o644)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
runner := NewSAPHANARunner(executor)
|
||||
result, err := runner.Run(context.Background(), TaskSpec{
|
||||
Name: "hana-incremental",
|
||||
Type: "saphana",
|
||||
Database: DatabaseSpec{
|
||||
Host: "10.0.0.1",
|
||||
Port: 30015,
|
||||
User: "SYSTEM",
|
||||
Password: "secret",
|
||||
Names: []string{"HDB"},
|
||||
BackupLevel: "incremental",
|
||||
},
|
||||
}, NopLogWriter{})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(result.TempDir)
|
||||
|
||||
if !strings.Contains(capturedSQL, "INCREMENTAL USING FILE") {
|
||||
t.Fatalf("expected INCREMENTAL in SQL, got: %s", capturedSQL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSAPHANARunnerRun_LogBackup(t *testing.T) {
|
||||
var capturedSQL string
|
||||
executor := &fakeCommandExecutor{
|
||||
runFunc: func(name string, args []string, options CommandOptions) error {
|
||||
capturedSQL = args[len(args)-1]
|
||||
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+"_logbackup_0_1", []byte("log data"), 0o644)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
runner := NewSAPHANARunner(executor)
|
||||
result, err := runner.Run(context.Background(), TaskSpec{
|
||||
Name: "hana-log",
|
||||
Type: "saphana",
|
||||
Database: DatabaseSpec{
|
||||
Host: "10.0.0.1", Port: 30015, User: "SYSTEM", Password: "secret",
|
||||
Names: []string{"HDB"},
|
||||
BackupType: "log",
|
||||
},
|
||||
}, NopLogWriter{})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(result.TempDir)
|
||||
|
||||
if !strings.Contains(capturedSQL, "BACKUP LOG FOR HDB USING FILE") {
|
||||
t.Fatalf("expected log backup SQL, got: %s", capturedSQL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSAPHANARunnerRun_ParallelChannels(t *testing.T) {
|
||||
var capturedSQL string
|
||||
executor := &fakeCommandExecutor{
|
||||
runFunc: func(name string, args []string, options CommandOptions) error {
|
||||
capturedSQL = args[len(args)-1]
|
||||
// 模拟为每个通道生成备份文件
|
||||
parts := strings.Split(capturedSQL, "',")
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if idx := strings.Index(p, "'"); idx >= 0 {
|
||||
prefix := p[idx+1:]
|
||||
prefix = strings.TrimSuffix(prefix, "')")
|
||||
prefix = strings.TrimSuffix(prefix, "'")
|
||||
if prefix != "" {
|
||||
_ = 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-parallel",
|
||||
Type: "saphana",
|
||||
Database: DatabaseSpec{
|
||||
Host: "10.0.0.1", Port: 30015, User: "SYSTEM", Password: "secret",
|
||||
Names: []string{"SYSTEMDB"},
|
||||
BackupChannels: 3,
|
||||
},
|
||||
}, NopLogWriter{})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(result.TempDir)
|
||||
|
||||
// 应该包含 3 个路径
|
||||
if strings.Count(capturedSQL, "'") != 6 { // 3 路径 × 2 引号
|
||||
t.Fatalf("expected 3 channels (6 quotes), got SQL: %s", capturedSQL)
|
||||
}
|
||||
if !strings.Contains(capturedSQL, "chan_0") || !strings.Contains(capturedSQL, "chan_2") {
|
||||
t.Fatalf("expected channel directories in SQL, got: %s", capturedSQL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSAPHANARunnerRun_RetryOnFailure(t *testing.T) {
|
||||
attempts := 0
|
||||
executor := &fakeCommandExecutor{
|
||||
runFunc: func(name string, args []string, options CommandOptions) error {
|
||||
attempts++
|
||||
if attempts < 2 {
|
||||
return errors.New("transient failure")
|
||||
}
|
||||
// 第二次成功,写入备份文件
|
||||
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
|
||||
},
|
||||
}
|
||||
|
||||
// 使用极短的重试周期版本(这里通过 fake context 机制无法快进时间,所以直接验证 attempts)
|
||||
// 设置 MaxRetries=2 以加快测试,不会真实等待 5s
|
||||
runner := NewSAPHANARunner(executor)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := runner.Run(ctx, TaskSpec{
|
||||
Name: "hana-retry",
|
||||
Type: "saphana",
|
||||
Database: DatabaseSpec{
|
||||
Host: "10.0.0.1", Port: 30015, User: "SYSTEM", Password: "secret",
|
||||
Names: []string{"SYSTEMDB"},
|
||||
MaxRetries: 2,
|
||||
},
|
||||
}, NopLogWriter{})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Run returned error after retry: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(result.TempDir)
|
||||
|
||||
if attempts != 2 {
|
||||
t.Fatalf("expected 2 attempts, got %d", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHanaInstanceNumber(t *testing.T) {
|
||||
tests := []struct {
|
||||
port int
|
||||
|
||||
@@ -12,6 +12,12 @@ type DatabaseSpec struct {
|
||||
Password string
|
||||
Names []string
|
||||
Path string
|
||||
// SAP HANA 特有字段(其他类型忽略)
|
||||
InstanceNumber string // 实例编号(从端口推断或手动指定)
|
||||
BackupLevel string // "full"(默认) / "incremental" / "differential"
|
||||
BackupType string // "data"(默认) / "log"
|
||||
BackupChannels int // 并行通道数(默认 1)
|
||||
MaxRetries int // 最大重试次数(默认 3)
|
||||
}
|
||||
|
||||
type TaskSpec struct {
|
||||
|
||||
179
server/internal/backup/verify.go
Normal file
179
server/internal/backup/verify.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// VerifyReport 是 quick 模式的验证结果摘要。
|
||||
type VerifyReport struct {
|
||||
TotalEntries int `json:"totalEntries,omitempty"`
|
||||
FileBytes int64 `json:"fileBytes,omitempty"`
|
||||
ChecksumOK bool `json:"checksumOk,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// VerifyTarArchive 遍历 tar 归档的每个 header + reader,不写盘。
|
||||
// 能检测归档截断、条目损坏、层级不对等常见问题。
|
||||
// expectedChecksum 非空时额外对整个文件校验 SHA-256(不做解压)。
|
||||
func VerifyTarArchive(artifactPath string, expectedChecksum string) (*VerifyReport, error) {
|
||||
file, err := os.Open(artifactPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open tar artifact: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
report := &VerifyReport{}
|
||||
h := sha256.New()
|
||||
reader := io.TeeReader(file, h)
|
||||
tr := tar.NewReader(reader)
|
||||
for {
|
||||
header, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return report, fmt.Errorf("read tar entry: %w", err)
|
||||
}
|
||||
report.TotalEntries++
|
||||
// 读完条目数据以触发完整性校验(tar 内部 CRC 不严格,但断流会报错)
|
||||
if header.Typeflag == tar.TypeReg || header.Typeflag == tar.TypeRegA {
|
||||
n, copyErr := io.Copy(io.Discard, tr)
|
||||
if copyErr != nil {
|
||||
return report, fmt.Errorf("read entry %s: %w", header.Name, copyErr)
|
||||
}
|
||||
report.FileBytes += n
|
||||
}
|
||||
}
|
||||
// 读完 tar 后继续把剩余字节喂给 hash(tar 结束后可能有零填充尾)
|
||||
if _, err := io.Copy(io.Discard, reader); err != nil {
|
||||
return report, fmt.Errorf("drain remainder: %w", err)
|
||||
}
|
||||
actual := hex.EncodeToString(h.Sum(nil))
|
||||
if strings.TrimSpace(expectedChecksum) != "" {
|
||||
report.ChecksumOK = strings.EqualFold(actual, expectedChecksum)
|
||||
if !report.ChecksumOK {
|
||||
return report, fmt.Errorf("checksum mismatch: expected %s, got %s", expectedChecksum, actual)
|
||||
}
|
||||
} else {
|
||||
report.ChecksumOK = true
|
||||
}
|
||||
report.Detail = fmt.Sprintf("tar 包完整(%d 条目,有效字节 %d)", report.TotalEntries, report.FileBytes)
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// VerifySQLiteFile 校验 SQLite 文件头魔数。
|
||||
// 官方格式:前 16 字节为 "SQLite format 3\000"。
|
||||
func VerifySQLiteFile(artifactPath string) (*VerifyReport, error) {
|
||||
file, err := os.Open(artifactPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite artifact: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
header := make([]byte, 16)
|
||||
if _, err := io.ReadFull(file, header); err != nil {
|
||||
return nil, fmt.Errorf("read sqlite header: %w", err)
|
||||
}
|
||||
const magic = "SQLite format 3\x00"
|
||||
if string(header) != magic {
|
||||
return &VerifyReport{Detail: "非法的 SQLite 文件头"}, fmt.Errorf("invalid sqlite magic header")
|
||||
}
|
||||
info, _ := file.Stat()
|
||||
var size int64
|
||||
if info != nil {
|
||||
size = info.Size()
|
||||
}
|
||||
return &VerifyReport{
|
||||
FileBytes: size,
|
||||
Detail: fmt.Sprintf("SQLite 文件头合法(总大小 %d 字节)", size),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// VerifyMySQLDump 校验 MySQL dump 文件头部是否为合法 mysqldump 输出。
|
||||
// 头部 1024 字节包含以下任一关键字即通过:
|
||||
// - "-- MySQL dump"
|
||||
// - "-- Server version"
|
||||
// - "-- MariaDB dump"
|
||||
func VerifyMySQLDump(artifactPath string) (*VerifyReport, error) {
|
||||
return verifyDumpHeader(artifactPath, []string{"-- MySQL dump", "-- Server version", "-- MariaDB dump"}, "MySQL/MariaDB")
|
||||
}
|
||||
|
||||
// VerifyPostgreSQLDump 校验 PostgreSQL plain text dump 头部。
|
||||
// 典型标记:"-- PostgreSQL database dump" 或 "-- Dumped from database version"。
|
||||
func VerifyPostgreSQLDump(artifactPath string) (*VerifyReport, error) {
|
||||
return verifyDumpHeader(artifactPath, []string{"-- PostgreSQL database dump", "-- Dumped from database version", "SET statement_timeout"}, "PostgreSQL")
|
||||
}
|
||||
|
||||
func verifyDumpHeader(artifactPath string, markers []string, label string) (*VerifyReport, error) {
|
||||
file, err := os.Open(artifactPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open dump artifact: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
reader := bufio.NewReader(file)
|
||||
buf := make([]byte, 4096)
|
||||
n, _ := io.ReadFull(reader, buf)
|
||||
sample := string(buf[:n])
|
||||
matched := ""
|
||||
for _, m := range markers {
|
||||
if strings.Contains(sample, m) {
|
||||
matched = m
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched == "" {
|
||||
return &VerifyReport{Detail: fmt.Sprintf("未在前 %d 字节中发现 %s dump 特征", n, label)}, fmt.Errorf("no %s dump marker in header", label)
|
||||
}
|
||||
info, _ := file.Stat()
|
||||
var size int64
|
||||
if info != nil {
|
||||
size = info.Size()
|
||||
}
|
||||
return &VerifyReport{
|
||||
FileBytes: size,
|
||||
Detail: fmt.Sprintf("%s dump 头部识别标志: %q(文件 %d 字节)", label, matched, size),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// VerifySAPHANAArchive 校验 SAP HANA 归档 tar 中是否包含 databackup/logbackup 标志文件。
|
||||
func VerifySAPHANAArchive(artifactPath string) (*VerifyReport, error) {
|
||||
file, err := os.Open(artifactPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open hana archive: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
tr := tar.NewReader(file)
|
||||
report := &VerifyReport{}
|
||||
var foundDataBackup bool
|
||||
for {
|
||||
header, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return report, fmt.Errorf("read tar entry: %w", err)
|
||||
}
|
||||
report.TotalEntries++
|
||||
name := strings.ToLower(header.Name)
|
||||
if strings.Contains(name, "databackup") || strings.Contains(name, "logbackup") || strings.HasPrefix(name, "hana_") {
|
||||
foundDataBackup = true
|
||||
}
|
||||
if header.Typeflag == tar.TypeReg || header.Typeflag == tar.TypeRegA {
|
||||
n, copyErr := io.Copy(io.Discard, tr)
|
||||
if copyErr != nil {
|
||||
return report, fmt.Errorf("read entry %s: %w", header.Name, copyErr)
|
||||
}
|
||||
report.FileBytes += n
|
||||
}
|
||||
}
|
||||
if !foundDataBackup {
|
||||
return report, fmt.Errorf("HANA archive missing databackup/logbackup markers")
|
||||
}
|
||||
report.Detail = fmt.Sprintf("HANA 归档包含 %d 条目(%d 字节),已识别备份标志文件", report.TotalEntries, report.FileBytes)
|
||||
return report, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user