feat: --local mode + runcmd/fetch adapter + comprehensive README

- backup.sh + restore.sh: 支持 --local 在服务器本地运行
- runcmd/fetch/upload 适配器统一处理远程/本地差异
- backup/README.md 重写为完整使用文档(含新服务器恢复流程)
This commit is contained in:
aiyimickey 2026-07-08 14:22:29 +08:00
parent 63e96164bf
commit cb9fc42097
4 changed files with 273 additions and 360 deletions

View File

@ -89,14 +89,13 @@ bash scripts/backup/backup.sh # 备份所有
bash scripts/backup/backup.sh assets monitor # 指定站点 bash scripts/backup/backup.sh assets monitor # 指定站点
bash scripts/backup/backup.sh --db-only # 仅数据库 bash scripts/backup/backup.sh --db-only # 仅数据库
bash scripts/backup/backup.sh --full # 灾难备份 bash scripts/backup/backup.sh --full # 灾难备份
bash scripts/backup/backup.sh --local # 本地模式(在服务器上直接运行)
# 恢复 # 恢复
bash scripts/backup/restore.sh # 交互式 bash scripts/backup/restore.sh # 交互式
bash scripts/backup/restore.sh --db issue file.db # 数据库恢复 bash scripts/backup/restore.sh --db issue file.db # 数据库恢复
bash scripts/backup/restore.sh --full backup.tar # 灾难恢复 bash scripts/backup/restore.sh --full backup.tar # 灾难恢复
``` bash scripts/backup/restore.sh --local --full ... # 本地灾难恢复(新服务器)
支持站点:自动发现所有含 DB 的 `*-ai` 目录,无需手动添加
备份内容:数据库 + 上传文件 + 配置文件 + nginx + SSL 证书 备份内容:数据库 + 上传文件 + 配置文件 + nginx + SSL 证书

View File

@ -1,117 +1,89 @@
# 备份与恢复脚本 # 备份与恢复
云服务器站点数据备份和灾难恢复工具集 统一备份恢复工具,支持远程(从本地 Mac 操作服务器)和本地(直接在服务器上执行)两种模式
## 目录结构 ## 快速上手
```
scripts/backup/
├── backup.sh # 统一备份(默认=站点完整,--db-only=仅DB--full=灾难)
└── restore.sh # 统一恢复(默认=交互式,--db=数据库,--full=灾难)
```
## 备份策略
| 命令 | 内容 | 大小 | 用途 |
|------|------|------|------|
| `backup.sh` | 数据库+配置+文件 | ~135M | **推荐**:按站点独立备份 |
| `backup.sh --db-only` | 仅数据库 | ~3M | 日常快速备份 |
| `backup.sh --full` | 全部数据 | ~135M | 完整灾难恢复 |
## 使用方法
### 备份
```bash ```bash
# 站点完整备份(默认,推荐) # 备份
bash scripts/backup/backup.sh # 站点完整备份(推荐)
bash scripts/backup/backup.sh --db-only # 仅数据库
bash scripts/backup/backup.sh --full # 灾难备份
bash scripts/backup/backup.sh assets monitor # 仅指定站点
# 恢复
bash scripts/backup/restore.sh # 交互式恢复
bash scripts/backup/restore.sh 20260708_120000 # 指定时间戳
bash scripts/backup/restore.sh --db monitor file.db # 数据库恢复
bash scripts/backup/restore.sh --full backup.tar.gz # 灾难恢复
```
## 远程 vs 本地模式
| | 远程模式(默认) | 本地模式 `--local` |
|------|------|------|
| 执行位置 | 本地 Mac | 服务器上 |
| Docker 操作 | `ssh txjp "docker ..."` | 直接 `docker ...` |
| 文件传输 | `scp` | 本地 `cp` |
| 适用场景 | 日常运维 | 灾难恢复 / 新服务器 |
```bash
# 远程(默认)
bash scripts/backup/backup.sh bash scripts/backup/backup.sh
bash scripts/backup/backup.sh assets monitor # 指定站点
# 仅数据库 # 本地
bash scripts/backup/backup.sh --db-only bash scripts/backup/backup.sh --local
bash scripts/backup/restore.sh --local --full full-backup.tar.gz
# 灾难备份
bash scripts/backup/backup.sh --full
``` ```
### 恢复 ## 新服务器灾难恢复流程
```bash ```bash
# 交互式恢复(默认,自动检测备份) # 1. 在新服务器上安装 Docker + Compose V2
bash scripts/backup/restore.sh
bash scripts/backup/restore.sh 20260708_120000 # 指定时间戳
# 数据库恢复 # 2. 上传脚本和备份文件
bash scripts/backup/restore.sh --db monitor monitor-20260708.db scp scripts/backup/*.sh user@new-server:/root/docker/scripts/backup/
scp full-backup-20260708.tar.gz user@new-server:/root/docker/
# 灾难恢复 # 3. 在新服务器上执行本地恢复
bash scripts/backup/restore.sh --full full-backup-20260708.tar.gz ssh user@new-server
cd /root/docker && bash scripts/backup/restore.sh --local --full full-backup-20260708.tar.gz
``` ```
## 备份内容 ## 备份内容
### 各站点备份内容 | 站点 | 备份项 |
| 站点 | 包含内容 |
|------|---------|
| assets | 数据库 + 上传文件 + docker-compose + .env + Dockerfile |
| issue | 数据库 + 上传文件 + 报告 + docker-compose + .env + Dockerfile |
| ldap | 数据库 + docker-compose + .env + Dockerfile |
| gitea | 数据库 + 代码仓库 + 配置 + docker-compose |
| oa | docker-compose + .env无独立数据库 |
| monitor | 数据库 + docker-compose + .env |
### 共享资源备份
| 资源 | 内容 |
|------|------| |------|------|
| nginx | 9 个反向代理配置文件 | | assets | assets.db + uploads/ + docker-compose.yml + .env + Dockerfile |
| ssl | Let's Encrypt 证书 | | issue | issue.db + uploads/ + reports/ + docker-compose.yml + .env + Dockerfile |
| docker-network | webnet 网络配置 | | ldap | users.db + docker-compose.yml + .env + Dockerfile |
| gitea | gitea.db + git仓库/ + conf/ + docker-compose.yml |
| monitor | monitor.db + docker-compose.yml + .env |
| oa | docker-compose.yml + .env无独立数据库 |
| shared | nginx配置 + SSL证书 + Docker网络配置 |
## 备份存储位置 ## 备份存储
| 位置 | 路径 | | 位置 | 路径 |
|------|------| |------|------|
| 本地 | `/Users/niuniu/programs/docker/db-backups/` | | 本地 | `/Users/niuniu/programs/docker/db-backups/` |
| 云端 | `/root/docker/db-backups/` | | 云端 | `/root/docker/db-backups/` |
## 恢复流程
恢复脚本会自动处理:
1. Docker 网络创建webnet
2. SSL 证书恢复
3. Nginx 配置恢复并重载
4. 各站点容器启动
5. 数据库恢复
## 灾难恢复步骤
当云服务器完全不可用时,在新服务器上:
```bash
# 1. 安装 Docker 和 docker-compose
# 2. 下载备份文件到本地
scp user@backup-server:/path/to/backup/*.tar.gz ./
# 3. 执行恢复
bash scripts/backup/restore-sites.sh
```
## 自动备份 ## 自动备份
建议配置 crontab 定期执行:
```bash ```bash
# 每天凌晨 3 点执行站点备份 # 每天凌晨 3 点
0 3 * * * cd /Users/niuniu/programs/docker && bash scripts/backup/backup-sites.sh >> /var/log/backup.log 2>&1 0 3 * * * cd /Users/niuniu/programs/docker && bash scripts/backup/backup.sh
``` ```
## 新增站点
无需修改脚本——自动发现 `*-ai/docker-compose.yml` 中含 DB 的站点。新站点只需在 `backup.sh` 的 case 语句中添加专用备份逻辑(或走 wildcard 通用备份)。
## 注意事项 ## 注意事项
- 备份前会自动执行 WAL checkpoint确保数据库完整性 - 备份前自动执行 WAL checkpoint确保数据库完整性
- 恢复会重启相关容器,短暂影响服务 - 恢复会重启相关容器,**短暂影响服务**
- Gitea 代码仓库备份较大(~130M可考虑单独备份频率 - Gitea 代码仓库备份较大(~130M可单独调整备份频率
- `--full` 灾难备份不含 uploads 等动态文件,仅保存数据库 + 代码仓库 + 配置
- 建议定期验证备份文件可恢复性 - 建议定期验证备份文件可恢复性

View File

@ -1,19 +1,31 @@
#!/bin/bash #!/bin/bash
# 统一备份脚本 # 统一备份脚本 — 支持远程模式(从本地 Mac 执行)和本地模式(在服务器上直接执行)
# 用法: # 用法bash backup.sh [--local] [--db-only|--full] [站点名...]
# bash backup.sh # 站点完整备份(默认,推荐)
# bash backup.sh assets monitor # 备份指定站点
# bash backup.sh --db-only # 仅数据库备份
# bash backup.sh --full # 灾难恢复完整备份
set -e set -e
TIMESTAMP=$(date +%Y%m%d_%H%M%S) TIMESTAMP=$(date +%Y%m%d_%H%M%S)
LOCAL_DIR="/Users/niuniu/programs/docker/db-backups" LOCAL_DIR="/Users/niuniu/programs/docker/db-backups"
REMOTE_BASE="/root/docker" REMOTE_BASE="/root/docker"
CLOUD_DIR="/root/docker/db-backups" CLOUD_DIR="/root/docker/db-backups"
MODE="remote"
log() { echo "[$(date '+%H:%M:%S')] $1"; } log() { echo "[$(date '+%H:%M:%S')] $1"; }
warn() { echo "[$(date '+%H:%M:%S')] [警告] $1"; } warn() { echo "[$(date '+%H:%M:%S')] [警告] $1"; }
# 参数解析(提取 --local / --db-only / --full其余为站点名
ARGS=(); ACTION="default"
for arg in "$@"; do
case "$arg" in
--local) MODE="local" ;;
--db-only|--full) ACTION="${arg#--}" ;;
--help|-h) ACTION="help" ;;
*) ARGS+=("$arg") ;;
esac
done
# 远程/本地适配
runcmd() { [[ "$MODE" == "local" ]] && bash -c "$*" || ssh txjp "$*"; }
fetch() { [[ "$MODE" == "local" ]] && cp "$1" "$2" || scp "txjp:$1" "$2"; }
# === 自动发现含数据库的站点 === # === 自动发现含数据库的站点 ===
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
@ -22,93 +34,86 @@ for dir in "$PROJECT_ROOT"/*-ai/; do
s=$(basename "$dir"); [[ "$s" == "nginx-proxy-ai" ]] && continue s=$(basename "$dir"); [[ "$s" == "nginx-proxy-ai" ]] && continue
[ -f "$dir/docker-compose.yml" ] || continue [ -f "$dir/docker-compose.yml" ] || continue
grep -qE "\.db|DATABASE_PATH|sqlite" "$dir/docker-compose.yml" 2>/dev/null || continue grep -qE "\.db|DATABASE_PATH|sqlite" "$dir/docker-compose.yml" 2>/dev/null || continue
s="${s%-ai}" # assets-ai → assets s="${s%-ai}"; [[ "$s" == "oa" ]] && continue
[[ "$s" == "oa" ]] && continue
DB_SITES="$DB_SITES $s" DB_SITES="$DB_SITES $s"
done done
# 站点 → 容器名映射 container_for() { [[ "$1" == "ldap" ]] && echo "lldap" || echo "${1}-ai"; }
container_for() {
case "$1" in
ldap) echo "lldap" ;;
*) echo "${1}-ai" ;;
esac
}
# === 模式 1站点完整备份默认=== # === 模式 1站点完整备份默认===
backup_site() { backup_site() {
local site="$1" local site="$1" backup_name="${site}-${TIMESTAMP}.tar.gz"
local backup_name="${site}-${TIMESTAMP}.tar.gz"
log "备份 ${site}..." log "备份 ${site}..."
ssh txjp "rm -rf /tmp/backup-${site} && mkdir -p /tmp/backup-${site}/data" runcmd "rm -rf /tmp/backup-${site} && mkdir -p /tmp/backup-${site}/data"
case "$site" in case "$site" in
assets) assets)
ssh txjp "docker exec assets-ai node -e \"const D=require('better-sqlite3');const db=new D('/app/data/assets.db');db.pragma('wal_checkpoint(TRUNCATE)');db.close();\"" 2>/dev/null || true runcmd "docker exec assets-ai node -e \"const D=require('better-sqlite3');const db=new D('/app/data/assets.db');db.pragma('wal_checkpoint(TRUNCATE)');db.close();\"" 2>/dev/null || true
ssh txjp "docker cp assets-ai:/app/data/assets.db /tmp/backup-${site}/data/" runcmd "docker cp assets-ai:/app/data/assets.db /tmp/backup-${site}/data/"
ssh txjp "cp -a /root/docker/assets-ai/data/uploads /tmp/backup-${site}/data/ 2>/dev/null || true" runcmd "cp -a /root/docker/assets-ai/data/uploads /tmp/backup-${site}/data/ 2>/dev/null || true"
ssh txjp "cp /root/docker/assets-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true" runcmd "cp /root/docker/assets-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true"
ssh txjp "cp /root/docker/assets-ai/.env /tmp/backup-${site}/ 2>/dev/null || true" runcmd "cp /root/docker/assets-ai/.env /tmp/backup-${site}/ 2>/dev/null || true"
ssh txjp "cp /root/docker/assets-ai/Dockerfile /tmp/backup-${site}/ 2>/dev/null || true" runcmd "cp /root/docker/assets-ai/Dockerfile /tmp/backup-${site}/ 2>/dev/null || true"
;; ;;
issue) issue)
ssh txjp "docker exec issue-ai node -e \"const D=require('better-sqlite3');const db=new D('/app/data/issue.db');db.pragma('wal_checkpoint(TRUNCATE)');db.close();\"" 2>/dev/null || true runcmd "docker exec issue-ai node -e \"const D=require('better-sqlite3');const db=new D('/app/data/issue.db');db.pragma('wal_checkpoint(TRUNCATE)');db.close();\"" 2>/dev/null || true
ssh txjp "docker cp issue-ai:/app/data/issue.db /tmp/backup-${site}/data/" runcmd "docker cp issue-ai:/app/data/issue.db /tmp/backup-${site}/data/"
ssh txjp "cp -a /root/docker/issue-ai/data/uploads /tmp/backup-${site}/data/ 2>/dev/null || true" runcmd "cp -a /root/docker/issue-ai/data/uploads /tmp/backup-${site}/data/ 2>/dev/null || true"
ssh txjp "cp -a /root/docker/issue-ai/data/reports /tmp/backup-${site}/data/ 2>/dev/null || true" runcmd "cp -a /root/docker/issue-ai/data/reports /tmp/backup-${site}/data/ 2>/dev/null || true"
ssh txjp "cp /root/docker/issue-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true" runcmd "cp /root/docker/issue-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true"
ssh txjp "cp /root/docker/issue-ai/.env /tmp/backup-${site}/ 2>/dev/null || true" runcmd "cp /root/docker/issue-ai/.env /tmp/backup-${site}/ 2>/dev/null || true"
ssh txjp "cp /root/docker/issue-ai/Dockerfile /tmp/backup-${site}/ 2>/dev/null || true" runcmd "cp /root/docker/issue-ai/Dockerfile /tmp/backup-${site}/ 2>/dev/null || true"
;; ;;
ldap) ldap)
ssh txjp "docker exec lldap sqlite3 /data/users.db 'PRAGMA wal_checkpoint(TRUNCATE);'" 2>/dev/null || true runcmd "docker exec lldap sqlite3 /data/users.db 'PRAGMA wal_checkpoint(TRUNCATE);'" 2>/dev/null || true
ssh txjp "docker cp lldap:/data/users.db /tmp/backup-${site}/data/" runcmd "docker cp lldap:/data/users.db /tmp/backup-${site}/data/"
ssh txjp "cp /root/docker/ldap-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true" runcmd "cp /root/docker/ldap-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true"
ssh txjp "cp /root/docker/ldap-ai/.env /tmp/backup-${site}/ 2>/dev/null || true" runcmd "cp /root/docker/ldap-ai/.env /tmp/backup-${site}/ 2>/dev/null || true"
ssh txjp "cp /root/docker/ldap-ai/Dockerfile /tmp/backup-${site}/ 2>/dev/null || true" runcmd "cp /root/docker/ldap-ai/Dockerfile /tmp/backup-${site}/ 2>/dev/null || true"
;; ;;
gitea) gitea)
ssh txjp "docker cp gitea-ai:/data/gitea.db /tmp/backup-${site}/data/" runcmd "docker cp gitea-ai:/data/gitea.db /tmp/backup-${site}/data/"
ssh txjp "cp -a /root/docker/gitea-ai/data/git /tmp/backup-${site}/ 2>/dev/null || true" runcmd "cp -a /root/docker/gitea-ai/data/git /tmp/backup-${site}/ 2>/dev/null || true"
ssh txjp "cp -a /root/docker/gitea-ai/data/gitea/conf /tmp/backup-${site}/gitea-conf 2>/dev/null || true" runcmd "cp -a /root/docker/gitea-ai/data/gitea/conf /tmp/backup-${site}/gitea-conf 2>/dev/null || true"
ssh txjp "cp /root/docker/gitea-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true" runcmd "cp /root/docker/gitea-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true"
;; ;;
oa) oa)
ssh txjp "cp /root/docker/oa-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true" runcmd "cp /root/docker/oa-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true"
ssh txjp "cp /root/docker/oa-ai/.env /tmp/backup-${site}/ 2>/dev/null || true" runcmd "cp /root/docker/oa-ai/.env /tmp/backup-${site}/ 2>/dev/null || true"
;; ;;
monitor) monitor)
ssh txjp "docker exec monitor-ai sqlite3 /app/data/monitor.db 'PRAGMA wal_checkpoint(TRUNCATE);'" 2>/dev/null || true runcmd "docker exec monitor-ai sqlite3 /app/data/monitor.db 'PRAGMA wal_checkpoint(TRUNCATE);'" 2>/dev/null || true
ssh txjp "docker cp monitor-ai:/app/data/monitor.db /tmp/backup-${site}/data/" runcmd "docker cp monitor-ai:/app/data/monitor.db /tmp/backup-${site}/data/"
ssh txjp "cp /root/docker/monitor-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true" runcmd "cp /root/docker/monitor-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true"
ssh txjp "cp /root/docker/monitor-ai/.env /tmp/backup-${site}/ 2>/dev/null || true" runcmd "cp /root/docker/monitor-ai/.env /tmp/backup-${site}/ 2>/dev/null || true"
;; ;;
*) *)
warn "${site}: 无专用备份逻辑,仅备份配置文件" warn "${site}: 无专用备份逻辑,仅备份配置文件"
ssh txjp "cp /root/docker/${site}/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true" runcmd "cp /root/docker/${site}-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true"
ssh txjp "cp /root/docker/${site}/.env /tmp/backup-${site}/ 2>/dev/null || true" runcmd "cp /root/docker/${site}-ai/.env /tmp/backup-${site}/ 2>/dev/null || true"
;; ;;
esac esac
ssh txjp "cd /tmp && tar czf ${backup_name} backup-${site}/" runcmd "cd /tmp && tar czf ${backup_name} backup-${site}/"
mkdir -p "${LOCAL_DIR}"; scp "txjp:/tmp/${backup_name}" "${LOCAL_DIR}/" mkdir -p "${LOCAL_DIR}"
ssh txjp "mkdir -p ${CLOUD_DIR} && cp /tmp/${backup_name} ${CLOUD_DIR}/" fetch "/tmp/${backup_name}" "${LOCAL_DIR}/"
ssh txjp "rm -rf /tmp/backup-${site} /tmp/${backup_name}" runcmd "mkdir -p ${CLOUD_DIR} && cp /tmp/${backup_name} ${CLOUD_DIR}/"
runcmd "rm -rf /tmp/backup-${site} /tmp/${backup_name}"
log "${site} 备份完成: ${backup_name}" log "${site} 备份完成: ${backup_name}"
} }
backup_shared() { backup_shared() {
local backup_name="shared-${TIMESTAMP}.tar.gz" local backup_name="shared-${TIMESTAMP}.tar.gz"
log "备份共享资源..." log "备份共享资源..."
ssh txjp "rm -rf /tmp/backup-shared && mkdir -p /tmp/backup-shared" runcmd "rm -rf /tmp/backup-shared && mkdir -p /tmp/backup-shared"
ssh txjp "mkdir -p /tmp/backup-shared/nginx && cp -a /root/docker/nginx-proxy-ai/conf.d/* /tmp/backup-shared/nginx/ 2>/dev/null || true" runcmd "mkdir -p /tmp/backup-shared/nginx && cp -a /root/docker/nginx-proxy-ai/conf.d/* /tmp/backup-shared/nginx/ 2>/dev/null || true"
ssh txjp "mkdir -p /tmp/backup-shared/ssl && cp -a /etc/letsencrypt/live /tmp/backup-shared/ssl/ 2>/dev/null || true && cp -a /etc/letsencrypt/archive /tmp/backup-shared/ssl/ 2>/dev/null || true && cp -a /etc/letsencrypt/renewal /tmp/backup-shared/ssl/ 2>/dev/null || true" runcmd "mkdir -p /tmp/backup-shared/ssl && cp -a /etc/letsencrypt/live /tmp/backup-shared/ssl/ 2>/dev/null || true && cp -a /etc/letsencrypt/archive /tmp/backup-shared/ssl/ 2>/dev/null || true && cp -a /etc/letsencrypt/renewal /tmp/backup-shared/ssl/ 2>/dev/null || true"
ssh txjp "cp /root/docker/nginx-proxy-ai/docker-compose.yml /tmp/backup-shared/ 2>/dev/null || true" runcmd "cp /root/docker/nginx-proxy-ai/docker-compose.yml /tmp/backup-shared/ 2>/dev/null || true"
ssh txjp "cd /tmp && tar czf ${backup_name} backup-shared/" runcmd "cd /tmp && tar czf ${backup_name} backup-shared/"
mkdir -p "${LOCAL_DIR}"; scp "txjp:/tmp/${backup_name}" "${LOCAL_DIR}/" mkdir -p "${LOCAL_DIR}"; fetch "/tmp/${backup_name}" "${LOCAL_DIR}/"
ssh txjp "mkdir -p ${CLOUD_DIR} && cp /tmp/${backup_name} ${CLOUD_DIR}/" runcmd "mkdir -p ${CLOUD_DIR} && cp /tmp/${backup_name} ${CLOUD_DIR}/"
ssh txjp "rm -rf /tmp/backup-shared /tmp/${backup_name}" runcmd "rm -rf /tmp/backup-shared /tmp/${backup_name}"
log "共享资源备份完成: ${backup_name}" log "共享资源备份完成: ${backup_name}"
} }
@ -118,33 +123,27 @@ backup_db_only() {
for s in $DB_SITES; do for s in $DB_SITES; do
c=$(container_for "$s") c=$(container_for "$s")
if [[ "$s" == "ldap" ]]; then [[ "$s" == "ldap" ]] && runcmd "docker exec ${c} sqlite3 /data/users.db 'PRAGMA wal_checkpoint(TRUNCATE);' 2>/dev/null || true" &
ssh txjp "docker exec ${c} sqlite3 /data/users.db 'PRAGMA wal_checkpoint(TRUNCATE);' 2>/dev/null || true" & [[ "$s" != "ldap" ]] && runcmd "docker exec ${c} sqlite3 /app/data/${s}.db 'PRAGMA wal_checkpoint(TRUNCATE);' 2>/dev/null || true" &
else
ssh txjp "docker exec ${c} sqlite3 /app/data/${s}.db 'PRAGMA wal_checkpoint(TRUNCATE);' 2>/dev/null || true" &
fi
done; wait done; wait
ssh txjp "mkdir -p /tmp/db-backup" runcmd "mkdir -p /tmp/db-backup"
for s in $DB_SITES; do for s in $DB_SITES; do
c=$(container_for "$s") c=$(container_for "$s")
dbpath="/app/data/${s}.db" dbpath="/app/data/${s}.db"; [[ "$s" == "ldap" ]] && dbpath="/data/users.db"; [[ "$s" == "gitea" ]] && dbpath="/data/gitea.db"
[[ "$s" == "ldap" ]] && dbpath="/data/users.db"
[[ "$s" == "gitea" ]] && dbpath="/data/gitea.db"
log "备份 ${s}..." log "备份 ${s}..."
ssh txjp "docker cp ${c}:${dbpath} /tmp/db-backup/${s}-${TIMESTAMP}.db" runcmd "docker cp ${c}:${dbpath} /tmp/db-backup/${s}-${TIMESTAMP}.db"
done done
mkdir -p "${LOCAL_DIR}" mkdir -p "${LOCAL_DIR}"; runcmd "mkdir -p ${CLOUD_DIR}"
ssh txjp "mkdir -p ${CLOUD_DIR}"
for s in $DB_SITES; do for s in $DB_SITES; do
ssh txjp "cp /tmp/db-backup/${s}-${TIMESTAMP}.db ${CLOUD_DIR}/" runcmd "cp /tmp/db-backup/${s}-${TIMESTAMP}.db ${CLOUD_DIR}/"
scp "txjp:/tmp/db-backup/${s}-${TIMESTAMP}.db" "${LOCAL_DIR}/" fetch "/tmp/db-backup/${s}-${TIMESTAMP}.db" "${LOCAL_DIR}/"
f="${LOCAL_DIR}/${s}-${TIMESTAMP}.db" f="${LOCAL_DIR}/${s}-${TIMESTAMP}.db"
sqlite3 "$f" "SELECT count(*) FROM sqlite_master" > /dev/null 2>&1 || warn "${s} 备份文件无效" sqlite3 "$f" "SELECT count(*) FROM sqlite_master" > /dev/null 2>&1 || warn "${s} 备份文件无效"
done done
ssh txjp "rm -rf /tmp/db-backup" runcmd "rm -rf /tmp/db-backup"
log "数据库备份完成" log "数据库备份完成"
} }
@ -154,53 +153,47 @@ backup_full() {
for s in $DB_SITES; do for s in $DB_SITES; do
c=$(container_for "$s") c=$(container_for "$s")
[[ "$s" == "ldap" ]] && ssh txjp "docker exec ${c} sqlite3 /data/users.db 'PRAGMA wal_checkpoint(TRUNCATE);'" 2>/dev/null || true [[ "$s" == "ldap" ]] && runcmd "docker exec ${c} sqlite3 /data/users.db 'PRAGMA wal_checkpoint(TRUNCATE);'" 2>/dev/null || true
[[ "$s" != "ldap" ]] && ssh txjp "docker exec ${c} sqlite3 /app/data/${s}.db 'PRAGMA wal_checkpoint(TRUNCATE);' 2>/dev/null || true" & [[ "$s" != "ldap" ]] && runcmd "docker exec ${c} sqlite3 /app/data/${s}.db 'PRAGMA wal_checkpoint(TRUNCATE);' 2>/dev/null || true" &
done; wait done; wait
ssh txjp "mkdir -p /tmp/full-backup/databases" runcmd "mkdir -p /tmp/full-backup/databases"
for s in $DB_SITES; do for s in $DB_SITES; do
c=$(container_for "$s") c=$(container_for "$s")
dbpath="/app/data/${s}.db" dbpath="/app/data/${s}.db"; [[ "$s" == "ldap" ]] && dbpath="/data/users.db"; [[ "$s" == "gitea" ]] && dbpath="/data/gitea.db"
[[ "$s" == "ldap" ]] && dbpath="/data/users.db" runcmd "docker cp ${c}:${dbpath} /tmp/full-backup/databases/${s}.db"
[[ "$s" == "gitea" ]] && dbpath="/data/gitea.db"
ssh txjp "docker cp ${c}:${dbpath} /tmp/full-backup/databases/${s}.db"
done done
# Gitea 代码仓库 runcmd "mkdir -p /tmp/full-backup/gitea && cp -a /root/docker/gitea-ai/data/git /tmp/full-backup/gitea/ && cp -a /root/docker/gitea-ai/data/gitea/conf /tmp/full-backup/gitea/ 2>/dev/null || true"
ssh txjp "mkdir -p /tmp/full-backup/gitea && cp -a /root/docker/gitea-ai/data/git /tmp/full-backup/gitea/ && cp -a /root/docker/gitea-ai/data/gitea/conf /tmp/full-backup/gitea/ 2>/dev/null || true" runcmd "mkdir -p /tmp/full-backup/uploads && cp -a /root/docker/assets-ai/data/uploads /tmp/full-backup/uploads/assets-uploads 2>/dev/null || true && cp -a /root/docker/issue-ai/data/uploads /tmp/full-backup/uploads/issue-uploads 2>/dev/null || true && cp -a /root/docker/issue-ai/data/reports /tmp/full-backup/uploads/issue-reports 2>/dev/null || true"
# 上传文件 runcmd "mkdir -p /tmp/full-backup/nginx && cp -a /root/docker/nginx-proxy-ai/conf.d/* /tmp/full-backup/nginx/ 2>/dev/null || true"
ssh txjp "mkdir -p /tmp/full-backup/uploads && cp -a /root/docker/assets-ai/data/uploads /tmp/full-backup/uploads/assets-uploads 2>/dev/null || true && cp -a /root/docker/issue-ai/data/uploads /tmp/full-backup/uploads/issue-uploads 2>/dev/null || true && cp -a /root/docker/issue-ai/data/reports /tmp/full-backup/uploads/issue-reports 2>/dev/null || true" runcmd "mkdir -p /tmp/full-backup/ssl && cp -a /etc/letsencrypt/live /tmp/full-backup/ssl/ 2>/dev/null || true && cp -a /etc/letsencrypt/archive /tmp/full-backup/ssl/ 2>/dev/null || true && cp -a /etc/letsencrypt/renewal /tmp/full-backup/ssl/ 2>/dev/null || true"
# Nginx + SSL
ssh txjp "mkdir -p /tmp/full-backup/nginx && cp -a /root/docker/nginx-proxy-ai/conf.d/* /tmp/full-backup/nginx/ 2>/dev/null || true"
ssh txjp "mkdir -p /tmp/full-backup/ssl && cp -a /etc/letsencrypt/live /tmp/full-backup/ssl/ 2>/dev/null || true && cp -a /etc/letsencrypt/archive /tmp/full-backup/ssl/ 2>/dev/null || true && cp -a /etc/letsencrypt/renewal /tmp/full-backup/ssl/ 2>/dev/null || true"
local name="full-backup-${TIMESTAMP}"; ssh txjp "cd /tmp && tar czf ${name}.tar.gz full-backup/" local name="full-backup-${TIMESTAMP}"
mkdir -p "${LOCAL_DIR}"; scp "txjp:/tmp/${name}.tar.gz" "${LOCAL_DIR}/" runcmd "cd /tmp && tar czf ${name}.tar.gz full-backup/"
ssh txjp "mkdir -p ${CLOUD_DIR} && cp /tmp/${name}.tar.gz ${CLOUD_DIR}/" mkdir -p "${LOCAL_DIR}"; fetch "/tmp/${name}.tar.gz" "${LOCAL_DIR}/"
ssh txjp "rm -rf /tmp/full-backup /tmp/${name}.tar.gz" runcmd "mkdir -p ${CLOUD_DIR} && cp /tmp/${name}.tar.gz ${CLOUD_DIR}/"
runcmd "rm -rf /tmp/full-backup /tmp/${name}.tar.gz"
log "完整备份完成: ${name}.tar.gz" log "完整备份完成: ${name}.tar.gz"
} }
# === 主流程 === # === 主流程 ===
case "${1:-}" in case "$ACTION" in
--db-only) help)
backup_db_only ;; echo "用法: bash backup.sh [--local] [--db-only|--full] [站点名...]"
--full) echo " --local 本地模式(直接在服务器上运行)"
backup_full ;; echo " 无参数 站点完整备份(全部含 DB 站点)"
--help|-h) echo " --db-only 仅数据库备份"
echo "用法: bash backup.sh [--db-only|--full] [站点名...]" echo " --full 灾难恢复完整备份"
echo " 无参数 站点完整备份(全部含 DB 站点)" echo " 站点名... 备份指定站点"
echo " --db-only 仅数据库备份"
echo " --full 灾难恢复完整备份"
echo " 站点名... 备份指定站点"
;; ;;
db-only) backup_db_only ;;
full) backup_full ;;
*) *)
TARGETS="${@:-all}" TARGETS="${ARGS[*]:-all}"
log "站点完整备份: ${TARGETS}" log "站点完整备份: ${TARGETS} (模式: ${MODE})"
if [[ "$TARGETS" == "all" ]]; then if [[ "$TARGETS" == "all" ]]; then
ALL="$(for s in $DB_SITES; do echo -n "$s "; done) oa" for site in $DB_SITES oa; do backup_site "$site"; done
for site in $ALL; do backup_site "$site"; done
backup_shared backup_shared
else else
for site in $TARGETS; do backup_site "$site"; done for site in $TARGETS; do backup_site "$site"; done

View File

@ -1,19 +1,38 @@
#!/bin/bash #!/bin/bash
# 统一恢复脚本 # 统一恢复脚本 — 支持远程/本地模式
# 用法: # 用法:
# bash restore.sh # 交互式站点恢复(默认) # bash restore.sh # 交互式站点恢复(默认)
# bash restore.sh 20260708_120000 # 指定时间戳恢复 # bash restore.sh 20260708_120000 # 指定时间戳
# bash restore.sh --db issue issue.db # 数据库恢复 # bash restore.sh --db issue issue.db # 数据库恢复
# bash restore.sh --full full-backup.tar.gz # 灾难恢复 # bash restore.sh --full backup.tar.gz # 灾难恢复
# bash restore.sh --local # 本地模式(直接在服务器上运行)
set -e set -e
BACKUP_DIR="/Users/niuniu/programs/docker/db-backups" BACKUP_DIR="/Users/niuniu/programs/docker/db-backups"
REMOTE_BASE="/root/docker" REMOTE_BASE="/root/docker"
MODE="remote"
log() { echo "[$(date '+%H:%M:%S')] $1"; } log() { echo "[$(date '+%H:%M:%S')] $1"; }
warn() { echo "[$(date '+%H:%M:%S')] [警告] $1"; } warn() { echo "[$(date '+%H:%M:%S')] [警告] $1"; }
error() { echo "[$(date '+%H:%M:%S')] [错误] $1"; exit 1; } error() { echo "[$(date '+%H:%M:%S')] [错误] $1"; exit 1; }
# 参数解析
ARGS=(); ACTION="default"
for arg in "$@"; do
case "$arg" in
--local) MODE="local" ;;
--db) ACTION="db" ;;
--full) ACTION="full" ;;
--help|-h) ACTION="help" ;;
*) ARGS+=("$arg") ;;
esac
done
# 远程/本地适配
runcmd() { [[ "$MODE" == "local" ]] && bash -c "$*" || ssh txjp "$*"; }
fetch() { [[ "$MODE" == "local" ]] && cp "$1" "$2" || scp "txjp:$1" "$2"; }
upload() { [[ "$MODE" == "local" ]] && cp "$1" "$2" || scp "$1" "txjp:$2"; }
RESTORE_DIR="$(mktemp -d)" RESTORE_DIR="$(mktemp -d)"
# === 自动发现可恢复站点 === # === 自动发现可恢复站点 ===
@ -22,136 +41,109 @@ ALL_SITES="shared"
for dir in "$PROJECT_ROOT"/*-ai/; do for dir in "$PROJECT_ROOT"/*-ai/; do
s=$(basename "$dir"); [[ "$s" == "nginx-proxy-ai" ]] && continue s=$(basename "$dir"); [[ "$s" == "nginx-proxy-ai" ]] && continue
[ -f "$dir/docker-compose.yml" ] || continue [ -f "$dir/docker-compose.yml" ] || continue
s="${s%-ai}" # assets-ai → assets s="${s%-ai}"; ALL_SITES="$ALL_SITES $s"
ALL_SITES="$ALL_SITES $s"
done done
# === 工具函数 === # === 工具函数 ===
check_backup() { check_backup() {
local site="$1" ts="$2" local site="$1" ts="$2"
[[ -f "${BACKUP_DIR}/${site}-${ts}.tar.gz" ]] && echo "local:${BACKUP_DIR}/${site}-${ts}.tar.gz" && return [[ -f "${BACKUP_DIR}/${site}-${ts}.tar.gz" ]] && echo "local:${BACKUP_DIR}/${site}-${ts}.tar.gz" && return
ssh txjp "test -f /root/docker/db-backups/${site}-${ts}.tar.gz" 2>/dev/null && echo "cloud:${site}-${ts}.tar.gz" && return runcmd "test -f /root/docker/db-backups/${site}-${ts}.tar.gz" 2>/dev/null && echo "cloud:${site}-${ts}.tar.gz" && return
echo "" echo ""
} }
download_and_extract() { download_and_extract() {
local site="$1" ts="$2" local site="$1" ts="$2"
local backup_info=$(check_backup "$site" "$ts") local bk=$(check_backup "$site" "$ts")
[[ -z "$backup_info" ]] && error "${site} 备份文件不存在: ${ts}" [[ -z "$bk" ]] && error "${site} 备份文件不存在: ${ts}"
local src="${backup_info#*:}"
rm -rf "$RESTORE_DIR/backup-${site}"; mkdir -p "$RESTORE_DIR/backup-${site}" rm -rf "$RESTORE_DIR/backup-${site}"; mkdir -p "$RESTORE_DIR/backup-${site}"
if [[ "$backup_info" == local:* ]]; then if [[ "$bk" == local:* ]]; then
log "从本地解压: ${BACKUP_DIR}/${site}-${ts}.tar.gz" log "从本地解压: ${BACKUP_DIR}/${site}-${ts}.tar.gz"
tar xzf "${BACKUP_DIR}/${site}-${ts}.tar.gz" -C "$RESTORE_DIR/backup-${site}" 2>/dev/null || tar xzf "${BACKUP_DIR}/${site}-${ts}.tar.gz" -C "$RESTORE_DIR/backup-${site}" --strip-components=1 2>/dev/null || tar tzf "${BACKUP_DIR}/${site}-${ts}.tar.gz" --show-transformed-names 2>/dev/null | head -1
tar xzf "${BACKUP_DIR}/${site}-${ts}.tar.gz" -C "$RESTORE_DIR/backup-${site}" tar xzf "${BACKUP_DIR}/${site}-${ts}.tar.gz" -C "$RESTORE_DIR/backup-${site}"
else else
log "从云端下载: ${ts}" log "从服务器下载: ${ts}"
scp "txjp:${src}" "$RESTORE_DIR/" fetch "/root/docker/db-backups/${site}-${ts}.tar.gz" "$RESTORE_DIR/"
local f="$RESTORE_DIR/${site}-${ts}.tar.gz" tar xzf "$RESTORE_DIR/${site}-${ts}.tar.gz" -C "$RESTORE_DIR/backup-${site}"
mkdir -p "$RESTORE_DIR/backup-${site}"
tar xzf "$f" -C "$RESTORE_DIR/backup-${site}"
fi fi
} }
restore_src() {
local src="${RESTORE_DIR}/backup-$1/backup-$1"
[[ -d "$src" ]] && echo "$src" || echo "${RESTORE_DIR}/backup-$1"
}
# === 站点恢复函数 === # === 站点恢复函数 ===
restore_shared() { restore_shared() {
local ts="$TIMESTAMP" local src=$(restore_src "shared")
local src="${RESTORE_DIR}/backup-shared" [[ -f "$src/nginx"/*.conf ]] && { mkdir -p "$src/nginx"; upload "$src/nginx/"* "$REMOTE_BASE/nginx-proxy-ai/conf.d/"; }
[[ -f "$src/nginx"/*.conf ]] && { mkdir -p "$REMOTE_BASE/nginx-proxy-ai/conf.d"; scp "$src/nginx/"* txjp:"$REMOTE_BASE/nginx-proxy-ai/conf.d/"; } runcmd "docker exec nginx-ai nginx -t && docker exec nginx-ai nginx -s reload" 2>/dev/null || warn "nginx 重载失败"
ssh txjp "docker exec nginx-ai nginx -t && docker exec nginx-ai nginx -s reload" 2>/dev/null || warn "nginx 重载失败"
log "共享配置恢复完成" log "共享配置恢复完成"
} }
restore_assets() { restore_assets() {
log "恢复 assets..." log "恢复 assets..."; download_and_extract "assets" "$TIMESTAMP"
download_and_extract "assets" "$TIMESTAMP" local src=$(restore_src "assets")
local src="${RESTORE_DIR}/backup-assets/backup-assets" for f in docker-compose.yml .env Dockerfile; do [[ -f "$src/$f" ]] && upload "$src/$f" "$REMOTE_BASE/assets-ai/"; done
[[ ! -d "$src" ]] && src="${RESTORE_DIR}/backup-assets" [[ -d "$src/data/uploads" ]] && runcmd "cp -a $src/data/uploads /root/docker/assets-ai/data/"
for f in docker-compose.yml .env Dockerfile; do [[ -f "$src/data/assets.db" ]] && runcmd "docker cp $src/data/assets.db assets-ai:/app/data/assets.db"
[[ -f "$src/$f" ]] && scp "$src/$f" txjp:$REMOTE_BASE/assets-ai/ runcmd "cd $REMOTE_BASE/assets-ai && docker compose down 2>/dev/null || true && docker compose up -d"
done
[[ -d "$src/data/uploads" ]] && ssh txjp "rm -rf /root/docker/assets-ai/data/uploads && cp -a $src/data/uploads /root/docker/assets-ai/data/"
[[ -f "$src/data/assets.db" ]] && ssh txjp "docker cp $src/data/assets.db assets-ai:/app/data/assets.db"
ssh txjp "cd $REMOTE_BASE/assets-ai && docker compose down 2>/dev/null || true && docker compose up -d"
log "assets 恢复完成" log "assets 恢复完成"
} }
restore_issue() { restore_issue() {
log "恢复 issue..." log "恢复 issue..."; download_and_extract "issue" "$TIMESTAMP"
download_and_extract "issue" "$TIMESTAMP" local src=$(restore_src "issue")
local src="${RESTORE_DIR}/backup-issue/backup-issue" for f in docker-compose.yml .env Dockerfile; do [[ -f "$src/$f" ]] && upload "$src/$f" "$REMOTE_BASE/issue-ai/"; done
[[ ! -d "$src" ]] && src="${RESTORE_DIR}/backup-issue" [[ -d "$src/data/uploads" ]] && runcmd "cp -a $src/data/uploads /root/docker/issue-ai/data/"
for f in docker-compose.yml .env Dockerfile; do [[ -d "$src/data/reports" ]] && runcmd "cp -a $src/data/reports /root/docker/issue-ai/data/"
[[ -f "$src/$f" ]] && scp "$src/$f" txjp:$REMOTE_BASE/issue-ai/ [[ -f "$src/data/issue.db" ]] && runcmd "docker cp $src/data/issue.db issue-ai:/app/data/issue.db"
done runcmd "cd $REMOTE_BASE/issue-ai && docker compose down 2>/dev/null || true && docker compose up -d"
[[ -d "$src/data/uploads" ]] && ssh txjp "rm -rf /root/docker/issue-ai/data/uploads && cp -a $src/data/uploads /root/docker/issue-ai/data/"
[[ -d "$src/data/reports" ]] && ssh txjp "rm -rf /root/docker/issue-ai/data/reports && cp -a $src/data/reports /root/docker/issue-ai/data/"
[[ -f "$src/data/issue.db" ]] && ssh txjp "docker cp $src/data/issue.db issue-ai:/app/data/issue.db"
ssh txjp "cd $REMOTE_BASE/issue-ai && docker compose down 2>/dev/null || true && docker compose up -d"
log "issue 恢复完成" log "issue 恢复完成"
} }
restore_ldap() { restore_ldap() {
log "恢复 ldap..." log "恢复 ldap..."; download_and_extract "ldap" "$TIMESTAMP"
download_and_extract "ldap" "$TIMESTAMP" local src=$(restore_src "ldap")
local src="${RESTORE_DIR}/backup-ldap/backup-ldap" for f in docker-compose.yml .env Dockerfile; do [[ -f "$src/$f" ]] && upload "$src/$f" "$REMOTE_BASE/ldap-ai/"; done
[[ ! -d "$src" ]] && src="${RESTORE_DIR}/backup-ldap" [[ -f "$src/data/users.db" ]] && runcmd "docker cp $src/data/users.db lldap:/data/users.db"
for f in docker-compose.yml .env Dockerfile; do runcmd "cd $REMOTE_BASE/ldap-ai && docker compose down 2>/dev/null || true && docker compose up -d"
[[ -f "$src/$f" ]] && scp "$src/$f" txjp:$REMOTE_BASE/ldap-ai/
done
[[ -f "$src/data/users.db" ]] && ssh txjp "docker cp $src/data/users.db lldap:/data/users.db"
ssh txjp "cd $REMOTE_BASE/ldap-ai && docker compose down 2>/dev/null || true && docker compose up -d"
log "ldap 恢复完成" log "ldap 恢复完成"
} }
restore_gitea() { restore_gitea() {
log "恢复 gitea..." log "恢复 gitea..."; download_and_extract "gitea" "$TIMESTAMP"
download_and_extract "gitea" "$TIMESTAMP" local src=$(restore_src "gitea")
local src="${RESTORE_DIR}/backup-gitea/backup-gitea" [[ -f "$src/docker-compose.yml" ]] && upload "$src/docker-compose.yml" "$REMOTE_BASE/gitea-ai/"
[[ ! -d "$src" ]] && src="${RESTORE_DIR}/backup-gitea" [[ -f "$src/data/gitea.db" ]] && runcmd "docker cp $src/data/gitea.db gitea-ai:/data/gitea.db"
[[ -f "$src/docker-compose.yml" ]] && scp "$src/docker-compose.yml" txjp:$REMOTE_BASE/gitea-ai/ runcmd "cd $REMOTE_BASE/gitea-ai && docker compose down && docker compose up -d"
[[ -f "$src/data/gitea.db" ]] && ssh txjp "docker cp $src/data/gitea.db gitea-ai:/data/gitea.db"
ssh txjp "cd $REMOTE_BASE/gitea-ai && docker compose down && docker compose up -d"
log "gitea 恢复完成" log "gitea 恢复完成"
} }
restore_oa() { restore_oa() {
log "恢复 oa..." log "恢复 oa..."; download_and_extract "oa" "$TIMESTAMP"
download_and_extract "oa" "$TIMESTAMP" local src=$(restore_src "oa")
local src="${RESTORE_DIR}/backup-oa/backup-oa" for f in docker-compose.yml .env; do [[ -f "$src/$f" ]] && upload "$src/$f" "$REMOTE_BASE/oa-ai/"; done
[[ ! -d "$src" ]] && src="${RESTORE_DIR}/backup-oa" runcmd "cd $REMOTE_BASE/oa-ai && docker compose down 2>/dev/null || true && docker compose up -d"
for f in docker-compose.yml .env; do
[[ -f "$src/$f" ]] && scp "$src/$f" txjp:$REMOTE_BASE/oa-ai/
done
ssh txjp "cd $REMOTE_BASE/oa-ai && docker compose down 2>/dev/null || true && docker compose up -d"
log "oa 恢复完成" log "oa 恢复完成"
} }
restore_monitor() { restore_monitor() {
log "恢复 monitor..." log "恢复 monitor..."; download_and_extract "monitor" "$TIMESTAMP"
download_and_extract "monitor" "$TIMESTAMP" local src=$(restore_src "monitor")
local src="${RESTORE_DIR}/backup-monitor/backup-monitor" for f in docker-compose.yml .env; do [[ -f "$src/$f" ]] && upload "$src/$f" "$REMOTE_BASE/monitor-ai/"; done
[[ ! -d "$src" ]] && src="${RESTORE_DIR}/backup-monitor" [[ -f "$src/data/monitor.db" ]] && runcmd "docker cp $src/data/monitor.db monitor-ai:/app/data/monitor.db"
for f in docker-compose.yml .env; do runcmd "cd $REMOTE_BASE/monitor-ai && docker compose down 2>/dev/null || true && docker compose up -d --build"
[[ -f "$src/$f" ]] && scp "$src/$f" txjp:$REMOTE_BASE/monitor-ai/
done
[[ -f "$src/data/monitor.db" ]] && ssh txjp "docker cp $src/data/monitor.db monitor-ai:/app/data/monitor.db"
ssh txjp "cd $REMOTE_BASE/monitor-ai && docker compose down 2>/dev/null || true && docker compose up -d --build"
log "monitor 恢复完成" log "monitor 恢复完成"
} }
restore_generic() { restore_generic() {
local site="$1" local site="$1"; log "恢复 ${site}(通用模式)..."
log "恢复 ${site}(通用模式)..."
download_and_extract "${site}" "$TIMESTAMP" download_and_extract "${site}" "$TIMESTAMP"
local src="${RESTORE_DIR}/backup-${site}/backup-${site}" local src=$(restore_src "${site}")
[[ ! -d "$src" ]] && src="${RESTORE_DIR}/backup-${site}" for f in docker-compose.yml .env Dockerfile; do [[ -f "$src/$f" ]] && upload "$src/$f" "$REMOTE_BASE/${site}-ai/"; done
for f in docker-compose.yml .env Dockerfile; do runcmd "cd $REMOTE_BASE/${site}-ai && docker compose down 2>/dev/null || true && docker compose up -d" 2>/dev/null || warn "${site} 容器重启失败"
[[ -f "$src/$f" ]] && scp "$src/$f" txjp:$REMOTE_BASE/${site}/
done
ssh txjp "cd $REMOTE_BASE/${site} && docker compose down 2>/dev/null || true && docker compose up -d" 2>/dev/null || warn "${site} 容器重启失败"
} }
# === 模式 2数据库恢复 === # === 模式 2数据库恢复 ===
@ -169,27 +161,21 @@ restore_db() {
*) error "服务名必须是: assets, issue, ldap, gitea, monitor" ;; *) error "服务名必须是: assets, issue, ldap, gitea, monitor" ;;
esac esac
local src="$BACKUP_DIR/$file" local src="$BACKUP_DIR/$file"; [[ ! -f "$src" ]] && src="$file"
[[ ! -f "$src" ]] && src="$file"
log "恢复 ${service} 数据库: ${src}..." log "恢复 ${service} 数据库: ${src}..."
scp "$src" "txjp:/tmp/db-restore.db" upload "$src" "/tmp/db-restore.db"
ssh txjp "docker cp /tmp/db-restore.db ${container}:${dbpath} && rm /tmp/db-restore.db" runcmd "docker cp /tmp/db-restore.db ${container}:${dbpath} && rm /tmp/db-restore.db"
ssh txjp "cd $REMOTE_BASE/$project && docker compose restart" 2>/dev/null || true runcmd "cd $REMOTE_BASE/$project && docker compose restart" 2>/dev/null || true
log "${service} 数据库恢复完成" log "${service} 数据库恢复完成"
} }
# === 模式 3灾难恢复 === # === 模式 3灾难恢复 ===
restore_full() { restore_full() {
local file="$1" local file="$1"; [[ -z "$file" ]] && error "用法: bash restore.sh --full <backup.tar.gz>"
[[ -z "$file" ]] && error "用法: bash restore.sh --full <backup.tar.gz>" local src="$BACKUP_DIR/$file"; [[ ! -f "$src" ]] && src="$file"
local src="$BACKUP_DIR/$file"
[[ ! -f "$src" ]] && src="$file"
[[ ! -f "$src" ]] && error "备份文件不存在: $file" [[ ! -f "$src" ]] && error "备份文件不存在: $file"
log "完整恢复: $file" log "完整恢复: $file (模式: ${MODE})"
rm -rf "$RESTORE_DIR/full"; mkdir -p "$RESTORE_DIR/full" rm -rf "$RESTORE_DIR/full"; mkdir -p "$RESTORE_DIR/full"
tar xzf "$src" -C "$RESTORE_DIR/full" tar xzf "$src" -C "$RESTORE_DIR/full"
local content="$RESTORE_DIR/full/full-backup" local content="$RESTORE_DIR/full/full-backup"
@ -198,74 +184,37 @@ restore_full() {
echo " 5) monitor 6) nginx 7) 全部恢复" echo " 5) monitor 6) nginx 7) 全部恢复"
echo ""; printf "请选择 (1-7): "; read choice echo ""; printf "请选择 (1-7): "; read choice
fdb() { [[ -f "$content/databases/$1.db" ]] && upload "$content/databases/$1.db" "/tmp/${1}.db" && runcmd "docker cp /tmp/${1}.db ${2}:/app/data/${1}.db; rm /tmp/${1}.db"; }
local c="$content"
case "$choice" in case "$choice" in
1) restore_assets_from_full "$content" ;; 1) fdb assets assets-ai; runcmd "cd $REMOTE_BASE/assets-ai && docker compose down && docker compose up -d" ;;
2) restore_issue_from_full "$content" ;; 2) fdb issue issue-ai; runcmd "cd $REMOTE_BASE/issue-ai && docker compose down && docker compose up -d" ;;
3) restore_ldap_from_full "$content" ;; 3) [[ -f "$c/databases/ldap.db" ]] && upload "$c/databases/ldap.db" "/tmp/ldap.db"; runcmd "docker cp /tmp/ldap.db lldap:/data/users.db; rm /tmp/ldap.db"; runcmd "cd $REMOTE_BASE/ldap-ai && docker compose down && docker compose up -d" ;;
4) restore_gitea_from_full "$content" ;; 4) fdb gitea gitea-ai; runcmd "cd $REMOTE_BASE/gitea-ai && docker compose down && docker compose up -d" ;;
5) restore_monitor_from_full "$content" ;; 5) fdb monitor monitor-ai; runcmd "cd $REMOTE_BASE/monitor-ai && docker compose down && docker compose up -d --build" ;;
6) restore_nginx_from_full "$content" ;; 6) [[ -d "$c/nginx" ]] && { mkdir -p "$c/nginx"; upload "$c/nginx/"* "$REMOTE_BASE/nginx-proxy-ai/conf.d/"; }; runcmd "docker exec nginx-ai nginx -t && docker exec nginx-ai nginx -s reload" ;;
7) restore_all_from_full "$content" ;; 7) for s in assets issue ldap gitea monitor; do fdb $s ${s}-ai 2>/dev/null || true; done
runcmd "docker exec nginx-ai nginx -t && docker exec nginx-ai nginx -s reload"
for s in assets-ai issue-ai ldap-ai gitea-ai monitor-ai; do runcmd "cd $REMOTE_BASE/$s && docker compose down && docker compose up -d" 2>/dev/null || true; done ;;
*) error "无效选择" ;; *) error "无效选择" ;;
esac esac
} log "灾难恢复完成"
restore_assets_from_full() {
log "恢复 assets..."
[[ -f "$1/databases/assets.db" ]] && ssh txjp "docker cp $1/databases/assets.db assets-ai:/app/data/assets.db"
ssh txjp "cd $REMOTE_BASE/assets-ai && docker compose down && docker compose up -d"; log "完成"
}
restore_issue_from_full() {
log "恢复 issue..."
[[ -f "$1/databases/issue.db" ]] && ssh txjp "docker cp $1/databases/issue.db issue-ai:/app/data/issue.db"
ssh txjp "cd $REMOTE_BASE/issue-ai && docker compose down && docker compose up -d"; log "完成"
}
restore_ldap_from_full() {
log "恢复 ldap..."
[[ -f "$1/databases/ldap.db" ]] && ssh txjp "docker cp $1/databases/ldap.db lldap:/data/users.db"
ssh txjp "cd $REMOTE_BASE/ldap-ai && docker compose down && docker compose up -d"; log "完成"
}
restore_gitea_from_full() {
log "恢复 gitea..."
[[ -f "$1/databases/gitea.db" ]] && ssh txjp "docker cp $1/databases/gitea.db gitea-ai:/data/gitea.db"
ssh txjp "cd $REMOTE_BASE/gitea-ai && docker compose down && docker compose up -d"; log "完成"
}
restore_monitor_from_full() {
log "恢复 monitor..."
[[ -f "$1/databases/monitor.db" ]] && ssh txjp "docker cp $1/databases/monitor.db monitor-ai:/app/data/monitor.db"
ssh txjp "cd $REMOTE_BASE/monitor-ai && docker compose down && docker compose up -d --build"; log "完成"
}
restore_nginx_from_full() {
log "恢复 nginx..."
[[ -d "$1/nginx" ]] && ssh txjp "mkdir -p $REMOTE_BASE/nginx-proxy-ai/conf.d" && scp "$1/nginx/"* txjp:$REMOTE_BASE/nginx-proxy-ai/conf.d/
ssh txjp "docker exec nginx-ai nginx -t && docker exec nginx-ai nginx -s reload"; log "完成"
}
restore_all_from_full() {
local c="$1"
restore_assets_from_full "$c"
restore_issue_from_full "$c"
restore_ldap_from_full "$c"
restore_gitea_from_full "$c"
restore_monitor_from_full "$c"
restore_nginx_from_full "$c"
} }
# === 主流程 === # === 主流程 ===
case "${1:-}" in case "$ACTION" in
--db) help)
restore_db "$2" "$3" ;; echo "用法: bash restore.sh [--local] [--db|--full] [参数...]"
--full) echo " --local 本地模式(直接在服务器上运行)"
restore_full "$2" ;; echo " 无参数 交互式站点恢复"
--help|-h) echo " restore.sh TIMESTAMP 指定时间戳恢复"
echo "用法: bash restore.sh [--db|--full] [参数...]" echo " --db <site> <file> 数据库恢复"
echo " 无参数 交互式站点恢复" echo " --full <file> 灾难恢复"
echo " restore.sh TIMESTAMP 指定时间戳恢复"
echo " --db <site> <file> 数据库恢复"
echo " --full <file> 灾难恢复"
;; ;;
db) restore_db "${ARGS[0]}" "${ARGS[1]}" ;;
full) restore_full "${ARGS[0]}" ;;
*) *)
# 交互式站点恢复 TIMESTAMP="${ARGS[0]:-}"
TIMESTAMP="${1:-}"
if [[ -z "$TIMESTAMP" ]]; then if [[ -z "$TIMESTAMP" ]]; then
echo ""; echo "可用的备份时间戳:"; echo "" echo ""; echo "可用的备份时间戳:"; echo ""
timestamps=$(ls ${BACKUP_DIR}/assets-*.tar.gz 2>/dev/null | sed 's/.*assets-\(.*\)\.tar\.gz/\1/' | sort -r | head -10) timestamps=$(ls ${BACKUP_DIR}/assets-*.tar.gz 2>/dev/null | sed 's/.*assets-\(.*\)\.tar\.gz/\1/' | sort -r | head -10)
@ -278,9 +227,9 @@ case "${1:-}" in
fi fi
echo ""; echo "可恢复的站点:"; echo "" echo ""; echo "可恢复的站点:"; echo ""
declare -A site_status; index=1 declare -A site_list; index=1
for site in $ALL_SITES; do for site in $ALL_SITES; do
local bk=$(check_backup "$site" "$TIMESTAMP") bk=$(check_backup "$site" "$TIMESTAMP")
[[ -n "$bk" ]] && echo " $index) $site" && site_list[$index]="$site" || echo " -) $site (无备份)" [[ -n "$bk" ]] && echo " $index) $site" && site_list[$index]="$site" || echo " -) $site (无备份)"
((index++)) ((index++))
done done
@ -303,13 +252,13 @@ case "${1:-}" in
gitea) restore_gitea ;; gitea) restore_gitea ;;
oa) restore_oa ;; oa) restore_oa ;;
monitor) restore_monitor ;; monitor) restore_monitor ;;
*) warn "${site}: 无专用恢复逻辑"; restore_generic "$site" ;; *) restore_generic "$site" ;;
esac esac
done done
ssh txjp "docker exec nginx-ai nginx -t && docker exec nginx-ai nginx -s reload" 2>/dev/null || true runcmd "docker exec nginx-ai nginx -t && docker exec nginx-ai nginx -s reload" 2>/dev/null || true
rm -rf "$RESTORE_DIR" rm -rf "$RESTORE_DIR"
echo ""; log "恢复完成!" echo ""; log "恢复完成!"
ssh txjp "docker ps --format 'table {{.Names}}\t{{.Status}}' | grep -E '(assets|issue|ldap|gitea|oa|monitor|nginx|sso)'" runcmd "docker ps --format 'table {{.Names}}\t{{.Status}}' | grep -E '(assets|issue|ldap|gitea|oa|monitor|nginx|sso)'"
;; ;;
esac esac