diff --git a/.github/workflows/build-artifact.yml b/.github/workflows/build-artifact.yml index ab5814e..8c4039e 100644 --- a/.github/workflows/build-artifact.yml +++ b/.github/workflows/build-artifact.yml @@ -1,12 +1,17 @@ name: Build Artifact on: - push: - branches-ignore: - - main pull_request: branches: - main + paths: + - 'src/**' + workflow_dispatch: + inputs: + ref: + description: '要构建的分支或标签' + required: true + default: 'main' permissions: contents: read @@ -16,7 +21,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - + with: + ref: ${{ inputs.ref || github.ref }} - name: Run make run: | make diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml deleted file mode 100644 index b5070f0..0000000 --- a/.github/workflows/dev-release.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Development Release - -on: - push: - branches: - - main - -permissions: - contents: write - -jobs: - build-and-release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Run make - run: | - make - - - name: Update Latest Release - uses: "marvinpinto/action-automatic-releases@latest" - with: - repo_token: "${{ secrets.GITHUB_TOKEN }}" - automatic_release_tag: "latest" - prerelease: true - title: "Development Build (Latest)" - files: | - yewresin.sh - .env.example diff --git a/.github/workflows/go-build-artifact.yml b/.github/workflows/go-build-artifact.yml new file mode 100644 index 0000000..847ba17 --- /dev/null +++ b/.github/workflows/go-build-artifact.yml @@ -0,0 +1,61 @@ +name: Go Build Artifact + +on: + pull_request: + branches: + - main + paths: + - 'go/**' + workflow_dispatch: + inputs: + ref: + description: '要构建的分支或标签' + required: true + default: 'main' + platform: + description: '选择构建平台' + required: true + default: 'all' + type: choice + options: + - all + - linux + - darwin + - windows + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.23' + + - name: Run tests + working-directory: go + run: go test -v ./... + + - name: Build selected platform + working-directory: go + run: make ${{ inputs.platform || 'all' }} + + - name: Prepare artifact root + run: | + mkdir -p artifacts + cp go/dist/* artifacts/ + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: yewresin-go-${{ github.sha }} + path: | + artifacts/* + retention-days: 7 diff --git a/.github/workflows/go-prod-release.yml b/.github/workflows/go-prod-release.yml new file mode 100644 index 0000000..c07de5f --- /dev/null +++ b/.github/workflows/go-prod-release.yml @@ -0,0 +1,38 @@ +name: Go Production Release + +on: + push: + tags: + - "v2*" + +permissions: + contents: write + +jobs: + build-and-release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.23' + + - name: Run tests + working-directory: go + run: go test -v ./... + + - name: Build all platforms + working-directory: go + run: VERSION=${{ github.ref_name }} make all + + - name: Create Tagged Release + uses: softprops/action-gh-release@v2 + with: + files: | + go/dist/* + .env.example + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..fc53e96 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,76 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## 项目概述 + +YewResin 是一个 Docker Compose 服务的自动化备份脚本,使用 Kopia + rclone 实现本地快照与云端同步。脚本会依次停止所有 Docker 服务,创建一致性快照,然后按优先级恢复服务。 + +## 构建命令 + +```bash +# 合并 src/ 模块生成 yewresin.sh(必须在修改代码后执行) +make build + +# 清理生成的脚本 +make clean +``` + +## 架构 + +脚本采用模块化设计,源代码在 `src/` 目录下,按数字前缀顺序拼接: + +| 模块 | 职责 | +|------|------| +| `00-header.sh` | shebang、set -eo pipefail、记录开始时间 | +| `01-logging.sh` | 日志输出(tee 到文件和终端)、`log()` 函数 | +| `02-args.sh` | 命令行参数解析(`--dry-run`、`-y`、`--help`) | +| `03-config.sh` | 配置加载(从 `.env` 读取)、默认值、`print_config()` | +| `04-utils.sh` | 通用工具函数 | +| `05-notification.sh` | Apprise 通知发送 | +| `06-gist.sh` | GitHub Gist 日志上传和清理 | +| `07-dependencies.sh` | 依赖检查(rclone、kopia) | +| `08-services.sh` | Docker 服务管理:停止、启动、状态检查、cleanup | +| `09-main.sh` | 主流程:停止服务 → Kopia 快照 → 启动服务 | + +**核心函数在 `08-services.sh`:** +- `stop_all_services()` / `start_all_services()` - 批量服务管理 +- `is_service_running()` - 检测服务运行状态 +- `cleanup()` - 异常退出时自动恢复服务(trap EXIT) + +**服务启停优先级逻辑:** +- 优先服务(`PRIORITY_SERVICES`):最后停止,最先启动(如网关 caddy/nginx) +- 普通服务:先停止,后启动 +- 只恢复原本在运行的服务(通过 `RUNNING_SERVICES` 关联数组追踪) + +## 开发流程 + +1. 修改 `src/` 下的模块文件 +2. 执行 `make build` 重新生成 `yewresin.sh` +3. 提交 `src/`、`Makefile` 和 `yewresin.sh` + +## 运行与测试 + +```bash +# 本地模拟运行(不执行实际操作) +./yewresin.sh --dry-run + +# 执行备份(需确认) +./yewresin.sh + +# 跳过确认(用于 cron) +./yewresin.sh -y +``` + +## 配置 + +必须在 `.env` 中设置: +- `BASE_DIR` - Docker Compose 项目总目录 +- `EXPECTED_REMOTE` - Kopia 远程路径(如 `gdrive:backup`) + +可参考 `.env.example` 查看完整配置项。 + +## CI/CD + +- `dev-release.yml` - main 分支推送后自动构建并发布到 `latest` tag +- `prod-release.yml` - 手动触发正式版本发布 diff --git a/README.md b/README.md index 9d6a73d..174f47d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,12 @@ # YewResin - Docker 服务备份工具 -一个自动化的 Docker Compose 服务备份脚本,使用 Kopia + rclone 实现本地快照与云端同步。 +一个自动化的 Docker Compose 服务备份工具,使用 Kopia + rclone 实现本地快照与云端同步。 + +提供两个版本: +- **Shell 版本** (`v1.x`) - 轻量级 Bash 脚本,适合简单部署 +- **Go 版本** (`v2.x`) - 跨平台二进制,性能更优,并行处理 + +两个版本的核心功能完全一致,依赖也相同,可根据使用场景自由选择。 ## 功能特点 @@ -38,36 +44,49 @@ sudo apt update && sudo apt install kopia # 连接 Kopia 仓库 kopia repository connect rclone --remote-path="gdrive:backup" +``` + +### 2. 下载 YewResin -# 下载该脚本 -mkdir ~/yewresin -cd ~/yewresin +#### Shell 版本 (v1.x) + +```bash +mkdir ~/yewresin && cd ~/yewresin wget https://github.com/YewFence/YewResin/releases/download/latest/yewresin.sh +chmod +x yewresin.sh ``` -该标签内的脚本会在main分支推送后自动更新,也可以自行下载指定版本的脚本 +#### Go 版本 (v2.x) -> 也可以下载源码后自定义逻辑 -> ```bash -> git clone https://github.com/YewFence/YewResin.git -> cd YewResin -> ``` -> 然后自行更改 `src/` 下的各个模块,可能需要自定义的有 `src/08-services.sh` 内路径内是否含有服务的识别逻辑,启停服务的脚本的名称/具体的命令 -> -> 然后,使用 -> ```bash -> make -> ``` -> 生成最终脚本,它会输出在项目根目录的 `yewresin.sh` +```bash +mkdir ~/yewresin && cd ~/yewresin +# 根据系统架构选择对应的二进制文件 +# Linux x64 +wget https://github.com/YewFence/YewResin/releases/latest/download/yewresin-linux-amd64 -O yewresin +# Linux ARM64 +wget https://github.com/YewFence/YewResin/releases/latest/download/yewresin-linux-arm64 -O yewresin +# macOS Apple Silicon +wget https://github.com/YewFence/YewResin/releases/latest/download/yewresin-darwin-arm64 -O yewresin +# macOS Intel +wget https://github.com/YewFence/YewResin/releases/latest/download/yewresin-darwin-amd64 -O yewresin +# Windows +# 下载 yewresin-windows-amd64.exe + +chmod +x yewresin +``` -### 2. 配置 +> `latest` 标签会在 main 分支推送后自动更新,也可以下载指定版本: +> - Shell 版本:`v1.x.x` 标签 +> - Go 版本:`v2.x.x` 标签 -创建 `.env` 文件(与 `yewresin.sh` 同目录): +### 3. 配置 + +创建 `.env` 文件(与 yewresin 同目录): ```bash # 在脚本所在目录下载示例文件 -wget https://github.com/YewFence/YewResin/releases/download/latest/default.env.example -cp default.env.example .env +wget https://github.com/YewFence/YewResin/releases/download/latest/.env.example +cp .env.example .env ``` 必要环境变量配置: @@ -78,23 +97,26 @@ BASE_DIR=/opt/docker_file EXPECTED_REMOTE=gdrive:backup ``` -### 3. 运行 +### 4. 运行 ```bash # 模拟运行(推荐先测试) -./yewresin.sh --dry-run +./yewresin --dry-run # Go 版本 +./yewresin.sh --dry-run # Shell 版本 # 执行备份(需确认) +./yewresin ./yewresin.sh # 跳过确认直接执行(适用于 cron) +./yewresin -y ./yewresin.sh -y ``` -### 4. 定时任务 +### 5. 定时任务 > 按需配置,此处我们以每天北京时间凌晨三点运行为例(假设服务器使用 UTC 时区) ```bash -(crontab -l 2>/dev/null; echo '0 19 * * * /path/to/yewresin.sh -y') | crontab - +(crontab -l 2>/dev/null; echo '0 19 * * * /path/to/yewresin -y') | crontab - ``` > **注意**: @@ -103,37 +125,40 @@ EXPECTED_REMOTE=gdrive:backup ## 命令行参数 -| 参数 | 说明 | -|------|------| -| `--dry-run`, `-n` | 模拟运行,只检查依赖和显示操作,不实际执行 | -| `-y`, `--yes` | 跳过交互式确认 | -| `--help`, `-h` | 显示帮助信息 | +| 参数 | 说明 | Shell | Go | +|------|------|:-----:|:--:| +| `--dry-run`, `-n` | 模拟运行,只检查依赖和显示操作,不实际执行 | ✓ | ✓ | +| `-y`, `--yes` | 跳过交互式确认 | ✓ | ✓ | +| `--help`, `-h` | 显示帮助信息 | ✓ | ✓ | +| `--config ` | 指定配置文件路径 | - | ✓ | +| `--version` | 显示版本信息 | - | ✓ | ## 环境变量 -| 变量 | 默认值 | 说明 | -|------|--------|------| -| `BASE_DIR` | - | Docker Compose 项目目录 | -| `EXPECTED_REMOTE` | - | Kopia 远程路径 | -| `KOPIA_PASSWORD` | - | Kopia 远程仓库密码 | -| `PRIORITY_SERVICES_LIST` | `caddy nginx gateway` | 优先服务列表(空格分隔) | -| `LOCK_FILE` | `/tmp/backup_maintenance.lock` | 锁文件路径 | -| `LOG_FILE` | 脚本同目录下 `yewresin.log` | 日志文件路径 | -| `DEVICE_NAME` | - | 设备名称,用于区分不同服务器的通知 | -| `APPRISE_URL` | - | Apprise 服务地址 | -| `APPRISE_NOTIFY_URL` | - | 通知目标 URL | -| `GIST_TOKEN` | - | GitHub Personal Access Token(需要 gist 权限)| -| `GIST_ID` | - | GitHub Gist ID(日志上传目标)| -| `GIST_LOG_PREFIX` | `yewresin-backup` | Gist 日志文件名前缀 | -| `GIST_MAX_LOGS` | `30` | Gist 最大保留日志数量(设为 0 禁用清理)| -| `GIST_KEEP_FIRST_FILE` | `true` | 清理时保留第一个文件(用于自定义 Gist 标题)| -| `CONFIG_FILE` | `./yewresin.sh` 同目录的 `.env` | 配置文件路径 | +| 变量 | 默认值 | 说明 | Shell | Go | +|------|--------|------|:-----:|:--:| +| `BASE_DIR` | - | Docker Compose 项目目录 | ✓ | ✓ | +| `EXPECTED_REMOTE` | - | Kopia 远程路径 | ✓ | ✓ | +| `KOPIA_PASSWORD` | - | Kopia 远程仓库密码 | ✓ | ✓ | +| `PRIORITY_SERVICES_LIST` | `caddy nginx gateway` | 优先服务列表(空格分隔) | ✓ | ✓ | +| `LOCK_FILE` | `/tmp/backup_maintenance.lock` | 锁文件路径 | ✓ | ✓ | +| `LOG_FILE` | 脚本同目录下 `yewresin.log` | 日志文件路径 | ✓ | ✓ | +| `DOCKER_COMMAND_TIMEOUT_SECONDS` | `120` | Docker 命令超时时间(秒) | - | ✓ | +| `DEVICE_NAME` | - | 设备名称,用于区分不同服务器的通知 | ✓ | ✓ | +| `APPRISE_URL` | - | Apprise 服务地址 | ✓ | ✓ | +| `APPRISE_NOTIFY_URL` | - | 通知目标 URL | ✓ | ✓ | +| `GIST_TOKEN` | - | GitHub Personal Access Token(需要 gist 权限)| ✓ | ✓ | +| `GIST_ID` | - | GitHub Gist ID(日志上传目标)| ✓ | ✓ | +| `GIST_LOG_PREFIX` | `yewresin-backup` | Gist 日志文件名前缀 | ✓ | ✓ | +| `GIST_MAX_LOGS` | `30` | Gist 最大保留日志数量(设为 0 禁用清理)| ✓ | ✓ | +| `GIST_KEEP_FIRST_FILE` | `true` | 清理时保留第一个文件(用于自定义 Gist 标题)| ✓ | ✓ | +| `CONFIG_FILE` | `./yewresin.sh` 同目录的 `.env` | 配置文件路径 | ✓ | ✓ | ## 关键要求 ### 目录结构要求 -``` +```text /opt/docker_file/ # BASE_DIR ├── caddy/ # 网关服务 │ ├── compose.yaml # 支持多种命名格式 @@ -171,13 +196,17 @@ EXPECTED_REMOTE=gdrive:backup ## 开发说明 +项目包含两个版本的实现,核心逻辑一致。 + +### Shell 版本 + 脚本采用模块化结构,源代码位于 `src/` 目录,通过 Makefile 合并生成最终的 `yewresin.sh`。 -### 源码结构 +#### 源码结构 ``` YewResin/ -├── yewresin.sh # 生成的脚本(由 make build 生成) +├── yewresin.sh # 生成的脚本(由 make build 生成) ├── Makefile # 构建工具 └── src/ # 模块源文件 ├── 00-header.sh # shebang 和初始化 @@ -192,25 +221,74 @@ YewResin/ └── 09-main.sh # 主流程逻辑 ``` -### 构建命令 +#### 构建命令 ```bash -# 合并模块生成 yewresin.sh -make build - -# 删除生成的 yewresin.sh -make clean - -# 查看帮助 -make help +make build # 合并模块生成 yewresin.sh +make clean # 删除生成的 yewresin.sh +make help # 查看帮助 ``` -### 开发流程 +#### 开发流程 1. 修改 `src/` 目录下的模块文件 2. 运行 `make build` 重新生成 `yewresin.sh` 3. 提交 `src/`、`Makefile` 和 `yewresin.sh` +### Go 版本 + +Go 版本位于 `go/` 目录,提供跨平台支持和并行处理能力。 + +#### 源码结构 + +``` +go/ +├── main.go # 程序入口,CLI 参数解析 +├── orchestrator.go # 备份流程编排器 +├── config.go # 配置管理 +├── docker.go # Docker Compose 服务管理 +├── backup.go # Kopia 备份操作 +├── logger.go # 日志系统 +├── gist.go # GitHub Gist 日志上传 +├── notify.go # Apprise 通知 +├── Makefile # 交叉编译脚本 +└── dist/ # 编译产物目录 +``` + +#### 构建命令 + +```bash +cd go +make build # 构建当前平台 +make all # 构建所有平台 (linux/darwin/windows) +make linux # 仅构建 Linux (amd64, arm64) +make darwin # 仅构建 macOS (amd64, arm64) +make windows # 仅构建 Windows (amd64) +make test # 运行测试 +make clean # 清理构建产物 +make help # 查看帮助 + +# 指定版本构建 +VERSION=v2.0.0 make all +``` + +#### 开发流程 + +1. 修改 `go/` 目录下的源文件 +2. 运行 `make test` 确保测试通过 +3. 运行 `make build` 构建当前平台进行本地测试 +4. 提交代码 + +### 版本号规则 + +- **Shell 版本**:`v1.x.x` 标签 +- **Go 版本**:`v2.x.x` 标签 + +两者共用 CI/CD 流程,通过主版本号区分: +- `latest` 标签:包含两个版本的最新开发构建 +- `v1.*` 标签:触发 Shell 版本正式发布 +- `v2.*` 标签:触发 Go 版本正式发布 + ## 工作流程 1. 检查依赖(rclone、kopia) diff --git a/go/.gitignore b/go/.gitignore new file mode 100644 index 0000000..a15f154 --- /dev/null +++ b/go/.gitignore @@ -0,0 +1,4 @@ +*.exe +dist/ + +yewresin \ No newline at end of file diff --git a/go/Makefile b/go/Makefile new file mode 100644 index 0000000..122837b --- /dev/null +++ b/go/Makefile @@ -0,0 +1,100 @@ +# YewResin Go 版本构建脚本 +# 支持多平台交叉编译 + +BINARY := yewresin +DIST_DIR := dist + +# 版本号:优先使用 VERSION 环境变量,否则尝试 git tag,最后使用时间戳 +VERSION ?= $(shell git describe --tags --always 2>/dev/null || date -u +"%Y%m%d.%H%M%S") + +# 构建标志 +LDFLAGS := -s -w -X main.version=$(VERSION) +GO_BUILD := CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" + +# 目标平台 +PLATFORMS := linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 + +.PHONY: build all linux darwin windows clean test help + +# 默认目标:构建当前平台 +build: + @echo "Building $(BINARY) $(VERSION) for current platform..." + $(GO_BUILD) -o $(BINARY) . + @echo "Done: $(BINARY)" + +# 构建所有平台 +all: clean + @echo "Building $(BINARY) $(VERSION) for all platforms..." + @mkdir -p $(DIST_DIR) + @$(MAKE) --no-print-directory linux + @$(MAKE) --no-print-directory darwin + @$(MAKE) --no-print-directory windows + @echo "" + @echo "All builds completed:" + @ls -lh $(DIST_DIR)/ + +# Linux 平台 +linux: $(DIST_DIR)/$(BINARY)-linux-amd64 $(DIST_DIR)/$(BINARY)-linux-arm64 + +$(DIST_DIR)/$(BINARY)-linux-amd64: + @mkdir -p $(DIST_DIR) + @echo " Building linux/amd64..." + @GOOS=linux GOARCH=amd64 $(GO_BUILD) -o $@ . + +$(DIST_DIR)/$(BINARY)-linux-arm64: + @mkdir -p $(DIST_DIR) + @echo " Building linux/arm64..." + @GOOS=linux GOARCH=arm64 $(GO_BUILD) -o $@ . + +# macOS 平台 +darwin: $(DIST_DIR)/$(BINARY)-darwin-amd64 $(DIST_DIR)/$(BINARY)-darwin-arm64 + +$(DIST_DIR)/$(BINARY)-darwin-amd64: + @mkdir -p $(DIST_DIR) + @echo " Building darwin/amd64..." + @GOOS=darwin GOARCH=amd64 $(GO_BUILD) -o $@ . + +$(DIST_DIR)/$(BINARY)-darwin-arm64: + @mkdir -p $(DIST_DIR) + @echo " Building darwin/arm64..." + @GOOS=darwin GOARCH=arm64 $(GO_BUILD) -o $@ . + +# Windows 平台 +windows: $(DIST_DIR)/$(BINARY)-windows-amd64.exe + +$(DIST_DIR)/$(BINARY)-windows-amd64.exe: + @mkdir -p $(DIST_DIR) + @echo " Building windows/amd64..." + @GOOS=windows GOARCH=amd64 $(GO_BUILD) -o $@ . + +# 清理构建产物 +clean: + @echo "Cleaning..." + @rm -rf $(DIST_DIR) + @rm -f $(BINARY) $(BINARY).exe + @echo "Done" + +# 运行测试 +test: + @echo "Running tests..." + @go test -v ./... + +help: + @echo "YewResin Go 版本构建脚本" + @echo "" + @echo "用法:" + @echo " make - 构建当前平台" + @echo " make all - 构建所有平台" + @echo " make linux - 构建 Linux (amd64, arm64)" + @echo " make darwin - 构建 macOS (amd64, arm64)" + @echo " make windows - 构建 Windows (amd64)" + @echo " make test - 运行测试" + @echo " make clean - 清理构建产物" + @echo " make help - 显示帮助信息" + @echo "" + @echo "环境变量:" + @echo " VERSION - 指定版本号 (默认: git tag 或时间戳)" + @echo "" + @echo "示例:" + @echo " make all # 构建所有平台" + @echo " VERSION=v2.0.0 make all # 指定版本构建" diff --git a/go/TODO.md b/go/TODO.md new file mode 100644 index 0000000..211c36a --- /dev/null +++ b/go/TODO.md @@ -0,0 +1,15 @@ +# YewResin Go 版本 TODO + +## 待实现功能 + +- [x] Gist 日志上传(参考 src/06-gist.sh) +- [x] 并行停止/启动服务(goroutine + sync.WaitGroup) +- [x] 日志文件输出(当前只输出到终端) +- [x] 交叉编译脚本 / Makefile +- [x] GitHub Actions CI/CD + +## 可选改进 + +- [x] 单元测试 +- [ ] 更结构化的配置格式(YAML/TOML) +- [ ] JSON 日志格式输出选项 diff --git a/go/backup.go b/go/backup.go new file mode 100644 index 0000000..924eb76 --- /dev/null +++ b/go/backup.go @@ -0,0 +1,121 @@ +package main + +import ( + "encoding/json" + "fmt" + "log/slog" + "os" + "os/exec" +) + +// KopiaBackup Kopia 备份管理器 +type KopiaBackup struct { + expectedRemote string + password string + dryRun bool +} + +// NewKopiaBackup 创建 Kopia 备份管理器 +func NewKopiaBackup(expectedRemote, password string, dryRun bool) *KopiaBackup { + return &KopiaBackup{ + expectedRemote: expectedRemote, + password: password, + dryRun: dryRun, + } +} + +// CheckDependencies 检查 Kopia 和 rclone 是否已安装 +func (k *KopiaBackup) CheckDependencies() error { + // 检查 kopia + if _, err := exec.LookPath("kopia"); err != nil { + return fmt.Errorf("kopia 未安装,请先安装: https://kopia.io/docs/installation/") + } + + // 检查 rclone + if _, err := exec.LookPath("rclone"); err != nil { + return fmt.Errorf("rclone 未安装,请先安装: https://rclone.org/downloads/") + } + + slog.Info("依赖检查通过", "kopia", "✓", "rclone", "✓") + return nil +} + +// CheckRepository 检查 Kopia 仓库连接状态 +func (k *KopiaBackup) CheckRepository() error { + // 先检查仓库状态 + cmd := exec.Command("kopia", "repository", "status", "--json") + output, err := cmd.CombinedOutput() + + if err != nil { + // 仓库未连接,尝试连接 + slog.Info("Kopia 仓库未连接,尝试连接...") + if k.password == "" { + return fmt.Errorf("KOPIA_PASSWORD 未设置,无法自动连接仓库") + } + return k.connectRepository() + } + + var status struct { + Storage struct { + Type string `json:"type"` + Config struct { + RemotePath string `json:"remotePath"` + } `json:"config"` + } `json:"storage"` + } + + if err := json.Unmarshal(output, &status); err != nil { + return fmt.Errorf("解析 Kopia 仓库状态失败: %w", err) + } + + if status.Storage.Config.RemotePath == "" { + return fmt.Errorf("Kopia 仓库状态缺少 remotePath") + } + + if status.Storage.Config.RemotePath != k.expectedRemote { + return fmt.Errorf("Kopia 仓库路径不匹配,期望: %s", k.expectedRemote) + } + + slog.Info("Kopia 仓库已连接", "remote", k.expectedRemote) + return nil +} + + +// connectRepository 连接 Kopia 仓库 +func (k *KopiaBackup) connectRepository() error { + cmd := exec.Command("kopia", "repository", "connect", "rclone", + "--remote-path="+k.expectedRemote) + + // 设置密码环境变量 + cmd.Env = append(os.Environ(), "KOPIA_PASSWORD="+k.password) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("连接 Kopia 仓库失败: %w", err) + } + + slog.Info("Kopia 仓库连接成功") + return nil +} + +// CreateSnapshot 创建快照 +func (k *KopiaBackup) CreateSnapshot(path string) error { + if k.dryRun { + slog.Info("[DRY-RUN] 将执行快照", "path", path) + return nil + } + + slog.Info("开始创建快照", "path", path) + + cmd := exec.Command("kopia", "snapshot", "create", path) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("创建快照失败: %w", err) + } + + slog.Info("快照创建成功") + return nil +} diff --git a/go/config.go b/go/config.go new file mode 100644 index 0000000..c5e923d --- /dev/null +++ b/go/config.go @@ -0,0 +1,186 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/joho/godotenv" +) + +// Config 应用配置 +type Config struct { + // 必填配置 + BaseDir string // Docker Compose 项目总目录 + ExpectedRemote string // Kopia 远程路径 + + // 服务管理 + PriorityServices []string // 优先服务列表(最后停,最先启) + DockerCommandTimeoutSeconds int // Docker 命令超时时间(秒) + + // 文件路径 + LockFile string // 锁文件路径 + LogFile string // 日志文件路径 + + // 通知配置 + DeviceName string // 设备名称(用于通知标题) + AppriseURL string // Apprise 服务地址 + AppriseNotifyURL string // 通知目标 URL + + // Gist 配置 + GistToken string // GitHub Token + GistID string // Gist ID + GistLogPrefix string // 日志文件名前缀 + GistMaxLogs int // 最大保留日志数 + GistKeepFirstFile bool // 清理时保留第一个文件 + + // Kopia + KopiaPassword string // Kopia 仓库密码 +} + +// LoadConfig 从 .env 文件和环境变量加载配置 +func LoadConfig(configPath string) (*Config, error) { + originalPath := configPath + // 确定配置文件路径 + if configPath == "" { + // 默认使用程序所在目录的 .env + exe, err := os.Executable() + if err != nil { + return nil, fmt.Errorf("获取程序路径失败: %w", err) + } + configPath = filepath.Join(filepath.Dir(exe), ".env") + } + + // 加载 .env 文件(如果存在) + if _, err := os.Stat(configPath); err != nil { + if originalPath == "" && os.IsNotExist(err) { + // 默认路径不存在时允许继续 + } else if originalPath == "" { + return nil, fmt.Errorf("检查配置文件失败: %w", err) + } else { + return nil, fmt.Errorf("配置文件不存在或不可访问: %w", err) + } + } else { + if err := godotenv.Load(configPath); err != nil { + return nil, fmt.Errorf("加载配置文件失败: %w", err) + } + } + + cfg := &Config{ + // 必填项 + BaseDir: os.Getenv("BASE_DIR"), + ExpectedRemote: os.Getenv("EXPECTED_REMOTE"), + + // 默认值 + LockFile: getEnvDefault("LOCK_FILE", "/tmp/backup_maintenance.lock"), + LogFile: getEnvDefault("LOG_FILE", ""), + DockerCommandTimeoutSeconds: getEnvInt("DOCKER_COMMAND_TIMEOUT_SECONDS", 120), + + // 通知 + DeviceName: os.Getenv("DEVICE_NAME"), + AppriseURL: os.Getenv("APPRISE_URL"), + AppriseNotifyURL: os.Getenv("APPRISE_NOTIFY_URL"), + + // Gist + GistToken: os.Getenv("GIST_TOKEN"), + GistID: os.Getenv("GIST_ID"), + GistLogPrefix: getEnvDefault("GIST_LOG_PREFIX", "yewresin-backup"), + GistMaxLogs: getEnvInt("GIST_MAX_LOGS", 30), + GistKeepFirstFile: getEnvBool("GIST_KEEP_FIRST_FILE", true), + + // Kopia + KopiaPassword: os.Getenv("KOPIA_PASSWORD"), + } + + // 解析优先服务列表 + priorityList := getEnvDefault("PRIORITY_SERVICES_LIST", "caddy nginx gateway") + cfg.PriorityServices = strings.Fields(priorityList) + + // 验证必填项 + if cfg.BaseDir == "" { + return nil, fmt.Errorf("BASE_DIR 未设置") + } + if cfg.ExpectedRemote == "" { + return nil, fmt.Errorf("EXPECTED_REMOTE 未设置") + } + + // 检查 BASE_DIR 是否存在 + if info, err := os.Stat(cfg.BaseDir); err != nil || !info.IsDir() { + return nil, fmt.Errorf("BASE_DIR 目录不存在: %s", cfg.BaseDir) + } + + return cfg, nil +} + +// Print 打印配置信息(敏感信息脱敏) +func (c *Config) Print() { + fmt.Println() + fmt.Println("==========================================") + fmt.Println("当前配置信息") + fmt.Println("==========================================") + + printField("BASE_DIR(工作目录)", c.BaseDir) + printField("EXPECTED_REMOTE(Kopia远程仓库)", c.ExpectedRemote) + printField("PRIORITY_SERVICES(优先服务)", strings.Join(c.PriorityServices, ", ")) + printField("DOCKER_COMMAND_TIMEOUT_SECONDS(Docker超时秒)", fmt.Sprintf("%d", c.DockerCommandTimeoutSeconds)) + printField("LOCK_FILE(锁文件路径)", c.LockFile) + + if c.DeviceName != "" { + printField("DEVICE_NAME(设备名称)", c.DeviceName) + } + + if c.GistToken != "" && c.GistID != "" { + printField("GIST_ID", c.GistID) + printField("GIST_TOKEN", "******(已配置)") + } + + if c.KopiaPassword != "" { + printField("KOPIA_PASSWORD", "******(已配置)") + } + + if c.AppriseURL != "" { + printField("APPRISE_URL", maskString(c.AppriseURL)) + } + + fmt.Println("==========================================") + fmt.Println() +} + +// 辅助函数 + +func getEnvDefault(key, defaultVal string) string { + if val := os.Getenv(key); val != "" { + return val + } + return defaultVal +} + +func getEnvInt(key string, defaultVal int) int { + if val := os.Getenv(key); val != "" { + if i, err := strconv.Atoi(val); err == nil { + return i + } + } + return defaultVal +} + +func getEnvBool(key string, defaultVal bool) bool { + if val := os.Getenv(key); val != "" { + val = strings.ToLower(val) + return val == "true" || val == "1" || val == "yes" + } + return defaultVal +} + +func printField(name, value string) { + fmt.Printf(" %-40s %s\n", name+":", value) +} + +func maskString(s string) string { + if len(s) <= 15 { + return "****(已配置)" + } + return s[:8] + "..." + s[len(s)-4:] +} diff --git a/go/config_test.go b/go/config_test.go new file mode 100644 index 0000000..bcf7c85 --- /dev/null +++ b/go/config_test.go @@ -0,0 +1,136 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeTempEnvFile(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "test.env") + if err := os.WriteFile(path, []byte(""), 0o600); err != nil { + t.Fatalf("failed to write temp env: %v", err) + } + return path +} + +func TestGetEnvDefault(t *testing.T) { + t.Setenv("TEST_DEFAULT", "") + if got := getEnvDefault("TEST_DEFAULT", "fallback"); got != "fallback" { + t.Fatalf("expected fallback, got %q", got) + } + + t.Setenv("TEST_DEFAULT", "value") + if got := getEnvDefault("TEST_DEFAULT", "fallback"); got != "value" { + t.Fatalf("expected value, got %q", got) + } +} + +func TestGetEnvInt(t *testing.T) { + t.Setenv("TEST_INT", "") + if got := getEnvInt("TEST_INT", 7); got != 7 { + t.Fatalf("expected default 7, got %d", got) + } + + t.Setenv("TEST_INT", "42") + if got := getEnvInt("TEST_INT", 7); got != 42 { + t.Fatalf("expected 42, got %d", got) + } + // Test invalid input - should return default + t.Setenv("TEST_INT", "invalid") + if got := getEnvInt("TEST_INT", 7); got != 7 { + t.Fatalf("expected default 7 for invalid input, got %d", got) + } +} + +func TestGetEnvBool(t *testing.T) { + cases := []struct { + val string + want bool + }{ + {"true", true}, + {"1", true}, + {"yes", true}, + {"false", false}, + {"0", false}, + {"no", false}, + } + + for _, c := range cases { + t.Setenv("TEST_BOOL", c.val) + if got := getEnvBool("TEST_BOOL", false); got != c.want { + t.Fatalf("val=%q expected %v, got %v", c.val, c.want, got) + } + } + + t.Setenv("TEST_BOOL", "") + if got := getEnvBool("TEST_BOOL", true); got != true { + t.Fatalf("expected default true, got %v", got) + } +} + +func TestMaskString(t *testing.T) { + if got := maskString("short"); got != "****(已配置)" { + t.Fatalf("expected masked short, got %q", got) + } + + longVal := "https://example.com/path" + if got := maskString(longVal); got != "https://...path" { + t.Fatalf("expected masked long, got %q", got) + } +} + +func TestLoadConfigSuccess(t *testing.T) { + baseDir := t.TempDir() + t.Setenv("BASE_DIR", baseDir) + t.Setenv("EXPECTED_REMOTE", "rclone:backup") + t.Setenv("PRIORITY_SERVICES_LIST", "db api") + + cfg, err := LoadConfig(writeTempEnvFile(t)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cfg.BaseDir != baseDir { + t.Fatalf("expected BaseDir %q, got %q", baseDir, cfg.BaseDir) + } + if cfg.ExpectedRemote != "rclone:backup" { + t.Fatalf("expected ExpectedRemote, got %q", cfg.ExpectedRemote) + } + if got := strings.Join(cfg.PriorityServices, ","); got != "db,api" { + t.Fatalf("unexpected PriorityServices: %q", got) + } +} + +func TestLoadConfigMissingBaseDir(t *testing.T) { + t.Setenv("BASE_DIR", "") + t.Setenv("EXPECTED_REMOTE", "rclone:backup") + + _, err := LoadConfig(writeTempEnvFile(t)) + if err == nil || !strings.Contains(err.Error(), "BASE_DIR 未设置") { + t.Fatalf("expected BASE_DIR error, got %v", err) + } +} + +func TestLoadConfigMissingExpectedRemote(t *testing.T) { + baseDir := t.TempDir() + t.Setenv("BASE_DIR", baseDir) + t.Setenv("EXPECTED_REMOTE", "") + + _, err := LoadConfig(writeTempEnvFile(t)) + if err == nil || !strings.Contains(err.Error(), "EXPECTED_REMOTE 未设置") { + t.Fatalf("expected EXPECTED_REMOTE error, got %v", err) + } +} + +func TestLoadConfigBaseDirNotExist(t *testing.T) { + t.Setenv("BASE_DIR", filepath.Join(t.TempDir(), "missing")) + t.Setenv("EXPECTED_REMOTE", "rclone:backup") + + _, err := LoadConfig(writeTempEnvFile(t)) + if err == nil || !strings.Contains(err.Error(), "BASE_DIR 目录不存在") { + t.Fatalf("expected BASE_DIR not exist error, got %v", err) + } +} diff --git a/go/docker.go b/go/docker.go new file mode 100644 index 0000000..a9c2665 --- /dev/null +++ b/go/docker.go @@ -0,0 +1,318 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "time" +) + +// ComposeFileNames 支持的 compose 配置文件名 +var ComposeFileNames = []string{ + "compose.yaml", + "compose.yml", + "docker-compose.yaml", + "docker-compose.yml", +} + +// Service 表示一个 Docker Compose 服务 +type Service struct { + Name string // 服务目录名 + Path string // 服务完整路径 + Running bool // 是否正在运行(备份前的状态) +} + +// ServiceError 标记具体服务的错误 +type ServiceError struct { + Service string + Err error +} + +func (e *ServiceError) Error() string { + return fmt.Sprintf("%s: %v", e.Service, e.Err) +} + +func (e *ServiceError) Unwrap() error { + return e.Err +} + +// DockerManager 管理 Docker Compose 服务 +type DockerManager struct { + baseDir string + dryRun bool + commandTimeout time.Duration +} + +// NewDockerManager 创建 Docker 管理器 +func NewDockerManager(baseDir string, dryRun bool, commandTimeout time.Duration) *DockerManager { + if commandTimeout <= 0 { + commandTimeout = 120 * time.Second + } + return &DockerManager{ + baseDir: baseDir, + dryRun: dryRun, + commandTimeout: commandTimeout, + } +} + +func (dm *DockerManager) CheckDependencies() error { + // 检查 docker 命令 + if _, err := exec.LookPath("docker"); err != nil { + return fmt.Errorf("docker 未安装或不可用,请先安装: https://docs.docker.com/get-docker/") + } + slog.Info("依赖检查通过", "docker", "✓") + return nil +} + +// DiscoverServices 发现所有 Docker Compose 服务 +func (dm *DockerManager) DiscoverServices() ([]*Service, error) { + entries, err := os.ReadDir(dm.baseDir) + if err != nil { + return nil, fmt.Errorf("读取目录失败: %w", err) + } + + var services []*Service + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + svcPath := filepath.Join(dm.baseDir, entry.Name()) + if dm.hasComposeFile(svcPath) || dm.hasComposeScript(svcPath) { + svc := &Service{ + Name: entry.Name(), + Path: svcPath, + } + // 检查服务是否正在运行 + running, err := dm.IsRunning(svc) + if err != nil { + return nil, fmt.Errorf("检查服务运行状态失败 for %s: %w", svc.Name, err) + } + svc.Running = running + services = append(services, svc) + } else { + slog.Info("跳过目录(未发现 compose 配置/脚本)", "service", entry.Name(), "path", svcPath) + } + } + + return services, nil +} + +// hasComposeFile 检查目录下是否有 compose 配置文件 +func (dm *DockerManager) hasComposeFile(path string) bool { + for _, name := range ComposeFileNames { + if _, err := os.Stat(filepath.Join(path, name)); err == nil { + return true + } + } + return false +} + +// hasComposeScript 检查目录下是否有 compose 脚本 +func (dm *DockerManager) hasComposeScript(path string) bool { + scripts := []string{"compose-up.sh", "compose-stop.sh", "compose-down.sh"} + for _, script := range scripts { + if info, err := os.Stat(filepath.Join(path, script)); err == nil && !info.IsDir() { + return true + } + } + return false +} + +// IsRunning 检查服务是否正在运行 +func (dm *DockerManager) IsRunning(svc *Service) (bool, error) { + // 在服务目录下执行 docker compose ps -q + ctx, cancel := context.WithTimeout(context.Background(), dm.commandTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, "docker", "compose", "ps", "-q") + cmd.Dir = svc.Path + + output, err := cmd.Output() + if err != nil { + return false, fmt.Errorf("执行 docker compose ps 失败: %w", err) + } + + // 如果有输出(容器 ID),说明有容器在运行 + return len(bytes.TrimSpace(output)) > 0, nil +} + +// Stop 停止服务 +func (dm *DockerManager) Stop(svc *Service) error { + if !svc.Running { + slog.Info("跳过停止(服务未运行)", "service", svc.Name) + return nil + } + + // 确定停止方式 + var cmd *exec.Cmd + var method string + + stopScript := filepath.Join(svc.Path, "compose-stop.sh") + downScript := filepath.Join(svc.Path, "compose-down.sh") + + if dm.isExecutable(stopScript) { + ctx, cancel := context.WithTimeout(context.Background(), dm.commandTimeout) + defer cancel() + cmd = exec.CommandContext(ctx, "./compose-stop.sh") + method = "compose-stop.sh" + } else if dm.isExecutable(downScript) { + ctx, cancel := context.WithTimeout(context.Background(), dm.commandTimeout) + defer cancel() + cmd = exec.CommandContext(ctx, "./compose-down.sh") + method = "compose-down.sh" + } else if dm.hasComposeFile(svc.Path) { + ctx, cancel := context.WithTimeout(context.Background(), dm.commandTimeout) + defer cancel() + cmd = exec.CommandContext(ctx, "docker", "compose", "stop") + method = "docker compose stop" + } else { + return fmt.Errorf("无法识别停止方法") + } + + if dm.dryRun { + slog.Info("[DRY-RUN] 将停止服务", "service", svc.Name, "method", method) + return nil + } + + slog.Info("停止服务", "service", svc.Name, "method", method) + cmd.Dir = svc.Path + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("停止失败: %w", err) + } + return nil +} + +// Start 启动服务 +func (dm *DockerManager) Start(svc *Service) error { + if !svc.Running { + slog.Info("跳过启动(原本未运行)", "service", svc.Name) + return nil + } + + // 确定启动方式 + var cmd *exec.Cmd + var method string + + upScript := filepath.Join(svc.Path, "compose-up.sh") + + if dm.isExecutable(upScript) { + ctx, cancel := context.WithTimeout(context.Background(), dm.commandTimeout) + defer cancel() + cmd = exec.CommandContext(ctx, "./compose-up.sh") + method = "compose-up.sh" + } else if dm.hasComposeFile(svc.Path) { + ctx, cancel := context.WithTimeout(context.Background(), dm.commandTimeout) + defer cancel() + cmd = exec.CommandContext(ctx, "docker", "compose", "up", "-d") + method = "docker compose up -d" + } else { + return fmt.Errorf("无法识别启动方法") + } + + if dm.dryRun { + slog.Info("[DRY-RUN] 将启动服务", "service", svc.Name, "method", method) + return nil + } + + slog.Info("启动服务", "service", svc.Name, "method", method) + cmd.Dir = svc.Path + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("启动失败: %w", err) + } + return nil +} + +// isExecutable 检查文件是否可执行 +func (dm *DockerManager) isExecutable(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + if runtime.GOOS == "windows" { + return !info.IsDir() && strings.EqualFold(filepath.Ext(path), ".sh") + } + // 在 Unix 系统上检查执行权限 + return info.Mode()&0111 != 0 +} + +// ClassifyServices 将服务分类为优先服务和普通服务 +func ClassifyServices(services []*Service, priorityNames []string) (priority, normal []*Service) { + prioritySet := make(map[string]bool) + for _, name := range priorityNames { + prioritySet[strings.ToLower(name)] = true + } + + for _, svc := range services { + if prioritySet[strings.ToLower(svc.Name)] { + priority = append(priority, svc) + } else { + normal = append(normal, svc) + } + } + return +} + +// StopParallel 并行停止多个服务 +func (dm *DockerManager) StopParallel(services []*Service) []error { + if len(services) == 0 { + return nil + } + + var wg sync.WaitGroup + var mu sync.Mutex + var errors []error + + for _, svc := range services { + wg.Add(1) + go func(s *Service) { + defer wg.Done() + if err := dm.Stop(s); err != nil { + mu.Lock() + errors = append(errors, &ServiceError{Service: s.Name, Err: err}) + mu.Unlock() + } + }(svc) + } + + wg.Wait() + return errors +} + +// StartParallel 并行启动多个服务 +func (dm *DockerManager) StartParallel(services []*Service) []error { + if len(services) == 0 { + return nil + } + + var wg sync.WaitGroup + var mu sync.Mutex + var errors []error + + for _, svc := range services { + wg.Add(1) + go func(s *Service) { + defer wg.Done() + if err := dm.Start(s); err != nil { + mu.Lock() + errors = append(errors, &ServiceError{Service: s.Name, Err: err}) + mu.Unlock() + } + }(svc) + } + + wg.Wait() + return errors +} diff --git a/go/docker_test.go b/go/docker_test.go new file mode 100644 index 0000000..e45b906 --- /dev/null +++ b/go/docker_test.go @@ -0,0 +1,147 @@ +package main + +import ( + "os" + "path/filepath" + "runtime" + "testing" + "time" +) + +func TestHasComposeFile(t *testing.T) { + baseDir := t.TempDir() + dm := NewDockerManager(baseDir, true, time.Second) + + // 空目录时不应识别到 compose 配置文件 + if dm.hasComposeFile(baseDir) { + t.Fatalf("expected no compose file") + } + + path := filepath.Join(baseDir, "compose.yaml") + if err := os.WriteFile(path, []byte("services:\n"), 0o600); err != nil { + t.Fatalf("write compose file: %v", err) + } + + // 写入标准 compose 文件后应能被识别 + if !dm.hasComposeFile(baseDir) { + t.Fatalf("expected compose file detected") + } +} + +func TestHasComposeScript(t *testing.T) { + baseDir := t.TempDir() + dm := NewDockerManager(baseDir, true, time.Second) + + // 空目录时不应识别到 compose 脚本 + if dm.hasComposeScript(baseDir) { + t.Fatalf("expected no compose script") + } + + path := filepath.Join(baseDir, "compose-stop.sh") + if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0o600); err != nil { + t.Fatalf("write compose script: %v", err) + } + + // 写入脚本后应能被识别 + if !dm.hasComposeScript(baseDir) { + t.Fatalf("expected compose script detected") + } +} + +func TestIsExecutable(t *testing.T) { + baseDir := t.TempDir() + dm := NewDockerManager(baseDir, true, time.Second) + + // .sh 脚本在不同平台的可执行判断 + shPath := filepath.Join(baseDir, "compose-up.sh") + if err := os.WriteFile(shPath, []byte("#!/bin/sh\n"), 0o600); err != nil { + t.Fatalf("write script: %v", err) + } + + if runtime.GOOS == "windows" { + if !dm.isExecutable(shPath) { + t.Fatalf("expected .sh file executable on windows") + } + } else { + if err := os.Chmod(shPath, 0o700); err != nil { + t.Fatalf("chmod script: %v", err) + } + if !dm.isExecutable(shPath) { + t.Fatalf("expected executable script") + } + + nonExec := filepath.Join(baseDir, "not-exec.sh") + if err := os.WriteFile(nonExec, []byte("#!/bin/sh\n"), 0o600); err != nil { + t.Fatalf("write non-exec: %v", err) + } + if dm.isExecutable(nonExec) { + t.Fatalf("expected non-exec file to be non-executable") + } + } + + txtPath := filepath.Join(baseDir, "note.txt") + if err := os.WriteFile(txtPath, []byte("hello"), 0o600); err != nil { + t.Fatalf("write txt: %v", err) + } + if dm.isExecutable(txtPath) { + t.Fatalf("expected non-script not executable") + } +} + +func TestClassifyServices(t *testing.T) { + // 按优先级名称(不区分大小写)分类服务 + services := []*Service{ + {Name: "db"}, + {Name: "api"}, + {Name: "cache"}, + } + + priority, normal := ClassifyServices(services, []string{"DB", "cache"}) + if len(priority) != 2 || len(normal) != 1 { + t.Fatalf("unexpected classification: priority=%d normal=%d", len(priority), len(normal)) + } + if priority[0].Name == normal[0].Name || priority[1].Name == normal[0].Name { + t.Fatalf("priority and normal overlap") + } +} + +func TestStopStartDryRun(t *testing.T) { + baseDir := t.TempDir() + svcDir := filepath.Join(baseDir, "svc") + if err := os.MkdirAll(svcDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(svcDir, "compose.yaml"), []byte("services:\n"), 0o600); err != nil { + t.Fatalf("write compose: %v", err) + } + + // dry-run 模式下应不执行外部命令但流程可跑通 + dm := NewDockerManager(baseDir, true, time.Second) + svc := &Service{Name: "svc", Path: svcDir, Running: true} + + if err := dm.Stop(svc); err != nil { + t.Fatalf("Stop dry-run error: %v", err) + } + if err := dm.Start(svc); err != nil { + t.Fatalf("Start dry-run error: %v", err) + } +} + +func TestStopStartMissingMethod(t *testing.T) { + baseDir := t.TempDir() + svcDir := filepath.Join(baseDir, "svc") + if err := os.MkdirAll(svcDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + // 没有 compose 文件/脚本时应报无法识别方法 + dm := NewDockerManager(baseDir, true, time.Second) + svc := &Service{Name: "svc", Path: svcDir, Running: true} + + if err := dm.Stop(svc); err == nil { + t.Fatalf("expected stop missing method error") + } + if err := dm.Start(svc); err == nil { + t.Fatalf("expected start missing method error") + } +} diff --git a/go/gist.go b/go/gist.go new file mode 100644 index 0000000..64b922a --- /dev/null +++ b/go/gist.go @@ -0,0 +1,245 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "sort" + "strings" + "time" +) + +// GistManager 管理 GitHub Gist 日志上传 +type GistManager struct { + token string + gistID string + logPrefix string + maxLogs int + keepFirstFile bool + client *http.Client +} + +// NewGistManager 创建 Gist 管理器 +func NewGistManager(token, gistID, logPrefix string, maxLogs int, keepFirstFile bool) *GistManager { + return &GistManager{ + token: token, + gistID: gistID, + logPrefix: logPrefix, + maxLogs: maxLogs, + keepFirstFile: keepFirstFile, + client: &http.Client{Timeout: 30 * time.Second}, + } +} + +// IsConfigured 检查是否已配置 Gist +func (g *GistManager) IsConfigured() bool { + return g.token != "" && g.gistID != "" +} + +// Upload 上传日志到 Gist +func (g *GistManager) Upload(logContent string, success bool, startTime time.Time, duration time.Duration) error { + if !g.IsConfigured() { + return nil + } + + slog.Info("上传日志到 GitHub Gist...") + + // 生成文件名 + timestamp := time.Now().UTC().Format("2006-01-02_15-04-05") + filename := fmt.Sprintf("%s-%s.log", g.logPrefix, timestamp) + + // 构建日志内容 + statusText := "✅ 成功" + if !success { + statusText = "⚠️ 有警告" + } + + hours := int(duration.Hours()) + minutes := int(duration.Minutes()) % 60 + seconds := int(duration.Seconds()) % 60 + + var durationStr string + if hours > 0 { + durationStr = fmt.Sprintf("%d 小时 %d 分 %d 秒", hours, minutes, seconds) + } else if minutes > 0 { + durationStr = fmt.Sprintf("%d 分 %d 秒", minutes, seconds) + } else { + durationStr = fmt.Sprintf("%d 秒", seconds) + } + + content := fmt.Sprintf(`======================================== +YewResin Docker 备份日志 +======================================== +执行状态: %s +开始时间: %s +耗时: %s +结束时间: %s +======================================== +详细日志: +======================================== +%s +`, statusText, startTime.Format("2006-01-02 15:04:05"), durationStr, + time.Now().UTC().Format("2006-01-02 15:04:05"), logContent) + + // 构建请求 payload + payload := map[string]interface{}{ + "files": map[string]interface{}{ + filename: map[string]string{ + "content": content, + }, + }, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("序列化 JSON 失败: %w", err) + } + + // 发送请求 + url := fmt.Sprintf("https://api.github.com/gists/%s", g.gistID) + req, err := http.NewRequest("PATCH", url, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("创建请求失败: %w", err) + } + + req.Header.Set("Authorization", "token "+g.token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/vnd.github.v3+json") + + resp, err := g.client.Do(req) + if err != nil { + return fmt.Errorf("发送请求失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("Gist API 返回错误 %d: %s", resp.StatusCode, string(body)) + } + + slog.Info("✓ 日志已上传到 Gist", "url", fmt.Sprintf("https://gist.github.com/%s", g.gistID)) + + // 上传成功后清理旧日志 + if err := g.cleanupOldLogs(); err != nil { + slog.Warn("清理旧日志失败", "error", err) + } + + return nil +} + +// gistResponse Gist API 响应结构 +type gistResponse struct { + ID string `json:"id"` + Files map[string]interface{} `json:"files"` +} + +// cleanupOldLogs 清理旧的 Gist 日志文件 +func (g *GistManager) cleanupOldLogs() error { + if g.maxLogs <= 0 { + return nil + } + + slog.Info("检查 Gist 日志数量...") + + // 获取 Gist 信息 + url := fmt.Sprintf("https://api.github.com/gists/%s", g.gistID) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return err + } + + req.Header.Set("Authorization", "token "+g.token) + req.Header.Set("Accept", "application/vnd.github.v3+json") + + resp, err := g.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return fmt.Errorf("获取 Gist 信息失败: %d", resp.StatusCode) + } + + var gist gistResponse + if err := json.NewDecoder(resp.Body).Decode(&gist); err != nil { + return err + } + + // 获取所有文件名并排序 + var allFiles []string + for filename := range gist.Files { + allFiles = append(allFiles, filename) + } + sort.Strings(allFiles) + + // 如果启用了保留第一个文件,从列表中排除 + filesToConsider := allFiles + if g.keepFirstFile && len(allFiles) > 0 { + firstFile := allFiles[0] + slog.Info("保留第一个文件", "file", firstFile) + filesToConsider = allFiles[1:] + } + + // 只统计匹配前缀的日志文件 + var logFiles []string + for _, f := range filesToConsider { + if strings.HasPrefix(f, g.logPrefix) { + logFiles = append(logFiles, f) + } + } + + // 如果文件数量未超过限制,跳过清理 + if len(logFiles) <= g.maxLogs { + slog.Info("当前日志数量未超过限制,无需清理", + "current", len(logFiles), "max", g.maxLogs) + return nil + } + + // 计算需要删除的文件数量(删除最旧的) + deleteCount := len(logFiles) - g.maxLogs + filesToDelete := logFiles[:deleteCount] + + slog.Info("需要删除旧日志文件", "count", deleteCount) + + // 构建删除 payload(将文件内容设为 null 表示删除) + deleteFiles := make(map[string]interface{}) + for _, f := range filesToDelete { + deleteFiles[f] = nil + } + + payload := map[string]interface{}{ + "files": deleteFiles, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return err + } + + // 发送删除请求 + req, err = http.NewRequest("PATCH", url, bytes.NewBuffer(jsonData)) + if err != nil { + return err + } + + req.Header.Set("Authorization", "token "+g.token) + req.Header.Set("Content-Type", "application/json") + + resp, err = g.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("删除旧日志失败: %s", string(body)) + } + + slog.Info("✓ 已清理旧日志文件", "count", deleteCount) + return nil +} diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 0000000..afbbfc9 --- /dev/null +++ b/go/go.mod @@ -0,0 +1,5 @@ +module github.com/YewFence/yewresin + +go 1.23 + +require github.com/joho/godotenv v1.5.1 diff --git a/go/go.sum b/go/go.sum new file mode 100644 index 0000000..d61b19e --- /dev/null +++ b/go/go.sum @@ -0,0 +1,2 @@ +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= diff --git a/go/logger.go b/go/logger.go new file mode 100644 index 0000000..416cc91 --- /dev/null +++ b/go/logger.go @@ -0,0 +1,75 @@ +package main + +import ( + "io" + "log/slog" + "os" + "sync" +) + +// LogWriter 保存日志写入器的引用,用于获取日志内容 +var LogWriter *LogCapture + +// LogCapture 捕获日志内容,同时写入多个目标 +type LogCapture struct { + mu sync.Mutex + buffer []byte + writers []io.Writer +} + +// NewLogCapture 创建日志捕获器 +func NewLogCapture(writers ...io.Writer) *LogCapture { + return &LogCapture{ + writers: writers, + } +} + +// Write 实现 io.Writer 接口 +func (lc *LogCapture) Write(p []byte) (n int, err error) { + lc.mu.Lock() + // 保存到缓冲区 + lc.buffer = append(lc.buffer, p...) + lc.mu.Unlock() + + // 写入所有目标 + for _, w := range lc.writers { + w.Write(p) + } + return len(p), nil +} + +// GetContent 获取捕获的日志内容 +func (lc *LogCapture) GetContent() string { + lc.mu.Lock() + buf := make([]byte, len(lc.buffer)) + copy(buf, lc.buffer) + lc.mu.Unlock() + return string(buf) +} + +// InitLogger 初始化日志系统 +// 返回日志文件句柄(如果有)和 LogCapture 用于获取日志内容 +func InitLogger(logFile string) (*os.File, error) { + writers := []io.Writer{os.Stdout} + + var file *os.File + if logFile != "" { + f, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + return nil, err + } + file = f + writers = append(writers, f) + } + + // 创建日志捕获器 + LogWriter = NewLogCapture(writers...) + + // 配置 slog + logger := slog.New(slog.NewTextHandler(LogWriter, &slog.HandlerOptions{ + Level: slog.LevelInfo, + })) + slog.SetDefault(logger) + + return file, nil +} diff --git a/go/logger_test.go b/go/logger_test.go new file mode 100644 index 0000000..243fe90 --- /dev/null +++ b/go/logger_test.go @@ -0,0 +1,49 @@ +package main + +import ( + "log/slog" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestInitLoggerNoFile(t *testing.T) { + file, err := InitLogger("") + if err != nil { + t.Fatalf("InitLogger error: %v", err) + } + if file != nil { + t.Fatalf("expected nil file, got %v", file) + } + + slog.Info("hello logger") + if LogWriter == nil { + t.Fatalf("LogWriter should not be nil") + } + if !strings.Contains(LogWriter.GetContent(), "hello logger") { + t.Fatalf("log content missing message: %q", LogWriter.GetContent()) + } +} + +func TestInitLoggerWithFile(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "app.log") + file, err := InitLogger(logPath) + if err != nil { + t.Fatalf("InitLogger error: %v", err) + } + if file == nil { + t.Fatalf("expected log file handle") + } + + slog.Info("hello file") + file.Close() + + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("read log file error: %v", err) + } + if !strings.Contains(string(data), "hello file") { + t.Fatalf("log file missing message: %q", string(data)) + } +} diff --git a/go/main.go b/go/main.go new file mode 100644 index 0000000..8499b46 --- /dev/null +++ b/go/main.go @@ -0,0 +1,121 @@ +package main + +import ( + "flag" + "fmt" + "log/slog" + "os" + "os/signal" + "strings" + "syscall" + "time" +) + +// 版本信息,构建时注入 +var version = "dev" + +func main() { + // CLI 参数定义 + dryRun := flag.Bool("dry-run", false, "模拟运行,不执行实际操作") + flag.BoolVar(dryRun, "n", false, "模拟运行(-dry-run 的简写)") + + autoConfirm := flag.Bool("yes", false, "跳过交互式确认") + flag.BoolVar(autoConfirm, "y", false, "跳过确认(-yes 的简写)") + + configFile := flag.String("config", "", "配置文件路径(默认为程序同目录的 .env)") + showVersion := flag.Bool("version", false, "显示版本信息") + + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "YewResin - Docker 服务备份工具 (Go 版本)\n\n") + fmt.Fprintf(os.Stderr, "用法: %s [选项]\n\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "选项:\n") + flag.PrintDefaults() + fmt.Fprintf(os.Stderr, "\n示例:\n") + fmt.Fprintf(os.Stderr, " %s --dry-run # 模拟运行\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s -y # 跳过确认直接执行\n", os.Args[0]) + } + + flag.Parse() + + if *showVersion { + fmt.Printf("YewResin %s\n", version) + os.Exit(0) + } + + // 加载配置(先加载配置以获取日志文件路径) + cfg, err := LoadConfig(*configFile) + if err != nil { + fmt.Fprintf(os.Stderr, "加载配置失败: %v\n", err) + os.Exit(1) + } + + // 初始化日志(支持文件输出) + logFile, err := InitLogger(cfg.LogFile) + if err != nil { + fmt.Fprintf(os.Stderr, "初始化日志失败: %v\n", err) + os.Exit(1) + } + if logFile != nil { + defer logFile.Close() + } + + // 打印配置信息 + cfg.Print() + + // 创建备份编排器 + orch := NewOrchestrator(cfg, *dryRun) + + // 设置信号处理(Ctrl+C 等) + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + go func() { + sig := <-sigChan + slog.Warn("收到终止信号,正在清理...", "signal", sig) + orch.Cleanup() + os.Exit(1) + }() + + // 检查依赖 + if err := orch.CheckDependencies(); err != nil { + slog.Error("依赖检查失败", "error", err) + os.Exit(1) + } + + // 交互式确认 + if !*dryRun && !*autoConfirm { + if !confirm() { + fmt.Println("已取消操作") + os.Exit(0) + } + } + + // 执行备份 + startTime := time.Now().UTC() + if err := orch.Run(); err != nil { + slog.Error("备份失败", "error", err) + os.Exit(1) + } + + // 打印耗时 + elapsed := time.Since(startTime) + slog.Info("备份完成", "耗时", elapsed.Round(time.Second)) +} + +// confirm 交互式确认 +func confirm() bool { + fmt.Println() + fmt.Println("==========================================") + fmt.Println("⚠️ 警告:即将执行备份操作") + fmt.Println("==========================================") + fmt.Println() + fmt.Println("此操作将会:") + fmt.Println(" 1. 停止所有 Docker 服务") + fmt.Println(" 2. 创建 Kopia 快照备份") + fmt.Println(" 3. 重新启动所有服务") + fmt.Println() + fmt.Print("确认执行备份?[y/N] ") + + var response string + fmt.Scanln(&response) + return strings.EqualFold(response, "y") || strings.EqualFold(response, "yes") +} diff --git a/go/main_test.go b/go/main_test.go new file mode 100644 index 0000000..81cc33a --- /dev/null +++ b/go/main_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "os" + "testing" +) + +func withStdin(t *testing.T, input string, fn func()) { + t.Helper() + // 用管道模拟标准输入,便于测试交互式确认 + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + if _, err := w.Write([]byte(input)); err != nil { + t.Fatalf("write stdin: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("close writer: %v", err) + } + + old := os.Stdin + os.Stdin = r + defer func() { + os.Stdin = old + r.Close() + }() + + fn() +} + +func TestConfirm(t *testing.T) { + // y 应该通过确认 + withStdin(t, "y\n", func() { + if !confirm() { + t.Fatalf("expected confirm to accept y") + } + }) + + // yes 应该通过确认 + withStdin(t, "yes\n", func() { + if !confirm() { + t.Fatalf("expected confirm to accept yes") + } + }) + + // n 应该拒绝确认 + withStdin(t, "n\n", func() { + if confirm() { + t.Fatalf("expected confirm to reject n") + } + }) +} diff --git a/go/notify.go b/go/notify.go new file mode 100644 index 0000000..6cd4631 --- /dev/null +++ b/go/notify.go @@ -0,0 +1,81 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "sync" + "time" +) + +// Notifier Apprise 通知发送器 +type Notifier struct { + url string // Apprise 服务地址 + notifyURL string // 通知目标 URL + deviceName string // 设备名称(可选) + wg sync.WaitGroup +} + +// NewNotifier 创建通知器 +func NewNotifier(url, notifyURL, deviceName string) *Notifier { + return &Notifier{ + url: url, + notifyURL: notifyURL, + deviceName: deviceName, + } +} + +// Send 发送通知 +func (n *Notifier) Send(title, body string) { + if n.url == "" || n.notifyURL == "" { + slog.Debug("通知未配置,跳过发送", "title", title) + return + } + + // 如果配置了设备名称,添加到标题前 + if n.deviceName != "" { + title = fmt.Sprintf("[%s] %s", n.deviceName, title) + } + + n.wg.Add(1) + go n.sendAsync(title, body) +} + +// sendAsync 异步发送通知(不阻塞主流程) +func (n *Notifier) sendAsync(title, body string) { + defer n.wg.Done() + + payload := map[string]string{ + "urls": n.notifyURL, + "title": title, + "body": body, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + slog.Warn("序列化通知数据失败", "error", err) + return + } + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Post(n.url, "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + slog.Warn("发送通知失败", "error", err) + return + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + slog.Warn("通知服务返回错误", "status", resp.StatusCode) + return + } + + slog.Debug("通知发送成功", "title", title) +} + +// Wait 等待所有通知发送完成 +func (n *Notifier) Wait() { + n.wg.Wait() +} diff --git a/go/orchestrator.go b/go/orchestrator.go new file mode 100644 index 0000000..c8b6381 --- /dev/null +++ b/go/orchestrator.go @@ -0,0 +1,234 @@ +package main + +import ( + "errors" + "fmt" + "log/slog" + "os" + "strings" + "time" +) + +// Orchestrator 备份流程编排器 +type Orchestrator struct { + cfg *Config + dryRun bool + docker dockerController + kopia kopiaController + notifier notifierClient + gist gistClient + + // 服务分类 + priorityServices []*Service + normalServices []*Service + + // 锁文件 + lockAcquired bool + + // 执行时间记录 + startTime time.Time +} + +type dockerController interface { + CheckDependencies() error + DiscoverServices() ([]*Service, error) + StopParallel(services []*Service) []error + Stop(svc *Service) error + StartParallel(services []*Service) []error + Start(svc *Service) error +} + +type kopiaController interface { + CheckRepository() error + CreateSnapshot(path string) error +} + +type notifierClient interface { + Send(title, body string) + Wait() +} + +type gistClient interface { + Upload(logContent string, success bool, startTime time.Time, duration time.Duration) error +} + +// NewOrchestrator 创建编排器 +func NewOrchestrator(cfg *Config, dryRun bool) *Orchestrator { + return &Orchestrator{ + cfg: cfg, + dryRun: dryRun, + docker: NewDockerManager(cfg.BaseDir, dryRun, time.Duration(cfg.DockerCommandTimeoutSeconds)*time.Second), + kopia: NewKopiaBackup(cfg.ExpectedRemote, cfg.KopiaPassword, dryRun), + notifier: NewNotifier(cfg.AppriseURL, cfg.AppriseNotifyURL, cfg.DeviceName), + gist: NewGistManager(cfg.GistToken, cfg.GistID, cfg.GistLogPrefix, cfg.GistMaxLogs, cfg.GistKeepFirstFile), + } +} + +// 检查依赖项 +func (o *Orchestrator) CheckDependencies() error { + // 检查 Docker 可用性 + if err := o.docker.CheckDependencies(); err != nil { + return err + } + + // 检查 Kopia 仓库连接状态 + if err := o.kopia.CheckRepository(); err != nil { + return err + } + + return nil +} + +// Run 执行备份流程 +func (o *Orchestrator) Run() error { + o.startTime = time.Now().UTC() + defer o.notifier.Wait() + + // 1. 获取锁 + if err := o.acquireLock(); err != nil { + return err + } + defer o.releaseLock() + + // 2. 发现并分类服务 + services, err := o.docker.DiscoverServices() + if err != nil { + return fmt.Errorf("发现服务失败: %w", err) + } + + o.priorityServices, o.normalServices = ClassifyServices(services, o.cfg.PriorityServices) + + slog.Info("发现服务", + "total", len(services), + "priority", len(o.priorityServices), + "normal", len(o.normalServices)) + + // 3. 发送开始通知 + o.notifier.Send("🔄 备份开始", "开始执行服务器备份任务") + + // 4. 停止服务(普通服务并行,优先服务顺序) + slog.Info(">>> 并行停止普通服务...") + if errs := o.docker.StopParallel(o.normalServices); len(errs) > 0 { + errMsgs := make([]string, len(errs)) + for i, e := range errs { + errMsgs[i] = e.Error() + } + o.notifier.Send("❌ 备份中止", fmt.Sprintf("服务停止失败: %s", strings.Join(errMsgs, ", "))) + o.startAllServices() + return fmt.Errorf("停止普通服务失败: %v", errs) + } + + slog.Info(">>> 顺序停止优先服务(网关)...") + for _, svc := range o.priorityServices { + if err := o.docker.Stop(svc); err != nil { + o.notifier.Send("❌ 备份中止", fmt.Sprintf("服务 %s 停止失败", svc.Name)) + o.startAllServices() + return fmt.Errorf("停止服务 %s 失败: %w", svc.Name, err) + } + } + + // 5. 创建快照 + slog.Info(">>> 所有服务已停止,开始创建快照...") + backupErr := o.kopia.CreateSnapshot(o.cfg.BaseDir) + + // 6. 恢复服务(无论备份是否成功) + o.startAllServices() + + // 7. 上传日志到 Gist + success := backupErr == nil + duration := time.Since(o.startTime) + if LogWriter != nil { + if err := o.gist.Upload(LogWriter.GetContent(), success, o.startTime, duration); err != nil { + slog.Warn("上传日志到 Gist 失败", "error", err) + } + } + + // 8. 发送结果通知 + if backupErr != nil { + o.notifier.Send("❌ 备份失败", "快照创建失败,服务已恢复") + return backupErr + } + + if o.dryRun { + o.notifier.Send("🧪 DRY-RUN 完成", "模拟运行完成,未执行实际操作") + } else { + o.notifier.Send("✅ 备份成功", "所有服务已恢复运行") + } + + return nil +} + +// startAllServices 启动所有服务(优先服务顺序启动,普通服务并行启动) +func (o *Orchestrator) startAllServices() { + var failedServices []string + + // 优先服务顺序启动(先恢复网关) + slog.Info(">>> 顺序恢复优先服务(网关)...") + for _, svc := range o.priorityServices { + if err := o.docker.Start(svc); err != nil { + slog.Error("启动服务失败", "service", svc.Name, "error", err) + failedServices = append(failedServices, svc.Name) + } + } + + // 普通服务并行启动 + slog.Info(">>> 并行恢复普通服务...") + if errs := o.docker.StartParallel(o.normalServices); len(errs) > 0 { + for _, err := range errs { + slog.Error("启动服务失败", "error", err) + // 从结构化错误中提取服务名 + var svcErr *ServiceError + if errors.As(err, &svcErr) { + failedServices = append(failedServices, svcErr.Service) + } else { + failedServices = append(failedServices, "unknown") + } + } + } + + if len(failedServices) > 0 { + o.notifier.Send("⚠️ 服务恢复异常", fmt.Sprintf("以下服务启动失败: %v", failedServices)) + } +} + +// Cleanup 清理函数(异常退出时调用) +func (o *Orchestrator) Cleanup() { + slog.Warn("执行清理,尝试恢复服务...") + o.startAllServices() + o.releaseLock() + o.notifier.Wait() +} + +// acquireLock 获取锁(使用目录作为锁,原子操作) +func (o *Orchestrator) acquireLock() error { + if o.dryRun { + slog.Info("[DRY-RUN] 将获取锁", "path", o.cfg.LockFile) + return nil + } + + err := os.Mkdir(o.cfg.LockFile, 0755) + if err != nil { + if os.IsExist(err) { + return fmt.Errorf("另一个备份进程正在运行 (锁文件: %s)", o.cfg.LockFile) + } + return fmt.Errorf("创建锁文件失败: %w", err) + } + + o.lockAcquired = true + slog.Info("获取锁成功", "path", o.cfg.LockFile) + return nil +} + +// releaseLock 释放锁 +func (o *Orchestrator) releaseLock() { + if !o.lockAcquired { + return + } + + if err := os.RemoveAll(o.cfg.LockFile); err != nil { + slog.Warn("释放锁失败", "error", err) + } else { + slog.Info("释放锁成功") + } + o.lockAcquired = false +} diff --git a/go/orchestrator_test.go b/go/orchestrator_test.go new file mode 100644 index 0000000..b2fb9c8 --- /dev/null +++ b/go/orchestrator_test.go @@ -0,0 +1,282 @@ +package main + +import ( + "errors" + "io" + "os" + "path/filepath" + "testing" + "time" +) + +type fakeDocker struct { + services []*Service + checkErr error + discoverErr error + stopParallelErrs []error + startParallelErrs []error + stopErrs map[string]error + startErrs map[string]error + stopCalls []string + startCalls []string + stopParallelCalled int + startParallelCalled int +} + +func (f *fakeDocker) CheckDependencies() error { + return f.checkErr +} + +func (f *fakeDocker) DiscoverServices() ([]*Service, error) { + if f.discoverErr != nil { + return nil, f.discoverErr + } + return f.services, nil +} + +func (f *fakeDocker) StopParallel(services []*Service) []error { + f.stopParallelCalled++ + return f.stopParallelErrs +} + +func (f *fakeDocker) Stop(svc *Service) error { + f.stopCalls = append(f.stopCalls, svc.Name) + if f.stopErrs == nil { + return nil + } + return f.stopErrs[svc.Name] +} + +func (f *fakeDocker) StartParallel(services []*Service) []error { + f.startParallelCalled++ + return f.startParallelErrs +} + +func (f *fakeDocker) Start(svc *Service) error { + f.startCalls = append(f.startCalls, svc.Name) + if f.startErrs == nil { + return nil + } + return f.startErrs[svc.Name] +} + +type fakeKopia struct { + checkErr error + createErr error + createCalls []string +} + +func (f *fakeKopia) CheckRepository() error { + return f.checkErr +} + +func (f *fakeKopia) CreateSnapshot(path string) error { + f.createCalls = append(f.createCalls, path) + return f.createErr +} + +type notifyCall struct { + title string + body string +} + +type fakeNotifier struct { + sends []notifyCall + waitCount int +} + +func (f *fakeNotifier) Send(title, body string) { + f.sends = append(f.sends, notifyCall{title: title, body: body}) +} + +func (f *fakeNotifier) Wait() { + f.waitCount++ +} + +type uploadCall struct { + content string + success bool + start time.Time + duration time.Duration +} + +type fakeGist struct { + uploads []uploadCall + uploadErr error +} + +func (f *fakeGist) Upload(logContent string, success bool, startTime time.Time, duration time.Duration) error { + f.uploads = append(f.uploads, uploadCall{ + content: logContent, + success: success, + start: startTime, + duration: duration, + }) + return f.uploadErr +} + +func TestOrchestratorRunSuccessDryRun(t *testing.T) { + // dry-run 模式下完整流程应走通,且不依赖真实外部服务 + baseDir := t.TempDir() + lockPath := filepath.Join(t.TempDir(), "lock") + + cfg := &Config{ + BaseDir: baseDir, + ExpectedRemote: "remote", + PriorityServices: []string{"gateway"}, + LockFile: lockPath, + } + + docker := &fakeDocker{ + services: []*Service{ + {Name: "gateway"}, + {Name: "db"}, + }, + } + kopia := &fakeKopia{} + notifier := &fakeNotifier{} + gist := &fakeGist{} + + LogWriter = NewLogCapture(io.Discard) + + // 手动组装 Orchestrator,注入 fake 依赖 + o := &Orchestrator{ + cfg: cfg, + dryRun: true, + docker: docker, + kopia: kopia, + notifier: notifier, + gist: gist, + } + + if err := o.Run(); err != nil { + t.Fatalf("Run error: %v", err) + } + + // 快照应该被调用一次 + if len(kopia.createCalls) != 1 || kopia.createCalls[0] != baseDir { + t.Fatalf("expected snapshot created for %q, got %v", baseDir, kopia.createCalls) + } + + // 普通服务走并行停止,优先服务走顺序停止 + if docker.stopParallelCalled != 1 { + t.Fatalf("expected StopParallel called once, got %d", docker.stopParallelCalled) + } + if len(docker.stopCalls) != 1 || docker.stopCalls[0] != "gateway" { + t.Fatalf("expected Stop called for gateway, got %v", docker.stopCalls) + } + if docker.startParallelCalled != 1 { + t.Fatalf("expected StartParallel called once, got %d", docker.startParallelCalled) + } + if len(docker.startCalls) != 1 || docker.startCalls[0] != "gateway" { + t.Fatalf("expected Start called for gateway, got %v", docker.startCalls) + } + + if notifier.waitCount != 1 { + t.Fatalf("expected notifier Wait called once, got %d", notifier.waitCount) + } + // 起始通知与完成通知都应发送 + if len(notifier.sends) < 2 { + t.Fatalf("expected at least 2 notifications, got %d", len(notifier.sends)) + } + + // 成功时应上传日志 + if len(gist.uploads) != 1 { + t.Fatalf("expected gist upload called once, got %d", len(gist.uploads)) + } + if !gist.uploads[0].success { + t.Fatalf("expected gist upload success flag true") + } +} + +func TestOrchestratorRunStopParallelError(t *testing.T) { + // 普通服务并行停止失败时,应中止流程并尝试恢复服务 + baseDir := t.TempDir() + lockPath := filepath.Join(t.TempDir(), "lock") + + cfg := &Config{ + BaseDir: baseDir, + ExpectedRemote: "remote", + PriorityServices: []string{"gateway"}, + LockFile: lockPath, + } + + docker := &fakeDocker{ + services: []*Service{ + {Name: "gateway"}, + {Name: "db"}, + }, + stopParallelErrs: []error{errors.New("stop failed")}, + } + kopia := &fakeKopia{} + notifier := &fakeNotifier{} + gist := &fakeGist{} + + LogWriter = NewLogCapture(io.Discard) + + // 手动组装 Orchestrator,注入 fake 依赖 + o := &Orchestrator{ + cfg: cfg, + dryRun: true, + docker: docker, + kopia: kopia, + notifier: notifier, + gist: gist, + } + + if err := o.Run(); err == nil { + t.Fatalf("expected Run to fail") + } + + // 失败后应尝试启动服务回滚 + if docker.startParallelCalled != 1 { + t.Fatalf("expected StartParallel called once after failure, got %d", docker.startParallelCalled) + } + // 早期失败不应上传日志 + if len(gist.uploads) != 0 { + t.Fatalf("expected no gist upload on stop failure") + } +} + +func TestOrchestratorLockLifecycle(t *testing.T) { + // 获取锁后应创建目录,释放锁后应删除 + lockPath := filepath.Join(t.TempDir(), "lock") + cfg := &Config{ + BaseDir: t.TempDir(), + ExpectedRemote: "remote", + LockFile: lockPath, + } + + o := &Orchestrator{cfg: cfg, dryRun: false} + + if err := o.acquireLock(); err != nil { + t.Fatalf("acquireLock error: %v", err) + } + if _, err := os.Stat(lockPath); err != nil { + t.Fatalf("expected lock dir created: %v", err) + } + + o.releaseLock() + if _, err := os.Stat(lockPath); !os.IsNotExist(err) { + t.Fatalf("expected lock dir removed, got %v", err) + } +} + +func TestOrchestratorAcquireLockAlreadyExists(t *testing.T) { + // 锁目录已存在时应返回错误 + lockPath := filepath.Join(t.TempDir(), "lock") + if err := os.Mkdir(lockPath, 0o755); err != nil { + t.Fatalf("create lock dir: %v", err) + } + + cfg := &Config{ + BaseDir: t.TempDir(), + ExpectedRemote: "remote", + LockFile: lockPath, + } + o := &Orchestrator{cfg: cfg, dryRun: false} + + if err := o.acquireLock(); err == nil { + t.Fatalf("expected acquireLock to fail when lock exists") + } +}