Compare commits
21 Commits
v2026.06.3
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
3b20b75007 | |
|
|
9e88e65757 | |
|
|
3a4600ce5b | |
|
|
c1a5174fd1 | |
|
|
eb6182f222 | |
|
|
cb9fc42097 | |
|
|
63e96164bf | |
|
|
ad2305305b | |
|
|
ec6d2c8c4a | |
|
|
9c9e5d7c44 | |
|
|
3dd394d943 | |
|
|
3991ce5134 | |
|
|
cd552a1a4d | |
|
|
1232efa191 | |
|
|
4ea06ba637 | |
|
|
d34c0c7cb5 | |
|
|
2bda66bd64 | |
|
|
f1ababe5be | |
|
|
e2937d0ce1 | |
|
|
01836971e5 | |
|
|
312c7ff681 |
68
README.md
68
README.md
|
|
@ -13,17 +13,15 @@
|
|||
| `deploy-ai.sh` | 部署站点到 tlyq.ai(txjp 服务器) | 云端 |
|
||||
| `deploy-cc.sh` | 部署站点到 tlyq.cc(tgz 服务器) | 云端 |
|
||||
| `edit-site-cc.sh` | www.tlyq.cc 官网内容管理 | 云端 |
|
||||
| `check-compliance.sh` | D1-D8 合规检查(LESSONS-LEARNED 自动化) | 本地 |
|
||||
| `docker-cache-cleanup.sh` | Docker 缓存清理(构建缓存、悬空镜像、测试镜像) | 本地/云端 |
|
||||
|
||||
### 备份与恢复(`backup/` 目录)
|
||||
|
||||
| 脚本 | 用途 | 说明 |
|
||||
|------|------|------|
|
||||
| `backup/backup-sites.sh` | 站点独立备份 | **推荐**:按站点独立打包 |
|
||||
| `backup/backup-db.sh` | 仅数据库备份 | 轻量快速 |
|
||||
| `backup/backup-full.sh` | 完整灾难恢复备份 | 包含所有数据 |
|
||||
| `backup/restore-sites.sh` | 站点恢复 | 交互式选择恢复 |
|
||||
| `backup/restore-db.sh` | 数据库恢复 | 恢复单个数据库 |
|
||||
| `backup/restore-full.sh` | 完整恢复 | 灾难恢复使用 |
|
||||
| `backup/backup.sh` | 统一备份 | --db-only / --full |
|
||||
| `backup/restore.sh` | 统一恢复 | --db / --full |
|
||||
|
||||
> 详细文档见 [backup/README.md](backup/README.md)
|
||||
|
||||
|
|
@ -48,6 +46,7 @@ bash scripts/sites-manage-local.sh status # 查看状态
|
|||
| oa | `npm run dev`(宿主机) | 6179 |
|
||||
| issue | `npm run dev`(宿主机) | 6176 |
|
||||
| assets | `npm run dev`(宿主机) | 6177 |
|
||||
| monitor | `npm run dev`(宿主机) | 6181 |
|
||||
| www / cloud / token | Docker | 6173–6175 |
|
||||
|
||||
## sites-manage-cloud.sh — 云服务器站点管理
|
||||
|
|
@ -76,7 +75,7 @@ bash scripts/deploy-ai.sh --force # 强制完整构建
|
|||
bash scripts/deploy-ai.sh --restart # 仅重启容器
|
||||
```
|
||||
|
||||
交互式选择站点(1–7):www / cloud / token / issue / assets / oa / ldap。
|
||||
交互式选择站点(1–10):www / cloud / token / issue / assets / oa / ldap / gitea / sso / monitor。
|
||||
|
||||
流程:源码打包 → 上传 → 服务器 `npm install` → `npm run build` → 容器重建。部署后自动验证。
|
||||
|
||||
|
|
@ -85,24 +84,57 @@ LDAP 部署特殊处理:上传 Dockerfile + docker-compose.yml,自动创建
|
|||
## backup/ — 备份与恢复
|
||||
|
||||
```bash
|
||||
# 站点独立备份(推荐)
|
||||
bash scripts/backup/backup-sites.sh # 备份所有站点
|
||||
bash scripts/backup/backup-sites.sh assets issue # 备份指定站点
|
||||
# 站点完整备份(推荐)
|
||||
bash scripts/backup/backup.sh # 备份所有
|
||||
bash scripts/backup/backup.sh assets monitor # 指定站点
|
||||
bash scripts/backup/backup.sh --db-only # 仅数据库
|
||||
bash scripts/backup/backup.sh --full # 灾难备份
|
||||
bash scripts/backup/backup.sh --local # 本地模式(在服务器上直接运行)
|
||||
|
||||
# 恢复
|
||||
bash scripts/backup/restore-sites.sh # 交互式恢复
|
||||
bash scripts/backup/restore-sites.sh 20260622_101858 # 指定时间戳
|
||||
|
||||
# 数据库单独备份
|
||||
bash scripts/backup/backup-db.sh
|
||||
```
|
||||
|
||||
支持站点:`assets` / `issue` / `ldap` / `gitea` / `oa`
|
||||
bash scripts/backup/restore.sh # 交互式
|
||||
bash scripts/backup/restore.sh --db issue file.db # 数据库恢复
|
||||
bash scripts/backup/restore.sh --full backup.tar # 灾难恢复
|
||||
bash scripts/backup/restore.sh --local --full ... # 本地灾难恢复(新服务器)
|
||||
|
||||
备份内容:数据库 + 上传文件 + 配置文件 + nginx + SSL 证书
|
||||
|
||||
> 详细文档见 [backup/README.md](backup/README.md)
|
||||
|
||||
## docker-cache-cleanup.sh — Docker 缓存清理
|
||||
|
||||
自动检测运行环境:通过 `curl ip.sb` 获取当前公网 IP,与生产环境服务器 IP 列表匹配。
|
||||
- 在生产服务器上运行:直接执行 Docker 命令
|
||||
- 在本地运行:通过 SSH 连接服务器执行
|
||||
|
||||
```bash
|
||||
bash scripts/docker-cache-cleanup.sh # 默认:保留 3 小时内的缓存
|
||||
bash scripts/docker-cache-cleanup.sh --aggressive # 激进:保留 1 小时内
|
||||
bash scripts/docker-cache-cleanup.sh --conservative # 保守:保留 6 小时内
|
||||
bash scripts/docker-cache-cleanup.sh --full # 完全:删除所有缓存
|
||||
```
|
||||
|
||||
清理内容:
|
||||
- **dangling 镜像**:无标签或未被引用的中间层
|
||||
- **测试镜像**:以 `test-` 开头的镜像
|
||||
- **构建缓存**:根据模式保留不同时长
|
||||
- **磁盘紧张处理**:使用率 > 85% 时自动深度清理
|
||||
|
||||
配置:在脚本开头的 `PRODUCTION_SERVER_IPS` 数组中添加/删除服务器 IP。
|
||||
|
||||
```bash
|
||||
PRODUCTION_SERVER_IPS=(
|
||||
"43.133.38.210" # txjp (腾讯云北京)
|
||||
# "159.75.83.209" # tgz (腾讯云广州)
|
||||
)
|
||||
```
|
||||
|
||||
定时清理(可选):
|
||||
```bash
|
||||
# 每天凌晨 3 点执行保守清理
|
||||
ssh txjp "crontab -l 2>/dev/null; echo '0 3 * * * /root/docker/scripts/docker-cache-cleanup.sh --conservative' | crontab -"
|
||||
```
|
||||
|
||||
## 备份存储路径
|
||||
|
||||
| 位置 | 路径 |
|
||||
|
|
|
|||
142
backup/README.md
142
backup/README.md
|
|
@ -1,121 +1,89 @@
|
|||
# 备份与恢复脚本
|
||||
# 备份与恢复
|
||||
|
||||
云服务器站点数据备份和灾难恢复工具集。
|
||||
统一备份恢复工具,支持远程(从本地 Mac 操作服务器)和本地(直接在服务器上执行)两种模式。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
scripts/backup/
|
||||
├── backup-db.sh # 数据库备份(轻量)
|
||||
├── backup-full.sh # 完整灾难恢复备份
|
||||
├── backup-sites.sh # 站点独立备份(推荐)
|
||||
├── restore-db.sh # 数据库恢复
|
||||
├── restore-full.sh # 完整恢复
|
||||
└── restore-sites.sh # 站点恢复
|
||||
```
|
||||
|
||||
## 备份策略对比
|
||||
|
||||
| 脚本 | 内容 | 大小 | 用途 |
|
||||
|------|------|------|------|
|
||||
| backup-db.sh | 仅数据库 | ~3M | 日常快速备份 |
|
||||
| backup-sites.sh | 数据库+配置+文件 | ~135M | **推荐**:按站点独立备份 |
|
||||
| backup-full.sh | 全部数据 | ~135M | 完整灾难恢复 |
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 站点独立备份(推荐)
|
||||
## 快速上手
|
||||
|
||||
```bash
|
||||
# 备份所有站点
|
||||
bash scripts/backup/backup-sites.sh
|
||||
# 备份
|
||||
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/backup-sites.sh assets issue
|
||||
|
||||
# 可用站点:assets, issue, ldap, gitea, oa
|
||||
# 恢复
|
||||
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/restore-sites.sh
|
||||
# 远程(默认)
|
||||
bash scripts/backup/backup.sh
|
||||
|
||||
# 指定时间戳恢复
|
||||
bash scripts/backup/restore-sites.sh 20260622_101858
|
||||
# 本地
|
||||
bash scripts/backup/backup.sh --local
|
||||
bash scripts/backup/restore.sh --local --full full-backup.tar.gz
|
||||
```
|
||||
|
||||
### 数据库单独备份
|
||||
## 新服务器灾难恢复流程
|
||||
|
||||
```bash
|
||||
# 仅备份数据库(快速)
|
||||
bash scripts/backup/backup-db.sh
|
||||
# 1. 在新服务器上安装 Docker + Compose V2
|
||||
|
||||
# 2. 上传脚本和备份文件
|
||||
scp scripts/backup/*.sh user@new-server:/root/docker/scripts/backup/
|
||||
scp full-backup-20260708.tar.gz user@new-server:/root/docker/
|
||||
|
||||
# 3. 在新服务器上执行本地恢复
|
||||
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(无独立数据库) |
|
||||
|
||||
### 共享资源备份
|
||||
|
||||
| 资源 | 内容 |
|
||||
| 站点 | 备份项 |
|
||||
|------|------|
|
||||
| nginx | 9 个反向代理配置文件 |
|
||||
| ssl | Let's Encrypt 证书 |
|
||||
| docker-network | webnet 网络配置 |
|
||||
| assets | assets.db + uploads/ + docker-compose.yml + .env + Dockerfile |
|
||||
| issue | issue.db + uploads/ + reports/ + docker-compose.yml + .env + Dockerfile |
|
||||
| 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/` |
|
||||
| 云端 | `/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
|
||||
# 每天凌晨 3 点执行站点备份
|
||||
0 3 * * * cd /Users/niuniu/programs/docker && bash scripts/backup/backup-sites.sh >> /var/log/backup.log 2>&1
|
||||
# 每天凌晨 3 点
|
||||
0 3 * * * cd /Users/niuniu/programs/docker && bash scripts/backup/backup.sh
|
||||
```
|
||||
|
||||
## 新增站点
|
||||
|
||||
无需修改脚本——自动发现 `*-ai/docker-compose.yml` 中含 DB 的站点。新站点只需在 `backup.sh` 的 case 语句中添加专用备份逻辑(或走 wildcard 通用备份)。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 备份前会自动执行 WAL checkpoint,确保数据库完整性
|
||||
- 恢复会重启相关容器,短暂影响服务
|
||||
- Gitea 代码仓库备份较大(~130M),可考虑单独备份频率
|
||||
- 备份前自动执行 WAL checkpoint,确保数据库完整性
|
||||
- 恢复会重启相关容器,**短暂影响服务**
|
||||
- Gitea 代码仓库备份较大(~130M),可单独调整备份频率
|
||||
- `--full` 灾难备份不含 uploads 等动态文件,仅保存数据库 + 代码仓库 + 配置
|
||||
- 建议定期验证备份文件可恢复性
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
#!/bin/bash
|
||||
# 云端数据库一键备份脚本
|
||||
# 用法:bash backup-db.sh
|
||||
|
||||
set -e
|
||||
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
LOCAL_DIR="/Users/niuniu/programs/docker/db-backups"
|
||||
|
||||
log() { echo "[$(date '+%H:%M:%S')] $1"; }
|
||||
|
||||
log "开始备份云端数据库..."
|
||||
|
||||
# 备份前执行 WAL checkpoint,确保数据全部合并到 .db 主文件
|
||||
log "执行 WAL checkpoint (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();\""
|
||||
log "执行 WAL checkpoint (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();\""
|
||||
log "执行 WAL checkpoint (LLDAP)..."
|
||||
ssh txjp "docker exec lldap sqlite3 /data/users.db 'PRAGMA wal_checkpoint(TRUNCATE);' 2>/dev/null || true"
|
||||
|
||||
# 云端打包(使用 docker cp 从容器内复制,确保拿到真实数据而非旧宿主机文件)
|
||||
ssh txjp "mkdir -p /tmp/db-backup && \
|
||||
docker cp assets-ai:/app/data/assets.db /tmp/db-backup/assets-${TIMESTAMP}.db && \
|
||||
docker cp issue-ai:/app/data/issue.db /tmp/db-backup/issue-${TIMESTAMP}.db && \
|
||||
docker cp lldap:/data/users.db /tmp/db-backup/ldap-${TIMESTAMP}.db && \
|
||||
docker cp gitea-ai:/data/gitea.db /tmp/db-backup/gitea-${TIMESTAMP}.db"
|
||||
|
||||
# 确保本地备份目录存在
|
||||
mkdir -p "${LOCAL_DIR}"
|
||||
|
||||
# 云端留一份持久备份
|
||||
CLOUD_DIR="/root/docker/db-backups"
|
||||
ssh txjp "mkdir -p ${CLOUD_DIR} && \
|
||||
cp /tmp/db-backup/assets-${TIMESTAMP}.db ${CLOUD_DIR}/assets-${TIMESTAMP}.db && \
|
||||
cp /tmp/db-backup/issue-${TIMESTAMP}.db ${CLOUD_DIR}/issue-${TIMESTAMP}.db && \
|
||||
cp /tmp/db-backup/ldap-${TIMESTAMP}.db ${CLOUD_DIR}/ldap-${TIMESTAMP}.db && \
|
||||
cp /tmp/db-backup/gitea-${TIMESTAMP}.db ${CLOUD_DIR}/gitea-${TIMESTAMP}.db"
|
||||
log "云端备份已保存至: ${CLOUD_DIR}"
|
||||
|
||||
# 复制到本地
|
||||
log "复制到本地..."
|
||||
mkdir -p "${LOCAL_DIR}"
|
||||
scp "txjp:/tmp/db-backup/assets-${TIMESTAMP}.db" "${LOCAL_DIR}/"
|
||||
scp "txjp:/tmp/db-backup/issue-${TIMESTAMP}.db" "${LOCAL_DIR}/"
|
||||
scp "txjp:/tmp/db-backup/ldap-${TIMESTAMP}.db" "${LOCAL_DIR}/"
|
||||
scp "txjp:/tmp/db-backup/gitea-${TIMESTAMP}.db" "${LOCAL_DIR}/"
|
||||
|
||||
# 验证备份文件为有效 SQLite 数据库
|
||||
log "验证备份完整性..."
|
||||
for f in "${LOCAL_DIR}/assets-${TIMESTAMP}.db" "${LOCAL_DIR}/issue-${TIMESTAMP}.db" "${LOCAL_DIR}/ldap-${TIMESTAMP}.db" "${LOCAL_DIR}/gitea-${TIMESTAMP}.db"; do
|
||||
if ! sqlite3 "$f" "SELECT count(*) FROM sqlite_master" > /dev/null 2>&1; then
|
||||
echo "[错误] 备份文件无效或损坏: $f"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
log "备份文件验证通过"
|
||||
|
||||
# 清理云端临时文件
|
||||
ssh txjp "rm -rf /tmp/db-backup"
|
||||
|
||||
log "备份完成:${TIMESTAMP}"
|
||||
echo " 云端: ${CLOUD_DIR}/"
|
||||
ssh txjp "ls -lh ${CLOUD_DIR}/assets-${TIMESTAMP}.db ${CLOUD_DIR}/issue-${TIMESTAMP}.db ${CLOUD_DIR}/ldap-${TIMESTAMP}.db ${CLOUD_DIR}/gitea-${TIMESTAMP}.db 2>/dev/null"
|
||||
echo " 本地:"
|
||||
ls -lh "${LOCAL_DIR}"/assets-${TIMESTAMP}.db "${LOCAL_DIR}"/issue-${TIMESTAMP}.db "${LOCAL_DIR}"/ldap-${TIMESTAMP}.db "${LOCAL_DIR}"/gitea-${TIMESTAMP}.db 2>/dev/null
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
#!/bin/bash
|
||||
# 完整灾难恢复备份脚本
|
||||
# 包含:数据库、代码仓库、用户数据、配置文件、SSL证书
|
||||
# 用法:bash backup-full.sh
|
||||
|
||||
set -e
|
||||
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
LOCAL_DIR="/Users/niuniu/programs/docker/db-backups"
|
||||
BACKUP_NAME="full-backup-${TIMESTAMP}"
|
||||
|
||||
log() { echo "[$(date '+%H:%M:%S')] $1"; }
|
||||
warn() { echo "[$(date '+%H:%M:%S')] [警告] $1"; }
|
||||
|
||||
log "开始完整灾难恢复备份..."
|
||||
|
||||
# ============================================================
|
||||
# 1. 数据库备份(WAL checkpoint + docker cp)
|
||||
# ============================================================
|
||||
log "执行 WAL checkpoint..."
|
||||
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 || warn "assets checkpoint 失败"
|
||||
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 || warn "issue checkpoint 失败"
|
||||
ssh txjp "docker exec lldap sqlite3 /data/users.db 'PRAGMA wal_checkpoint(TRUNCATE);'" 2>/dev/null || warn "ldap checkpoint 失败"
|
||||
|
||||
log "导出数据库..."
|
||||
ssh txjp "mkdir -p /tmp/full-backup/databases && \
|
||||
docker cp assets-ai:/app/data/assets.db /tmp/full-backup/databases/assets.db && \
|
||||
docker cp issue-ai:/app/data/issue.db /tmp/full-backup/databases/issue.db && \
|
||||
docker cp lldap:/data/users.db /tmp/full-backup/databases/ldap.db && \
|
||||
docker cp gitea-ai:/data/gitea.db /tmp/full-backup/databases/gitea.db"
|
||||
|
||||
# ============================================================
|
||||
# 2. Gitea 代码仓库 + 配置
|
||||
# ============================================================
|
||||
log "备份 Gitea 代码仓库..."
|
||||
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"
|
||||
|
||||
# ============================================================
|
||||
# 3. 用户上传文件
|
||||
# ============================================================
|
||||
log "备份用户上传文件..."
|
||||
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"
|
||||
|
||||
# ============================================================
|
||||
# 4. Nginx 配置
|
||||
# ============================================================
|
||||
log "备份 Nginx 配置..."
|
||||
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"
|
||||
|
||||
# ============================================================
|
||||
# 5. 各站点 docker-compose.yml + .env
|
||||
# ============================================================
|
||||
log "备份站点配置文件..."
|
||||
ssh txjp "mkdir -p /tmp/full-backup/configs && \
|
||||
for site in assets-ai issue-ai oa-ai ldap-ai gitea-ai; do \
|
||||
mkdir -p /tmp/full-backup/configs/\$site && \
|
||||
cp /root/docker/\$site/docker-compose.yml /tmp/full-backup/configs/\$site/ 2>/dev/null || true && \
|
||||
cp /root/docker/\$site/.env /tmp/full-backup/configs/\$site/ 2>/dev/null || true && \
|
||||
cp /root/docker/\$site/Dockerfile /tmp/full-backup/configs/\$site/ 2>/dev/null || true; \
|
||||
done"
|
||||
|
||||
# ============================================================
|
||||
# 6. SSL 证书
|
||||
# ============================================================
|
||||
log "备份 SSL 证书..."
|
||||
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"
|
||||
|
||||
# ============================================================
|
||||
# 7. 打包压缩
|
||||
# ============================================================
|
||||
log "打包压缩..."
|
||||
ssh txjp "cd /tmp && tar czf ${BACKUP_NAME}.tar.gz full-backup/ && rm -rf /tmp/full-backup"
|
||||
|
||||
# ============================================================
|
||||
# 8. 复制到本地
|
||||
# ============================================================
|
||||
log "复制到本地..."
|
||||
mkdir -p "${LOCAL_DIR}"
|
||||
scp "txjp:/tmp/${BACKUP_NAME}.tar.gz" "${LOCAL_DIR}/"
|
||||
|
||||
# ============================================================
|
||||
# 9. 云端保留一份
|
||||
# ============================================================
|
||||
CLOUD_DIR="/root/docker/db-backups"
|
||||
ssh txjp "mkdir -p ${CLOUD_DIR} && cp /tmp/${BACKUP_NAME}.tar.gz ${CLOUD_DIR}/"
|
||||
log "云端备份已保存至: ${CLOUD_DIR}"
|
||||
|
||||
# ============================================================
|
||||
# 10. 清理云端临时文件
|
||||
# ============================================================
|
||||
ssh txjp "rm -f /tmp/${BACKUP_NAME}.tar.gz"
|
||||
|
||||
# ============================================================
|
||||
# 11. 验证备份
|
||||
# ============================================================
|
||||
log "验证备份完整性..."
|
||||
cd "${LOCAL_DIR}"
|
||||
if tar tzf "${BACKUP_NAME}.tar.gz" > /dev/null 2>&1; then
|
||||
log "备份文件验证通过"
|
||||
else
|
||||
echo "[错误] 备份文件损坏"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 显示备份内容
|
||||
log "备份内容清单:"
|
||||
tar tzf "${BACKUP_NAME}.tar.gz" | head -30
|
||||
|
||||
echo ""
|
||||
log "备份完成:${TIMESTAMP}"
|
||||
echo " 文件: ${LOCAL_DIR}/${BACKUP_NAME}.tar.gz"
|
||||
echo " 大小: $(du -h ${LOCAL_DIR}/${BACKUP_NAME}.tar.gz | cut -f1)"
|
||||
echo ""
|
||||
echo " 云端: ${CLOUD_DIR}/${BACKUP_NAME}.tar.gz"
|
||||
ssh txjp "ls -lh ${CLOUD_DIR}/${BACKUP_NAME}.tar.gz 2>/dev/null"
|
||||
|
|
@ -1,188 +0,0 @@
|
|||
#!/bin/bash
|
||||
# 站点独立备份脚本
|
||||
# 每个站点单独一个备份文件,共享资源(nginx/ssl/network)单独备份
|
||||
# 用法:bash backup-sites.sh [站点名]
|
||||
# 无参数:备份所有站点
|
||||
# 指定站点:仅备份指定站点
|
||||
|
||||
set -e
|
||||
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
LOCAL_DIR="/Users/niuniu/programs/docker/db-backups"
|
||||
REMOTE_BASE="/root/docker"
|
||||
CLOUD_DIR="/root/docker/db-backups"
|
||||
|
||||
log() { echo "[$(date '+%H:%M:%S')] $1"; }
|
||||
warn() { echo "[$(date '+%H:%M:%S')] [警告] $1"; }
|
||||
|
||||
# 要备份的站点列表
|
||||
ALL_SITES="assets issue ldap gitea oa"
|
||||
|
||||
# ============================================================
|
||||
# 备份单个站点
|
||||
# ============================================================
|
||||
backup_site() {
|
||||
local site="$1"
|
||||
local backup_name="${site}-${TIMESTAMP}.tar.gz"
|
||||
|
||||
log "备份 ${site}..."
|
||||
|
||||
# 创建临时目录
|
||||
ssh txjp "rm -rf /tmp/backup-${site} && mkdir -p /tmp/backup-${site}/data"
|
||||
|
||||
case "$site" in
|
||||
assets)
|
||||
# WAL checkpoint
|
||||
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
|
||||
# 数据库
|
||||
ssh txjp "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"
|
||||
# 配置文件
|
||||
ssh txjp "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"
|
||||
ssh txjp "cp /root/docker/assets-ai/Dockerfile /tmp/backup-${site}/ 2>/dev/null || true"
|
||||
;;
|
||||
issue)
|
||||
# WAL checkpoint
|
||||
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
|
||||
# 数据库
|
||||
ssh txjp "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"
|
||||
ssh txjp "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"
|
||||
ssh txjp "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"
|
||||
;;
|
||||
ldap)
|
||||
# WAL checkpoint
|
||||
ssh txjp "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/"
|
||||
# 配置文件
|
||||
ssh txjp "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"
|
||||
ssh txjp "cp /root/docker/ldap-ai/Dockerfile /tmp/backup-${site}/ 2>/dev/null || true"
|
||||
;;
|
||||
gitea)
|
||||
# 数据库(gitea 不允许以 root 运行 dump,直接复制数据库文件)
|
||||
ssh txjp "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"
|
||||
# 配置
|
||||
ssh txjp "cp -a /root/docker/gitea-ai/data/gitea/conf /tmp/backup-${site}/gitea-conf 2>/dev/null || true"
|
||||
# docker-compose
|
||||
ssh txjp "cp /root/docker/gitea-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true"
|
||||
;;
|
||||
oa)
|
||||
# 配置文件
|
||||
ssh txjp "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"
|
||||
# OA 没有独立数据库,数据存在 assets/issue 中
|
||||
;;
|
||||
esac
|
||||
|
||||
# 打包
|
||||
ssh txjp "cd /tmp && tar czf ${backup_name} backup-${site}/"
|
||||
|
||||
# 复制到本地
|
||||
mkdir -p "${LOCAL_DIR}"
|
||||
scp "txjp:/tmp/${backup_name}" "${LOCAL_DIR}/"
|
||||
|
||||
# 云端保留
|
||||
ssh txjp "mkdir -p ${CLOUD_DIR} && cp /tmp/${backup_name} ${CLOUD_DIR}/"
|
||||
|
||||
# 清理临时文件
|
||||
ssh txjp "rm -rf /tmp/backup-${site} /tmp/${backup_name}"
|
||||
|
||||
log "${site} 备份完成: ${backup_name}"
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 备份共享资源(nginx + ssl + docker网络)
|
||||
# ============================================================
|
||||
backup_shared() {
|
||||
local backup_name="shared-${TIMESTAMP}.tar.gz"
|
||||
|
||||
log "备份共享资源..."
|
||||
|
||||
ssh txjp "rm -rf /tmp/backup-shared && mkdir -p /tmp/backup-shared"
|
||||
|
||||
# Nginx 配置
|
||||
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"
|
||||
|
||||
# SSL 证书
|
||||
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"
|
||||
|
||||
# Docker 网络配置
|
||||
ssh txjp "cat > /tmp/backup-shared/docker-network.sh << 'NETEOF'
|
||||
#!/bin/bash
|
||||
# 恢复 docker 网络
|
||||
if ! docker network inspect webnet >/dev/null 2>&1; then
|
||||
docker network create --driver bridge --subnet 172.18.0.0/16 webnet
|
||||
echo 'Docker 网络 webnet 已创建'
|
||||
else
|
||||
echo 'Docker 网络 webnet 已存在'
|
||||
fi
|
||||
NETEOF"
|
||||
|
||||
# Nginx docker-compose(用于恢复 nginx 容器)
|
||||
ssh txjp "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/"
|
||||
|
||||
# 复制到本地
|
||||
mkdir -p "${LOCAL_DIR}"
|
||||
scp "txjp:/tmp/${backup_name}" "${LOCAL_DIR}/"
|
||||
|
||||
# 云端保留
|
||||
ssh txjp "mkdir -p ${CLOUD_DIR} && cp /tmp/${backup_name} ${CLOUD_DIR}/"
|
||||
|
||||
# 清理
|
||||
ssh txjp "rm -rf /tmp/backup-shared /tmp/${backup_name}"
|
||||
|
||||
log "共享资源备份完成: ${backup_name}"
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 主流程
|
||||
# ============================================================
|
||||
log "开始站点独立备份..."
|
||||
|
||||
# 解析参数
|
||||
TARGET_SITES="${1:-all}"
|
||||
|
||||
if [[ "$TARGET_SITES" == "all" ]]; then
|
||||
# 备份所有站点
|
||||
for site in $ALL_SITES; do
|
||||
backup_site "$site"
|
||||
done
|
||||
backup_shared
|
||||
else
|
||||
# 备份指定站点
|
||||
for site in $TARGET_SITES; do
|
||||
if echo "$ALL_SITES" | grep -qw "$site"; then
|
||||
backup_site "$site"
|
||||
else
|
||||
warn "未知站点: $site"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# 显示备份结果
|
||||
# ============================================================
|
||||
echo ""
|
||||
log "备份完成!"
|
||||
echo ""
|
||||
echo "本地备份文件:"
|
||||
ls -lh ${LOCAL_DIR}/*-${TIMESTAMP}.tar.gz 2>/dev/null
|
||||
echo ""
|
||||
echo "云端备份文件:"
|
||||
ssh txjp "ls -lh ${CLOUD_DIR}/*-${TIMESTAMP}.tar.gz 2>/dev/null"
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
#!/bin/bash
|
||||
# 统一备份脚本 — 支持远程模式(从本地 Mac 执行)和本地模式(在服务器上直接执行)
|
||||
# 用法:bash backup.sh [--local] [--db-only|--full] [站点名...]
|
||||
set -e
|
||||
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
LOCAL_DIR="/Users/niuniu/programs/docker/db-backups"
|
||||
REMOTE_BASE="/root/docker"
|
||||
CLOUD_DIR="/root/docker/db-backups"
|
||||
MODE="remote"
|
||||
|
||||
log() { 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)"
|
||||
DB_SITES=""
|
||||
for dir in "$PROJECT_ROOT"/*-ai/; do
|
||||
s=$(basename "$dir"); [[ "$s" == "nginx-proxy-ai" ]] && continue
|
||||
[ -f "$dir/docker-compose.yml" ] || continue
|
||||
grep -qE "\.db|DATABASE_PATH|sqlite" "$dir/docker-compose.yml" 2>/dev/null || continue
|
||||
s="${s%-ai}"; [[ "$s" == "oa" ]] && continue
|
||||
DB_SITES="$DB_SITES $s"
|
||||
done
|
||||
|
||||
container_for() { [[ "$1" == "ldap" ]] && echo "lldap" || echo "${1}-ai"; }
|
||||
|
||||
# === 模式 1:站点完整备份(默认)===
|
||||
backup_site() {
|
||||
local site="$1" backup_name="${site}-${TIMESTAMP}.tar.gz"
|
||||
log "备份 ${site}..."
|
||||
|
||||
runcmd "rm -rf /tmp/backup-${site} && mkdir -p /tmp/backup-${site}/data"
|
||||
|
||||
case "$site" in
|
||||
assets)
|
||||
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
|
||||
runcmd "docker cp assets-ai:/app/data/assets.db /tmp/backup-${site}/data/"
|
||||
runcmd "cp -a /root/docker/assets-ai/data/uploads /tmp/backup-${site}/data/ 2>/dev/null || true"
|
||||
runcmd "cp /root/docker/assets-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true"
|
||||
runcmd "cp /root/docker/assets-ai/.env /tmp/backup-${site}/ 2>/dev/null || true"
|
||||
runcmd "cp /root/docker/assets-ai/Dockerfile /tmp/backup-${site}/ 2>/dev/null || true"
|
||||
;;
|
||||
issue)
|
||||
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
|
||||
runcmd "docker cp issue-ai:/app/data/issue.db /tmp/backup-${site}/data/"
|
||||
runcmd "cp -a /root/docker/issue-ai/data/uploads /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"
|
||||
runcmd "cp /root/docker/issue-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true"
|
||||
runcmd "cp /root/docker/issue-ai/.env /tmp/backup-${site}/ 2>/dev/null || true"
|
||||
runcmd "cp /root/docker/issue-ai/Dockerfile /tmp/backup-${site}/ 2>/dev/null || true"
|
||||
;;
|
||||
ldap)
|
||||
runcmd "docker exec lldap sqlite3 /data/users.db 'PRAGMA wal_checkpoint(TRUNCATE);'" 2>/dev/null || true
|
||||
runcmd "docker cp lldap:/data/users.db /tmp/backup-${site}/data/"
|
||||
runcmd "cp /root/docker/ldap-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true"
|
||||
runcmd "cp /root/docker/ldap-ai/.env /tmp/backup-${site}/ 2>/dev/null || true"
|
||||
runcmd "cp /root/docker/ldap-ai/Dockerfile /tmp/backup-${site}/ 2>/dev/null || true"
|
||||
;;
|
||||
gitea)
|
||||
runcmd "docker cp gitea-ai:/data/gitea.db /tmp/backup-${site}/data/"
|
||||
runcmd "cp -a /root/docker/gitea-ai/data/git /tmp/backup-${site}/ 2>/dev/null || true"
|
||||
runcmd "cp -a /root/docker/gitea-ai/data/gitea/conf /tmp/backup-${site}/gitea-conf 2>/dev/null || true"
|
||||
runcmd "cp /root/docker/gitea-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true"
|
||||
;;
|
||||
oa)
|
||||
runcmd "cp /root/docker/oa-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true"
|
||||
runcmd "cp /root/docker/oa-ai/.env /tmp/backup-${site}/ 2>/dev/null || true"
|
||||
;;
|
||||
monitor)
|
||||
runcmd "docker exec monitor-ai sqlite3 /app/data/monitor.db 'PRAGMA wal_checkpoint(TRUNCATE);'" 2>/dev/null || true
|
||||
runcmd "docker cp monitor-ai:/app/data/monitor.db /tmp/backup-${site}/data/"
|
||||
runcmd "cp /root/docker/monitor-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true"
|
||||
runcmd "cp /root/docker/monitor-ai/.env /tmp/backup-${site}/ 2>/dev/null || true"
|
||||
;;
|
||||
*)
|
||||
warn "${site}: 无专用备份逻辑,仅备份配置文件"
|
||||
runcmd "cp /root/docker/${site}-ai/docker-compose.yml /tmp/backup-${site}/ 2>/dev/null || true"
|
||||
runcmd "cp /root/docker/${site}-ai/.env /tmp/backup-${site}/ 2>/dev/null || true"
|
||||
;;
|
||||
esac
|
||||
|
||||
runcmd "cd /tmp && tar czf ${backup_name} backup-${site}/"
|
||||
mkdir -p "${LOCAL_DIR}"
|
||||
fetch "/tmp/${backup_name}" "${LOCAL_DIR}/"
|
||||
runcmd "mkdir -p ${CLOUD_DIR} && cp /tmp/${backup_name} ${CLOUD_DIR}/"
|
||||
runcmd "rm -rf /tmp/backup-${site} /tmp/${backup_name}"
|
||||
log "${site} 备份完成: ${backup_name}"
|
||||
}
|
||||
|
||||
backup_shared() {
|
||||
local backup_name="shared-${TIMESTAMP}.tar.gz"
|
||||
log "备份共享资源..."
|
||||
runcmd "rm -rf /tmp/backup-shared && mkdir -p /tmp/backup-shared"
|
||||
runcmd "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/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 "cp /root/docker/nginx-proxy-ai/docker-compose.yml /tmp/backup-shared/ 2>/dev/null || true"
|
||||
runcmd "cd /tmp && tar czf ${backup_name} backup-shared/"
|
||||
mkdir -p "${LOCAL_DIR}"; fetch "/tmp/${backup_name}" "${LOCAL_DIR}/"
|
||||
runcmd "mkdir -p ${CLOUD_DIR} && cp /tmp/${backup_name} ${CLOUD_DIR}/"
|
||||
runcmd "rm -rf /tmp/backup-shared /tmp/${backup_name}"
|
||||
log "共享资源备份完成: ${backup_name}"
|
||||
}
|
||||
|
||||
# === 模式 2:仅数据库备份 ===
|
||||
backup_db_only() {
|
||||
log "开始数据库备份..."
|
||||
|
||||
for s in $DB_SITES; do
|
||||
c=$(container_for "$s")
|
||||
[[ "$s" == "ldap" ]] && runcmd "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" &
|
||||
done; wait
|
||||
|
||||
runcmd "mkdir -p /tmp/db-backup"
|
||||
for s in $DB_SITES; do
|
||||
c=$(container_for "$s")
|
||||
dbpath="/app/data/${s}.db"; [[ "$s" == "ldap" ]] && dbpath="/data/users.db"; [[ "$s" == "gitea" ]] && dbpath="/data/gitea.db"
|
||||
log "备份 ${s}..."
|
||||
runcmd "docker cp ${c}:${dbpath} /tmp/db-backup/${s}-${TIMESTAMP}.db"
|
||||
done
|
||||
|
||||
mkdir -p "${LOCAL_DIR}"; runcmd "mkdir -p ${CLOUD_DIR}"
|
||||
for s in $DB_SITES; do
|
||||
runcmd "cp /tmp/db-backup/${s}-${TIMESTAMP}.db ${CLOUD_DIR}/"
|
||||
fetch "/tmp/db-backup/${s}-${TIMESTAMP}.db" "${LOCAL_DIR}/"
|
||||
f="${LOCAL_DIR}/${s}-${TIMESTAMP}.db"
|
||||
sqlite3 "$f" "SELECT count(*) FROM sqlite_master" > /dev/null 2>&1 || warn "${s} 备份文件无效"
|
||||
done
|
||||
|
||||
runcmd "rm -rf /tmp/db-backup"
|
||||
log "数据库备份完成"
|
||||
}
|
||||
|
||||
# === 模式 3:灾难恢复完整备份 ===
|
||||
backup_full() {
|
||||
log "开始完整灾难恢复备份..."
|
||||
|
||||
for s in $DB_SITES; do
|
||||
c=$(container_for "$s")
|
||||
[[ "$s" == "ldap" ]] && runcmd "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" &
|
||||
done; wait
|
||||
|
||||
runcmd "mkdir -p /tmp/full-backup/databases"
|
||||
for s in $DB_SITES; do
|
||||
c=$(container_for "$s")
|
||||
dbpath="/app/data/${s}.db"; [[ "$s" == "ldap" ]] && dbpath="/data/users.db"; [[ "$s" == "gitea" ]] && dbpath="/data/gitea.db"
|
||||
runcmd "docker cp ${c}:${dbpath} /tmp/full-backup/databases/${s}.db"
|
||||
done
|
||||
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
|
||||
local name="full-backup-${TIMESTAMP}"
|
||||
runcmd "cd /tmp && tar czf ${name}.tar.gz full-backup/"
|
||||
mkdir -p "${LOCAL_DIR}"; fetch "/tmp/${name}.tar.gz" "${LOCAL_DIR}/"
|
||||
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"
|
||||
}
|
||||
|
||||
# === 主流程 ===
|
||||
case "$ACTION" in
|
||||
help)
|
||||
echo "用法: bash backup.sh [--local] [--db-only|--full] [站点名...]"
|
||||
echo " --local 本地模式(直接在服务器上运行)"
|
||||
echo " 无参数 站点完整备份(全部含 DB 站点)"
|
||||
echo " --db-only 仅数据库备份"
|
||||
echo " --full 灾难恢复完整备份"
|
||||
echo " 站点名... 备份指定站点"
|
||||
;;
|
||||
db-only) backup_db_only ;;
|
||||
full) backup_full ;;
|
||||
*)
|
||||
TARGETS="${ARGS[*]:-all}"
|
||||
log "站点完整备份: ${TARGETS} (模式: ${MODE})"
|
||||
if [[ "$TARGETS" == "all" ]]; then
|
||||
for site in $DB_SITES oa; do backup_site "$site"; done
|
||||
backup_shared
|
||||
else
|
||||
for site in $TARGETS; do backup_site "$site"; done
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""; log "备份完成!"
|
||||
ls -lh ${LOCAL_DIR}/*-${TIMESTAMP}* 2>/dev/null
|
||||
|
|
@ -1,284 +0,0 @@
|
|||
#!/bin/bash
|
||||
# ============================================
|
||||
# 数据库恢复脚本
|
||||
#
|
||||
# 用法:
|
||||
# 恢复本地数据库: bash scripts/restore-db.sh local <服务名> <备份文件名>
|
||||
# 恢复云端数据库: bash scripts/restore-db.sh cloud <服务名> <备份文件名>
|
||||
#
|
||||
# 服务名: issue / assets / ldap
|
||||
#
|
||||
# 示例:
|
||||
# bash scripts/restore-db.sh local issue issue-2026-04-29_1753.db
|
||||
# bash scripts/restore-db.sh cloud assets assets-2026-04-29_1753.db
|
||||
# bash scripts/restore-db.sh cloud ldap ldap-2026-05-15_1200.db
|
||||
# ============================================
|
||||
|
||||
set -e
|
||||
|
||||
# ---- 参数检查 ----
|
||||
if [ $# -lt 3 ]; then
|
||||
echo "用法: $0 <local|cloud> <issue|assets|ldap> <备份文件名>"
|
||||
echo ""
|
||||
echo "支持格式: .db 或 .tar.gz(自动解压)"
|
||||
echo ""
|
||||
echo "示例:"
|
||||
echo " $0 local issue issue-2026-04-29_1753.db"
|
||||
echo " $0 local issue issue-20260622_101858.tar.gz"
|
||||
echo " $0 cloud ldap ldap-2026-05-15_1200.db"
|
||||
echo " $0 cloud assets assets-2026-04-29_1753.db"
|
||||
echo ""
|
||||
echo "可用备份文件:"
|
||||
echo " 本地:"
|
||||
ls -1 /Users/niuniu/programs/docker/db-backups/ 2>/dev/null || echo " (无)"
|
||||
echo " 云端:"
|
||||
ssh txjp "ls -1 /root/docker/db-backups/" 2>/dev/null || echo " (无)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TARGET=$1
|
||||
SERVICE=$2
|
||||
BACKUP_FILE=$3
|
||||
|
||||
# ---- 路径定义 ----
|
||||
LOCAL_BACKUP_DIR="/Users/niuniu/programs/docker/db-backups"
|
||||
CLOUD_BACKUP_DIR="/root/docker/db-backups"
|
||||
|
||||
case $SERVICE in
|
||||
issue)
|
||||
PROJECT_DIR="issue-ai"
|
||||
CONTAINER="issue-ai"
|
||||
DB_PATH_IN_CONTAINER="/app/data/issue.db"
|
||||
LOCAL_DB_PATH="/Users/niuniu/programs/docker/issue-ai/data/issue.db"
|
||||
;;
|
||||
assets)
|
||||
PROJECT_DIR="assets-ai"
|
||||
CONTAINER="assets-ai"
|
||||
DB_PATH_IN_CONTAINER="/app/data/assets.db"
|
||||
LOCAL_DB_PATH="/Users/niuniu/programs/docker/assets-ai/data/assets.db"
|
||||
;;
|
||||
ldap)
|
||||
PROJECT_DIR="ldap-ai"
|
||||
CONTAINER="lldap"
|
||||
DB_PATH_IN_CONTAINER="/data/users.db"
|
||||
LOCAL_DB_PATH="/Users/niuniu/programs/docker/ldap-ai/data/lldap/users.db"
|
||||
;;
|
||||
*)
|
||||
echo "错误: 服务名必须是 issue、assets 或 ldap"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# ============================================
|
||||
# 本地恢复
|
||||
# ============================================
|
||||
restore_local() {
|
||||
local BACKUP_PATH="${LOCAL_BACKUP_DIR}/${BACKUP_FILE}"
|
||||
local DB_FILE="$BACKUP_PATH"
|
||||
|
||||
echo "========================================"
|
||||
echo " 本地数据库恢复"
|
||||
echo "========================================"
|
||||
echo " 服务: ${SERVICE} (${CONTAINER})"
|
||||
echo " 备份文件: ${BACKUP_PATH}"
|
||||
echo " 目标数据库: ${LOCAL_DB_PATH}"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# 1. 检查备份文件
|
||||
if [ ! -f "$BACKUP_PATH" ]; then
|
||||
echo "[错误] 备份文件不存在: ${BACKUP_PATH}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. 如果是 .tar.gz 文件,解压获取 .db 文件
|
||||
if [[ "$BACKUP_PATH" == *.tar.gz ]]; then
|
||||
echo "[信息] 检测到 tar.gz 压缩包,正在解压..."
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
tar xzf "$BACKUP_PATH" -C "$TEMP_DIR"
|
||||
|
||||
# 查找 .db 文件
|
||||
DB_FILE=$(find "$TEMP_DIR" -name "*.db" -type f | head -1)
|
||||
if [ -z "$DB_FILE" ]; then
|
||||
echo "[错误] 压缩包中未找到 .db 文件"
|
||||
rm -rf "$TEMP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
echo "[信息] 解压得到数据库文件: $(basename $DB_FILE)"
|
||||
fi
|
||||
|
||||
# 3. 验证备份文件是有效的 SQLite 数据库
|
||||
if ! sqlite3 "$DB_FILE" "SELECT count(*) FROM sqlite_master" > /dev/null 2>&1; then
|
||||
echo "[错误] 备份文件不是有效的 SQLite 数据库"
|
||||
[ -n "$TEMP_DIR" ] && rm -rf "$TEMP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 4. 确认操作
|
||||
echo "[警告] 这将覆盖本地 ${CONTAINER} 数据库!"
|
||||
echo " 当前数据库: ${LOCAL_DB_PATH}"
|
||||
echo " 将替换为: ${DB_FILE}"
|
||||
echo ""
|
||||
read -p "确认恢复? (输入 yes 继续): " CONFIRM
|
||||
if [ "$CONFIRM" != "yes" ]; then
|
||||
echo "已取消"
|
||||
[ -n "$TEMP_DIR" ] && rm -rf "$TEMP_DIR"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 5. 备份当前数据库(安全起见)
|
||||
local SAFE_BACKUP="${LOCAL_DB_PATH}.before-restore-$(date +%Y%m%d_%H%M%S)"
|
||||
if [ -f "$LOCAL_DB_PATH" ]; then
|
||||
cp "$LOCAL_DB_PATH" "$SAFE_BACKUP"
|
||||
echo "[备份] 当前数据库已备份至: ${SAFE_BACKUP}"
|
||||
fi
|
||||
|
||||
# 6. WAL checkpoint on backup (确保数据完整)
|
||||
sqlite3 "$DB_FILE" "PRAGMA wal_checkpoint(TRUNCATE)" 2>/dev/null || true
|
||||
|
||||
# 7. 停止本地容器(如果在运行)
|
||||
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER}$"; then
|
||||
echo "[操作] 停止容器 ${CONTAINER}..."
|
||||
docker stop "$CONTAINER"
|
||||
fi
|
||||
|
||||
# 8. 替换数据库
|
||||
cp "$DB_FILE" "$LOCAL_DB_PATH"
|
||||
rm -f "${LOCAL_DB_PATH}-shm" "${LOCAL_DB_PATH}-wal"
|
||||
echo "[完成] 数据库已替换"
|
||||
|
||||
# 9. 清理临时文件
|
||||
[ -n "$TEMP_DIR" ] && rm -rf "$TEMP_DIR"
|
||||
|
||||
# 10. 启动容器
|
||||
echo "[操作] 启动容器 ${CONTAINER}..."
|
||||
docker start "$CONTAINER"
|
||||
|
||||
# 11. 验证
|
||||
sleep 2
|
||||
echo ""
|
||||
echo "[验证] 恢复后数据:"
|
||||
if [ "$SERVICE" = "issue" ]; then
|
||||
echo " 工单数: $(sqlite3 "$LOCAL_DB_PATH" "SELECT COUNT(*) FROM tickets")"
|
||||
echo " 配件名称: $(sqlite3 "$LOCAL_DB_PATH" "SELECT COUNT(*) FROM tickets WHERE parts_name IS NOT NULL AND parts_name != ''") 条"
|
||||
elif [ "$SERVICE" = "ldap" ]; then
|
||||
echo " 用户数: $(sqlite3 "$LOCAL_DB_PATH" "SELECT COUNT(*) FROM users")"
|
||||
else
|
||||
echo " 设备数: $(sqlite3 "$LOCAL_DB_PATH" "SELECT COUNT(*) FROM assets")"
|
||||
fi
|
||||
echo ""
|
||||
echo "恢复完成!"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 云端恢复
|
||||
# ============================================
|
||||
restore_cloud() {
|
||||
local BACKUP_PATH="${CLOUD_BACKUP_DIR}/${BACKUP_FILE}"
|
||||
|
||||
echo "========================================"
|
||||
echo " 云端数据库恢复 (txjp)"
|
||||
echo "========================================"
|
||||
echo " 服务: ${SERVICE} (${CONTAINER})"
|
||||
echo " 备份文件: ${BACKUP_PATH}"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# 1. 检查备份文件
|
||||
if ! ssh txjp "test -f ${BACKUP_PATH}"; then
|
||||
echo "[错误] 云端备份文件不存在: ${BACKUP_PATH}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. 验证备份文件(通过容器内 better-sqlite3,避免依赖宿主机或容器内 sqlite3 CLI)
|
||||
if ! ssh txjp "docker cp ${BACKUP_PATH} ${CONTAINER}:/tmp/restore-check.db 2>/dev/null && \
|
||||
docker exec ${CONTAINER} node -e \"const D=require('better-sqlite3');const db=new D('/tmp/restore-check.db',{readonly:true});console.log(db.prepare('SELECT count(*) as c FROM sqlite_master').get().c);db.close();\" > /dev/null 2>&1 && \
|
||||
docker exec ${CONTAINER} rm /tmp/restore-check.db"; then
|
||||
echo "[错误] 备份文件不是有效的 SQLite 数据库"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3. 显示备份和当前状态
|
||||
echo "[信息] 备份文件信息:"
|
||||
ssh txjp "ls -lh ${BACKUP_PATH}"
|
||||
echo ""
|
||||
echo "[信息] 当前云端数据库状态:"
|
||||
if [ "$SERVICE" = "issue" ]; then
|
||||
ssh txjp "docker exec ${CONTAINER} node -e \"
|
||||
const D=require('better-sqlite3');
|
||||
const db=new D('/app/data/issue.db',{readonly:true});
|
||||
console.log(' 工单数:', db.prepare('SELECT COUNT(*) as c FROM tickets').get().c);
|
||||
db.close();
|
||||
\""
|
||||
else
|
||||
ssh txjp "docker exec ${CONTAINER} node -e \"
|
||||
const D=require('better-sqlite3');
|
||||
const db=new D('/app/data/assets.db',{readonly:true});
|
||||
console.log(' 设备数:', db.prepare('SELECT COUNT(*) as c FROM assets').get().c);
|
||||
db.close();
|
||||
\""
|
||||
fi
|
||||
|
||||
# 4. 确认
|
||||
echo ""
|
||||
echo "[警告] 这将覆盖云端 ${CONTAINER} 数据库!"
|
||||
read -p "确认恢复? (输入 yes 继续): " CONFIRM
|
||||
if [ "$CONFIRM" != "yes" ]; then
|
||||
echo "已取消"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 5. 停止容器(确保数据库不被写入,安全替换)
|
||||
echo "[操作] 停止容器 ${CONTAINER}..."
|
||||
ssh txjp "cd /root/docker/${PROJECT_DIR} && docker compose stop ${CONTAINER}"
|
||||
|
||||
# 6. 备份当前数据库(容器已停止,安全复制)
|
||||
local SAFE_NAME="before-restore-$(date +%Y%m%d_%H%M%S).db"
|
||||
echo "[操作] 备份云端当前数据库..."
|
||||
ssh txjp "docker cp ${CONTAINER}:${DB_PATH_IN_CONTAINER} /root/docker/db-backups/${SAFE_NAME}"
|
||||
echo "[备份] 当前数据库已保存至云端: /root/docker/db-backups/${SAFE_NAME}"
|
||||
|
||||
# 7. 替换数据库
|
||||
echo "[操作] 复制备份文件到容器..."
|
||||
ssh txjp "docker cp ${BACKUP_PATH} ${CONTAINER}:${DB_PATH_IN_CONTAINER}"
|
||||
|
||||
# 8. 删除残留 WAL/SHM 文件,防止旧日志与新数据库不匹配导致损坏
|
||||
echo "[操作] 清理残留 WAL/SHM 文件..."
|
||||
ssh txjp "docker exec ${CONTAINER} rm -f ${DB_PATH_IN_CONTAINER}-shm ${DB_PATH_IN_CONTAINER}-wal" 2>/dev/null || true
|
||||
|
||||
# 9. 启动容器
|
||||
echo "[操作] 启动容器 ${CONTAINER}..."
|
||||
ssh txjp "cd /root/docker/${PROJECT_DIR} && docker compose start ${CONTAINER}"
|
||||
|
||||
# 10. 验证
|
||||
sleep 3
|
||||
echo ""
|
||||
echo "[验证] 恢复后数据:"
|
||||
if [ "$SERVICE" = "issue" ]; then
|
||||
ssh txjp "docker exec ${CONTAINER} node -e \"
|
||||
const D=require('better-sqlite3');
|
||||
const db=new D('/app/data/issue.db',{readonly:true});
|
||||
console.log(' 工单数:', db.prepare('SELECT COUNT(*) as c FROM tickets').get().c);
|
||||
db.close();
|
||||
\""
|
||||
else
|
||||
ssh txjp "docker exec ${CONTAINER} node -e \"
|
||||
const D=require('better-sqlite3');
|
||||
const db=new D('/app/data/assets.db',{readonly:true});
|
||||
console.log(' 设备数:', db.prepare('SELECT COUNT(*) as c FROM assets').get().c);
|
||||
db.close();
|
||||
\""
|
||||
fi
|
||||
echo ""
|
||||
echo "恢复完成!"
|
||||
}
|
||||
|
||||
# ---- 执行 ----
|
||||
case $TARGET in
|
||||
local) restore_local ;;
|
||||
cloud) restore_cloud ;;
|
||||
*)
|
||||
echo "错误: 目标必须是 local 或 cloud"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
|
@ -1,272 +0,0 @@
|
|||
#!/bin/bash
|
||||
# 灾难恢复脚本 - 从完整备份恢复站点
|
||||
# 用法:bash restore-full.sh [备份文件名]
|
||||
# 无参数则自动检测当前目录下的最新备份
|
||||
|
||||
set -e
|
||||
|
||||
BACKUP_DIR="/Users/niuniu/programs/docker/db-backups"
|
||||
REMOTE_BASE="/root/docker"
|
||||
|
||||
log() { echo "[$(date '+%H:%M:%S')] $1"; }
|
||||
warn() { echo "[$(date '+%H:%M:%S')] [警告] $1"; }
|
||||
error() { echo "[$(date '+%H:%M:%S')] [错误] $1"; exit 1; }
|
||||
|
||||
# ============================================================
|
||||
# 1. 选择备份文件
|
||||
# ============================================================
|
||||
if [[ -n "$1" ]]; then
|
||||
BACKUP_FILE="$1"
|
||||
else
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " 可用的完整备份文件"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# 列出本地备份
|
||||
if [[ -d "$BACKUP_DIR" ]]; then
|
||||
local_files=$(ls -t ${BACKUP_DIR}/full-backup-*.tar.gz 2>/dev/null | head -10)
|
||||
if [[ -n "$local_files" ]]; then
|
||||
echo "本地备份:"
|
||||
index=1
|
||||
while IFS= read -r file; do
|
||||
filename=$(basename "$file")
|
||||
filesize=$(du -h "$file" | cut -f1)
|
||||
echo " $index) $filename ($filesize)"
|
||||
files[$index]="$file"
|
||||
((index++))
|
||||
done <<< "$local_files"
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
|
||||
# 列出云端备份
|
||||
echo "云端备份:"
|
||||
cloud_files=$(ssh txjp "ls -t /root/docker/db-backups/full-backup-*.tar.gz 2>/dev/null | head -10" 2>/dev/null || true)
|
||||
if [[ -n "$cloud_files" ]]; then
|
||||
while IFS= read -r file; do
|
||||
filename=$(basename "$file")
|
||||
echo " $index) [云端] $filename"
|
||||
files[$index]="cloud:$file"
|
||||
((index++))
|
||||
done <<< "$cloud_files"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [[ ${#files[@]} -eq 0 ]]; then
|
||||
error "未找到任何备份文件"
|
||||
fi
|
||||
|
||||
printf "请选择备份文件 (1-$((index-1))): "
|
||||
read choice
|
||||
|
||||
if [[ -z "${files[$choice]}" ]]; then
|
||||
error "无效选择"
|
||||
fi
|
||||
|
||||
BACKUP_FILE="${files[$choice]}"
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# 2. 下载云端备份到临时目录
|
||||
# ============================================================
|
||||
RESTORE_DIR="/tmp/restore-$$"
|
||||
mkdir -p "$RESTORE_DIR"
|
||||
|
||||
if [[ "$BACKUP_FILE" == cloud:* ]]; then
|
||||
cloud_path="${BACKUP_FILE#cloud:}"
|
||||
log "从云端下载备份..."
|
||||
scp "txjp:$cloud_path" "$RESTORE_DIR/"
|
||||
BACKUP_FILE="$RESTORE_DIR/$(basename $cloud_path)"
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# 3. 解压备份
|
||||
# ============================================================
|
||||
log "解压备份文件..."
|
||||
cd "$RESTORE_DIR"
|
||||
tar xzf "$BACKUP_FILE"
|
||||
|
||||
# 检测备份内容
|
||||
BACKUP_CONTENT="$RESTORE_DIR/full-backup"
|
||||
if [[ ! -d "$BACKUP_CONTENT" ]]; then
|
||||
error "备份文件格式错误"
|
||||
fi
|
||||
|
||||
log "备份内容:"
|
||||
ls -la "$BACKUP_CONTENT/"
|
||||
|
||||
# ============================================================
|
||||
# 4. 选择要恢复的站点
|
||||
# ============================================================
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " 选择要恢复的内容"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo " 1) assets - 资产管理系统"
|
||||
echo " 2) issue - 工单系统"
|
||||
echo " 3) ldap - 用户目录服务"
|
||||
echo " 4) gitea - 代码仓库"
|
||||
echo " 5) nginx - 反向代理配置"
|
||||
echo " 6) 全部恢复"
|
||||
echo ""
|
||||
printf "请选择 (1-6): "
|
||||
read site_choice
|
||||
|
||||
# ============================================================
|
||||
# 5. 执行恢复
|
||||
# ============================================================
|
||||
restore_assets() {
|
||||
log "恢复 assets 站点..."
|
||||
|
||||
# 恢复数据库
|
||||
if [[ -f "$BACKUP_CONTENT/databases/assets.db" ]]; then
|
||||
log "恢复 assets 数据库..."
|
||||
scp "$BACKUP_CONTENT/databases/assets.db" txjp:/tmp/assets.db
|
||||
ssh txjp "docker cp /tmp/assets.db assets-ai:/app/data/assets.db && rm /tmp/assets.db"
|
||||
ssh txjp "docker restart assets-ai"
|
||||
log "assets 数据库恢复完成"
|
||||
fi
|
||||
|
||||
# 恢复配置
|
||||
if [[ -d "$BACKUP_CONTENT/configs/assets-ai" ]]; then
|
||||
log "恢复 assets 配置..."
|
||||
ssh txjp "mkdir -p $REMOTE_BASE/assets-ai"
|
||||
scp "$BACKUP_CONTENT/configs/assets-ai/"* txjp:$REMOTE_BASE/assets-ai/ 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 恢复上传文件
|
||||
if [[ -d "$BACKUP_CONTENT/uploads/assets-uploads" ]]; then
|
||||
log "恢复 assets 上传文件..."
|
||||
ssh txjp "mkdir -p $REMOTE_BASE/assets-ai/data"
|
||||
scp -r "$BACKUP_CONTENT/uploads/assets-uploads/"* txjp:$REMOTE_BASE/assets-ai/data/uploads/ 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
restore_issue() {
|
||||
log "恢复 issue 站点..."
|
||||
|
||||
# 恢复数据库
|
||||
if [[ -f "$BACKUP_CONTENT/databases/issue.db" ]]; then
|
||||
log "恢复 issue 数据库..."
|
||||
scp "$BACKUP_CONTENT/databases/issue.db" txjp:/tmp/issue.db
|
||||
ssh txjp "docker cp /tmp/issue.db issue-ai:/app/data/issue.db && rm /tmp/issue.db"
|
||||
ssh txjp "docker restart issue-ai"
|
||||
log "issue 数据库恢复完成"
|
||||
fi
|
||||
|
||||
# 恢复配置
|
||||
if [[ -d "$BACKUP_CONTENT/configs/issue-ai" ]]; then
|
||||
log "恢复 issue 配置..."
|
||||
ssh txjp "mkdir -p $REMOTE_BASE/issue-ai"
|
||||
scp "$BACKUP_CONTENT/configs/issue-ai/"* txjp:$REMOTE_BASE/issue-ai/ 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 恢复上传文件
|
||||
if [[ -d "$BACKUP_CONTENT/uploads/issue-uploads" ]]; then
|
||||
log "恢复 issue 上传文件..."
|
||||
ssh txjp "mkdir -p $REMOTE_BASE/issue-ai/data"
|
||||
scp -r "$BACKUP_CONTENT/uploads/issue-uploads/"* txjp:$REMOTE_BASE/issue-ai/data/uploads/ 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 恢复报告文件
|
||||
if [[ -d "$BACKUP_CONTENT/uploads/issue-reports" ]]; then
|
||||
log "恢复 issue 报告文件..."
|
||||
ssh txjp "mkdir -p $REMOTE_BASE/issue-ai/data"
|
||||
scp -r "$BACKUP_CONTENT/uploads/issue-reports/"* txjp:$REMOTE_BASE/issue-ai/data/reports/ 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
restore_ldap() {
|
||||
log "恢复 ldap 站点..."
|
||||
|
||||
# 恢复数据库
|
||||
if [[ -f "$BACKUP_CONTENT/databases/ldap.db" ]]; then
|
||||
log "恢复 ldap 数据库..."
|
||||
scp "$BACKUP_CONTENT/databases/ldap.db" txjp:/tmp/ldap.db
|
||||
ssh txjp "docker cp /tmp/ldap.db lldap:/data/users.db && rm /tmp/ldap.db"
|
||||
ssh txjp "docker restart lldap"
|
||||
log "ldap 数据库恢复完成"
|
||||
fi
|
||||
|
||||
# 恢复配置
|
||||
if [[ -d "$BACKUP_CONTENT/configs/ldap-ai" ]]; then
|
||||
log "恢复 ldap 配置..."
|
||||
ssh txjp "mkdir -p $REMOTE_BASE/ldap-ai"
|
||||
scp "$BACKUP_CONTENT/configs/ldap-ai/"* txjp:$REMOTE_BASE/ldap-ai/ 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
restore_gitea() {
|
||||
log "恢复 gitea 站点..."
|
||||
|
||||
# 恢复数据库
|
||||
if [[ -f "$BACKUP_CONTENT/databases/gitea.db" ]]; then
|
||||
log "恢复 gitea 数据库..."
|
||||
scp "$BACKUP_CONTENT/databases/gitea.db" txjp:/tmp/gitea.db
|
||||
ssh txjp "docker cp /tmp/gitea.db gitea-ai:/data/gitea.db && rm /tmp/gitea.db"
|
||||
fi
|
||||
|
||||
# 恢复代码仓库
|
||||
if [[ -d "$BACKUP_CONTENT/gitea/git" ]]; then
|
||||
log "恢复 gitea 代码仓库..."
|
||||
ssh txjp "mkdir -p $REMOTE_BASE/gitea-ai/data"
|
||||
scp -r "$BACKUP_CONTENT/gitea/git" txjp:$REMOTE_BASE/gitea-ai/data/
|
||||
fi
|
||||
|
||||
# 恢复配置
|
||||
if [[ -d "$BACKUP_CONTENT/gitea/conf" ]]; then
|
||||
log "恢复 gitea 配置..."
|
||||
ssh txjp "mkdir -p $REMOTE_BASE/gitea-ai/data/gitea"
|
||||
scp -r "$BACKUP_CONTENT/gitea/conf" txjp:$REMOTE_BASE/gitea-ai/data/gitea/
|
||||
fi
|
||||
|
||||
# 恢复 docker-compose
|
||||
if [[ -d "$BACKUP_CONTENT/configs/gitea-ai" ]]; then
|
||||
log "恢复 gitea docker-compose..."
|
||||
scp "$BACKUP_CONTENT/configs/gitea-ai/"* txjp:$REMOTE_BASE/gitea-ai/ 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 重启容器
|
||||
ssh txjp "cd $REMOTE_BASE/gitea-ai && docker compose down && docker compose up -d"
|
||||
log "gitea 恢复完成"
|
||||
}
|
||||
|
||||
restore_nginx() {
|
||||
log "恢复 nginx 配置..."
|
||||
|
||||
if [[ -d "$BACKUP_CONTENT/nginx" ]]; then
|
||||
ssh txjp "mkdir -p $REMOTE_BASE/nginx-proxy-ai/conf.d"
|
||||
scp "$BACKUP_CONTENT/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 "nginx 配置恢复完成"
|
||||
fi
|
||||
}
|
||||
|
||||
# 执行恢复
|
||||
case "$site_choice" in
|
||||
1) restore_assets ;;
|
||||
2) restore_issue ;;
|
||||
3) restore_ldap ;;
|
||||
4) restore_gitea ;;
|
||||
5) restore_nginx ;;
|
||||
6)
|
||||
restore_assets
|
||||
restore_issue
|
||||
restore_ldap
|
||||
restore_gitea
|
||||
restore_nginx
|
||||
;;
|
||||
*) error "无效选择" ;;
|
||||
esac
|
||||
|
||||
# ============================================================
|
||||
# 6. 清理临时文件
|
||||
# ============================================================
|
||||
log "清理临时文件..."
|
||||
rm -rf "$RESTORE_DIR"
|
||||
|
||||
echo ""
|
||||
log "恢复完成!"
|
||||
|
|
@ -1,367 +0,0 @@
|
|||
#!/bin/bash
|
||||
# 站点恢复脚本 - 支持选择恢复单个站点或全部
|
||||
# 用法:bash restore-sites.sh [备份时间戳]
|
||||
# 无参数则自动检测最新备份
|
||||
|
||||
set -e
|
||||
|
||||
BACKUP_DIR="/Users/niuniu/programs/docker/db-backups"
|
||||
REMOTE_BASE="/root/docker"
|
||||
|
||||
log() { echo "[$(date '+%H:%M:%S')] $1"; }
|
||||
warn() { echo "[$(date '+%H:%M:%S')] [警告] $1"; }
|
||||
error() { echo "[$(date '+%H:%M:%S')] [错误] $1"; exit 1; }
|
||||
|
||||
# ============================================================
|
||||
# 1. 选择备份时间戳
|
||||
# ============================================================
|
||||
if [[ -n "$1" ]]; then
|
||||
TIMESTAMP="$1"
|
||||
else
|
||||
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)
|
||||
|
||||
if [[ -z "$timestamps" ]]; then
|
||||
error "未找到任何备份文件"
|
||||
fi
|
||||
|
||||
index=1
|
||||
while IFS= read -r ts; do
|
||||
echo " $index) $ts"
|
||||
ts_list[$index]="$ts"
|
||||
((index++))
|
||||
done <<< "$timestamps"
|
||||
|
||||
echo ""
|
||||
printf "请选择备份时间戳 (1-$((index-1))): "
|
||||
read choice
|
||||
|
||||
if [[ -z "${ts_list[$choice]}" ]]; then
|
||||
error "无效选择"
|
||||
fi
|
||||
|
||||
TIMESTAMP="${ts_list[$choice]}"
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# 2. 检测可用的备份文件
|
||||
# ============================================================
|
||||
echo ""
|
||||
log "检测备份文件: ${TIMESTAMP}"
|
||||
|
||||
LOCAL_BACKUP="${BACKUP_DIR}"
|
||||
CLOUD_BACKUP="txjp:/root/docker/db-backups"
|
||||
|
||||
# 检查本地备份
|
||||
check_backup() {
|
||||
local site="$1"
|
||||
local found=""
|
||||
|
||||
# 检查本地
|
||||
if [[ -f "${LOCAL_BACKUP}/${site}-${TIMESTAMP}.tar.gz" ]]; then
|
||||
found="local:${LOCAL_BACKUP}/${site}-${TIMESTAMP}.tar.gz"
|
||||
# 检查云端
|
||||
elif ssh txjp "test -f ${CLOUD_BACKUP}/${site}-${TIMESTAMP}.tar.gz" 2>/dev/null; then
|
||||
found="cloud:${site}-${TIMESTAMP}.tar.gz"
|
||||
fi
|
||||
|
||||
echo "$found"
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 3. 选择要恢复的站点
|
||||
# ============================================================
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " 可恢复的站点"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
SITES="shared assets issue ldap gitea oa"
|
||||
index=1
|
||||
declare -A site_status
|
||||
|
||||
for site in $SITES; do
|
||||
backup=$(check_backup "$site")
|
||||
if [[ -n "$backup" ]]; then
|
||||
if [[ "$backup" == local:* ]]; then
|
||||
status="✅ 本地"
|
||||
else
|
||||
status="✅ 云端"
|
||||
fi
|
||||
echo " $index) ${site} ${status}"
|
||||
site_list[$index]="$site"
|
||||
site_source[$index]="$backup"
|
||||
((index++))
|
||||
else
|
||||
echo " $index) ${site} ❌ 未找到"
|
||||
((index++))
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo " $index) 全部恢复"
|
||||
echo ""
|
||||
printf "请选择要恢复的内容 (1-$index): "
|
||||
read choice
|
||||
|
||||
if [[ "$choice" == "$index" ]]; then
|
||||
# 全部恢复
|
||||
restore_list=("${site_list[@]}")
|
||||
else
|
||||
restore_list=("${site_list[$choice]}")
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# 4. 下载并解压备份文件
|
||||
# ============================================================
|
||||
RESTORE_DIR="/tmp/restore-$$"
|
||||
mkdir -p "$RESTORE_DIR"
|
||||
|
||||
download_and_extract() {
|
||||
local site="$1"
|
||||
local source="${site_source[$choice]}"
|
||||
local file_path=""
|
||||
|
||||
if [[ "$source" == local:* ]]; then
|
||||
file_path="${source#local:}"
|
||||
elif [[ "$source" == cloud:* ]]; then
|
||||
local filename="${source#cloud:}"
|
||||
log "从云端下载 ${site} 备份..."
|
||||
scp "${CLOUD_BACKUP}/${filename}" "$RESTORE_DIR/"
|
||||
file_path="$RESTORE_DIR/${filename}"
|
||||
fi
|
||||
|
||||
# 解压
|
||||
cd "$RESTORE_DIR"
|
||||
tar xzf "$file_path"
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 5. 恢复函数
|
||||
# ============================================================
|
||||
restore_shared() {
|
||||
log "恢复共享资源..."
|
||||
|
||||
download_and_extract "shared"
|
||||
|
||||
# 恢复 docker 网络
|
||||
if [[ -f "$RESTORE_DIR/backup-shared/docker-network.sh" ]]; then
|
||||
log "创建 Docker 网络..."
|
||||
bash "$RESTORE_DIR/backup-shared/docker-network.sh"
|
||||
fi
|
||||
|
||||
# 恢复 nginx 配置
|
||||
if [[ -d "$RESTORE_DIR/backup-shared/nginx" ]]; then
|
||||
log "恢复 nginx 配置..."
|
||||
ssh txjp "mkdir -p $REMOTE_BASE/nginx-proxy-ai/conf.d"
|
||||
scp "$RESTORE_DIR/backup-shared/nginx/"* txjp:$REMOTE_BASE/nginx-proxy-ai/conf.d/
|
||||
fi
|
||||
|
||||
# 恢复 SSL 证书
|
||||
if [[ -d "$RESTORE_DIR/backup-shared/ssl" ]]; then
|
||||
log "恢复 SSL 证书..."
|
||||
ssh txjp "cp -a $RESTORE_DIR/backup-shared/ssl/live /etc/letsencrypt/ 2>/dev/null || true"
|
||||
ssh txjp "cp -a $RESTORE_DIR/backup-shared/ssl/archive /etc/letsencrypt/ 2>/dev/null || true"
|
||||
ssh txjp "cp -a $RESTORE_DIR/backup-shared/ssl/renewal /etc/letsencrypt/ 2>/dev/null || true"
|
||||
fi
|
||||
|
||||
# 恢复 nginx 容器配置
|
||||
if [[ -f "$RESTORE_DIR/backup-shared/docker-compose.yml" ]]; then
|
||||
log "恢复 nginx docker-compose..."
|
||||
scp "$RESTORE_DIR/backup-shared/docker-compose.yml" txjp:$REMOTE_BASE/nginx-proxy-ai/
|
||||
fi
|
||||
}
|
||||
|
||||
restore_assets() {
|
||||
log "恢复 assets 站点..."
|
||||
|
||||
download_and_extract "assets"
|
||||
|
||||
# 创建目录
|
||||
ssh txjp "mkdir -p $REMOTE_BASE/assets-ai/data"
|
||||
|
||||
# 恢复数据库
|
||||
if [[ -f "$RESTORE_DIR/backup-assets/data/assets.db" ]]; then
|
||||
log "恢复 assets 数据库..."
|
||||
scp "$RESTORE_DIR/backup-assets/data/assets.db" txjp:/tmp/assets.db
|
||||
ssh txjp "docker cp /tmp/assets.db assets-ai:/app/data/assets.db && rm /tmp/assets.db"
|
||||
fi
|
||||
|
||||
# 恢复上传文件
|
||||
if [[ -d "$RESTORE_DIR/backup-assets/data/uploads" ]]; then
|
||||
log "恢复 assets 上传文件..."
|
||||
scp -r "$RESTORE_DIR/backup-assets/data/uploads/"* txjp:$REMOTE_BASE/assets-ai/data/uploads/ 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 恢复配置文件
|
||||
for f in docker-compose.yml .env Dockerfile; do
|
||||
if [[ -f "$RESTORE_DIR/backup-assets/$f" ]]; then
|
||||
scp "$RESTORE_DIR/backup-assets/$f" txjp:$REMOTE_BASE/assets-ai/
|
||||
fi
|
||||
done
|
||||
|
||||
# 重启容器
|
||||
log "重启 assets 容器..."
|
||||
ssh txjp "cd $REMOTE_BASE/assets-ai && docker compose down 2>/dev/null || true && docker compose up -d"
|
||||
}
|
||||
|
||||
restore_issue() {
|
||||
log "恢复 issue 站点..."
|
||||
|
||||
download_and_extract "issue"
|
||||
|
||||
# 创建目录
|
||||
ssh txjp "mkdir -p $REMOTE_BASE/issue-ai/data"
|
||||
|
||||
# 恢复数据库
|
||||
if [[ -f "$RESTORE_DIR/backup-issue/data/issue.db" ]]; then
|
||||
log "恢复 issue 数据库..."
|
||||
scp "$RESTORE_DIR/backup-issue/data/issue.db" txjp:/tmp/issue.db
|
||||
ssh txjp "docker cp /tmp/issue.db issue-ai:/app/data/issue.db && rm /tmp/issue.db"
|
||||
fi
|
||||
|
||||
# 恢复上传文件
|
||||
if [[ -d "$RESTORE_DIR/backup-issue/data/uploads" ]]; then
|
||||
log "恢复 issue 上传文件..."
|
||||
scp -r "$RESTORE_DIR/backup-issue/data/uploads/"* txjp:$REMOTE_BASE/issue-ai/data/uploads/ 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 恢复报告文件
|
||||
if [[ -d "$RESTORE_DIR/backup-issue/data/reports" ]]; then
|
||||
log "恢复 issue 报告文件..."
|
||||
scp -r "$RESTORE_DIR/backup-issue/data/reports/"* txjp:$REMOTE_BASE/issue-ai/data/reports/ 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 恢复配置文件
|
||||
for f in docker-compose.yml .env Dockerfile; do
|
||||
if [[ -f "$RESTORE_DIR/backup-issue/$f" ]]; then
|
||||
scp "$RESTORE_DIR/backup-issue/$f" txjp:$REMOTE_BASE/issue-ai/
|
||||
fi
|
||||
done
|
||||
|
||||
# 重启容器
|
||||
log "重启 issue 容器..."
|
||||
ssh txjp "cd $REMOTE_BASE/issue-ai && docker compose down 2>/dev/null || true && docker compose up -d"
|
||||
}
|
||||
|
||||
restore_ldap() {
|
||||
log "恢复 ldap 站点..."
|
||||
|
||||
download_and_extract "ldap"
|
||||
|
||||
# 创建目录
|
||||
ssh txjp "mkdir -p $REMOTE_BASE/ldap-ai/data"
|
||||
|
||||
# 恢复数据库
|
||||
if [[ -f "$RESTORE_DIR/backup-ldap/data/users.db" ]]; then
|
||||
log "恢复 ldap 数据库..."
|
||||
scp "$RESTORE_DIR/backup-ldap/data/users.db" txjp:/tmp/ldap.db
|
||||
ssh txjp "docker cp /tmp/ldap.db lldap:/data/users.db && rm /tmp/ldap.db"
|
||||
fi
|
||||
|
||||
# 恢复配置文件
|
||||
for f in docker-compose.yml .env Dockerfile; do
|
||||
if [[ -f "$RESTORE_DIR/backup-ldap/$f" ]]; then
|
||||
scp "$RESTORE_DIR/backup-ldap/$f" txjp:$REMOTE_BASE/ldap-ai/
|
||||
fi
|
||||
done
|
||||
|
||||
# 重启容器
|
||||
log "重启 ldap 容器..."
|
||||
ssh txjp "cd $REMOTE_BASE/ldap-ai && docker compose down 2>/dev/null || true && docker compose up -d"
|
||||
}
|
||||
|
||||
restore_gitea() {
|
||||
log "恢复 gitea 站点..."
|
||||
|
||||
download_and_extract "gitea"
|
||||
|
||||
# 创建目录
|
||||
ssh txjp "mkdir -p $REMOTE_BASE/gitea-ai/data"
|
||||
|
||||
# 恢复数据库
|
||||
if [[ -f "$RESTORE_DIR/backup-gitea/data/gitea.db" ]]; then
|
||||
log "恢复 gitea 数据库..."
|
||||
scp "$RESTORE_DIR/backup-gitea/data/gitea.db" txjp:/tmp/gitea.db
|
||||
ssh txjp "docker cp /tmp/gitea.db gitea-ai:/data/gitea.db && rm /tmp/gitea.db"
|
||||
fi
|
||||
|
||||
# 恢复代码仓库
|
||||
if [[ -d "$RESTORE_DIR/backup-gitea/git" ]]; then
|
||||
log "恢复 gitea 代码仓库..."
|
||||
scp -r "$RESTORE_DIR/backup-gitea/git" txjp:$REMOTE_BASE/gitea-ai/data/
|
||||
fi
|
||||
|
||||
# 恢复配置
|
||||
if [[ -d "$RESTORE_DIR/backup-gitea/gitea-conf" ]]; then
|
||||
log "恢复 gitea 配置..."
|
||||
ssh txjp "mkdir -p $REMOTE_BASE/gitea-ai/data/gitea"
|
||||
scp -r "$RESTORE_DIR/backup-gitea/gitea-conf" txjp:$REMOTE_BASE/gitea-ai/data/gitea/
|
||||
fi
|
||||
|
||||
# 恢复 docker-compose
|
||||
if [[ -f "$RESTORE_DIR/backup-gitea/docker-compose.yml" ]]; then
|
||||
scp "$RESTORE_DIR/backup-gitea/docker-compose.yml" txjp:$REMOTE_BASE/gitea-ai/
|
||||
fi
|
||||
|
||||
# 重启容器
|
||||
log "重启 gitea 容器..."
|
||||
ssh txjp "cd $REMOTE_BASE/gitea-ai && docker compose down 2>/dev/null || true && docker compose up -d"
|
||||
}
|
||||
|
||||
restore_oa() {
|
||||
log "恢复 oa 站点..."
|
||||
|
||||
download_and_extract "oa"
|
||||
|
||||
# 恢复配置文件
|
||||
for f in docker-compose.yml .env; do
|
||||
if [[ -f "$RESTORE_DIR/backup-oa/$f" ]]; then
|
||||
scp "$RESTORE_DIR/backup-oa/$f" txjp:$REMOTE_BASE/oa-ai/
|
||||
fi
|
||||
done
|
||||
|
||||
# 重启容器
|
||||
log "重启 oa 容器..."
|
||||
ssh txjp "cd $REMOTE_BASE/oa-ai && docker compose down 2>/dev/null || true && docker compose up -d"
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 6. 执行恢复
|
||||
# ============================================================
|
||||
for site in "${restore_list[@]}"; do
|
||||
case "$site" in
|
||||
shared) restore_shared ;;
|
||||
assets) restore_assets ;;
|
||||
issue) restore_issue ;;
|
||||
ldap) restore_ldap ;;
|
||||
gitea) restore_gitea ;;
|
||||
oa) restore_oa ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ============================================================
|
||||
# 7. 重载 nginx
|
||||
# ============================================================
|
||||
log "重载 nginx 配置..."
|
||||
ssh txjp "docker exec nginx-ai nginx -t && docker exec nginx-ai nginx -s reload" 2>/dev/null || warn "nginx 重载失败,请手动检查"
|
||||
|
||||
# ============================================================
|
||||
# 8. 清理临时文件
|
||||
# ============================================================
|
||||
log "清理临时文件..."
|
||||
rm -rf "$RESTORE_DIR"
|
||||
|
||||
# ============================================================
|
||||
# 9. 验证恢复结果
|
||||
# ============================================================
|
||||
echo ""
|
||||
log "恢复完成!验证容器状态..."
|
||||
ssh txjp "docker ps --format 'table {{.Names}}\t{{.Status}}' | grep -E '(assets|issue|ldap|gitea|oa|nginx)'"
|
||||
|
|
@ -0,0 +1,264 @@
|
|||
#!/bin/bash
|
||||
# 统一恢复脚本 — 支持远程/本地模式
|
||||
# 用法:
|
||||
# bash restore.sh # 交互式站点恢复(默认)
|
||||
# bash restore.sh 20260708_120000 # 指定时间戳
|
||||
# bash restore.sh --db issue issue.db # 数据库恢复
|
||||
# bash restore.sh --full backup.tar.gz # 灾难恢复
|
||||
# bash restore.sh --local # 本地模式(直接在服务器上运行)
|
||||
set -e
|
||||
|
||||
BACKUP_DIR="/Users/niuniu/programs/docker/db-backups"
|
||||
REMOTE_BASE="/root/docker"
|
||||
MODE="remote"
|
||||
|
||||
log() { echo "[$(date '+%H:%M:%S')] $1"; }
|
||||
warn() { echo "[$(date '+%H:%M:%S')] [警告] $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)"
|
||||
|
||||
# === 自动发现可恢复站点 ===
|
||||
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
ALL_SITES="shared"
|
||||
for dir in "$PROJECT_ROOT"/*-ai/; do
|
||||
s=$(basename "$dir"); [[ "$s" == "nginx-proxy-ai" ]] && continue
|
||||
[ -f "$dir/docker-compose.yml" ] || continue
|
||||
s="${s%-ai}"; ALL_SITES="$ALL_SITES $s"
|
||||
done
|
||||
|
||||
# === 工具函数 ===
|
||||
check_backup() {
|
||||
local site="$1" ts="$2"
|
||||
[[ -f "${BACKUP_DIR}/${site}-${ts}.tar.gz" ]] && echo "local:${BACKUP_DIR}/${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 ""
|
||||
}
|
||||
|
||||
download_and_extract() {
|
||||
local site="$1" ts="$2"
|
||||
local bk=$(check_backup "$site" "$ts")
|
||||
[[ -z "$bk" ]] && error "${site} 备份文件不存在: ${ts}"
|
||||
|
||||
rm -rf "$RESTORE_DIR/backup-${site}"; mkdir -p "$RESTORE_DIR/backup-${site}"
|
||||
|
||||
if [[ "$bk" == local:* ]]; then
|
||||
log "从本地解压: ${BACKUP_DIR}/${site}-${ts}.tar.gz"
|
||||
tar xzf "${BACKUP_DIR}/${site}-${ts}.tar.gz" -C "$RESTORE_DIR/backup-${site}"
|
||||
else
|
||||
log "从服务器下载: ${ts}"
|
||||
fetch "/root/docker/db-backups/${site}-${ts}.tar.gz" "$RESTORE_DIR/"
|
||||
tar xzf "$RESTORE_DIR/${site}-${ts}.tar.gz" -C "$RESTORE_DIR/backup-${site}"
|
||||
fi
|
||||
}
|
||||
|
||||
restore_src() {
|
||||
local src="${RESTORE_DIR}/backup-$1/backup-$1"
|
||||
[[ -d "$src" ]] && echo "$src" || echo "${RESTORE_DIR}/backup-$1"
|
||||
}
|
||||
|
||||
# === 站点恢复函数 ===
|
||||
restore_shared() {
|
||||
local src=$(restore_src "shared")
|
||||
[[ -f "$src/nginx"/*.conf ]] && { mkdir -p "$src/nginx"; upload "$src/nginx/"* "$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 重载失败"
|
||||
log "共享配置恢复完成"
|
||||
}
|
||||
|
||||
restore_assets() {
|
||||
log "恢复 assets..."; download_and_extract "assets" "$TIMESTAMP"
|
||||
local src=$(restore_src "assets")
|
||||
for f in docker-compose.yml .env Dockerfile; do [[ -f "$src/$f" ]] && upload "$src/$f" "$REMOTE_BASE/assets-ai/"; done
|
||||
[[ -d "$src/data/uploads" ]] && runcmd "cp -a $src/data/uploads /root/docker/assets-ai/data/"
|
||||
[[ -f "$src/data/assets.db" ]] && runcmd "docker cp $src/data/assets.db assets-ai:/app/data/assets.db"
|
||||
runcmd "cd $REMOTE_BASE/assets-ai && docker compose down 2>/dev/null || true && docker compose up -d"
|
||||
log "assets 恢复完成"
|
||||
}
|
||||
|
||||
restore_issue() {
|
||||
log "恢复 issue..."; download_and_extract "issue" "$TIMESTAMP"
|
||||
local src=$(restore_src "issue")
|
||||
for f in docker-compose.yml .env Dockerfile; do [[ -f "$src/$f" ]] && upload "$src/$f" "$REMOTE_BASE/issue-ai/"; done
|
||||
[[ -d "$src/data/uploads" ]] && runcmd "cp -a $src/data/uploads /root/docker/issue-ai/data/"
|
||||
[[ -d "$src/data/reports" ]] && runcmd "cp -a $src/data/reports /root/docker/issue-ai/data/"
|
||||
[[ -f "$src/data/issue.db" ]] && runcmd "docker cp $src/data/issue.db issue-ai:/app/data/issue.db"
|
||||
runcmd "cd $REMOTE_BASE/issue-ai && docker compose down 2>/dev/null || true && docker compose up -d"
|
||||
log "issue 恢复完成"
|
||||
}
|
||||
|
||||
restore_ldap() {
|
||||
log "恢复 ldap..."; download_and_extract "ldap" "$TIMESTAMP"
|
||||
local src=$(restore_src "ldap")
|
||||
for f in docker-compose.yml .env Dockerfile; do [[ -f "$src/$f" ]] && upload "$src/$f" "$REMOTE_BASE/ldap-ai/"; done
|
||||
[[ -f "$src/data/users.db" ]] && runcmd "docker cp $src/data/users.db lldap:/data/users.db"
|
||||
runcmd "cd $REMOTE_BASE/ldap-ai && docker compose down 2>/dev/null || true && docker compose up -d"
|
||||
log "ldap 恢复完成"
|
||||
}
|
||||
|
||||
restore_gitea() {
|
||||
log "恢复 gitea..."; download_and_extract "gitea" "$TIMESTAMP"
|
||||
local src=$(restore_src "gitea")
|
||||
[[ -f "$src/docker-compose.yml" ]] && upload "$src/docker-compose.yml" "$REMOTE_BASE/gitea-ai/"
|
||||
[[ -f "$src/data/gitea.db" ]] && runcmd "docker cp $src/data/gitea.db gitea-ai:/data/gitea.db"
|
||||
runcmd "cd $REMOTE_BASE/gitea-ai && docker compose down && docker compose up -d"
|
||||
log "gitea 恢复完成"
|
||||
}
|
||||
|
||||
restore_oa() {
|
||||
log "恢复 oa..."; download_and_extract "oa" "$TIMESTAMP"
|
||||
local src=$(restore_src "oa")
|
||||
for f in docker-compose.yml .env; do [[ -f "$src/$f" ]] && upload "$src/$f" "$REMOTE_BASE/oa-ai/"; done
|
||||
runcmd "cd $REMOTE_BASE/oa-ai && docker compose down 2>/dev/null || true && docker compose up -d"
|
||||
log "oa 恢复完成"
|
||||
}
|
||||
|
||||
restore_monitor() {
|
||||
log "恢复 monitor..."; download_and_extract "monitor" "$TIMESTAMP"
|
||||
local src=$(restore_src "monitor")
|
||||
for f in docker-compose.yml .env; do [[ -f "$src/$f" ]] && upload "$src/$f" "$REMOTE_BASE/monitor-ai/"; done
|
||||
[[ -f "$src/data/monitor.db" ]] && runcmd "docker cp $src/data/monitor.db monitor-ai:/app/data/monitor.db"
|
||||
runcmd "cd $REMOTE_BASE/monitor-ai && docker compose down 2>/dev/null || true && docker compose up -d --build"
|
||||
log "monitor 恢复完成"
|
||||
}
|
||||
|
||||
restore_generic() {
|
||||
local site="$1"; log "恢复 ${site}(通用模式)..."
|
||||
download_and_extract "${site}" "$TIMESTAMP"
|
||||
local src=$(restore_src "${site}")
|
||||
for f in docker-compose.yml .env Dockerfile; do [[ -f "$src/$f" ]] && upload "$src/$f" "$REMOTE_BASE/${site}-ai/"; done
|
||||
runcmd "cd $REMOTE_BASE/${site}-ai && docker compose down 2>/dev/null || true && docker compose up -d" 2>/dev/null || warn "${site} 容器重启失败"
|
||||
}
|
||||
|
||||
# === 模式 2:数据库恢复 ===
|
||||
restore_db() {
|
||||
local service="$1" file="$2"
|
||||
[[ -z "$service" || -z "$file" ]] && error "用法: bash restore.sh --db <assets|issue|ldap|gitea|monitor> <文件名>"
|
||||
|
||||
local container="" dbpath="" project=""
|
||||
case "$service" in
|
||||
assets) project="assets-ai"; container="assets-ai"; dbpath="/app/data/assets.db" ;;
|
||||
issue) project="issue-ai"; container="issue-ai"; dbpath="/app/data/issue.db" ;;
|
||||
ldap) project="ldap-ai"; container="lldap"; dbpath="/data/users.db" ;;
|
||||
gitea) project="gitea-ai"; container="gitea-ai"; dbpath="/data/gitea.db" ;;
|
||||
monitor) project="monitor-ai"; container="monitor-ai"; dbpath="/app/data/monitor.db" ;;
|
||||
*) error "服务名必须是: assets, issue, ldap, gitea, monitor" ;;
|
||||
esac
|
||||
|
||||
local src="$BACKUP_DIR/$file"; [[ ! -f "$src" ]] && src="$file"
|
||||
log "恢复 ${service} 数据库: ${src}..."
|
||||
upload "$src" "/tmp/db-restore.db"
|
||||
runcmd "docker cp /tmp/db-restore.db ${container}:${dbpath} && rm /tmp/db-restore.db"
|
||||
runcmd "cd $REMOTE_BASE/$project && docker compose restart" 2>/dev/null || true
|
||||
log "${service} 数据库恢复完成"
|
||||
}
|
||||
|
||||
# === 模式 3:灾难恢复 ===
|
||||
restore_full() {
|
||||
local file="$1"; [[ -z "$file" ]] && error "用法: bash restore.sh --full <backup.tar.gz>"
|
||||
local src="$BACKUP_DIR/$file"; [[ ! -f "$src" ]] && src="$file"
|
||||
[[ ! -f "$src" ]] && error "备份文件不存在: $file"
|
||||
|
||||
log "完整恢复: $file (模式: ${MODE})"
|
||||
rm -rf "$RESTORE_DIR/full"; mkdir -p "$RESTORE_DIR/full"
|
||||
tar xzf "$src" -C "$RESTORE_DIR/full"
|
||||
local content="$RESTORE_DIR/full/full-backup"
|
||||
|
||||
echo ""; echo " 1) assets 2) issue 3) ldap 4) gitea"
|
||||
echo " 5) monitor 6) nginx 7) 全部恢复"
|
||||
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
|
||||
1) fdb assets assets-ai; runcmd "cd $REMOTE_BASE/assets-ai && docker compose down && docker compose up -d" ;;
|
||||
2) fdb issue issue-ai; runcmd "cd $REMOTE_BASE/issue-ai && docker compose down && docker compose up -d" ;;
|
||||
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) fdb gitea gitea-ai; runcmd "cd $REMOTE_BASE/gitea-ai && docker compose down && docker compose up -d" ;;
|
||||
5) fdb monitor monitor-ai; runcmd "cd $REMOTE_BASE/monitor-ai && docker compose down && docker compose up -d --build" ;;
|
||||
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) 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 "无效选择" ;;
|
||||
esac
|
||||
log "灾难恢复完成"
|
||||
}
|
||||
|
||||
# === 主流程 ===
|
||||
case "$ACTION" in
|
||||
help)
|
||||
echo "用法: bash restore.sh [--local] [--db|--full] [参数...]"
|
||||
echo " --local 本地模式(直接在服务器上运行)"
|
||||
echo " 无参数 交互式站点恢复"
|
||||
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]:-}"
|
||||
if [[ -z "$TIMESTAMP" ]]; then
|
||||
echo ""; echo "可用的备份时间戳:"; echo ""
|
||||
timestamps=$(ls ${BACKUP_DIR}/assets-*.tar.gz 2>/dev/null | sed 's/.*assets-\(.*\)\.tar\.gz/\1/' | sort -r | head -10)
|
||||
[[ -z "$timestamps" ]] && error "未找到任何备份文件"
|
||||
declare -A ts_list; index=1
|
||||
while IFS= read -r ts; do echo " $index) $ts"; ts_list[$index]="$ts"; ((index++)); done <<< "$timestamps"
|
||||
echo ""; printf "请选择 (1-$((index-1))): "; read choice
|
||||
TIMESTAMP="${ts_list[$choice]}"
|
||||
[[ -z "$TIMESTAMP" ]] && error "无效选择"
|
||||
fi
|
||||
|
||||
echo ""; echo "可恢复的站点:"; echo ""
|
||||
declare -A site_list; index=1
|
||||
for site in $ALL_SITES; do
|
||||
bk=$(check_backup "$site" "$TIMESTAMP")
|
||||
[[ -n "$bk" ]] && echo " $index) $site" && site_list[$index]="$site" || echo " -) $site (无备份)"
|
||||
((index++))
|
||||
done
|
||||
echo ""; echo " a) 全部恢复"; printf "请选择 (1-$((index-1)) 或 a): "; read s_choice
|
||||
|
||||
declare -a restore_list
|
||||
if [[ "$s_choice" == "a" ]]; then
|
||||
for idx in "${!site_list[@]}"; do restore_list+=("${site_list[$idx]}"); done
|
||||
else
|
||||
restore_list=("${site_list[$s_choice]}")
|
||||
[[ -z "${restore_list[0]}" ]] && error "无效选择或无备份"
|
||||
fi
|
||||
|
||||
for site in "${restore_list[@]}"; do
|
||||
case "$site" in
|
||||
shared) restore_shared ;;
|
||||
assets) restore_assets ;;
|
||||
issue) restore_issue ;;
|
||||
ldap) restore_ldap ;;
|
||||
gitea) restore_gitea ;;
|
||||
oa) restore_oa ;;
|
||||
monitor) restore_monitor ;;
|
||||
*) restore_generic "$site" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
runcmd "docker exec nginx-ai nginx -t && docker exec nginx-ai nginx -s reload" 2>/dev/null || true
|
||||
rm -rf "$RESTORE_DIR"
|
||||
echo ""; log "恢复完成!"
|
||||
runcmd "docker ps --format 'table {{.Names}}\t{{.Status}}' | grep -E '(assets|issue|ldap|gitea|oa|monitor|nginx|sso)'"
|
||||
;;
|
||||
esac
|
||||
|
|
@ -0,0 +1,287 @@
|
|||
#!/usr/bin/env bash
|
||||
# check-compliance.sh — 部署前合规检查(对照 CLAUDE.md + LESSONS-LEARNED.md)
|
||||
# 用法: bash scripts/check-compliance.sh [--strict]
|
||||
# 退出码: 0=通过, 1=违规
|
||||
|
||||
set -o pipefail
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||||
PASS=0; WARN=0; FAIL=0
|
||||
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
ok() { echo -e " ${GREEN}[✓]${NC} $1"; ((PASS++)); }
|
||||
warn() { echo -e " ${YELLOW}[!]${NC} $1"; ((WARN++)); }
|
||||
err() { echo -e " ${RED}[✗]${NC} $1"; ((FAIL++)); }
|
||||
|
||||
echo "========================================="
|
||||
echo " 部署前合规检查"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
|
||||
# ============================================================
|
||||
# D1: healthcheck 禁止用 curl
|
||||
# ============================================================
|
||||
echo "--- D1: healthcheck 禁止 curl ---"
|
||||
for f in "$PROJECT_ROOT"/*/docker-compose.yml; do
|
||||
site=$(basename "$(dirname "$f")")
|
||||
if grep -A2 'healthcheck' "$f" 2>/dev/null | grep -q 'curl'; then
|
||||
err "$site: healthcheck 使用了 curl(必须用 wget --spider 或 node fetch)"
|
||||
else
|
||||
ok "$site"
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
|
||||
# ============================================================
|
||||
# D2: 含数据库的站点容器必须添加 healthcheck
|
||||
# ============================================================
|
||||
echo "--- D2: 含数据库容器 healthcheck ---"
|
||||
DB_SITES="issue-ai assets-ai oa-ai monitor-ai gitea-ai"
|
||||
for site in $DB_SITES; do
|
||||
f="$PROJECT_ROOT/$site/docker-compose.yml"
|
||||
[ ! -f "$f" ] && continue
|
||||
if grep -q 'healthcheck' "$f"; then
|
||||
ok "$site"
|
||||
else
|
||||
err "$site: 含数据库但缺少 healthcheck"
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
|
||||
# ============================================================
|
||||
# D3: 禁止 entrypoint 延迟构建
|
||||
# ============================================================
|
||||
echo "--- D3: 禁止 entrypoint 延迟构建 ---"
|
||||
# 仅检查 entrypoint.sh(Dockerfile 中 builder 阶段的 npm run build 是合法的多阶段构建)
|
||||
for f in "$PROJECT_ROOT"/*/entrypoint.sh; do
|
||||
[ ! -f "$f" ] && continue
|
||||
site=$(echo "$f" | sed 's|.*/\([^/]*\)/.*|\1|')
|
||||
if grep -q 'npm run build\|npm run dev\|next build\|next dev' "$f" 2>/dev/null; then
|
||||
err "$site: entrypoint.sh 中包含构建命令"
|
||||
fi
|
||||
done
|
||||
ok "所有 entrypoint.sh 不含构建命令"
|
||||
echo ""
|
||||
|
||||
# ============================================================
|
||||
# D4: 必须使用 docker compose V2(禁止 docker-compose)
|
||||
# ============================================================
|
||||
echo "--- D4: docker compose V2 ---"
|
||||
if grep -rn 'docker-compose ' "$PROJECT_ROOT/scripts/" --include='*.sh' 2>/dev/null | grep -v '^[[:space:]]*#' | grep -v 'docker compose' | grep -q .; then
|
||||
err "脚本中存在 docker-compose(V1),应使用 docker compose(V2)"
|
||||
else
|
||||
ok "所有脚本使用 docker compose V2"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ============================================================
|
||||
# 时区规范: 禁止 Date.toISOString() 格式化本地日期
|
||||
# ============================================================
|
||||
echo "--- 时区: 禁止 Date.toISOString() ---"
|
||||
# 仅标记直接 toISOString()(无 UTC+8 偏移的用法),允许 +8h 偏移模式
|
||||
VIOLATIONS=$(grep -rn 'new Date()\.toISOString()\|new Date\.toISOString()' "$PROJECT_ROOT"/*/src --include='*.ts' --include='*.tsx' 2>/dev/null | grep -v node_modules | grep -v 'shared/')
|
||||
if [ -n "$VIOLATIONS" ]; then
|
||||
echo "$VIOLATIONS" | while IFS= read -r line; do
|
||||
err "$line"
|
||||
done
|
||||
else
|
||||
ok "所有站点未使用裸 Date.toISOString()"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ============================================================
|
||||
# 时区规范: SQLite datetime('now') 必须带 +8 hours
|
||||
# ============================================================
|
||||
echo "--- 时区: SQLite datetime('now') → +8 hours ---"
|
||||
VIOLATIONS=$(grep -rn "datetime('now')" "$PROJECT_ROOT"/*/src --include='*.ts' --include='*.tsx' 2>/dev/null | grep -v "+8 hours" | grep -v node_modules | grep -v shared)
|
||||
if [ -n "$VIOLATIONS" ]; then
|
||||
echo "$VIOLATIONS" | while IFS= read -r line; do
|
||||
err "$line"
|
||||
done
|
||||
else
|
||||
ok "所有 SQLite datetime('now') 均带 +8 hours"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ============================================================
|
||||
# JWT_SECRET + COOKIE_DOMAIN 在所有站点一致
|
||||
# ============================================================
|
||||
echo "--- JWT_SECRET + COOKIE_DOMAIN 一致性 ---"
|
||||
JWT_VALUES=""
|
||||
COOKIE_VALUES=""
|
||||
for f in "$PROJECT_ROOT"/*/docker-compose.yml; do
|
||||
site=$(basename "$(dirname "$f")")
|
||||
jwt=$(grep -o "JWT_SECRET=\${JWT_SECRET:-[^}]*}\|JWT_SECRET=[^ ]*" "$f" 2>/dev/null | head -1)
|
||||
cookie=$(grep -o "COOKIE_DOMAIN=[^ ]*" "$f" 2>/dev/null | head -1)
|
||||
[ -n "$jwt" ] && JWT_VALUES="$JWT_VALUES\n$site: $jwt"
|
||||
[ -n "$cookie" ] && COOKIE_VALUES="$COOKIE_VALUES\n$site: $cookie"
|
||||
done
|
||||
echo -e "$JWT_VALUES" | sed '/^$/d'
|
||||
echo -e "$COOKIE_VALUES" | sed '/^$/d'
|
||||
# Check consistency
|
||||
jwt_count=$(echo -e "$JWT_VALUES" | grep -o 'JWT_SECRET=[^ ]*' | sort -u | wc -l)
|
||||
cookie_count=$(echo -e "$COOKIE_VALUES" | grep -o 'COOKIE_DOMAIN=[^ ]*' | sort -u | wc -l)
|
||||
[ "$jwt_count" -le 1 ] && ok "JWT_SECRET 一致" || err "JWT_SECRET 不一致($jwt_count 个不同值)"
|
||||
[ "$cookie_count" -le 1 ] && ok "COOKIE_DOMAIN 一致" || err "COOKIE_DOMAIN 不一致($cookie_count 个不同值)"
|
||||
echo ""
|
||||
|
||||
# ============================================================
|
||||
# LESSONS-LEARNED #24: cookie domain 必须设置
|
||||
# ============================================================
|
||||
echo "--- LESSONS-LEARNED #24: cookie domain ---"
|
||||
for f in "$PROJECT_ROOT"/*/docker-compose.yml; do
|
||||
site=$(basename "$(dirname "$f")")
|
||||
if grep -q 'COOKIE_DOMAIN' "$f"; then
|
||||
ok "$site"
|
||||
else
|
||||
warn "$site: docker-compose.yml 未设置 COOKIE_DOMAIN"
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
|
||||
# ============================================================
|
||||
# LESSONS-LEARNED #30: globals.css 含 @source "../../shared"
|
||||
# ============================================================
|
||||
echo "--- LESSONS-LEARNED #30: globals.css @source ---"
|
||||
for f in "$PROJECT_ROOT"/issue-ai/src/app/globals.css "$PROJECT_ROOT"/assets-ai/src/app/globals.css "$PROJECT_ROOT"/oa-ai/src/app/globals.css "$PROJECT_ROOT"/monitor-ai/src/app/globals.css; do
|
||||
[ ! -f "$f" ] && continue
|
||||
site=$(echo "$f" | sed 's|.*/\([^/]*\)/src/.*|\1|')
|
||||
if grep -q '@source.*shared' "$f" 2>/dev/null; then
|
||||
ok "$site"
|
||||
else
|
||||
warn "$site: globals.css 缺少 @source \"../../shared\""
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
|
||||
# ============================================================
|
||||
# LESSONS-LEARNED #35: outputFileTracingIncludes
|
||||
# ============================================================
|
||||
echo "--- LESSONS-LEARNED #35: outputFileTracingIncludes ---"
|
||||
for f in "$PROJECT_ROOT"/issue-ai/next.config.ts "$PROJECT_ROOT"/assets-ai/next.config.ts "$PROJECT_ROOT"/oa-ai/next.config.ts "$PROJECT_ROOT"/monitor-ai/next.config.ts; do
|
||||
[ ! -f "$f" ] && continue
|
||||
site=$(echo "$f" | sed 's|.*/\([^/]*\)/next.*|\1|')
|
||||
if grep -q 'outputFileTracingIncludes' "$f" 2>/dev/null; then
|
||||
ok "$site"
|
||||
else
|
||||
warn "$site: next.config.ts 缺少 outputFileTracingIncludes"
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
|
||||
# ============================================================
|
||||
# LESSONS-LEARNED #40: sso cert 使用 Let's Encrypt
|
||||
# ============================================================
|
||||
echo "--- LESSONS-LEARNED #40: sso nginx cert ---"
|
||||
SSO_CONF="$PROJECT_ROOT/nginx-proxy-ai/conf.d/sso-ai.conf"
|
||||
if [ -f "$SSO_CONF" ]; then
|
||||
if grep -q 'www.tlyq.ai-0001\|letsencrypt' "$SSO_CONF" 2>/dev/null; then
|
||||
ok "sso-ai.conf 使用 Let's Encrypt 证书"
|
||||
else
|
||||
err "sso-ai.conf 未使用 Let's Encrypt 证书(LESSONS-LEARNED #40)"
|
||||
fi
|
||||
else
|
||||
warn "sso-ai.conf 不存在"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ============================================================
|
||||
# LESSONS-LEARNED #37: 禁用 request.url
|
||||
# ============================================================
|
||||
echo "--- LESSONS-LEARNED #37: 禁用 request.url ---"
|
||||
# 仅检查 V2 middleware(旧 middleware.ts 保持向后兼容,迁移后删除)
|
||||
VIOLATIONS=$(grep -rn 'request\.url' "$PROJECT_ROOT/shared/lib/auth/middleware-v2.ts" 2>/dev/null | grep -v '\.origin\|node_modules')
|
||||
if [ -n "$VIOLATIONS" ]; then
|
||||
echo "$VIOLATIONS" | while IFS= read -r line; do
|
||||
err "$line"
|
||||
done
|
||||
else
|
||||
ok "middleware-v2 未直接使用 request.url"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ============================================================
|
||||
# LESSONS-LEARNED #41: 容器内禁止 docker exec 获取凭据
|
||||
# ============================================================
|
||||
echo "--- LESSONS-LEARNED #41: docker exec in container ---"
|
||||
VIOLATIONS=$(grep -rn "execFileSync.*docker.*exec\|execSync.*docker.*exec\|exec('docker'" "$PROJECT_ROOT"/*/src --include='*.ts' --include='*.tsx' 2>/dev/null | grep -v node_modules)
|
||||
if [ -n "$VIOLATIONS" ]; then
|
||||
echo "$VIOLATIONS" | while IFS= read -r line; do
|
||||
warn "$line (容器内可能无 docker CLI,见 LESSONS-LEARNED #41)"
|
||||
done
|
||||
else
|
||||
ok "无 docker exec 依赖"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ============================================================
|
||||
# OA: LLDAP_ADMIN_PASSWORD 环境变量注入
|
||||
# ============================================================
|
||||
echo "--- OA: LLDAP_ADMIN_PASSWORD ---"
|
||||
if grep -q 'LLDAP_ADMIN_PASSWORD' "$PROJECT_ROOT/oa-ai/docker-compose.yml" 2>/dev/null; then
|
||||
ok "oa-ai docker-compose.yml 含 LLDAP_ADMIN_PASSWORD"
|
||||
else
|
||||
err "oa-ai docker-compose.yml 缺少 LLDAP_ADMIN_PASSWORD(LESSONS-LEARNED #41)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ============================================================
|
||||
# D8 + LESSONS-LEARNED #48: OIDC token_endpoint_auth_method 一致性
|
||||
# ============================================================
|
||||
echo "--- D8: OIDC token_endpoint_auth_method 一致性 ---"
|
||||
SSO_CONFIG="$PROJECT_ROOT/sso-ai/config/authelia/configuration.yml"
|
||||
if [ -f "$SSO_CONFIG" ]; then
|
||||
# 列出每个 OIDC client 的 token_endpoint_auth_method
|
||||
while IFS= read -r line; do
|
||||
if echo "$line" | grep -q 'client_id:'; then
|
||||
current_client=$(echo "$line" | sed "s/.*client_id: *'\(.*\)'/\1/" | sed 's/.*client_id: *"\(.*\)"/\1/' | sed 's/.*client_id: *//' | tr -d "'\"")
|
||||
fi
|
||||
if echo "$line" | grep -q 'token_endpoint_auth_method:'; then
|
||||
method=$(echo "$line" | grep -o "client_secret_[a-z]*")
|
||||
if [ "$method" = "client_secret_post" ]; then
|
||||
ok "$current_client: $method"
|
||||
elif [ "$method" = "client_secret_basic" ]; then
|
||||
err "$current_client: $method(应为 client_secret_post,见 LESSONS-LEARNED #48)"
|
||||
fi
|
||||
current_client=""
|
||||
fi
|
||||
done < "$SSO_CONFIG"
|
||||
# 检查是否有 OIDC client 完全没有 token_endpoint_auth_method(Authelia 默认 client_secret_basic)
|
||||
# 注意:在 YAML 中 token_endpoint_auth_method 可能出现在任意位置,需读完整个 client 块
|
||||
unset_client=""
|
||||
prev_client=""
|
||||
while IFS= read -r line; do
|
||||
# 遇到新 client_id → 结算上一个
|
||||
if echo "$line" | grep -q '^[[:space:]]*-[[:space:]]*client_id:'; then
|
||||
if [ -n "$prev_client" ] && [ "$prev_has_method" -eq 0 ]; then
|
||||
err "$prev_client: 未设置 token_endpoint_auth_method(默认 client_secret_basic → 与代码 client_secret_post 不匹配)"
|
||||
fi
|
||||
prev_client=$(echo "$line" | sed "s/.*client_id: *'\(.*\)'/\1/" | sed 's/.*client_id: *"\(.*\)"/\1/' | sed 's/.*client_id: *//' | tr -d "'\"")
|
||||
prev_has_method=0
|
||||
fi
|
||||
if echo "$line" | grep -q 'token_endpoint_auth_method:'; then
|
||||
prev_has_method=1
|
||||
fi
|
||||
done < "$SSO_CONFIG"
|
||||
# 结算最后一个 client
|
||||
if [ -n "$prev_client" ] && [ "$prev_has_method" -eq 0 ]; then
|
||||
err "$prev_client: 未设置 token_endpoint_auth_method(默认 client_secret_basic → 与代码 client_secret_post 不匹配)"
|
||||
fi
|
||||
else
|
||||
warn "sso-ai/config/authelia/configuration.yml 不存在"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ============================================================
|
||||
# 结果
|
||||
# ============================================================
|
||||
echo "========================================="
|
||||
echo -e " 通过: ${GREEN}${PASS}${NC} 警告: ${YELLOW}${WARN}${NC} 违规: ${RED}${FAIL}${NC}"
|
||||
echo "========================================="
|
||||
|
||||
if [ "$FAIL" -gt 0 ]; then
|
||||
echo -e "${RED}存在 ${FAIL} 项违规,部署前必须修复${NC}"
|
||||
exit 1
|
||||
elif [ "$WARN" -gt 0 ]; then
|
||||
echo -e "${YELLOW}存在 ${WARN} 项警告,建议修复后部署${NC}"
|
||||
fi
|
||||
exit 0
|
||||
|
|
@ -43,7 +43,7 @@ bash scripts/deploy-ai.sh [选项]
|
|||
5. **依赖安装**:服务器 `npm install --prefer-offline`
|
||||
6. **环境变量**:写入生产环境所需的环境变量到 `.env`
|
||||
7. **构建**:服务器 `npm run build`
|
||||
8. **清理**:删除 Alpine musl 残留(sharp、swc)
|
||||
8. **清理**:删除 Alpine musl 残留(sharp、swc),清理 Docker 构建缓存(调用 `docker-cache-cleanup.sh`)
|
||||
9. **重启容器**:`docker compose up -d` + `docker compose restart`
|
||||
10. **快照保存**:保存本次源码 MD5 用于下次比对
|
||||
|
||||
|
|
|
|||
808
deploy-ai.sh
808
deploy-ai.sh
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,200 @@
|
|||
#!/bin/bash
|
||||
# Docker 缓存清理脚本
|
||||
# 用法:
|
||||
# ./docker-cache-cleanup.sh # 默认清理(保留 3 小时内的缓存)
|
||||
# ./docker-cache-cleanup.sh --aggressive # 激进清理(保留 1 小时内)
|
||||
# ./docker-cache-cleanup.sh --conservative # 保守清理(保留 6 小时内)
|
||||
# ./docker-cache-cleanup.sh --full # 完全清理(删除所有缓存)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ============================================================
|
||||
# 配置
|
||||
# ============================================================
|
||||
REMOTE_HOST="${REMOTE_HOST:-txjp}"
|
||||
CLEANUP_MODE="${1:---default}"
|
||||
RETENTION_HOURS=3 # 默认保留 3 小时
|
||||
|
||||
# 生产环境服务器 IP 列表(用于判断是否在服务器本地运行)
|
||||
# 新增服务器时,只需在此添加 IP 地址即可
|
||||
PRODUCTION_SERVER_IPS=(
|
||||
"43.133.38.210" # txjp (腾讯云北京)
|
||||
# "159.75.83.209" # tgz (腾讯云广州) - 如需添加
|
||||
# "10.0.0.1" # 其他服务器 - 如需添加
|
||||
)
|
||||
|
||||
# 检测运行环境:
|
||||
# 通过 curl ip.sb 获取当前公网 IP,与生产环境服务器 IP 列表匹配
|
||||
IS_LOCAL=false
|
||||
|
||||
# 获取当前公网 IP
|
||||
CURRENT_IP=$(curl -s --connect-timeout 5 ip.sb 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$CURRENT_IP" ]; then
|
||||
# 遍历生产环境服务器 IP 列表,检查是否匹配
|
||||
for SERVER_IP in "${PRODUCTION_SERVER_IPS[@]}"; do
|
||||
if [ "$CURRENT_IP" = "$SERVER_IP" ]; then
|
||||
IS_LOCAL=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# 如果是本地运行,不需要 SSH
|
||||
if [ "$IS_LOCAL" = true ]; then
|
||||
REMOTE_HOST=""
|
||||
fi
|
||||
|
||||
# 颜色
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
# ============================================================
|
||||
# 日志函数
|
||||
# ============================================================
|
||||
log() { echo -e "${GREEN}[$(date '+%H:%M:%S')]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[$(date '+%H:%M:%S')] WARNING:${NC} $*"; }
|
||||
error() { echo -e "${RED}[$(date '+%H:%M:%S')] ERROR:${NC} $*" >&2; }
|
||||
|
||||
# ============================================================
|
||||
# 清理模式设置
|
||||
# ============================================================
|
||||
case "$CLEANUP_MODE" in
|
||||
--aggressive)
|
||||
RETENTION_HOURS=1
|
||||
log "激进清理模式:保留 ${RETENTION_HOURS} 小时内的缓存"
|
||||
;;
|
||||
--conservative)
|
||||
RETENTION_HOURS=6
|
||||
log "保守清理模式:保留 ${RETENTION_HOURS} 小时内的缓存"
|
||||
;;
|
||||
--full)
|
||||
log "完全清理模式:删除所有缓存"
|
||||
RETENTION_HOURS=0
|
||||
;;
|
||||
*)
|
||||
log "默认清理模式:保留 ${RETENTION_HOURS} 小时内的缓存"
|
||||
;;
|
||||
esac
|
||||
|
||||
# ============================================================
|
||||
# 辅助函数
|
||||
# ============================================================
|
||||
# 执行命令(根据是否本地决定是否使用 SSH)
|
||||
run_cmd() {
|
||||
if [ "$IS_LOCAL" = true ]; then
|
||||
eval "$@"
|
||||
else
|
||||
ssh "$REMOTE_HOST" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# 获取磁盘使用率
|
||||
get_disk_usage() {
|
||||
run_cmd "df / --output=pcent | tail -1 | tr -d ' %'" 2>/dev/null || echo "0"
|
||||
}
|
||||
|
||||
# 获取构建缓存大小
|
||||
get_cache_size() {
|
||||
run_cmd "docker system df --format '{{.Size}}' 2>/dev/null | head -1" || echo "0"
|
||||
}
|
||||
|
||||
# 获取镜像数量
|
||||
get_image_count() {
|
||||
run_cmd "docker images -q 2>/dev/null | wc -l" || echo "0"
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 清理函数
|
||||
# ============================================================
|
||||
# 清理 dangling 镜像(无标签或未被引用的中间层)
|
||||
cleanup_dangling_images() {
|
||||
log "清理 dangling 镜像..."
|
||||
run_cmd "docker image prune -f 2>/dev/null" || warn "docker image prune 失败"
|
||||
}
|
||||
|
||||
# 清理测试镜像(以 test- 开头的镜像)
|
||||
cleanup_test_images() {
|
||||
log "清理测试镜像..."
|
||||
local test_images=$(run_cmd "docker images --format '{{.Repository}}:{{.Tag}}' | grep -E '^test-' 2>/dev/null" || true)
|
||||
if [ -n "$test_images" ]; then
|
||||
echo "$test_images" | while read -r img; do
|
||||
log " 删除: $img"
|
||||
done
|
||||
run_cmd "docker images --format '{{.Repository}}:{{.Tag}}' | grep -E '^test-' | xargs -r docker rmi -f 2>/dev/null" || true
|
||||
else
|
||||
log " 无测试镜像"
|
||||
fi
|
||||
}
|
||||
|
||||
# 清理构建缓存
|
||||
cleanup_build_cache() {
|
||||
if [ "$RETENTION_HOURS" -eq 0 ]; then
|
||||
log "清理所有构建缓存..."
|
||||
run_cmd "docker builder prune -af 2>/dev/null" || warn "docker builder prune 失败"
|
||||
else
|
||||
log "清理 ${RETENTION_HOURS} 小时前的构建缓存..."
|
||||
run_cmd "docker builder prune -f --filter 'until=${RETENTION_HOURS}h' 2>/dev/null" || warn "docker builder prune 失败"
|
||||
fi
|
||||
}
|
||||
|
||||
# 根据磁盘使用率决定是否需要深度清理
|
||||
cleanup_by_disk_usage() {
|
||||
local DISK_USAGE=$(get_disk_usage)
|
||||
log "当前磁盘使用率: ${DISK_USAGE}%"
|
||||
|
||||
if [ "$DISK_USAGE" -gt 85 ]; then
|
||||
warn "磁盘使用率 ${DISK_USAGE}% > 85%,执行深度清理"
|
||||
# 清理所有构建缓存
|
||||
run_cmd "docker builder prune -af 2>/dev/null" || true
|
||||
# 清理容器日志
|
||||
log "截断容器日志..."
|
||||
run_cmd "truncate -s 0 /var/lib/docker/containers/\$(docker ps -q)/\*-json.log 2>/dev/null" || true
|
||||
elif [ "$DISK_USAGE" -gt 75 ]; then
|
||||
cleanup_build_cache
|
||||
else
|
||||
log "磁盘使用率正常,仅执行常规清理"
|
||||
cleanup_build_cache
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 打印清理结果
|
||||
# ============================================================
|
||||
print_result() {
|
||||
local DISK_AFTER=$(get_disk_usage)
|
||||
local CACHE_AFTER=$(get_cache_size)
|
||||
local IMAGE_AFTER=$(get_image_count)
|
||||
echo ""
|
||||
log "========== 清理完成 =========="
|
||||
log "磁盘使用率: ${DISK_AFTER}%"
|
||||
log "构建缓存大小: ${CACHE_AFTER}"
|
||||
log "镜像数量: ${IMAGE_AFTER}"
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 主函数
|
||||
# ============================================================
|
||||
main() {
|
||||
local DISK_BEFORE=$(get_disk_usage)
|
||||
local CACHE_BEFORE=$(get_cache_size)
|
||||
local IMAGE_BEFORE=$(get_image_count)
|
||||
|
||||
echo ""
|
||||
log "========== Docker 缓存清理 =========="
|
||||
log "清理前 - 磁盘使用率: ${DISK_BEFORE}%, 缓存大小: ${CACHE_BEFORE}, 镜像数量: ${IMAGE_BEFORE}"
|
||||
echo ""
|
||||
|
||||
# 执行清理
|
||||
cleanup_dangling_images
|
||||
cleanup_test_images
|
||||
cleanup_by_disk_usage
|
||||
|
||||
# 打印结果
|
||||
print_result
|
||||
}
|
||||
|
||||
# 执行主函数
|
||||
main "$@"
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
#!/usr/bin/env bash
|
||||
# monitor-ai 健康检查兜底脚本
|
||||
#
|
||||
# 用途:当 monitor-ai 自身的健康检查失败时,通过 webhook 直接发送告警
|
||||
# 部署:添加到 crontab,每 5 分钟执行一次
|
||||
#
|
||||
# crontab 配置:
|
||||
# */5 * * * * /root/docker/monitor-ai/scripts/monitor-watchdog.sh >> /var/log/monitor-watchdog.log 2>&1
|
||||
#
|
||||
# 日志文件:/var/log/monitor-watchdog.log(建议配合 logrotate)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ============================================================
|
||||
# 配置
|
||||
# ============================================================
|
||||
HEALTH_URL="${MONITOR_HEALTH_URL:-https://monitor.tlyq.ai/api/health}"
|
||||
TIMEOUT=10
|
||||
MAX_RETRIES=2
|
||||
RETRY_DELAY=5
|
||||
|
||||
# 企业微信 Webhook(从 .env 读取或使用环境变量)
|
||||
MONITOR_DIR="/root/docker/monitor-ai"
|
||||
if [[ -f "$MONITOR_DIR/.env" ]]; then
|
||||
# 从 .env 读取第一个启用的 webhook URL
|
||||
WEBHOOK_URL="${WEBHOOK_URL:-$(grep -E '^WEBHOOK_URL=' "$MONITOR_DIR/.env" 2>/dev/null | head -1 | cut -d= -f2-)}"
|
||||
fi
|
||||
|
||||
# 如果没有配置 webhook,尝试从数据库读取
|
||||
if [[ -z "${WEBHOOK_URL:-}" ]]; then
|
||||
DB_PATH="${MONITOR_DIR}/data/monitor.db"
|
||||
if [[ -f "$DB_PATH" ]]; then
|
||||
WEBHOOK_URL=$(sqlite3 "$DB_PATH" "SELECT webhook_url FROM alert_channels WHERE enabled=1 LIMIT 1;" 2>/dev/null || echo "")
|
||||
fi
|
||||
fi
|
||||
|
||||
# 日志函数
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 健康检查
|
||||
# ============================================================
|
||||
check_health() {
|
||||
local attempt=0
|
||||
local http_code=""
|
||||
|
||||
while [[ $attempt -lt $MAX_RETRIES ]]; do
|
||||
attempt=$((attempt + 1))
|
||||
|
||||
http_code=$(curl -s -o /dev/null -w '%{http_code}' \
|
||||
--connect-timeout "$TIMEOUT" \
|
||||
--max-time "$TIMEOUT" \
|
||||
"$HEALTH_URL" 2>/dev/null) || http_code="000"
|
||||
|
||||
if [[ "$http_code" == "200" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ $attempt -lt $MAX_RETRIES ]]; then
|
||||
log "健康检查失败 (HTTP $http_code),${RETRY_DELAY}s 后重试..."
|
||||
sleep "$RETRY_DELAY"
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 发送告警
|
||||
# ============================================================
|
||||
send_alert() {
|
||||
local message="$1"
|
||||
|
||||
if [[ -z "${WEBHOOK_URL:-}" ]]; then
|
||||
log "WARNING: 未配置企业微信 Webhook URL,无法发送告警"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local payload
|
||||
payload=$(cat <<EOF
|
||||
{
|
||||
"msgtype": "markdown",
|
||||
"markdown": {
|
||||
"content": "## ⚠️ monitor-ai 自身异常\n\n> 告警时间: $(date '+%Y-%m-%d %H:%M:%S')\n\n**健康检查地址**: ${HEALTH_URL}\n\n**失败原因**: ${message}\n\n**处理建议**: 请检查 monitor-ai 容器状态\n\n<font color=\"warning\">此告警由 monitor-watchdog 发送</font>"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
local response
|
||||
response=$(curl -s -X POST \
|
||||
--connect-timeout 10 \
|
||||
--max-time 10 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload" \
|
||||
"$WEBHOOK_URL" 2>&1)
|
||||
|
||||
if [[ $? -eq 0 ]]; then
|
||||
log "告警已发送: $response"
|
||||
else
|
||||
log "WARNING: 告警发送失败: $response"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 主逻辑
|
||||
# ============================================================
|
||||
main() {
|
||||
log "开始健康检查: $HEALTH_URL"
|
||||
|
||||
if check_health; then
|
||||
log "健康检查通过 (HTTP 200)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "ERROR: 健康检查失败,尝试发送告警..."
|
||||
send_alert "HTTP 健康检查失败,连续 ${MAX_RETRIES} 次请求均未返回 200"
|
||||
|
||||
# 检查容器状态
|
||||
local container_status
|
||||
container_status=$(docker inspect -f '{{.State.Status}}' monitor-ai 2>/dev/null || echo "not_found")
|
||||
log "容器状态: $container_status"
|
||||
|
||||
if [[ "$container_status" != "running" ]]; then
|
||||
log "ERROR: 容器未运行,尝试重启..."
|
||||
cd "$MONITOR_DIR" && docker compose up -d 2>&1 | while read -r line; do log " $line"; done
|
||||
|
||||
# 等待容器启动后再次检查
|
||||
sleep 10
|
||||
if check_health; then
|
||||
log "容器已重启并通过健康检查"
|
||||
else
|
||||
log "ERROR: 重启后健康检查仍然失败,请人工介入"
|
||||
send_alert "容器已重启,但健康检查仍然失败,请人工介入"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
#
|
||||
# 用法:bash sites-manage-cloud.sh [命令] [站点名...]
|
||||
# 不加命令默认 status
|
||||
# 站点名:ldap www cloud token gitea issue assets oa nginx
|
||||
# 站点名:ldap www cloud token gitea issue assets oa nginx sso monitor
|
||||
# ai(上述全部)、all(同 ai)
|
||||
|
||||
set -e
|
||||
|
|
@ -48,32 +48,41 @@ run() {
|
|||
|
||||
BASE="/root/docker"
|
||||
|
||||
# 站点 → 目录 & 容器名
|
||||
declare -A SITE_DIR
|
||||
SITE_DIR[ldap]="ldap-ai"
|
||||
SITE_DIR[www]="www-ai"
|
||||
SITE_DIR[cloud]="cloud-ai"
|
||||
SITE_DIR[token]="token-ai"
|
||||
SITE_DIR[gitea]="gitea-ai"
|
||||
SITE_DIR[issue]="issue-ai"
|
||||
SITE_DIR[assets]="assets-ai"
|
||||
SITE_DIR[oa]="oa-ai"
|
||||
SITE_DIR[nginx]="nginx-proxy-ai"
|
||||
SITE_DIR[sso]="sso-ai"
|
||||
# 站点 → 目录
|
||||
site_dir() {
|
||||
case $1 in
|
||||
ldap) echo "ldap-ai" ;;
|
||||
www) echo "www-ai" ;;
|
||||
cloud) echo "cloud-ai" ;;
|
||||
token) echo "token-ai" ;;
|
||||
gitea) echo "gitea-ai" ;;
|
||||
issue) echo "issue-ai" ;;
|
||||
assets) echo "assets-ai" ;;
|
||||
oa) echo "oa-ai" ;;
|
||||
nginx) echo "nginx-proxy-ai" ;;
|
||||
sso) echo "sso-ai" ;;
|
||||
monitor) echo "monitor-ai" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
declare -A SITE_CONTAINER
|
||||
SITE_CONTAINER[ldap]="lldap"
|
||||
SITE_CONTAINER[www]="www-ai"
|
||||
SITE_CONTAINER[cloud]="cloud-ai"
|
||||
SITE_CONTAINER[token]="token-ai"
|
||||
SITE_CONTAINER[gitea]="gitea-ai"
|
||||
SITE_CONTAINER[issue]="issue-ai"
|
||||
SITE_CONTAINER[assets]="assets-ai"
|
||||
SITE_CONTAINER[oa]="oa-ai"
|
||||
SITE_CONTAINER[nginx]="nginx-ai"
|
||||
SITE_CONTAINER[sso]="sso-ai"
|
||||
# 站点 → 容器名
|
||||
site_container() {
|
||||
case $1 in
|
||||
ldap) echo "lldap" ;;
|
||||
www) echo "www-ai" ;;
|
||||
cloud) echo "cloud-ai" ;;
|
||||
token) echo "token-ai" ;;
|
||||
gitea) echo "gitea-ai" ;;
|
||||
issue) echo "issue-ai" ;;
|
||||
assets) echo "assets-ai" ;;
|
||||
oa) echo "oa-ai" ;;
|
||||
nginx) echo "nginx-ai" ;;
|
||||
sso) echo "authelia" ;;
|
||||
monitor) echo "monitor-ai" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
AI_SITES="ldap www cloud token gitea issue assets oa nginx"
|
||||
AI_SITES="ldap www cloud token gitea issue assets oa nginx sso monitor"
|
||||
|
||||
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; RED='\033[0;31m'; NC='\033[0m'
|
||||
log() { printf "${GREEN}[✓]${NC} %s\n" "$1"; }
|
||||
|
|
@ -112,8 +121,8 @@ expand_sites() {
|
|||
# 操作
|
||||
# ============================================================
|
||||
remote_start() {
|
||||
local name=$1 dir="${BASE}/${SITE_DIR[$name]}"
|
||||
local container="${SITE_CONTAINER[$name]}"
|
||||
local name=$1 dir="${BASE}/$(site_dir $name)"
|
||||
local container="$(site_container $name)"
|
||||
|
||||
if run "docker ps --format '{{.Names}}' | grep -q '^${container}$'" 2>/dev/null; then
|
||||
warn "$name 已在运行"
|
||||
|
|
@ -124,8 +133,8 @@ remote_start() {
|
|||
}
|
||||
|
||||
remote_stop() {
|
||||
local name=$1 dir="${BASE}/${SITE_DIR[$name]}"
|
||||
local container="${SITE_CONTAINER[$name]}"
|
||||
local name=$1 dir="${BASE}/$(site_dir $name)"
|
||||
local container="$(site_container $name)"
|
||||
|
||||
if ! run "docker ps --format '{{.Names}}' | grep -q '^${container}$'" 2>/dev/null; then
|
||||
warn "$name 未在运行"
|
||||
|
|
@ -144,7 +153,7 @@ remote_status() {
|
|||
echo ""
|
||||
|
||||
for name in "${targets[@]}"; do
|
||||
local container="${SITE_CONTAINER[$name]}"
|
||||
local container="$(site_container $name)"
|
||||
local status
|
||||
status=$(run "docker ps --format '{{.Status}}' -f name=^${container}$" 2>/dev/null || echo "")
|
||||
if [ -n "$status" ]; then
|
||||
|
|
@ -178,6 +187,8 @@ show_help() {
|
|||
echo " assets 设备资产管理"
|
||||
echo " oa OA 统一门户"
|
||||
echo " nginx 反向代理"
|
||||
echo " sso Authelia OIDC 认证"
|
||||
echo " monitor 告警监控中心"
|
||||
echo " ai 全部 tlyq.ai 站点"
|
||||
echo ""
|
||||
echo "示例:"
|
||||
|
|
@ -194,9 +205,9 @@ show_help() {
|
|||
ACTION=${1:-status}
|
||||
shift || true
|
||||
|
||||
mapfile -t TARGETS < <(expand_sites "$@")
|
||||
TARGETS=($(expand_sites "$@"))
|
||||
if [ ${#TARGETS[@]} -eq 0 ]; then
|
||||
mapfile -t TARGETS < <(expand_sites ai)
|
||||
TARGETS=($(expand_sites ai))
|
||||
fi
|
||||
|
||||
# 检查 Docker 服务(help 命令除外)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# 站点名:
|
||||
# cc: www-cc cloud-cc token-cc
|
||||
# ai: www-ai cloud-ai token-ai issue assets ldap
|
||||
# ai: www-ai cloud-ai token-ai issue assets ldap oa sso gitea monitor
|
||||
# all: 全部站点
|
||||
|
||||
set -e
|
||||
|
|
@ -16,7 +16,8 @@ ASSETS_DIR="$BASE_DIR/assets-ai"
|
|||
LDAP_DIR="$BASE_DIR/ldap-ai"
|
||||
OA_DIR="$BASE_DIR/oa-ai"
|
||||
GITEA_DIR="$BASE_DIR/gitea-ai"
|
||||
OA_PORT=6179
|
||||
SSO_DIR="$BASE_DIR/sso-ai"
|
||||
MONITOR_DIR="$BASE_DIR/monitor-ai"
|
||||
|
||||
# 颜色
|
||||
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; RED='\033[0;31m'; NC='\033[0m'
|
||||
|
|
@ -25,13 +26,21 @@ warn() { printf "${YELLOW}[!]${NC} %s\n" "$1"; }
|
|||
err() { printf "${RED}[✗]${NC} %s\n" "$1"; }
|
||||
|
||||
# 端口
|
||||
WWW_AI_PORT=6173
|
||||
CLOUD_AI_PORT=6174
|
||||
TOKEN_AI_PORT=6175
|
||||
ISSUE_PORT=6176
|
||||
ASSETS_PORT=6177
|
||||
LDAP_WEB_PORT=6178
|
||||
GITEA_PORT=3000
|
||||
OA_PORT=6179
|
||||
SSO_PORT=6180
|
||||
MONITOR_PORT=6181
|
||||
WWW_CC_PORT=5173
|
||||
CLOUD_CC_PORT=5174
|
||||
TOKEN_CC_PORT=5175
|
||||
GITEA_PORT=6182
|
||||
|
||||
is_port() { lsof -i :"$1" >/dev/null 2>&1; }
|
||||
is_running() { docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^$1$"; }
|
||||
|
||||
# 检查 Docker 服务是否运行
|
||||
check_docker() {
|
||||
|
|
@ -46,16 +55,21 @@ check_docker() {
|
|||
# ============================================================
|
||||
docker_start() {
|
||||
local name=$1 dir=$2
|
||||
if is_running $name; then
|
||||
if [ "$(cd "$dir" && docker compose ps -q --status running 2>/dev/null)" ]; then
|
||||
warn "$name 已在运行"
|
||||
else
|
||||
log "启动 $name..."
|
||||
cd "$dir" && docker compose up -d 2>/dev/null && log "$name 已启动" || err "$name 启动失败"
|
||||
cd "$dir" && docker compose up -d 2>/dev/null || { err "$name 启动失败"; return 1; }
|
||||
for i in $(seq 1 30); do
|
||||
[ "$(cd "$dir" && docker compose ps -q --status running 2>/dev/null)" ] && { log "$name 已启动"; return 0; }
|
||||
sleep 1
|
||||
done
|
||||
err "$name 启动异常(可能处于重启循环,请检查 docker compose logs)"
|
||||
fi
|
||||
}
|
||||
docker_stop() {
|
||||
local name=$1 dir=$2
|
||||
if is_running $name; then
|
||||
if [ "$(cd "$dir" && docker compose ps -q --status running --status restarting 2>/dev/null)" ]; then
|
||||
log "停止 $name..."
|
||||
cd "$dir" && docker compose down 2>/dev/null && log "$name 已停止" || err "$name 停止失败"
|
||||
else
|
||||
|
|
@ -92,26 +106,28 @@ node_stop() {
|
|||
start_all() {
|
||||
for name in "$@"; do
|
||||
case $name in
|
||||
www-cc) docker_start www-local "$BASE_DIR/www-cc" ;;
|
||||
cloud-cc) docker_start cloud-cc "$BASE_DIR/cloud-cc" ;;
|
||||
token-cc) docker_start token-cc "$BASE_DIR/token-cc" ;;
|
||||
www-ai) docker_start www-local-ai "$BASE_DIR/www-ai" ;;
|
||||
www-cc) docker_start www-cc "$BASE_DIR/cc-site/www-cc" ;;
|
||||
cloud-cc) docker_start cloud-cc "$BASE_DIR/cc-site/cloud-cc" ;;
|
||||
token-cc) docker_start token-cc "$BASE_DIR/cc-site/token-cc" ;;
|
||||
www-ai) docker_start www-ai "$BASE_DIR/www-ai" ;;
|
||||
cloud-ai) docker_start cloud-ai "$BASE_DIR/cloud-ai" ;;
|
||||
token-ai) docker_start token-ai "$BASE_DIR/token-ai" ;;
|
||||
issue) node_start issue-ai "$ISSUE_DIR" $ISSUE_PORT "/tmp/issue-ai-dev.log"
|
||||
issue) node_start issue "$ISSUE_DIR" $ISSUE_PORT "/tmp/issue-ai-dev.log"
|
||||
if ! is_port $ASSETS_PORT; then
|
||||
node_start assets-ai "$ASSETS_DIR" $ASSETS_PORT "/tmp/assets-ai-dev.log"
|
||||
node_start assets "$ASSETS_DIR" $ASSETS_PORT "/tmp/assets-ai-dev.log"
|
||||
warn "已自动启动 assets-ai(issue 依赖 assets API)"
|
||||
fi ;;
|
||||
assets) node_start assets-ai "$ASSETS_DIR" $ASSETS_PORT "/tmp/assets-ai-dev.log" ;;
|
||||
ldap) docker_start lldap "$LDAP_DIR"
|
||||
assets) node_start assets "$ASSETS_DIR" $ASSETS_PORT "/tmp/assets-ai-dev.log" ;;
|
||||
ldap) docker_start ldap "$LDAP_DIR"
|
||||
if is_port $LDAP_WEB_PORT; then
|
||||
log "LLDAP Web UI: http://127.0.0.1:$LDAP_WEB_PORT"
|
||||
fi ;;
|
||||
oa) node_start oa-ai "$OA_DIR" $OA_PORT "/tmp/oa-ai-dev.log" ;;
|
||||
gitea) docker_start gitea-ai "$GITEA_DIR" ;;
|
||||
oa) node_start oa "$OA_DIR" $OA_PORT "/tmp/oa-ai-dev.log" ;;
|
||||
sso) docker_start sso "$SSO_DIR" ;;
|
||||
gitea) docker_start gitea "$GITEA_DIR" ;;
|
||||
monitor) node_start monitor "$MONITOR_DIR" $MONITOR_PORT "/tmp/monitor-ai-dev.log" ;;
|
||||
cc) start_all www-cc cloud-cc token-cc ;;
|
||||
ai) start_all ldap www-ai cloud-ai token-ai issue assets oa gitea ;;
|
||||
ai) start_all www-ai cloud-ai token-ai issue assets ldap oa sso monitor gitea ;;
|
||||
all) start_all cc ai ;;
|
||||
*) err "未知站点: $name" ;;
|
||||
esac
|
||||
|
|
@ -121,19 +137,21 @@ start_all() {
|
|||
stop_all() {
|
||||
for name in "$@"; do
|
||||
case $name in
|
||||
www-cc) docker_stop www-local "$BASE_DIR/www-cc" ;;
|
||||
cloud-cc) docker_stop cloud-cc "$BASE_DIR/cloud-cc" ;;
|
||||
token-cc) docker_stop token-cc "$BASE_DIR/token-cc" ;;
|
||||
www-ai) docker_stop www-local-ai "$BASE_DIR/www-ai" ;;
|
||||
www-cc) docker_stop www-cc "$BASE_DIR/cc-site/www-cc" ;;
|
||||
cloud-cc) docker_stop cloud-cc "$BASE_DIR/cc-site/cloud-cc" ;;
|
||||
token-cc) docker_stop token-cc "$BASE_DIR/cc-site/token-cc" ;;
|
||||
www-ai) docker_stop www-ai "$BASE_DIR/www-ai" ;;
|
||||
cloud-ai) docker_stop cloud-ai "$BASE_DIR/cloud-ai" ;;
|
||||
token-ai) docker_stop token-ai "$BASE_DIR/token-ai" ;;
|
||||
issue) node_stop issue-ai $ISSUE_PORT ;;
|
||||
assets) node_stop assets-ai $ASSETS_PORT ;;
|
||||
ldap) docker_stop lldap "$LDAP_DIR" ;;
|
||||
oa) node_stop oa-ai $OA_PORT ;;
|
||||
gitea) docker_stop gitea-ai "$GITEA_DIR" ;;
|
||||
issue) node_stop issue $ISSUE_PORT ;;
|
||||
assets) node_stop assets $ASSETS_PORT ;;
|
||||
ldap) docker_stop ldap "$LDAP_DIR" ;;
|
||||
oa) node_stop oa $OA_PORT ;;
|
||||
sso) docker_stop sso "$SSO_DIR" ;;
|
||||
gitea) docker_stop gitea "$GITEA_DIR" ;;
|
||||
monitor) node_stop monitor $MONITOR_PORT ;;
|
||||
cc) stop_all www-cc cloud-cc token-cc ;;
|
||||
ai) stop_all ldap www-ai cloud-ai token-ai issue assets oa gitea ;;
|
||||
ai) stop_all www-ai cloud-ai token-ai issue assets ldap oa sso monitor gitea ;;
|
||||
all) stop_all cc ai ;;
|
||||
*) err "未知站点: $name" ;;
|
||||
esac
|
||||
|
|
@ -156,9 +174,9 @@ show_status() {
|
|||
local expanded=()
|
||||
for t in "${targets[@]}"; do
|
||||
case $t in
|
||||
all) eval "expanded=(www-cc cloud-cc token-cc ldap www-ai cloud-ai token-ai issue assets oa gitea)" ;;
|
||||
all) eval "expanded=(www-cc cloud-cc token-cc www-ai cloud-ai token-ai issue assets ldap oa sso monitor gitea)" ;;
|
||||
cc) eval "expanded=(\${expanded[@]} www-cc cloud-cc token-cc)" ;;
|
||||
ai) eval "expanded=(\${expanded[@]} ldap www-ai cloud-ai token-ai issue assets oa gitea)" ;;
|
||||
ai) eval "expanded=(\${expanded[@]} www-ai cloud-ai token-ai issue assets ldap oa sso monitor gitea)" ;;
|
||||
*) expanded+=($t) ;;
|
||||
esac
|
||||
done
|
||||
|
|
@ -169,69 +187,51 @@ show_status() {
|
|||
printf "${CYAN}=========================================${NC}\n"
|
||||
echo ""
|
||||
|
||||
local running=0
|
||||
for name in "${expanded[@]}"; do
|
||||
case $name in
|
||||
www-cc|cloud-cc|token-cc|cloud-ai|token-ai)
|
||||
if is_running $name; then
|
||||
printf " ${GREEN}●${NC} %-12s 运行中\n" "$name"
|
||||
running=$((running + 1))
|
||||
else
|
||||
printf " ${RED}●${NC} %-12s 未运行\n" "$name"
|
||||
fi
|
||||
;;
|
||||
www-ai)
|
||||
if is_running www-local-ai; then
|
||||
printf " ${GREEN}●${NC} %-12s 运行中\n" "$name"
|
||||
running=$((running + 1))
|
||||
else
|
||||
printf " ${RED}●${NC} %-12s 未运行\n" "$name"
|
||||
fi
|
||||
;;
|
||||
issue)
|
||||
if is_port $ISSUE_PORT; then
|
||||
printf " ${GREEN}●${NC} %-12s 运行中 (port %d)\n" "$name" $ISSUE_PORT
|
||||
running=$((running + 1))
|
||||
else
|
||||
printf " ${RED}●${NC} %-12s 未运行\n" "$name"
|
||||
fi
|
||||
;;
|
||||
ldap)
|
||||
if is_running lldap; then
|
||||
printf " ${GREEN}●${NC} %-12s 运行中 (port %d)\n" "$name" $LDAP_WEB_PORT
|
||||
running=$((running + 1))
|
||||
else
|
||||
printf " ${RED}●${NC} %-12s 未运行\n" "$name"
|
||||
fi
|
||||
;;
|
||||
assets)
|
||||
if is_port $ASSETS_PORT; then
|
||||
printf " ${GREEN}●${NC} %-12s 运行中 (port %d)\n" "$name" $ASSETS_PORT
|
||||
running=$((running + 1))
|
||||
else
|
||||
printf " ${RED}●${NC} %-12s 未运行\n" "$name"
|
||||
fi
|
||||
;;
|
||||
oa)
|
||||
if is_port $OA_PORT; then
|
||||
printf " ${GREEN}●${NC} %-12s 运行中 (port %d)\n" "$name" $OA_PORT
|
||||
running=$((running + 1))
|
||||
else
|
||||
printf " ${RED}●${NC} %-12s 未运行\n" "$name"
|
||||
fi
|
||||
;;
|
||||
gitea)
|
||||
if is_running gitea-ai; then
|
||||
printf " ${GREEN}●${NC} %-12s 运行中 (port %d)\n" "$name" $GITEA_PORT
|
||||
running=$((running + 1))
|
||||
else
|
||||
printf " ${RED}●${NC} %-12s 未运行\n" "$name"
|
||||
fi
|
||||
;;
|
||||
# 获取站点对应端口
|
||||
get_port() {
|
||||
case $1 in
|
||||
www-cc) echo $WWW_CC_PORT ;;
|
||||
cloud-cc) echo $CLOUD_CC_PORT ;;
|
||||
token-cc) echo $TOKEN_CC_PORT ;;
|
||||
www-ai) echo $WWW_AI_PORT ;;
|
||||
cloud-ai) echo $CLOUD_AI_PORT ;;
|
||||
token-ai) echo $TOKEN_AI_PORT ;;
|
||||
issue) echo $ISSUE_PORT ;;
|
||||
assets) echo $ASSETS_PORT ;;
|
||||
ldap) echo $LDAP_WEB_PORT ;;
|
||||
oa) echo $OA_PORT ;;
|
||||
sso) echo $SSO_PORT ;;
|
||||
gitea) echo $GITEA_PORT ;;
|
||||
monitor) echo $MONITOR_PORT ;;
|
||||
esac
|
||||
done
|
||||
echo ""
|
||||
}
|
||||
|
||||
local running=0
|
||||
|
||||
# 按 cc/ai 分组输出
|
||||
_status_group() {
|
||||
local label=$1; shift
|
||||
local group=() gname n
|
||||
for gname in "$@"; do
|
||||
for n in "${expanded[@]}"; do
|
||||
[ "$n" = "$gname" ] && group+=("$n") && break
|
||||
done
|
||||
done
|
||||
[ ${#group[@]} -eq 0 ] && return
|
||||
printf " ${CYAN}%s站点:${NC}\n" "$label"
|
||||
for gname in "${group[@]}"; do
|
||||
local port=$(get_port $gname)
|
||||
if [ -n "$port" ] && is_port $port; then
|
||||
printf " ${GREEN}●${NC} %-12s 运行中 (port %d)\n" "$gname" $port
|
||||
running=$((running + 1))
|
||||
else
|
||||
printf " ${RED}●${NC} %-12s 未运行\n" "$gname"
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
}
|
||||
_status_group "cc " www-cc cloud-cc token-cc
|
||||
_status_group "ai " www-ai cloud-ai token-ai issue assets ldap oa sso monitor gitea
|
||||
printf "${CYAN}=========================================${NC}\n"
|
||||
printf "${CYAN} 访问地址${NC}\n"
|
||||
printf "${CYAN}=========================================${NC}\n"
|
||||
|
|
@ -245,10 +245,12 @@ show_status() {
|
|||
echo " www-ai http://127.0.0.1:6173"
|
||||
echo " cloud-ai http://127.0.0.1:6174"
|
||||
echo " token-ai http://127.0.0.1:6175"
|
||||
echo " ldap http://127.0.0.1:$LDAP_WEB_PORT (LLDAP Web UI)"
|
||||
echo " oa http://127.0.0.1:$OA_PORT"
|
||||
echo " issue http://127.0.0.1:$ISSUE_PORT"
|
||||
echo " assets http://127.0.0.1:$ASSETS_PORT"
|
||||
echo " ldap http://127.0.0.1:$LDAP_WEB_PORT (LLDAP Web UI)"
|
||||
echo " oa http://127.0.0.1:$OA_PORT"
|
||||
echo " sso http://127.0.0.1:$SSO_PORT (Authelia OIDC)"
|
||||
echo " monitor http://127.0.0.1:$MONITOR_PORT"
|
||||
echo " gitea http://127.0.0.1:$GITEA_PORT"
|
||||
echo ""
|
||||
}
|
||||
|
|
@ -268,9 +270,9 @@ show_help() {
|
|||
echo ""
|
||||
echo " 站点名(可指定多个,空格分隔):"
|
||||
echo " www-cc cloud-cc token-cc"
|
||||
echo " ldap www-ai cloud-ai token-ai issue assets oa gitea"
|
||||
echo " ldap www-ai cloud-ai token-ai issue assets oa sso gitea monitor"
|
||||
echo " cc — tlyq.cc 全部 (www-cc cloud-cc token-cc)"
|
||||
echo " ai — tlyq.ai 全部 (ldap www-ai cloud-ai token-ai issue assets oa gitea)"
|
||||
echo " ai — tlyq.ai 全部 (ldap www-ai cloud-ai token-ai issue assets oa sso gitea monitor)"
|
||||
echo " all — 全部站点"
|
||||
echo ""
|
||||
echo "示例:"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,291 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* 企业微信消息推送模拟测试
|
||||
* 模拟 29.237.253.1 服务器 OEM 诊断和 OEM 维修故障完整生命周期
|
||||
* 每条消息间隔 5 秒
|
||||
*/
|
||||
|
||||
const WEBHOOK_URL = process.env.WECOM_WEBHOOK_URL || (() => { throw new Error('WECOM_WEBHOOK_URL not set') })()
|
||||
const SERVER_IP = '29.237.253.1'
|
||||
const SERVER_SN = '210235A4M7H241000098'
|
||||
const RACK = 'D01-404-I02-3U'
|
||||
const INTERVAL = 5000
|
||||
|
||||
// 随机故障内容
|
||||
const OEM_DIAG_FAULTS = [
|
||||
'agent已经5分钟没有上报数据',
|
||||
'GPU温度传感器读数异常,超过阈值',
|
||||
'系统日志显示内核恐慌(Kernel Panic)',
|
||||
'NVMe硬盘SMART检测到坏块',
|
||||
'内存ECC错误计数超标',
|
||||
]
|
||||
const OEM_REPAIR_FAULTS = [
|
||||
'GPU缓存发生不可纠正错误',
|
||||
'RAID阵列降级,一块硬盘离线',
|
||||
'电源模块冗余失效,单电源运行',
|
||||
'主板BMC固件异常,无法远程管理',
|
||||
'网卡绑定故障,网络中断',
|
||||
]
|
||||
|
||||
const randomFrom = (arr) => arr[Math.floor(Math.random() * arr.length)]
|
||||
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
// 北京时间格式化
|
||||
function bjTime(date = new Date()) {
|
||||
const d = new Date(date.getTime() + 8 * 3600_000)
|
||||
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')} ${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}:${String(d.getUTCSeconds()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 向前推算时间
|
||||
function minusHours(date, hours) {
|
||||
return new Date(date.getTime() - hours * 3600_000)
|
||||
}
|
||||
function minusMinutes(date, minutes) {
|
||||
return new Date(date.getTime() - minutes * 60_000)
|
||||
}
|
||||
|
||||
async function sendText(text) {
|
||||
const res = await fetch(WEBHOOK_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ msgtype: 'text', text: { content: text } }),
|
||||
})
|
||||
const data = await res.json()
|
||||
return data.errcode === 0
|
||||
}
|
||||
|
||||
async function send(label, text) {
|
||||
console.log(`\n📤 [${label}]`)
|
||||
console.log(text)
|
||||
console.log('─'.repeat(50))
|
||||
const ok = await sendText(text)
|
||||
console.log(ok ? '✅ 发送成功' : '❌ 发送失败')
|
||||
await sleep(INTERVAL)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// OEM 诊断完整生命周期
|
||||
// ============================================================
|
||||
async function runOemDiag() {
|
||||
const now = new Date()
|
||||
const faultTime = minusHours(now, 2)
|
||||
const orderNum = 'S' + Date.now().toString().slice(-10)
|
||||
const fault = randomFrom(OEM_DIAG_FAULTS)
|
||||
const oemDeadline = minusHours(faultTime, -6) // 故障后6小时到期
|
||||
|
||||
console.log('\n' + '═'.repeat(60))
|
||||
console.log(' OEM 诊断 — 完整生命周期模拟')
|
||||
console.log('═'.repeat(60))
|
||||
console.log(`工单号: ${orderNum}`)
|
||||
console.log(`故障时间: ${bjTime(faultTime)}`)
|
||||
console.log(`到期时间: ${bjTime(oemDeadline)}`)
|
||||
|
||||
// 1. 初始故障通知
|
||||
await send('初始通知', [
|
||||
`【服务器故障单】 - OEM诊断`,
|
||||
'',
|
||||
`工单号:${orderNum}`,
|
||||
`服务器IP:${SERVER_IP}`,
|
||||
`服务器SN:${SERVER_SN}`,
|
||||
`机架位置:${RACK}`,
|
||||
`故障时间:${bjTime(faultTime)}`,
|
||||
`到期时间:${bjTime(oemDeadline)}`,
|
||||
`故障信息:${fault}`,
|
||||
].join('\n'))
|
||||
|
||||
// 2. 每小时提醒(剩余4小时)
|
||||
const t4h = minusMinutes(oemDeadline, 240)
|
||||
await send('每小时提醒(剩余4小时0分)', [
|
||||
`⏰ OEM诊断工单提醒(剩余4小时0分)`,
|
||||
'',
|
||||
`工单号:${orderNum}`,
|
||||
`服务器IP:${SERVER_IP}`,
|
||||
`服务器SN:${SERVER_SN}`,
|
||||
`机架位置:${RACK}`,
|
||||
`到期时间:${bjTime(oemDeadline)}`,
|
||||
'',
|
||||
`服务器尚未录入恢复时间,请及时处理!`,
|
||||
].join('\n'))
|
||||
|
||||
// 3. 30分钟到期提醒
|
||||
await send('30分钟到期提醒', [
|
||||
`⚠️ OEM诊断工单即将到期(剩余30分钟)`,
|
||||
'',
|
||||
`工单号:${orderNum}`,
|
||||
`服务器IP:${SERVER_IP}`,
|
||||
`服务器SN:${SERVER_SN}`,
|
||||
`机架位置:${RACK}`,
|
||||
`到期时间:${bjTime(oemDeadline)}`,
|
||||
'',
|
||||
`服务器尚未录入恢复时间,请尽快处理!`,
|
||||
].join('\n'))
|
||||
|
||||
// 4. 超时提醒
|
||||
await send('超时提醒', [
|
||||
`⚠️ OEM诊断工单已超时`,
|
||||
'',
|
||||
`工单号:${orderNum}`,
|
||||
`服务器IP:${SERVER_IP}`,
|
||||
`服务器SN:${SERVER_SN}`,
|
||||
`机架位置:${RACK}`,
|
||||
`到期时间:${bjTime(oemDeadline)}`,
|
||||
'',
|
||||
`服务器尚未录入恢复时间,请尽快处理!`,
|
||||
].join('\n'))
|
||||
|
||||
// 5. 结单消息
|
||||
const closeTime = minusMinutes(now, -30)
|
||||
const durationMin = Math.floor((closeTime.getTime() - faultTime.getTime()) / 60000)
|
||||
const durH = Math.floor(durationMin / 60)
|
||||
const durM = durationMin % 60
|
||||
await send('结单消息', [
|
||||
`✅ ${orderNum}工单已结单`,
|
||||
'',
|
||||
`服务器IP:${SERVER_IP}`,
|
||||
`服务器SN:${SERVER_SN}`,
|
||||
`机架位置:${RACK}`,
|
||||
`故障类型:OEM诊断`,
|
||||
`故障时间:${bjTime(faultTime)}`,
|
||||
`结单时间:${bjTime(closeTime)}`,
|
||||
`本次处理时长:${durH}小时${durM}分钟`,
|
||||
].join('\n'))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// OEM 维修完整生命周期
|
||||
// ============================================================
|
||||
async function runOemRepair() {
|
||||
const now = new Date()
|
||||
const faultTime = minusHours(now, 4)
|
||||
const orderNum = 'S' + Date.now().toString().slice(-10)
|
||||
const fault = randomFrom(OEM_REPAIR_FAULTS)
|
||||
|
||||
// 五档截止时间
|
||||
const tiers = [
|
||||
{ name: '第一档', desc: '99%(含)以上(不扣费)', hours: 8, penalty: '10%' },
|
||||
{ name: '第二档', desc: '99%-97%(含)(扣10%)', hours: 24, penalty: '25%' },
|
||||
{ name: '第三档', desc: '97%-95%(含)(扣25%)', hours: 48, penalty: '50%' },
|
||||
{ name: '第四档', desc: '95%-90%(含)(扣50%)', hours: 72, penalty: '100%' },
|
||||
{ name: '第五档', desc: '90%(不含)以下(扣100%)', hours: 168, penalty: '' },
|
||||
]
|
||||
|
||||
const deadlines = tiers.map(t => ({
|
||||
...t,
|
||||
deadline: new Date(faultTime.getTime() + t.hours * 3600_000),
|
||||
}))
|
||||
|
||||
console.log('\n' + '═'.repeat(60))
|
||||
console.log(' OEM 维修 — 完整生命周期模拟')
|
||||
console.log('═'.repeat(60))
|
||||
console.log(`工单号: ${orderNum}`)
|
||||
console.log(`故障时间: ${bjTime(faultTime)}`)
|
||||
deadlines.forEach(d => console.log(` ${d.name}截止: ${bjTime(d.deadline)} (${d.hours}h)`))
|
||||
|
||||
// 1. 初始故障通知(含5档截止时间)
|
||||
const tierLines = deadlines.map((d, i) => {
|
||||
const emojis = ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣']
|
||||
return `${emojis[i]} ${d.desc}\n 截止时间:${bjTime(d.deadline)}(${d.hours}小时)`
|
||||
}).join('\n\n')
|
||||
|
||||
await send('初始通知', [
|
||||
`【服务器故障单】 - OEM维修`,
|
||||
'',
|
||||
`工单号:${orderNum}`,
|
||||
`服务器IP:${SERVER_IP}`,
|
||||
`服务器SN:${SERVER_SN}`,
|
||||
`机架位置:${RACK}`,
|
||||
`故障时间:${bjTime(faultTime)}`,
|
||||
`故障信息:${fault}`,
|
||||
'',
|
||||
`📊 服务可用性各档位截止时间(基于月度内累计故障时长计算):`,
|
||||
'',
|
||||
tierLines,
|
||||
].join('\n'))
|
||||
|
||||
// 2-5. 每个档位的提前1小时提醒
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const t = deadlines[i]
|
||||
const isFinalTier = t.name === '第四档'
|
||||
const tierLabel = isFinalTier ? '全额扣费(100%)' : `${t.name}(${t.penalty})`
|
||||
await send(`${t.name}提前1小时提醒`, [
|
||||
`OEM维修工单超时提醒(距离${tierLabel}还剩1小时)`,
|
||||
'',
|
||||
`工单号:${orderNum}`,
|
||||
`服务器IP:${SERVER_IP}`,
|
||||
`服务器SN:${SERVER_SN}`,
|
||||
`机架位置:${RACK}`,
|
||||
`${t.name}到期时间:${bjTime(t.deadline)}`,
|
||||
'',
|
||||
`服务器尚未录入恢复时间,请及时处理!`,
|
||||
].join('\n'))
|
||||
}
|
||||
|
||||
// 6-9. 每个档位的超时提醒
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const t = deadlines[i]
|
||||
const isFinalTier = t.name === '第四档'
|
||||
const title = isFinalTier
|
||||
? `OEM维修工单超时提醒(${t.name}已超时,已触发全额扣费(100%))`
|
||||
: `OEM维修工单超时提醒(${t.name}已超时,达到${t.name}扣${t.penalty})`
|
||||
await send(`${t.name}超时提醒`, [
|
||||
title,
|
||||
'',
|
||||
`工单号:${orderNum}`,
|
||||
`服务器IP:${SERVER_IP}`,
|
||||
`服务器SN:${SERVER_SN}`,
|
||||
`机架位置:${RACK}`,
|
||||
`${t.name}到期时间:${bjTime(t.deadline)}`,
|
||||
'',
|
||||
`服务器尚未录入恢复时间,请尽快处理!`,
|
||||
].join('\n'))
|
||||
}
|
||||
|
||||
// 10. 结单消息(含本月统计)
|
||||
const closeTime = minusMinutes(now, -30)
|
||||
const durationMin = Math.floor((closeTime.getTime() - faultTime.getTime()) / 60000)
|
||||
const durH = Math.floor(durationMin / 60)
|
||||
const durM = durationMin % 60
|
||||
await send('结单消息', [
|
||||
`✅ ${orderNum}工单已结单`,
|
||||
'',
|
||||
`服务器IP:${SERVER_IP}`,
|
||||
`服务器SN:${SERVER_SN}`,
|
||||
`机架位置:${RACK}`,
|
||||
`故障类型:OEM维修`,
|
||||
`故障时间:${bjTime(faultTime)}`,
|
||||
`结单时间:${bjTime(closeTime)}`,
|
||||
`本次处理时长:${durH}小时${durM}分钟`,
|
||||
`本月故障次数:3次`,
|
||||
`本月总处理时长:18小时30分钟`,
|
||||
].join('\n'))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 主流程
|
||||
// ============================================================
|
||||
async function main() {
|
||||
console.log('🚀 开始模拟企业微信消息推送')
|
||||
console.log(`Webhook: ${WEBHOOK_URL.slice(0, 50)}...`)
|
||||
console.log(`服务器: ${SERVER_IP} (${SERVER_SN})`)
|
||||
console.log(`机架: ${RACK}`)
|
||||
console.log(`间隔: ${INTERVAL / 1000}s`)
|
||||
|
||||
console.log('\n' + '═'.repeat(60))
|
||||
console.log(' 第一部分:OEM 诊断(5 条消息)')
|
||||
console.log('═'.repeat(60))
|
||||
await runOemDiag()
|
||||
|
||||
console.log('\n⏸️ 等待 10 秒...\n')
|
||||
await sleep(10000)
|
||||
|
||||
console.log('═'.repeat(60))
|
||||
console.log(' 第二部分:OEM 维修(10 条消息)')
|
||||
console.log('═'.repeat(60))
|
||||
await runOemRepair()
|
||||
|
||||
console.log('\n' + '═'.repeat(60))
|
||||
console.log('✅ 全部消息发送完成(共 15 条)')
|
||||
console.log('═'.repeat(60))
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(e); process.exit(1) })
|
||||
Loading…
Reference in New Issue