Compare commits

...

29 Commits

Author SHA1 Message Date
dependabot[bot]
6a18580f7f build(deps): bump github.com/rclone/rclone
Bumps the go_modules group with 1 update in the /server directory: [github.com/rclone/rclone](https://github.com/rclone/rclone).


Updates `github.com/rclone/rclone` from 1.73.5 to 1.74.3
- [Release notes](https://github.com/rclone/rclone/releases)
- [Changelog](https://github.com/rclone/rclone/blob/master/RELEASE.md)
- [Commits](https://github.com/rclone/rclone/compare/v1.73.5...v1.74.3)

---
updated-dependencies:
- dependency-name: github.com/rclone/rclone
  dependency-version: 1.74.3
  dependency-type: direct:production
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-20 02:10:55 +00:00
Wu Qing
50ce6587d8 feat(restore): 恢复到指定目录(文件类型本机恢复 + 确认弹窗输入) (#86)
* feat(restore): 支持恢复到指定目录(文件类型本机恢复)

恢复此前只能覆盖原始源路径。新增「恢复到指定目录」:把文件备份还原到任意目录,
用于测试恢复、迁移、并排恢复而不覆盖现网数据。

- backup.TaskSpec +RestoreTargetPath;FileRunner.Restore 非空时把归档解压到该目录。
- model.RestoreRecord +TargetPath(持久化/审计)。
- RestoreService.Start 增加 targetPath 参数与校验:仅文件类型、需绝对路径、
  远程节点暂不支持(清晰报错);executeLocally 透传到 spec。
- 恢复触发端点接受可选请求体 {targetPath}(无 body 时恢复到原始路径)。
- 测试:恢复到指定目录后文件落在该目录;相对路径被拒。

* feat(restore): 恢复确认弹窗支持指定恢复目录

文件类型 + 本机恢复时,恢复确认弹窗新增「恢复到指定目录」输入(可选、绝对路径、
留空=原位置),并实时反映在「恢复目标」摘要中;经 startRestoreFromBackup 透传 targetPath。
2026-06-01 00:39:17 +08:00
Wu Qing
f7599dd9bd Update README.md (#95) 2026-06-01 00:34:09 +08:00
Wu Qing
bf0e91db57 chore(ci): 为工作流声明最小权限 (contents: read) (#96)
修复 CodeQL actions/missing-workflow-permissions 告警:ci.yml 未显式声明
GITHUB_TOKEN 权限,默认可写。构建/测试仅需读取仓库内容,故收敛为 contents: read。
2026-06-01 00:27:24 +08:00
dependabot[bot]
37092f3167 build(deps): bump the npm_and_yarn group across 1 directory with 2 updates (#93)
Bumps the npm_and_yarn group with 2 updates in the /web directory: [ws](https://github.com/websockets/ws) and [axios](https://github.com/axios/axios).


Updates `ws` from 8.19.0 to 8.21.0
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.19.0...8.21.0)

Updates `axios` from 1.15.2 to 1.16.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.15.2...v1.16.0)

---
updated-dependencies:
- dependency-name: ws
  dependency-version: 8.21.0
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: axios
  dependency-version: 1.16.0
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-31 23:37:13 +08:00
Wu Qing
51e4b0b0ce fix(backup): 修复差异/清单设计评审发现的三处问题 (#92)
1) DeleteRecord 拒绝删除仍被成功差异依赖的全量(+CountDependentDifferentials),堵住手动删除孤立差异链的数据完整性缺口;2) 列表查询 Omit(Manifest),差异基线改按需 FindByID 加载,避免清单大列拖累热路径;3) FileRunner.Run 每文件即时关闭句柄,杜绝大目录 FD 泄漏。含仓储层单测。
2026-05-28 13:38:10 +08:00
Wu Qing
493e1faff5 feat(backup): 新增按需(选择性)文件恢复 (#91)
在内容浏览基础上支持仅恢复勾选的文件/目录到原位置。FileRunner.Restore 按选中集合过滤提取与删除;RestoreService.StartSelective(Start 委托,零破坏);恢复端点接受可选 selectedPaths;前端内容弹窗支持勾选恢复。
2026-05-27 19:50:50 +08:00
Wu Qing
68bb964350 feat(backup): 新增备份内容浏览 (#90)
查看每次备份捕获的文件清单(路径/大小/目录),核对完整性、排查遗漏。清单取自全量备份记录,无需下载解压;差异记录回退基线清单。只读端点 + 前端可筛选弹窗。
2026-05-27 19:33:44 +08:00
Wu Qing
65cf3a04d4 feat(backup): 新增 zstd 压缩选项 (#89)
备份压缩在 gzip 之外新增 zstd(更高压缩率、更快解压)。pkg/compress 新增 ZstdFile/UnzstdFile,Master 与 Agent 压缩/解压按后缀分流,任务校验与前端下拉同步;往返单测覆盖。
2026-05-27 19:15:06 +08:00
Wu Qing
90b58d58d6 feat(backup): 新增差异备份(differential)模式 (#88)
文件备份新增差异模式:仅打包自上次全量以来的变更并记录删除,恢复自动按全量+差异链还原。含基线解析、链式恢复、保留链保护与本机文件任务校验;清单/比对/删除/往返/保留保护单测全覆盖。
2026-05-27 19:03:40 +08:00
Wu Qing
f584a0802a feat(backup): 新增 MongoDB 备份与恢复支持 (#87)
通过 mongodump/mongorestore --archive 流式管线接入 MongoDB 数据源,与现有数据库运行器架构一致;注册到 Master 与 Agent,含任务校验、默认端口与前端表单/恢复确认。5 个单测覆盖参数构造、全库、空产物与缺工具分支。
2026-05-27 18:35:10 +08:00
Wu Qing
992fc24150 feat(backup): GFS 分层保留策略(祖父-父-子)+ 任务表单配置 (#85)
新增 GFS 分层保留:按天/周/月/年保留代表性备份,各层级并集;任一>0 启用、全0 维持原策略(兼容),锁定记录豁免。后端 retention 算法+任务字段贯通,前端任务表单 GFS 配置。go test、tsc+vite 通过。
2026-05-27 15:36:58 +08:00
Wu Qing
386f12a11b feat(backup): 备份保留锁定 / 法律保留(豁免清理删除 + 记录页锁定) (#84)
新增保留锁定:锁定的备份豁免保留期清理与手动删除(迁移基线/合规快照/取证)。model+Locked、retention 剔除锁定记录、DeleteRecord 拒绝删除、PUT /backup/records/:id/lock、记录页锁定/解锁操作与标识。go test、tsc+vite、运行时路由验证均通过。
2026-05-27 13:59:05 +08:00
Wu Qing
f807ce10e6 feat(audit): 审计日志保留期清理(后端自动清理 + 审计页配置) (#83)
审计日志新增可配置保留期:AuditLogRepository.DeleteBefore + AuditService 保留期监控(每 6h 读取 audit_retention_days,0/缺省=永久保留);审计页新增管理员保留天数配置控件。后端 go test、前端 tsc+vite 通过。
2026-05-27 08:37:34 +08:00
Wu Qing
a0d1e66199 feat(reports): 企业合规报表(后端聚合 + CSV 导出 + 前端页面) (#82)
新增合规报表:ReportService 逐任务聚合备份合规证据(成功率/最近成功/SLA 判定/加密/受保护量)+ JSON/CSV API;前端新增 /reports 页面(汇总卡片+明细表+CSV 导出)。后端 go test、前端 tsc+vite、端到端路由验证均通过。
2026-05-27 08:14:56 +08:00
dependabot[bot]
74e29a0753 build(deps): bump go_modules group in /server (#71)
dependabot 安全更新:rclone 1.73.3→1.73.5、golang.org/x/crypto 0.48→0.50、otel 1.39→1.41、aws-sdk 等(均为 minor/patch)。#71 CI(go build/test)通过。
2026-05-27 01:23:34 +08:00
dependabot[bot]
01ce536ca8 build(deps): bump npm_and_yarn group in /docs-site (#72)
dependabot 安全更新(docs-site 站点依赖),CI 通过。
2026-05-27 01:23:26 +08:00
Wu Qing
ef2e15f500 test(replication): 为零覆盖的备份复制服务补齐测试 (#80)
新增 replication_service_test.go:备份→复制到目标存储(目标出现对象、源保留、终态 success)+ 同源拒绝。纯测试新增。
2026-05-27 01:19:49 +08:00
Wu Qing
bdf68eef7a test(verify): 为零覆盖的验证服务补齐测试 (#79)
新增 verification_service_test.go:合法压缩备份验证通过(回归保护 #77)+ 损坏对象验证必失败。纯测试新增。
2026-05-27 01:07:12 +08:00
Wu Qing
8747d6a21b fix(security): 节点文件浏览限制为非 viewer (#78)
GET /api/nodes/:id/fs/list 加 RequireNotViewer() 守卫,杜绝只读 viewer 枚举节点文件系统目录(信息泄露);与备份任务配置的权限级别对齐。
2026-05-27 00:54:44 +08:00
Wu Qing
04ad3c29f4 feat(verify): 验证流程同样比对备份 SHA-256,对齐恢复路径强度 (#77)
验证流程在解压前比对下载对象的 SHA-256(复用恢复路径实现),移除 verifyByType 失效的 checksum 形参。备份/恢复/验证三路径完整性校验一致。
2026-05-27 00:49:27 +08:00
Wu Qing
e63b8f0be8 feat(agent): Agent 远程恢复同样校验备份 SHA-256(补全 #75) (#76)
AgentRestoreSpec/RestoreSpec 新增 Checksum 并由 GetAgentRestoreSpec 透传;Agent ExecuteRestore 在解压前比对 SHA-256,不匹配即中止还原。两条恢复路径完整性保证一致。
2026-05-27 00:40:49 +08:00
Wu Qing
45bc210313 feat(restore): 还原前校验备份 SHA-256,拒绝损坏/被篡改的备份 (#75)
恢复路径在解密/解压前比对下载对象的 SHA-256(与备份记录一致),不匹配即中止还原且不触碰源数据;早期无 checksum 的备份跳过(向后兼容)。新增单元+集成测试。
2026-05-27 00:27:41 +08:00
Wu Qing
0f30e7bf52 refactor: 消除集群执行服务冗余逻辑 + 修复节点状态滞后缺陷 (#74)
抽取备份/恢复/验证/复制四服务的复制粘贴逻辑到 execution_helpers.go(净减约 250 行);节点状态改为按 LastSeen 实时推导,消除过期 online 误判;Agent systemd 单元补齐 LimitNOFILE 与单机端一致。go build/test 全绿。
2026-05-26 14:12:39 +08:00
Wu Qing
e4c52fd8f4 docs: 新增 CONTRIBUTING 贡献指南 (#73)
补充开发环境搭建、构建/测试命令、Conventional Commits(中文)规范、
PR 流程与编码约定。内容与仓库实际一致(Go 1.25、make 目标、vitest
前端测试、后端自动托管 web/dist)。

承接 #64 的思路并重写为与代码库一致的准确版本(不含 #63/#64 中关于
`-e PORT` 等不准确的 troubleshooting 内容)。

Co-authored-by: okwn <13228820+okwn@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 12:56:43 +08:00
Wu Qing
17f4ec63ae fix: 后端直接托管 Web 控制台修复 #62,并修复 CodeQL 安全告警 (#70)
* fix(server): 后端直接托管 Web 控制台,修复无 nginx 时 404 (#62)

问题 #62:在未安装 nginx 的服务器上,访问 :8340/ 返回
"route not found"(404),Web 控制台完全无法打开;同时 systemd
服务以 backupx 用户启动时因无权读取 root:root 0640 的配置文件
而反复退出(exit 1)。

修复:
- 后端新增 SPA 静态托管:自动探测前端目录(./web、./web/dist、
  /opt/backupx/web 等,或 server.web_root 显式指定),命中后直接
  提供静态文件与 index.html 回退,无需额外 nginx 反向代理即可访问
  控制台。/api、/health、/metrics、/install 等保留前缀仍返回结构化
  JSON 404,不会被 SPA 回退污染(沿用 issue #46 的约定)。
- 含 ".." 的请求路径由文件服务层直接拒绝,叠加 filepath.Rel 容器
  校验,杜绝目录穿越。
- install.sh 以 backupx:backupx 安装配置文件并显式 chown,修复历史
  版本 root:root 0640 导致服务无法读取配置而启动失败的问题;安装
  完成提示同步说明可直接通过 :8340 访问,并给出 journalctl 排查命令。
- 新增 spa_test.go 覆盖目录探测、保留前缀判定、SPA 回退与穿越防护。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security): 修复邮件头注入,加固 webhook 与整数转换

CodeQL 静态扫描在 main 上的真实告警修复:
- 邮件通知(email.go):From/To/Subject 头部此前直接拼接用户可控
  内容(备份任务名会进入 Subject),存在 SMTP 头注入风险(可注入
  Bcc 等额外头部或伪造正文)。新增 buildRawMessage/sanitizeHeaderValue
  剔除头部值中的 CR/LF;正文保持原样。新增 email_test.go 覆盖。
- webhook 通知(webhook.go):Validate 增加 URL 解析与 http/https
  协议校验,杜绝 file://、gopher:// 等可用于 SSRF 的协议。
- 整数转换(auth_service.go、storage_target_handler.go、
  backup_record_handler.go):将 ParseUint 的 bitSize 由 64 改为 0
  (即 uint 宽度),消除 uint64→uint 的潜在截断(32 位平台上为越界
  拒绝而非静默截断),并清除 go/incorrect-integer-conversion 告警。

注:archive.go/file_runner.go 的 zipslip 告警为误报(已有 HasPrefix
容器校验且不解压符号链接);node FS 浏览与 webhook 目标主机由设计上
的鉴权用户控制,不在本次行为变更范围内。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 12:50:57 +08:00
dependabot[bot]
5a936ee162 build(deps): bump the npm_and_yarn group across 2 directories with 7 updates (#59)
Bumps the npm_and_yarn group with 1 update in the /docs-site directory: [fast-uri](https://github.com/fastify/fast-uri).
Bumps the npm_and_yarn group with 6 updates in the /web directory:

| Package | From | To |
| --- | --- | --- |
| [follow-redirects](https://github.com/follow-redirects/follow-redirects) | `1.15.11` | `1.16.0` |
| [lodash](https://github.com/lodash/lodash) | `4.17.23` | `4.18.1` |
| [picomatch](https://github.com/micromatch/picomatch) | `4.0.3` | `4.0.4` |
| [postcss](https://github.com/postcss/postcss) | `8.5.8` | `8.5.14` |
| [axios](https://github.com/axios/axios) | `1.13.6` | `1.15.2` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `6.4.1` | `6.4.2` |



Updates `fast-uri` from 3.1.0 to 3.1.2
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.2)

Updates `follow-redirects` from 1.15.11 to 1.16.0
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.11...v1.16.0)

Updates `lodash` from 4.17.23 to 4.18.1
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.23...4.18.1)

Updates `picomatch` from 4.0.3 to 4.0.4
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/4.0.3...4.0.4)

Updates `postcss` from 8.5.8 to 8.5.14
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.8...8.5.14)

Updates `axios` from 1.13.6 to 1.15.2
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.13.6...v1.15.2)

Updates `vite` from 6.4.1 to 6.4.2
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.4.2/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.4.2/packages/vite)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: follow-redirects
  dependency-version: 1.16.0
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: lodash
  dependency-version: 4.18.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: picomatch
  dependency-version: 4.0.4
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: postcss
  dependency-version: 8.5.14
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: axios
  dependency-version: 1.15.2
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: vite
  dependency-version: 6.4.2
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 12:47:09 +08:00
dependabot[bot]
d39335bdde build(deps): bump golang.org/x/image (#36)
Bumps the go_modules group with 1 update in the /server directory: [golang.org/x/image](https://github.com/golang/image).


Updates `golang.org/x/image` from 0.32.0 to 0.38.0
- [Commits](https://github.com/golang/image/compare/v0.32.0...v0.38.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-version: 0.38.0
  dependency-type: indirect
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-26 12:43:21 +08:00
Wu Qing
7084d47c4b feat(BackupX): harden agent cluster backup workflow
Squash merge PR #61
2026-05-13 14:24:45 +08:00
104 changed files with 6331 additions and 1116 deletions

View File

@@ -6,6 +6,10 @@ on:
pull_request:
branches: [main, master]
# 最小权限:构建/测试仅需读取仓库内容,显式声明以收敛默认的可写令牌。
permissions:
contents: read
jobs:
backend:
name: Go Build & Test

106
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,106 @@
# Contributing to BackupX
感谢你对 BackupX 的关注!本指南介绍如何搭建开发环境并提交贡献。
Thanks for your interest in contributing to BackupX! This guide covers how to set up your environment and submit changes.
## 开发环境 / Development Setup
### 依赖 / Prerequisites
- **Go** 1.25+(见 `server/go.mod`
- **Node.js** 20+CI 与 Docker 使用 Node 20
- **npm** 9+
### 快速开始 / Quick Start
分别在两个终端启动前后端(后端 :8340前端 Vite HMR
```bash
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
# 终端 1 —— 后端(默认 http://localhost:8340
make dev-server
# 终端 2 —— 前端Vite 热更新,/api 代理到 8340
make dev-web
```
### 构建 / Building
```bash
make build # 同时构建后端与前端
make build-server # 仅后端 → server/bin/backupx
make build-web # 仅前端 → web/dist
make docker # 构建 Docker 镜像
make docker-cn # 国内镜像源加速构建
```
> 后端会自动托管 `web/dist`(或 `server.web_root` 指定目录),因此本地裸机部署无需额外的反向代理即可访问控制台。
## 测试 / Testing
提交前请确保测试通过:
```bash
make test # 后端 + 前端全部测试
make test-server # 仅后端cd server && go test ./...
make test-web # 仅前端cd web && npm run testvitest
```
新增功能或修复缺陷时,请尽量补充对应测试。
## 提交信息规范 / Commit Messages
本项目采用 **Conventional Commits**,正文用中文撰写:
```
<type>(<scope>): <subject>
<body>
```
| type | 说明 |
|------|------|
| `feat` | 新功能 |
| `fix` | 缺陷修复 |
| `docs` | 文档变更 |
| `style` | 不影响逻辑的格式调整 |
| `refactor` | 重构 |
| `perf` | 性能优化 |
| `test` | 测试相关 |
| `chore` | 构建/依赖/工具链 |
示例:
```
feat(storage): 新增 Wasabi S3 后端支持
fix(cluster): 修复跨节点恢复的终态处理
docs: 补充 CONTRIBUTING 指南
```
## Pull Request 流程
1. **Fork** 仓库并从最新的 `main` 切出特性分支;
2. **开发**功能或修复,必要时补充测试;
3. **自测**:确保 `make test` 通过;
4. **提交**:使用上述 Conventional Commits中文
5. **推送**并对着 `main` 发起 PR。
### PR 描述建议
- 清晰说明本 PR 做了什么;
- 对新功能/修复,补充动机与背景;
- 关联相关 Issue`Closes #62`
- 纯文档 PR 可不附测试。
> 请保持分支基于较新的 `main`:基线过旧的分支容易产生大范围冲突,难以评审与合入。
## 编码规范 / Coding Conventions
- **Go**:所有错误必须处理(禁止 `_ = err`),日志使用项目已有库(`zap`),禁止 `fmt.Println`;提交前执行 `gofmt`
- **前端**:遵循项目 ESLint/Prettier/tsconfig 配置,不擅自引入新的 CSS 框架或 UI 库。
- **包管理**`web/` 使用 npm请提交对应的 `package-lock.json`
## License
向 BackupX 贡献即表示你同意你的贡献以 [Apache License 2.0](LICENSE) 授权。

View File

@@ -94,6 +94,16 @@ See the [development guide](https://awuqing.github.io/BackupX/docs/development/s
Issues and pull requests welcome. Please read the [contributing guide](https://awuqing.github.io/BackupX/docs/development/contributing) before opening a PR — commit messages and PRs on this project are written in Chinese.
## Star History
<a href="https://www.star-history.com/?repos=Awuqing%2FBackupX&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=Awuqing/BackupX&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=Awuqing/BackupX&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=Awuqing/BackupX&type=date&legend=top-left" />
</picture>
</a>
## License
[Apache License 2.0](LICENSE)

View File

@@ -1,6 +1,10 @@
#!/bin/sh
set -e
if [ "${1:-}" = "agent" ]; then
exec /app/bin/backupx "$@"
fi
# Backend listens on internal port 8341, Nginx exposes 8340
export BACKUPX_SERVER_PORT="${BACKUPX_SERVER_PORT_INTERNAL:-8341}"

View File

@@ -59,8 +59,11 @@ cp -R "$WEB_SOURCE/." "$PREFIX/web/"
chown -R "$APP_USER:$APP_GROUP" "$PREFIX"
if [ ! -f "$ETC_DIR/config.yaml" ]; then
install -m 0640 "$CONFIG_TEMPLATE" "$ETC_DIR/config.yaml"
install -o "$APP_USER" -g "$APP_GROUP" -m 0640 "$CONFIG_TEMPLATE" "$ETC_DIR/config.yaml"
fi
# 确保服务账户能读取配置:历史版本曾以 root:root 0640 安装配置,
# 导致以 backupx 身份运行的服务因无权读取配置而启动失败exit 1
chown "$APP_USER:$APP_GROUP" "$ETC_DIR/config.yaml"
if [ -f "$SERVICE_SOURCE" ]; then
install -m 0644 "$SERVICE_SOURCE" "/etc/systemd/system/$SERVICE_NAME.service"
@@ -105,6 +108,14 @@ cat <<MESSAGE
- 配置文件:$ETC_DIR/config.yaml
- systemd 服务:/etc/systemd/system/$SERVICE_NAME.service
Web 控制台已由后端直接托管,无需额外的 nginx 反向代理即可访问:
http://<本机IP>:8340
(如已安装 nginx脚本会自动写入反向代理配置可继续用 80 端口访问。)
排查:若服务未监听端口,请查看日志:
journalctl -u "$SERVICE_NAME" -n 50 --no-pager
如需修改监听地址、数据库路径或日志级别,请编辑 "$ETC_DIR/config.yaml" 后执行:
systemctl restart "$SERVICE_NAME"
MESSAGE

View File

@@ -8,6 +8,8 @@ description: File, MySQL, PostgreSQL, SQLite and SAP HANA — what they back up
BackupX supports five built-in backup types. Type determines which runner executes the job.
When a task is routed to a remote Agent, the source tools and paths are resolved on that Agent host. Multi-target uploads are still tracked per storage target; if at least one target succeeds, the backup record is marked successful and the per-target result table shows partial failures.
## File / Directory
Tars (and optionally gzips) one or more filesystem paths.

View File

@@ -62,6 +62,8 @@ The script runs automatically and:
5. Runs `systemctl enable --now backupx-agent`
6. Polls `/api/v1/agent/self` until the master confirms `status: online` (up to 30 s)
Docker mode uses the same `BACKUPX_AGENT_MASTER`, `BACKUPX_AGENT_TOKEN`, and `BACKUPX_AGENT_TEMP_DIR=/var/lib/backupx-agent/tmp` environment contract. After starting the container, the installer also probes `/api/v1/agent/self`; if the node does not come online, it prints `docker ps` and `docker logs --tail=100 backupx-agent` diagnostics before exiting non-zero.
If you choose the URL-based fallback command and `curl` prints HTML or the shell reports `Syntax error: newline unexpected`, the install URL is being served by the web console instead of the backend. Ensure either `/api/install/` or `/install/` is forwarded to the BackupX backend, or use the embedded command generated by the console.
Reruns are idempotent — to upgrade or re-provision, simply generate a new install command and run it again. The one-time install link expires after its TTL or after first consumption, whichever is sooner.
@@ -81,9 +83,15 @@ In the **Backup Tasks** page, pick the target node when creating the task. When
- Local (`nodeId=0`) → Master executes in-process
- Remote node → Master enqueues the command → Agent claims → Agent runs locally → uploads → reports back
The node table shows the Agent health and command queue state: pending/dispatched depth, running long commands, timeouts, oldest active command age, and the latest Agent-side error. The same queue depth, running-command, and timeout snapshots are exported as Prometheus metrics:
- `backupx_agent_command_queue_depth`
- `backupx_agent_command_running`
- `backupx_agent_command_timeout_total`
## Known limitations
- **Encrypted backups don't work via Agent** — the Agent doesn't hold Master's AES-256 key. Tasks with `encrypt: true` will fail if routed to an Agent
- **Encrypted backups are Master-only** — the Agent doesn't hold Master's AES-256 key. Creating or updating a task with `encrypt: true` and a remote node or node pool is rejected up front
- **Directory browser timeout** — remote dir listing is a synchronous RPC through the queue (15s default)
- **Dispatched command timeout** — claimed-but-unfinished commands are marked `timeout` after 10 minutes

View File

@@ -42,6 +42,8 @@ Go to **Backup Tasks → New**. Three steps:
2. **Source** — paths for file backup (multi-source supported), or connection info for databases
3. **Storage & policy** — pick target(s), compression, retention days, encryption on/off
For Agent-routed tasks, encryption must stay off because the Agent never receives the Master's encryption key. BackupX rejects remote-node or node-pool tasks with encryption enabled during create/update.
Save, then click **Run Now** to trigger a test. Live logs stream on the **Backup Records** page.
:::note

View File

@@ -8,6 +8,8 @@ description: 文件、MySQL、PostgreSQL、SQLite 和 SAP HANA — 各自的能
BackupX 支持五种内置备份类型,类型决定了用哪个 runner 执行。
当任务路由到远程 Agent 时,源路径和外部工具都会在该 Agent 主机上解析。多存储目标上传仍会逐目标记录结果;只要至少一个目标上传成功,备份记录即为成功,详情中的目标结果表会展示部分失败。
## 文件 / 目录
打包(可选 gzip一个或多个文件系统路径。

View File

@@ -62,6 +62,8 @@ Web 控制台 → **节点管理** → **添加节点**,打开三步向导:
5. 执行 `systemctl enable --now backupx-agent`
6. 轮询 `/api/v1/agent/self`,直到 Master 确认 `status: online`(最多 30 秒)
Docker 模式使用同一组环境变量约定:`BACKUPX_AGENT_MASTER`、`BACKUPX_AGENT_TOKEN` 和 `BACKUPX_AGENT_TEMP_DIR=/var/lib/backupx-agent/tmp`。容器启动后,安装脚本同样会探测 `/api/v1/agent/self`;如果节点没有上线,会输出 `docker ps` 与 `docker logs --tail=100 backupx-agent` 排查命令,并以非零状态退出。
如果使用 URL 备用命令时 `curl` 输出 HTML或 shell 报 `Syntax error: newline unexpected`,说明安装 URL 被 Web 控制台接管而不是转发到后端。需要确保 `/api/install/` 或 `/install/` 至少一个路径能转发到 BackupX 后端,或改用控制台生成的嵌入式命令。
脚本是幂等的:升级或重装只需重新生成一条安装命令再跑一次。一次性安装链接在 TTL 到期或被首次消费后立即作废。
@@ -81,9 +83,15 @@ Web 控制台 → **节点管理** → **添加节点**,打开三步向导:
- 本机 / 未指定(`nodeId=0`Master 进程内直接执行
- 远程节点Master 写入命令队列 → Agent 拉取 → Agent 本地执行 → 上传 → 回报
节点列表会展示 Agent 健康与命令队列状态pending/dispatched 深度、运行中的长任务、超时数、最旧活跃命令年龄和最近 Agent 错误。同样的队列深度、运行中命令数和超时快照会导出为 Prometheus 指标:
- `backupx_agent_command_queue_depth`
- `backupx_agent_command_running`
- `backupx_agent_command_timeout_total`
## 已知限制
- **Agent 不支持加密备份**Agent 不持有 Master 的 AES-256 密钥。`encrypt: true` 的任务路由到 Agent 时会直接上报失败
- **加密备份仅支持 Master 本机执行**Agent 不持有 Master 的 AES-256 密钥。创建或更新任务时,如果 `encrypt: true` 且选择了远程节点或节点池,会在入口直接拒绝
- **目录浏览超时**:远程目录浏览通过命令队列做同步 RPC默认 15s 超时
- **派发命令超时**Agent 领取但未完成的命令超过 10 分钟会被置 `timeout`

View File

@@ -42,6 +42,8 @@ description: 部署 BackupX、添加存储目标、创建第一个备份任务
2. **源配置** — 文件备份选择源路径(支持多个),数据库备份填写连接信息
3. **存储与策略** — 选择存储目标(支持多个)、压缩策略、保留天数、是否加密
对于路由到 Agent 的任务,加密必须关闭,因为 Agent 不会拿到 Master 的加密密钥。BackupX 会在创建/更新阶段拒绝开启加密的远程节点或节点池任务。
保存后可点击 **立即执行** 测试,**备份记录** 页面实时查看执行日志。
:::note

View File

@@ -1,12 +1,12 @@
{
"name": "docs-site-tmp",
"version": "0.0.0",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "docs-site-tmp",
"version": "0.0.0",
"version": "1.0.0",
"dependencies": {
"@docusaurus/core": "3.10.0",
"@docusaurus/faster": "3.10.0",
@@ -164,7 +164,6 @@
"resolved": "https://registry.npmmirror.com/@algolia/client-search/-/client-search-5.50.2.tgz",
"integrity": "sha512-ypSboUJ3XJoQz5DeDo82hCnrRuwq3q9ZdFhVKAik9TnZh1DvLqoQsrbBjXg7C7zQOtV/Qbge/HmyoV6V5L7MhQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/client-common": "5.50.2",
"@algolia/requester-browser-xhr": "5.50.2",
@@ -263,12 +262,12 @@
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.28.5",
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
@@ -290,7 +289,6 @@
"resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -326,13 +324,13 @@
}
},
"node_modules/@babel/generator": {
"version": "7.29.1",
"resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.1.tgz",
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -451,9 +449,9 @@
}
},
"node_modules/@babel/helper-globals": {
"version": "7.28.0",
"resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -473,27 +471,27 @@
}
},
"node_modules/@babel/helper-module-imports": {
"version": "7.28.6",
"resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.28.6",
"@babel/types": "^7.28.6"
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-transforms": {
"version": "7.28.6",
"resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.28.6",
"@babel/helper-validator-identifier": "^7.28.5",
"@babel/traverse": "^7.28.6"
"@babel/helper-module-imports": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7",
"@babel/traverse": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -515,9 +513,9 @@
}
},
"node_modules/@babel/helper-plugin-utils": {
"version": "7.28.6",
"resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
"integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
"integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -571,18 +569,18 @@
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -625,12 +623,12 @@
}
},
"node_modules/@babel/parser": {
"version": "7.29.2",
"resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.2.tgz",
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.0"
"@babel/types": "^7.29.7"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -1215,15 +1213,15 @@
}
},
"node_modules/@babel/plugin-transform-modules-systemjs": {
"version": "7.29.0",
"resolved": "https://registry.npmmirror.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz",
"integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.7.tgz",
"integrity": "sha512-TM2ZcQLoG2/y4HODiStCo10DibYhWhGWAwVv+EQKmG/7GFl0N+AAmUiXOMKM+aiJ9XBJ9AHVZBvTzMnJ2sM3cQ==",
"license": "MIT",
"dependencies": {
"@babel/helper-module-transforms": "^7.28.6",
"@babel/helper-plugin-utils": "^7.28.6",
"@babel/helper-validator-identifier": "^7.28.5",
"@babel/traverse": "^7.29.0"
"@babel/helper-module-transforms": "^7.29.7",
"@babel/helper-plugin-utils": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7",
"@babel/traverse": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -1920,31 +1918,31 @@
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz",
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/parser": "^7.28.6",
"@babel/types": "^7.28.6"
"@babel/code-frame": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
"version": "7.29.0",
"resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.0.tgz",
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
"@babel/helper-globals": "^7.28.0",
"@babel/parser": "^7.29.0",
"@babel/template": "^7.28.6",
"@babel/types": "^7.29.0",
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-globals": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7",
"debug": "^4.3.1"
},
"engines": {
@@ -1952,13 +1950,13 @@
}
},
"node_modules/@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -2081,7 +2079,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -2104,7 +2101,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -2214,7 +2210,6 @@
"resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -2636,7 +2631,6 @@
"resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -3549,7 +3543,6 @@
"resolved": "https://registry.npmmirror.com/@docusaurus/faster/-/faster-3.10.0.tgz",
"integrity": "sha512-GNPtVH14ISjHfSwnHu3KiFGf86ICmJSQDeSv/QaanpBgiZGOtgZaslnC5q8WiguxM1EVkwcGxPuD8BXF4eggKw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@docusaurus/types": "3.10.0",
"@rspack/core": "^1.7.10",
@@ -3680,7 +3673,6 @@
"resolved": "https://registry.npmmirror.com/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.10.0.tgz",
"integrity": "sha512-9BjHhf15ct8Z7TThTC0xRndKDVvMKmVsAGAN7W9FpNRzfMdScOGcXtLmcCWtJGvAezjOJIm6CxOYCy3Io5+RnQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@docusaurus/core": "3.10.0",
"@docusaurus/logger": "3.10.0",
@@ -4712,7 +4704,6 @@
"resolved": "https://registry.npmmirror.com/@mdx-js/react/-/react-3.1.1.tgz",
"integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/mdx": "^2.0.0"
},
@@ -5417,7 +5408,6 @@
"resolved": "https://registry.npmmirror.com/@svgr/core/-/core-8.1.0.tgz",
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.21.3",
"@svgr/babel-preset": "8.1.0",
@@ -5522,7 +5512,6 @@
"integrity": "sha512-tglZGyx8N5PC+x1Nd/JrZxqpqlcZoSuG9gTDKO6AuFToFiVB3uS8HvbKFuO7g3lJzvFf9riAb94xs9HU2UhAHQ==",
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@swc/counter": "^0.1.3",
"@swc/types": "^0.1.26"
@@ -6247,7 +6236,6 @@
"resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -6589,7 +6577,6 @@
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -6657,7 +6644,6 @@
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -6703,7 +6689,6 @@
"resolved": "https://registry.npmmirror.com/algoliasearch/-/algoliasearch-5.50.2.tgz",
"integrity": "sha512-Tfp26yoNWurUjfgK4GOrVJQhSNXu9tJtHfFFNosgT2YClG+vPyUjX/gbC8rG39qLncnZg8Fj34iarQWpMkqefw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/abtesting": "1.16.2",
"@algolia/client-abtesting": "5.50.2",
@@ -7057,9 +7042,9 @@
}
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-1.20.4.tgz",
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
"version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
@@ -7070,7 +7055,7 @@
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.14.0",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
@@ -7082,7 +7067,7 @@
},
"node_modules/body-parser/node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
@@ -7091,7 +7076,7 @@
},
"node_modules/body-parser/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
@@ -7100,7 +7085,7 @@
},
"node_modules/body-parser/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
@@ -7183,7 +7168,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -7842,7 +7826,7 @@
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
@@ -8150,7 +8134,6 @@
"resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -9280,14 +9263,14 @@
}
},
"node_modules/express": {
"version": "4.22.1",
"resolved": "https://registry.npmmirror.com/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.3",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
@@ -9306,7 +9289,7 @@
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.14.0",
"qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
@@ -9414,9 +9397,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"funding": [
{
"type": "github",
@@ -9524,7 +9507,6 @@
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -10571,7 +10553,7 @@
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
@@ -12069,7 +12051,7 @@
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
@@ -14204,7 +14186,6 @@
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -14260,7 +14241,7 @@
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
@@ -14719,9 +14700,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.10",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.10.tgz",
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"funding": [
{
"type": "opencollective",
@@ -14737,7 +14718,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -15641,7 +15621,6 @@
"resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -16335,9 +16314,9 @@
}
},
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmmirror.com/qs/-/qs-6.14.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
@@ -16401,7 +16380,7 @@
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-2.5.3.tgz",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
@@ -16416,7 +16395,7 @@
},
"node_modules/raw-body/node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
@@ -16458,7 +16437,6 @@
"resolved": "https://registry.npmmirror.com/react/-/react-19.2.5.tgz",
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -16468,7 +16446,6 @@
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.5.tgz",
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -16524,7 +16501,6 @@
"resolved": "https://registry.npmmirror.com/@docusaurus/react-loadable/-/react-loadable-6.0.0.tgz",
"integrity": "sha512-YMMxTUQV/QFSnbgrP3tjDzLHRg7vsbMn8e9HAa8o/1iXoiomo48b7sk/kkmWEuWNDPJVlKSJRB6Y2fHqdJk+SQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/react": "*"
},
@@ -16553,7 +16529,6 @@
"resolved": "https://registry.npmmirror.com/react-router/-/react-router-5.3.4.tgz",
"integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.12.13",
"history": "^4.9.0",
@@ -17229,7 +17204,7 @@
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
@@ -17600,7 +17575,7 @@
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
@@ -17619,7 +17594,7 @@
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
@@ -17635,7 +17610,7 @@
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
@@ -17653,7 +17628,7 @@
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
@@ -18328,8 +18303,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"peer": true
"license": "0BSD"
},
"node_modules/tsyringe": {
"version": "4.10.0",
@@ -18363,7 +18337,7 @@
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
@@ -18376,7 +18350,7 @@
},
"node_modules/type-is/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
@@ -18385,7 +18359,7 @@
},
"node_modules/type-is/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
@@ -18410,7 +18384,6 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -18752,7 +18725,6 @@
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -18951,7 +18923,6 @@
"resolved": "https://registry.npmmirror.com/webpack/-/webpack-5.106.2.tgz",
"integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
@@ -19093,9 +19064,9 @@
}
},
"node_modules/webpack-dev-server": {
"version": "5.2.3",
"resolved": "https://registry.npmmirror.com/webpack-dev-server/-/webpack-dev-server-5.2.3.tgz",
"integrity": "sha512-9Gyu2F7+bg4Vv+pjbovuYDhHX+mqdqITykfzdM9UyKqKHlsE5aAjRhR+oOEfXW5vBeu8tarzlJFIZva4ZjAdrQ==",
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.4.tgz",
"integrity": "sha512-GqDPGZN9bRqKBTkp4aWkobDDHMsrXKoGSdOH56smIri8qR0JG8gfL8/v/f/OZR3/OKXjG8uwJbFVhKm/FNU/UA==",
"license": "MIT",
"dependencies": {
"@types/bonjour": "^3.5.13",

View File

@@ -4,6 +4,8 @@ server:
port: 8340
mode: "release" # debug | release
external_url: "" # 可选Master 对 Agent 可达的 URL例如 https://backup.example.com
web_root: "" # 前端静态目录;留空自动探测(./web、/opt/backupx/web 等)。
# 命中后后端直接托管 Web 控制台,无需额外 nginx 反向代理。
database:
path: "./data/backupx.db" # SQLite 数据库路径

View File

@@ -5,69 +5,72 @@ go 1.25.0
require (
github.com/gin-gonic/gin v1.10.1
github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/klauspost/compress v1.18.5
github.com/natefinch/lumberjack v2.0.0+incompatible
github.com/prometheus/client_golang v1.23.2
github.com/pquerna/otp v1.5.0
github.com/rclone/rclone v1.73.3
github.com/prometheus/client_golang v1.23.2
github.com/rclone/rclone v1.74.3
github.com/robfig/cron/v3 v3.0.1
github.com/spf13/viper v1.20.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.48.0
golang.org/x/oauth2 v0.34.0
google.golang.org/api v0.255.0
golang.org/x/crypto v0.52.0
golang.org/x/oauth2 v0.36.0
google.golang.org/api v0.275.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/gorm v1.25.12
)
require (
cloud.google.com/go/auth v0.17.0 // indirect
cloud.google.com/go/auth v0.20.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.3 // indirect
github.com/Azure/go-ntlmssp v0.0.2-0.20251110135918-10b7b7e7cd26 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.4 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/FilenCloudDienste/filen-sdk-go v0.0.37 // indirect
github.com/Files-com/files-sdk-go/v3 v3.2.264 // indirect
github.com/IBM/go-sdk-core/v5 v5.18.5 // indirect
github.com/FilenCloudDienste/filen-sdk-go v0.0.39 // indirect
github.com/Files-com/files-sdk-go/v3 v3.3.82 // indirect
github.com/IBM/go-sdk-core/v5 v5.21.2 // indirect
github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd // indirect
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf // indirect
github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/ProtonMail/go-crypto v1.4.1 // indirect
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect
github.com/ProtonMail/go-srp v0.0.7 // indirect
github.com/ProtonMail/gopenpgp/v2 v2.9.0 // indirect
github.com/PuerkitoBio/goquery v1.10.3 // indirect
github.com/PuerkitoBio/goquery v1.11.0 // indirect
github.com/a1ex3/zstd-seekable-format-go/pkg v0.10.0 // indirect
github.com/abbot/go-http-auth v0.4.0 // indirect
github.com/adrg/xdg v0.5.3 // indirect
github.com/anchore/go-lzo v0.1.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/appscode/go-querystring v0.0.0-20170504095604-0126cfb3f1dc // indirect
github.com/aws/aws-sdk-go-v2 v1.41.3 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 // indirect
github.com/aws/aws-sdk-go-v2/config v1.31.17 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.11 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.20 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.14 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.13 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
github.com/aws/smithy-go v1.25.1 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
@@ -80,10 +83,9 @@ require (
github.com/calebcase/tmpfile v1.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chilts/sid v0.0.0-20190607042430-660e94789ec9 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cloudinary/cloudinary-go/v2 v2.13.0 // indirect
github.com/cloudinary/cloudinary-go/v2 v2.15.0 // indirect
github.com/cloudsoda/go-smb2 v0.0.0-20250228001242-d4c70e6251cc // indirect
github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
@@ -91,35 +93,35 @@ require (
github.com/coreos/go-semver v0.3.1 // indirect
github.com/coreos/go-systemd/v22 v22.6.0 // indirect
github.com/creasty/defaults v1.8.0 // indirect
github.com/cronokirby/saferith v0.33.0 // indirect
github.com/cronokirby/saferith v0.33.1-0.20250226174546-1f11f94ce488 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/diskfs/go-diskfs v1.7.0 // indirect
github.com/dromara/dongle v1.0.1 // indirect
github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/emersion/go-message v0.18.2 // indirect
github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/flynn/noise v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/geoffgarside/ber v1.2.0 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 // indirect
github.com/go-git/go-billy/v5 v5.6.2 // indirect
github.com/go-git/go-billy/v5 v5.9.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-openapi/errors v0.22.4 // indirect
github.com/go-openapi/errors v0.22.6 // indirect
github.com/go-openapi/strfmt v0.25.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.28.0 // indirect
github.com/go-resty/resty/v2 v2.16.5 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/go-resty/resty/v2 v2.17.2 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
@@ -127,15 +129,15 @@ require (
github.com/google/btree v1.1.3 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
github.com/googleapis/gax-go/v2 v2.21.0 // indirect
github.com/gorilla/schema v1.4.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/internxt/rclone-adapter v0.0.0-20260220172730-613f4cc8b8fd // indirect
github.com/internxt/rclone-adapter v0.0.0-20260331173834-036f908d0160 // indirect
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
github.com/jcmturner/gofork v1.7.6 // indirect
@@ -144,11 +146,10 @@ require (
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jlaffaye/ftp v0.2.1-0.20240918233326-1b970516f5d3 // indirect
github.com/jlaffaye/ftp v0.2.1-0.20251026020404-6602e981a1bb // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/jtolio/noiseconn v0.0.0-20231127013910-f6d9ecbf1de7 // indirect
github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004 // indirect
github.com/klauspost/compress v1.18.1 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/koofr/go-httpclient v0.0.0-20240520111329-e20f8f203988 // indirect
github.com/koofr/go-koofrclient v0.0.0-20221207135200-cbd7fc9ad6a6 // indirect
@@ -157,11 +158,11 @@ require (
github.com/lanrat/extsort v1.4.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lpar/date v1.0.0 // indirect
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mattn/go-runewidth v0.0.22 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
@@ -169,13 +170,13 @@ require (
github.com/ncw/swift/v2 v2.0.5 // indirect
github.com/oklog/ulid v1.3.1 // indirect
github.com/onsi/ginkgo/v2 v2.19.0 // indirect
github.com/oracle/oci-go-sdk/v65 v65.104.0 // indirect
github.com/panjf2000/ants/v2 v2.11.3 // indirect
github.com/oracle/oci-go-sdk/v65 v65.111.0 // indirect
github.com/panjf2000/ants/v2 v2.11.5 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pengsrc/go-shared v0.2.1-0.20190131101655-1999055a4a14 // indirect
github.com/peterh/liner v1.2.2 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pierrec/lz4/v4 v4.1.25 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pkg/sftp v1.13.10 // indirect
@@ -183,19 +184,19 @@ require (
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/putdotio/go-putio/putio v0.0.0-20200123120452-16d982cac2b8 // indirect
github.com/rclone/Proton-API-Bridge v1.0.1-0.20260127174007-77f974840d11 // indirect
github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18 // indirect
github.com/rclone/Proton-API-Bridge v1.0.3 // indirect
github.com/rclone/go-proton-api v1.0.2 // indirect
github.com/relvacode/iso8601 v1.7.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rfjakob/eme v1.1.2 // indirect
github.com/rfjakob/eme v1.2.0 // indirect
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/samber/lo v1.52.0 // indirect
github.com/shirou/gopsutil/v4 v4.25.10 // indirect
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
github.com/sony/gobreaker v1.0.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
@@ -205,9 +206,9 @@ require (
github.com/spf13/pflag v1.0.10 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/t3rm1n4l/go-mega v0.0.0-20251031123324-a804aaa87491 // indirect
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/t3rm1n4l/go-mega v0.0.0-20251120131202-6845944c051c // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/tyler-smith/go-bip39 v1.1.0 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
@@ -219,30 +220,28 @@ require (
github.com/yusufpapurcu/wmi v1.2.4 // indirect
github.com/zeebo/blake3 v0.2.4 // indirect
github.com/zeebo/errs v1.4.0 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.mongodb.org/mongo-driver v1.17.6 // indirect
go.mongodb.org/mongo-driver v1.17.9 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/arch v0.14.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/image v0.32.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.41.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.10 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/image v0.41.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/validator.v2 v2.0.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
@@ -251,10 +250,11 @@ require (
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
moul.io/http2curl/v2 v2.3.0 // indirect
storj.io/common v0.0.0-20251107171817-6221ae45072c // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
storj.io/common v0.0.0-20260225132117-99155641c30a // indirect
storj.io/drpc v0.0.35-0.20250513201419-f7819ea69b55 // indirect
storj.io/eventkit v0.0.0-20250410172343-61f26d3de156 // indirect
storj.io/infectious v0.0.2 // indirect
storj.io/picobuf v0.0.4 // indirect
storj.io/uplink v1.13.1 // indirect
storj.io/uplink v1.14.0 // indirect
)

View File

@@ -13,8 +13,8 @@ cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKV
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ=
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
@@ -37,22 +37,22 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.0 h1:KpMC6LFL7mqpExyMC9jVOYRiVhLmamjeZfRsUpB7l4s=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.0/go.mod h1:J7MUC/wtRpfGVbQ5sIItY5/FuVWmvzlY21WAOfQnq/I=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA=
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 h1:ZJJNFaQ86GVKQ9ehwqyAFE6pIfyicpuJ8IkVaPBc6/4=
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3/go.mod h1:URuDvhmATVKqHBH9/0nOiNKk0+YcwfQ3WkK5PqHKxc8=
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.3 h1:sxgSqOB9CDToiaVFpxuvb5wGgGqWa3lCShcm5o0n3bE=
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.3/go.mod h1:XdED8i399lEVblYHTZM8eXaP07gv4Z58IL6ueMlVlrg=
github.com/Azure/go-ntlmssp v0.0.2-0.20251110135918-10b7b7e7cd26 h1:gy/jrlpp8EfSyA73a51fofoSfhp5rPNQAUvDr4Dm91c=
github.com/Azure/go-ntlmssp v0.0.2-0.20251110135918-10b7b7e7cd26/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 h1:jWQK1GI+LeGGUKBADtcH2rRqPxYB1Ljwms5gFA2LqrM=
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4/go.mod h1:8mwH4klAm9DUgR2EEHyEEAQlRDvLPyg5fQry3y+cDew=
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.4 h1:tZh20RjgfMxKBxJiIS75iTVAKIUxrST5X2dVHMTptL4=
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.4/go.mod h1:vGYAk36rhMVCfTP7v+RVruCR0zmPe6S+36KRpDCLySw=
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
@@ -61,85 +61,89 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/FilenCloudDienste/filen-sdk-go v0.0.37 h1:W8S9TrAyZ4//3PXsU6+Bi+fe/6uIL986GyS7PVzIDL4=
github.com/FilenCloudDienste/filen-sdk-go v0.0.37/go.mod h1:0cBhKXQg49XbKZZfk5TCDa3sVLP+xMxZTWL+7KY0XR0=
github.com/Files-com/files-sdk-go/v3 v3.2.264 h1:lMHTplAYI9FtmCo/QOcpRxmPA5REVAct1r2riQmDQKw=
github.com/Files-com/files-sdk-go/v3 v3.2.264/go.mod h1:wGqkOzRu/ClJibvDgcfuJNAqI2nLhe8g91tPlDKRCdE=
github.com/IBM/go-sdk-core/v5 v5.18.5 h1:g0JRl3sYXJczB/yuDlrN6x22LJ6jIxhp0Sa4ARNW60c=
github.com/IBM/go-sdk-core/v5 v5.18.5/go.mod h1:KonTFRR+8ZSgw5cxBSYo6E4WZoY1+7n1kfHM82VcjFU=
github.com/FilenCloudDienste/filen-sdk-go v0.0.39 h1:tgV5jYL6dsXop9TpDTIQU6UwJjws122HrwskaEE/igY=
github.com/FilenCloudDienste/filen-sdk-go v0.0.39/go.mod h1:0cBhKXQg49XbKZZfk5TCDa3sVLP+xMxZTWL+7KY0XR0=
github.com/Files-com/files-sdk-go/v3 v3.3.82 h1:2RfP0d2QgkFH64BjZSWd59aMsc28IyWsUuHqU0txBtY=
github.com/Files-com/files-sdk-go/v3 v3.3.82/go.mod h1:IPk80dOmc7VFC0DJ85xMTPmre+8xoXX6kGHAkf5jRRw=
github.com/IBM/go-sdk-core/v5 v5.21.2 h1:mJ5QbLPOm4g5qhZiVB6wbSllfpeUExftGoyPek2hk4M=
github.com/IBM/go-sdk-core/v5 v5.21.2/go.mod h1:ngpMgwkjur1VNUjqn11LPk3o5eCyOCRbcfg/0YAY7Hc=
github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g=
github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd h1:nzE1YQBdx1bq9IlZinHa+HVffy+NmVRoKr+wHN8fpLE=
github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd/go.mod h1:C8yoIfvESpM3GD07OCHU7fqI7lhwyZ2Td1rbNbTAhnc=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/bcrypt v0.0.0-20210511135022-227b4adcab57/go.mod h1:HecWFHognK8GfRDGnFQbW/LiV7A3MX3gZVs45vk5h8I=
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf h1:yc9daCCYUefEs69zUkSzubzjBbL+cmOXgnmt9Fyd9ug=
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf/go.mod h1:o0ESU9p83twszAU8LBeJKFAAMX14tISa0yk4Oo5TOqo=
github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e h1:lCsqUUACrcMC83lg5rTo9Y0PnPItE61JSfvMyIcANwk=
github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e/go.mod h1:Og5/Dz1MiGpCJn51XujZwxiLG7WzvvjE5PRpZBQmAHo=
github.com/ProtonMail/go-crypto v0.0.0-20230321155629-9a39f2531310/go.mod h1:8TI4H3IbrackdNgv+92dI+rhpCaLqM0IfpgCgenFvRE=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k=
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw=
github.com/ProtonMail/go-srp v0.0.7 h1:Sos3Qk+th4tQR64vsxGIxYpN3rdnG9Wf9K4ZloC1JrI=
github.com/ProtonMail/go-srp v0.0.7/go.mod h1:giCp+7qRnMIcCvI6V6U3S1lDDXDQYx2ewJ6F/9wdlJk=
github.com/ProtonMail/gopenpgp/v2 v2.9.0 h1:ruLzBmwe4dR1hdnrsEJ/S7psSBmV15gFttFUPP/+/kE=
github.com/ProtonMail/gopenpgp/v2 v2.9.0/go.mod h1:IldDyh9Hv1ZCCYatTuuEt1XZJ0OPjxLpTarDfglih7s=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
github.com/a1ex3/zstd-seekable-format-go/pkg v0.10.0 h1:iLDOF0rdGTrol/q8OfPIIs5kLD8XvA2q75o6Uq/tgak=
github.com/a1ex3/zstd-seekable-format-go/pkg v0.10.0/go.mod h1:DrEWcQJjz7t5iF2duaiyhg4jyoF0kxOD6LtECNGkZ/Q=
github.com/aalpar/deheap v0.0.0-20210914013432-0cc84d79dec3 h1:hhdWprfSpFbN7lz3W1gM40vOgvSh1WCSMxYD6gGB4Hs=
github.com/aalpar/deheap v0.0.0-20210914013432-0cc84d79dec3/go.mod h1:XaUnRxSCYgL3kkgX0QHIV0D+znljPIDImxlv2kbGv0Y=
github.com/aalpar/deheap v1.1.2 h1:MABHLcnjqsffb8GLkUFDigqpBBxOMz0DoKM9QfELeTw=
github.com/aalpar/deheap v1.1.2/go.mod h1:A+nfkD4JbS05sewV0he/MYgR/90vfqyMoNNROgs+rmA=
github.com/abbot/go-http-auth v0.4.0 h1:QjmvZ5gSC7jm3Zg54DqWE/T5m1t2AfDu6QlXJT0EVT0=
github.com/abbot/go-http-auth v0.4.0/go.mod h1:Cz6ARTIzApMJDzh5bRMSUou6UMSp0IEXg9km/ci7TJM=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs=
github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/appscode/go-querystring v0.0.0-20170504095604-0126cfb3f1dc h1:LoL75er+LKDHDUfU5tRvFwxH0LjPpZN8OoG8Ll+liGU=
github.com/appscode/go-querystring v0.0.0-20170504095604-0126cfb3f1dc/go.mod h1:w648aMHEgFYS6xb0KVMMtZ2uMeemhiKCuD2vj6gY52A=
github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA=
github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 h1:N4lRUXZpZ1KVEUn6hxtco/1d2lgYhNn1fHkkl8WhlyQ=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/config v1.31.17 h1:QFl8lL6RgakNK86vusim14P2k8BFSxjvUkcWLDjgz9Y=
github.com/aws/aws-sdk-go-v2/config v1.31.17/go.mod h1:V8P7ILjp/Uef/aX8TjGk6OHZN6IKPM5YW6S78QnRD5c=
github.com/aws/aws-sdk-go-v2/credentials v1.19.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc=
github.com/aws/aws-sdk-go-v2/credentials v1.19.11/go.mod h1:30yY2zqkMPdrvxBqzI9xQCM+WrlrZKSOpSJEsylVU+8=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 h1:INUvJxmhdEbVulJYHI061k4TVuS3jzzthNvjqvVvTKM=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19/go.mod h1:FpZN2QISLdEBWkayloda+sZjVJL+e9Gl0k1SyTgcswU=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.4 h1:2fjfz3/G9BRvIKuNZ655GwzpklC2kEH0cowZQGO7uBg=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.4/go.mod h1:Ymws824lvMypLFPwyyUXM52SXuGgxpu0+DISLfKvB+c=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 h1:/sECfyq2JTifMI2JPyZ4bdRN77zJmr6SrS1eL3augIA=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19/go.mod h1:dMf8A5oAqr9/oxOfLkC/c2LU/uMcALP0Rgn2BD5LWn0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 h1:AWeJMk33GTBf6J20XJe6qZoRSJo0WfUhsMdUKhoODXE=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.20 h1:qi3e/dmpdONhj1RyIZdi6DKKpDXS5Lb8ftr3p7cyHJc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.20/go.mod h1:V1K+TeJVD5JOk3D9e5tsX2KUdL7BlB+FV6cBhdobN8c=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11 h1:BYf7XNsJMzl4mObARUBUib+j2tf0U//JAAtTnYqvqCw=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11/go.mod h1:aEUS4WrNk/+FxkBZZa7tVgp4pGH+kFGW40Y8rCPqt5g=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7suZk9UCJHE1Iw9GMZJJl0dAnKXXP1NaSDHwmw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19 h1:JnQeStZvPHFHeyky/7LbMlyQjUa+jIBj36OlWm0pzIk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19/go.mod h1:HGyasyHvYdFQeJhvDHfH7HXkHh57htcJGKDZ+7z+I24=
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4 h1:4ExZyubQ6LQQVuF2Qp9OsfEvsTdAWh5Gfwf6PgIdLdk=
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4/go.mod h1:NF3JcMGOiARAss1ld3WGORCw71+4ExDD2cbbdKS5PpA=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12/go.mod h1:fEWYKTRGoZNl8tZ77i61/ccwOMJdGxwOhWCkp6TXAr0=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 h1:EnUdUqRP1CNzt2DkV67tJx6XDN4xlfBFm+bzeNOQVb0=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16/go.mod h1:Jic/xv0Rq/pFNCh3WwpH4BEqdbSAl+IyHro8LbibHD8=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 h1:XQTQTF75vnug2TXS8m7CVJfC2nniYPZnO1D4Np761Oo=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8/go.mod h1:Xgx+PR1NUOjNmQY+tRMnouRp83JRM8pRMw/vCaVhPkI=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY=
github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI=
github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo=
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.13 h1:uMC4oL6G3MNhodo358QEqSDjrgvzV3TUQ58nyQSGq2E=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.13/go.mod h1:Cer86AE2686DvVUe57LPve3jUBmbujuaonSX8pNzGgw=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lAsTJFeXWFDsDcWOU=
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -172,15 +176,13 @@ github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWR
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cloudinary/cloudinary-go/v2 v2.13.0 h1:ugiQwb7DwpWQnete2AZkTh94MonZKmxD7hDGy1qTzDs=
github.com/cloudinary/cloudinary-go/v2 v2.13.0/go.mod h1:ireC4gqVetsjVhYlwjUJwKTbZuWjEIynbR9zQTlqsvo=
github.com/cloudinary/cloudinary-go/v2 v2.15.0 h1:iLoIwb7BJECHTbNcmIhYDsQhoZiACWGNvEpyqQy97Dk=
github.com/cloudinary/cloudinary-go/v2 v2.15.0/go.mod h1:ireC4gqVetsjVhYlwjUJwKTbZuWjEIynbR9zQTlqsvo=
github.com/cloudsoda/go-smb2 v0.0.0-20250228001242-d4c70e6251cc h1:t8YjNUCt1DimB4HCIXBztwWMhgxr5yG5/YaRl9Afdfg=
github.com/cloudsoda/go-smb2 v0.0.0-20250228001242-d4c70e6251cc/go.mod h1:CgWpFCFWzzEA5hVkhAc6DZZzGd3czx+BblvOzjmg6KA=
github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc h1:0xCWmFKBmarCqqqLeM7jFBSw/Or81UEElFqO8MY+GDs=
@@ -197,8 +199,9 @@ github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5z
github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU=
github.com/creasty/defaults v1.8.0 h1:z27FJxCAa0JKt3utc0sCImAEb+spPucmKoOdLHvHYKk=
github.com/creasty/defaults v1.8.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM=
github.com/cronokirby/saferith v0.33.0 h1:TgoQlfsD4LIwx71+ChfRcIpjkw+RPOapDEVxa+LhwLo=
github.com/cronokirby/saferith v0.33.0/go.mod h1:QKJhjoqUtBsXCAVEjw38mFqoi7DebT7kthcD7UzbnoA=
github.com/cronokirby/saferith v0.33.1-0.20250226174546-1f11f94ce488 h1:tLWBZgPg6TV67oe76W4p+aUQEWIa52wbcuiz8GFd3vo=
github.com/cronokirby/saferith v0.33.1-0.20250226174546-1f11f94ce488/go.mod h1:QKJhjoqUtBsXCAVEjw38mFqoi7DebT7kthcD7UzbnoA=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -217,8 +220,8 @@ github.com/dsnet/try v0.0.3 h1:ptR59SsrcFUYbT/FhAbKTV6iLkeD6O18qfIWRml2fqI=
github.com/dsnet/try v0.0.3/go.mod h1:WBM8tRpUmnXXhY1U6/S8dt6UWdHTQ7y8A5YSkRCkq40=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab h1:h1UgjJdAAhj+uPL68n7XASS6bU+07ZX1WJvVS2eyoeY=
github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw=
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
@@ -240,8 +243,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/geoffgarside/ber v1.2.0 h1:/loowoRcs/MWLYmGX9QtIAbA+V/FrnVLsMMPhwiRm64=
github.com/geoffgarside/ber v1.2.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc=
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
@@ -256,8 +259,8 @@ github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 h1:JnrjqG5iR07/8k7NqrLNilRsl3s1EPRQEGvbPyOce68=
github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348/go.mod h1:Czxo/d1g948LtrALAZdL04TL/HnkopquAjxYUuI02bo=
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA=
github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
@@ -269,8 +272,8 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-openapi/errors v0.22.4 h1:oi2K9mHTOb5DPW2Zjdzs/NIvwi2N3fARKaTJLdNabaM=
github.com/go-openapi/errors v0.22.4/go.mod h1:z9S8ASTUqx7+CP1Q8dD8ewGH/1JWFFLX/2PmAYNQLgk=
github.com/go-openapi/errors v0.22.6 h1:eDxcf89O8odEnohIXwEjY1IB4ph5vmbUsBMsFNwXWPo=
github.com/go-openapi/errors v0.22.6/go.mod h1:z9S8ASTUqx7+CP1Q8dD8ewGH/1JWFFLX/2PmAYNQLgk=
github.com/go-openapi/strfmt v0.25.0 h1:7R0RX7mbKLa9EYCTHRcCuIPcaqlyQiWNPTXwClK0saQ=
github.com/go-openapi/strfmt v0.25.0/go.mod h1:nNXct7OzbwrMY9+5tLX4I21pzcmE6ccMGXl3jFdPfn8=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
@@ -281,14 +284,14 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM=
github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk=
github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
@@ -297,8 +300,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -356,12 +359,12 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI=
github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4=
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
@@ -387,8 +390,8 @@ github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/internxt/rclone-adapter v0.0.0-20260220172730-613f4cc8b8fd h1:dSIuz2mpJAPQfhHYtG57D0qwSkgC/vQ69gHfeyQ4kxA=
github.com/internxt/rclone-adapter v0.0.0-20260220172730-613f4cc8b8fd/go.mod h1:vdPya4AIcDjvng4ViaAzqjegJf0VHYpYHQguFx5xBp0=
github.com/internxt/rclone-adapter v0.0.0-20260331173834-036f908d0160 h1:PV6ipOJN0JIek4dqPqsTtenQmjI1SZntCUgPzhfk9gM=
github.com/internxt/rclone-adapter v0.0.0-20260331173834-036f908d0160/go.mod h1:yRuPDnHgGmsbvopF0amMqXxr4n32GynzX5GTwFYDHaw=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
@@ -405,8 +408,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jlaffaye/ftp v0.2.1-0.20240918233326-1b970516f5d3 h1:ZxO6Qr2GOXPdcW80Mcn3nemvilMPvpWqxrNfK2ZnNNs=
github.com/jlaffaye/ftp v0.2.1-0.20240918233326-1b970516f5d3/go.mod h1:dvLUr/8Fs9a2OBrEnCC5duphbkz/k/mSy5OkXg3PAgI=
github.com/jlaffaye/ftp v0.2.1-0.20251026020404-6602e981a1bb h1:6vkM8gO+zFV2m21QzGYyUSq5TP0VQgP2Xz3UQyCN2kI=
github.com/jlaffaye/ftp v0.2.1-0.20251026020404-6602e981a1bb/go.mod h1:H1+whwD0Qe3YOunlXIWhh3rlvzW5cZfkMDYGQPg+KAM=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
@@ -419,8 +422,8 @@ github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRt
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
@@ -447,8 +450,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lpar/date v1.0.0 h1:bq/zVqFTUmsxvd/CylidY4Udqpr9BOFrParoP6p0x/I=
github.com/lpar/date v1.0.0/go.mod h1:KjYe0dDyMQTgpqcUz4LEIeM5VZwhggjVx/V2dtc8NSo=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM=
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
@@ -456,8 +459,8 @@ github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stg
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.22 h1:76lXsPn6FyHtTY+jt2fTTvsMUCZq1k0qwRsAMuxzKAk=
github.com/mattn/go-runewidth v0.0.22/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
@@ -481,12 +484,12 @@ github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/ginkgo/v2 v2.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA=
github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/oracle/oci-go-sdk/v65 v65.104.0 h1:l9awEvzWvxmYhy/97A0hZ87pa7BncYXmcO/S8+rvgK0=
github.com/oracle/oci-go-sdk/v65 v65.104.0/go.mod h1:oB8jFGVc/7/zJ+DbleE8MzGHjhs2ioCz5stRTdZdIcY=
github.com/panjf2000/ants/v2 v2.11.3 h1:AfI0ngBoXJmYOpDh9m516vjqoUu2sLrIVgppI9TZVpg=
github.com/panjf2000/ants/v2 v2.11.3/go.mod h1:8u92CYMUc6gyvTIw8Ru7Mt7+/ESnJahz5EVtqfrilek=
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
github.com/oracle/oci-go-sdk/v65 v65.111.0 h1:eDkWg6ZN0uKwWzSekoFcQJhR+C+F/aVdTwr+lGHU9Qk=
github.com/oracle/oci-go-sdk/v65 v65.111.0/go.mod h1:8ZzvzuEG/cFLFZhxg/Mg1w19KqyXBKO3c17QIc5PkGs=
github.com/panjf2000/ants/v2 v2.11.5 h1:a7LMnMEeux/ebqTux140tRiaqcFTV0q2bEHF03nl6Rg=
github.com/panjf2000/ants/v2 v2.11.5/go.mod h1:8u92CYMUc6gyvTIw8Ru7Mt7+/ESnJahz5EVtqfrilek=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
@@ -495,8 +498,8 @@ github.com/pengsrc/go-shared v0.2.1-0.20190131101655-1999055a4a14 h1:XeOYlK9W1uC
github.com/pengsrc/go-shared v0.2.1-0.20190131101655-1999055a4a14/go.mod h1:jVblp62SafmidSkvWrXyxAme3gaTfEtWwRPGz5cpvHg=
github.com/peterh/liner v1.2.2 h1:aJ4AOodmL+JxOZZEL2u9iJf8omNRpqHc/EbrK+3mAXw=
github.com/peterh/liner v1.2.2/go.mod h1:xFwJyiKIXJZUKItq5dGHZSTBRAuG/CpeNpWLyiNRNwI=
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/diff v0.0.0-20200914180035-5b29258ca4f7/go.mod h1:zO8QMzTeZd5cpnIkz/Gn6iK0jDfGicM1nynOkkPIl28=
@@ -518,27 +521,27 @@ github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UH
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8=
github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/putdotio/go-putio/putio v0.0.0-20200123120452-16d982cac2b8 h1:Y258uzXU/potCYnQd1r6wlAnoMB68BiCkCcCnKx1SH8=
github.com/putdotio/go-putio/putio v0.0.0-20200123120452-16d982cac2b8/go.mod h1:bSJjRokAHHOhA+XFxplld8w2R/dXLH7Z3BZ532vhFwU=
github.com/quic-go/quic-go v0.53.0 h1:QHX46sISpG2S03dPeZBgVIZp8dGagIaiu2FiVYvpCZI=
github.com/quic-go/quic-go v0.53.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/rclone/Proton-API-Bridge v1.0.1-0.20260127174007-77f974840d11 h1:4MI2alxM/Ye2gIRBlYf28JGWTipZ4Zz7yAziPKrttjs=
github.com/rclone/Proton-API-Bridge v1.0.1-0.20260127174007-77f974840d11/go.mod h1:3HLX7dwZgvB7nt+Yl/xdzVPcargQ1yBmJEUg3n+jMKM=
github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18 h1:Lc+d3ISfQaMJKWZOE7z4ZSY4RVmdzbn1B0IM8xN18qM=
github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18/go.mod h1:LB2kCEaZMzNn3ocdz+qYfxXmuLxxN0ka62KJd2x53Bc=
github.com/rclone/rclone v1.73.3 h1:XKlobcnXxxzxnB6UBSVtRB+UeZmYDV9B4QExVSSGoAY=
github.com/rclone/rclone v1.73.3/go.mod h1:QJDWatpAY9sKGXfpKZUXbThvtHoeo78DcFP2+/cbkvc=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/rclone/Proton-API-Bridge v1.0.3 h1:Bs7RC4xCFSN0BPIYVda/BNxp0qo3NV0gB2VZqx2KIew=
github.com/rclone/Proton-API-Bridge v1.0.3/go.mod h1:26RAest751Ofk+F/d8xtl4UyWXrZvMQwn39U8rm/WKM=
github.com/rclone/go-proton-api v1.0.2 h1:cJtJUab0MGJ3C6q5kiEJs3pbyhSLnOKMyYOQehA0PBc=
github.com/rclone/go-proton-api v1.0.2/go.mod h1:LB2kCEaZMzNn3ocdz+qYfxXmuLxxN0ka62KJd2x53Bc=
github.com/rclone/rclone v1.74.3 h1:a2wln7pvEa0tS1WIZJKulEkVjxgC1DkCoyxYydkdiSY=
github.com/rclone/rclone v1.74.3/go.mod h1:t5Mh86PO49DD7xlPt0trnK/aNf2Z3M0uip4l1Jqwiv8=
github.com/relvacode/iso8601 v1.7.0 h1:BXy+V60stMP6cpswc+a93Mq3e65PfXCgDFfhvNNGrdo=
github.com/relvacode/iso8601 v1.7.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rfjakob/eme v1.1.2 h1:SxziR8msSOElPayZNFfQw4Tjx/Sbaeeh3eRvrHVMUs4=
github.com/rfjakob/eme v1.1.2/go.mod h1:cVvpasglm/G3ngEfcfT/Wt0GwhkuO32pf/poW6Nyk1k=
github.com/rfjakob/eme v1.2.0 h1:8dAHL+WVAw06+7DkRKnRiFp1JL3QjcJEZFqDnndUaSI=
github.com/rfjakob/eme v1.2.0/go.mod h1:cVvpasglm/G3ngEfcfT/Wt0GwhkuO32pf/poW6Nyk1k=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
@@ -551,11 +554,11 @@ github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppK
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/shirou/gopsutil/v4 v4.25.10 h1:at8lk/5T1OgtuCp+AwrDofFRjnvosn0nkN2OLQ6g8tA=
github.com/shirou/gopsutil/v4 v4.25.10/go.mod h1:+kSwyC8DRUD9XXEHCAFjK+0nuArFJM0lva+StQAcskM=
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af h1:Sp5TG9f7K39yfB+If0vjp97vuT74F72r8hfRpP8jLU0=
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/snabb/httpreaderat v1.0.1 h1:whlb+vuZmyjqVop8x1EKOg05l2NE4z9lsMMXjmSUCnY=
@@ -570,8 +573,8 @@ github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY=
@@ -596,13 +599,13 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/t3rm1n4l/go-mega v0.0.0-20251031123324-a804aaa87491 h1:rrGZv6xYk37hx0tW2sYfgbO0PqStbHqz6Bq6oc9Hurg=
github.com/t3rm1n4l/go-mega v0.0.0-20251031123324-a804aaa87491/go.mod h1:ykucQyiE9Q2qx1wLlEtZkkNn1IURib/2O+Mvd25i1Fo=
github.com/t3rm1n4l/go-mega v0.0.0-20251120131202-6845944c051c h1:dtcOwRimeiBFrlutmF6K94l0rxYFARNFMA+lSQ41C+M=
github.com/t3rm1n4l/go-mega v0.0.0-20251120131202-6845944c051c/go.mod h1:BF/l2jNyK+2h/BJZ7VLMAz6m/IWjA2F67gTjV1C/+Bo=
github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8=
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8=
@@ -634,14 +637,16 @@ github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE=
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
github.com/zeebo/mwc v0.0.7 h1:0NerGhCww6ZQx+/xCx5iwznftveokvto1KILpYfENZk=
github.com/zeebo/mwc v0.0.7/go.mod h1:0B32or6moOig1YGuqMoimBpU9QK9uYaGG2bBOuddqtE=
github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU=
go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
@@ -649,22 +654,20 @@ go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
@@ -688,8 +691,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -700,12 +703,12 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -730,8 +733,6 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -771,16 +772,16 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -796,8 +797,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -847,8 +848,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -860,8 +861,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -877,13 +878,13 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -931,14 +932,14 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
@@ -955,8 +956,8 @@ google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0M
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
google.golang.org/api v0.255.0 h1:OaF+IbRwOottVCYV2wZan7KUq7UeNUQn1BcPc4K7lE4=
google.golang.org/api v0.255.0/go.mod h1:d1/EtvCLdtiWEV4rAEHDHGh2bCnqsWhw+M8y2ECN4a8=
google.golang.org/api v0.275.0 h1:vfY5d9vFVJeWEZT65QDd9hbndr7FyZ2+6mIzGAh71NI=
google.golang.org/api v0.275.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -992,12 +993,12 @@ google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7Fc
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI=
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
@@ -1010,8 +1011,8 @@ google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKa
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -1022,8 +1023,8 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
@@ -1064,8 +1065,10 @@ nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYm
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
storj.io/common v0.0.0-20251107171817-6221ae45072c h1:UDXSrdeLJe3QFouavSW10fYdpclK0YNu3KvQHzqq2+k=
storj.io/common v0.0.0-20251107171817-6221ae45072c/go.mod h1:XNX7uykja6aco92y2y8RuqaXIDRPpt1YA2OQDKlKEUk=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
storj.io/common v0.0.0-20260225132117-99155641c30a h1:7gSBQY3vhQMIqi3vfaEXR7mreRjcBLfVsYY0rHIN7P0=
storj.io/common v0.0.0-20260225132117-99155641c30a/go.mod h1:kyxwKwlfH4paBZCZt/szvRB770ieAIJF73iDy2DpEHw=
storj.io/drpc v0.0.35-0.20250513201419-f7819ea69b55 h1:8OE12DvUnB9lfZcHe7IDGsuhjrY9GBAr964PVHmhsro=
storj.io/drpc v0.0.35-0.20250513201419-f7819ea69b55/go.mod h1:Y9LZaa8esL1PW2IDMqJE7CFSNq7d5bQ3RI7mGPtmKMg=
storj.io/eventkit v0.0.0-20250410172343-61f26d3de156 h1:5MZ0CyMbG6Pi0rRzUWVG6dvpXjbBYEX2oyXuj+tT+sk=
@@ -1074,5 +1077,5 @@ storj.io/infectious v0.0.2 h1:rGIdDC/6gNYAStsxsZU79D/MqFjNyJc1tsyyj9sTl7Q=
storj.io/infectious v0.0.2/go.mod h1:QEjKKww28Sjl1x8iDsjBpOM4r1Yp8RsowNcItsZJ1Vs=
storj.io/picobuf v0.0.4 h1:qswHDla+YZ2TovGtMnU4astjvrADSIz84FXRn0qgP6o=
storj.io/picobuf v0.0.4/go.mod h1:hSMxmZc58MS/2qSLy1I0idovlO7+6K47wIGUyRZa6mg=
storj.io/uplink v1.13.1 h1:C8RdW/upALoCyuF16Lod9XGCXEdbJAS+ABQy9JO/0pA=
storj.io/uplink v1.13.1/go.mod h1:x0MQr4UfFsQBwgVWZAtEsLpuwAn6dg7G0Mpne1r516E=
storj.io/uplink v1.14.0 h1:J1yXlt0aRr6kgLTHWXOWosNCFVfbamlcyd+CSxyIczo=
storj.io/uplink v1.14.0/go.mod h1:2ysmjzd/1Xtz4VKoErNcSqBQz3UC9WKTVuLMV1cNu6E=

View File

@@ -143,13 +143,24 @@ func (c *MasterClient) GetTaskSpec(ctx context.Context, taskID uint) (*TaskSpec,
// RecordUpdate 与 service.AgentRecordUpdate 对齐
type RecordUpdate struct {
Status string `json:"status,omitempty"`
FileName string `json:"fileName,omitempty"`
FileSize int64 `json:"fileSize,omitempty"`
Checksum string `json:"checksum,omitempty"`
StoragePath string `json:"storagePath,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
LogAppend string `json:"logAppend,omitempty"`
Status string `json:"status,omitempty"`
FileName string `json:"fileName,omitempty"`
FileSize int64 `json:"fileSize,omitempty"`
Checksum string `json:"checksum,omitempty"`
StoragePath string `json:"storagePath,omitempty"`
StorageTargetID uint `json:"storageTargetId,omitempty"`
StorageUploadResults []StorageResultItem `json:"storageUploadResults,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
LogAppend string `json:"logAppend,omitempty"`
}
type StorageResultItem struct {
StorageTargetID uint `json:"storageTargetId"`
StorageTargetName string `json:"storageTargetName"`
Status string `json:"status"`
StoragePath string `json:"storagePath,omitempty"`
FileSize int64 `json:"fileSize,omitempty"`
Error string `json:"error,omitempty"`
}
// UpdateRecord 上报备份记录的状态/日志
@@ -179,6 +190,7 @@ type RestoreSpec struct {
Storage StorageTargetConfig `json:"storage"`
StoragePath string `json:"storagePath"`
FileName string `json:"fileName"`
Checksum string `json:"checksum,omitempty"`
}
// RestoreUpdate 与 service.AgentRestoreUpdate 对齐

View File

@@ -34,6 +34,7 @@ func NewExecutor(client *MasterClient, tempDir string) *Executor {
backup.NewMySQLRunner(nil),
backup.NewPostgreSQLRunner(nil),
backup.NewSAPHANARunner(nil),
backup.NewMongoDBRunner(nil),
)
storageRegistry := storage.NewRegistry(
storageRclone.NewLocalDiskFactory(),
@@ -106,6 +107,14 @@ func (e *Executor) ExecuteRunTask(ctx context.Context, taskID, recordID uint) er
return compressErr
}
finalPath = compressedPath
} else if strings.EqualFold(spec.Compression, "zstd") && !strings.HasSuffix(strings.ToLower(finalPath), ".zst") {
e.appendLog(ctx, recordID, "[agent] 开始压缩备份文件zstd\n")
compressedPath, compressErr := compress.ZstdFile(finalPath)
if compressErr != nil {
e.reportRecordFailure(ctx, recordID, fmt.Sprintf("压缩失败: %v", compressErr))
return compressErr
}
finalPath = compressedPath
}
info, err := os.Stat(finalPath)
if err != nil {
@@ -126,22 +135,52 @@ func (e *Executor) ExecuteRunTask(ctx context.Context, taskID, recordID uint) er
e.reportRecordFailure(ctx, recordID, "没有关联的存储目标")
return fmt.Errorf("no storage targets")
}
uploadResults := make([]StorageResultItem, 0, len(spec.StorageTargets))
selectedStorageTargetID := uint(0)
var uploadErrors []string
for _, target := range spec.StorageTargets {
if err := e.uploadToTarget(ctx, recordID, target, finalPath, storagePath, fileSize, spec.TaskID); err != nil {
e.reportRecordFailure(ctx, recordID, fmt.Sprintf("上传到 %s 失败: %v", target.Name, err))
return err
uploadResults = append(uploadResults, StorageResultItem{
StorageTargetID: target.ID,
StorageTargetName: target.Name,
Status: "failed",
Error: err.Error(),
})
uploadErrors = append(uploadErrors, fmt.Sprintf("%s: %v", target.Name, err))
e.appendLog(ctx, recordID, fmt.Sprintf("[agent] 上传到存储目标 %s 失败: %v\n", target.Name, err))
continue
}
if selectedStorageTargetID == 0 {
selectedStorageTargetID = target.ID
}
uploadResults = append(uploadResults, StorageResultItem{
StorageTargetID: target.ID,
StorageTargetName: target.Name,
Status: "success",
StoragePath: storagePath,
FileSize: fileSize,
})
e.appendLog(ctx, recordID, fmt.Sprintf("[agent] 已上传到存储目标 %s\n", target.Name))
}
if selectedStorageTargetID == 0 {
msg := strings.Join(uploadErrors, "; ")
if msg == "" {
msg = "所有存储目标上传均失败"
}
e.reportRecordFailureWithUploadResults(ctx, recordID, msg, uploadResults)
return fmt.Errorf("%s", msg)
}
// 6) 上报最终成功
return e.client.UpdateRecord(ctx, recordID, RecordUpdate{
Status: "success",
FileName: fileName,
FileSize: fileSize,
Checksum: checksum,
StoragePath: storagePath,
LogAppend: fmt.Sprintf("[agent] 任务完成,总计 %d 字节\n", fileSize),
Status: "success",
FileName: fileName,
FileSize: fileSize,
Checksum: checksum,
StoragePath: storagePath,
StorageTargetID: selectedStorageTargetID,
StorageUploadResults: uploadResults,
LogAppend: fmt.Sprintf("[agent] 任务完成,总计 %d 字节\n", fileSize),
})
}
@@ -177,10 +216,15 @@ func (e *Executor) appendLog(ctx context.Context, recordID uint, line string) {
// reportRecordFailure 上报失败状态
func (e *Executor) reportRecordFailure(ctx context.Context, recordID uint, msg string) {
e.reportRecordFailureWithUploadResults(ctx, recordID, msg, nil)
}
func (e *Executor) reportRecordFailureWithUploadResults(ctx context.Context, recordID uint, msg string, uploadResults []StorageResultItem) {
_ = e.client.UpdateRecord(ctx, recordID, RecordUpdate{
Status: "failed",
ErrorMessage: msg,
LogAppend: fmt.Sprintf("[agent] 错误: %s\n", msg),
Status: "failed",
ErrorMessage: msg,
StorageUploadResults: uploadResults,
LogAppend: fmt.Sprintf("[agent] 错误: %s\n", msg),
})
}
@@ -344,6 +388,24 @@ func (e *Executor) ExecuteRestore(ctx context.Context, restoreRecordID uint) err
return err
}
// 2.5) 完整性校验:还原前比对下载对象的 SHA-256与 Master 本地恢复路径一致)。
// 拒绝还原损坏或被篡改的备份;早期无 checksum 的备份跳过(向后兼容)。
if strings.TrimSpace(spec.Checksum) != "" {
e.appendRestoreLog(ctx, restoreRecordID, "[agent] 校验备份完整性SHA-256\n")
actual, sumErr := computeFileSHA256(artifactPath)
if sumErr != nil {
msg := fmt.Sprintf("计算校验和失败: %v", sumErr)
e.reportRestoreFailure(ctx, restoreRecordID, msg)
return fmt.Errorf("%s", msg)
}
if !strings.EqualFold(actual, spec.Checksum) {
msg := "备份文件完整性校验失败SHA-256 不匹配,文件可能已损坏或被篡改"
e.reportRestoreFailure(ctx, restoreRecordID, msg)
return fmt.Errorf("%s期望 %s实际 %s", msg, spec.Checksum, actual)
}
e.appendRestoreLog(ctx, restoreRecordID, "[agent] 完整性校验通过\n")
}
// 3) 解压Agent 不支持加密,遇到 .enc 会直接失败)
preparedPath := artifactPath
if strings.HasSuffix(strings.ToLower(preparedPath), ".enc") {
@@ -360,6 +422,15 @@ func (e *Executor) ExecuteRestore(ctx context.Context, restoreRecordID uint) err
}
preparedPath = decompressed
}
if strings.HasSuffix(strings.ToLower(preparedPath), ".zst") {
e.appendRestoreLog(ctx, restoreRecordID, "[agent] 解压 zstd 压缩\n")
decompressed, err := compress.UnzstdFile(preparedPath)
if err != nil {
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("解压失败: %v", err))
return err
}
preparedPath = decompressed
}
// 4) 运行 runner.Restore
taskSpec := buildRestoreBackupTaskSpec(spec, time.Now().UTC(), tmpDir)

View File

@@ -1,9 +1,20 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"backupx/server/internal/storage"
)
func TestBuildBackupTaskSpecParsesJSONSourcePaths(t *testing.T) {
@@ -32,3 +43,191 @@ func TestParseStringListFieldKeepsLegacyLineFormat(t *testing.T) {
t.Fatalf("paths = %#v, want %#v", got, want)
}
}
func TestExecuteRunTaskRecordsPerTargetUploadResults(t *testing.T) {
sourceDir := t.TempDir()
if err := os.WriteFile(filepath.Join(sourceDir, "index.html"), []byte("hello"), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
var finalUpdate RecordUpdate
var updates []RecordUpdate
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/agent/tasks/1":
writeAgentEnvelope(t, w, TaskSpec{
TaskID: 1,
Name: "site",
Type: "file",
SourcePath: sourceDir,
Compression: "gzip",
StorageTargets: []StorageTargetConfig{
{ID: 11, Name: "broken", Type: "agent_test_storage", Config: json.RawMessage(`{"name":"broken"}`)},
{ID: 12, Name: "ok", Type: "agent_test_storage", Config: json.RawMessage(`{"name":"ok"}`)},
},
})
case r.Method == http.MethodPost && r.URL.Path == "/api/agent/records/99":
var update RecordUpdate
if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
t.Fatalf("Decode update returned error: %v", err)
}
updates = append(updates, update)
if update.Status != "" {
finalUpdate = update
}
writeAgentEnvelope(t, w, map[string]string{"status": "ok"})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
executor := NewExecutor(NewMasterClient(server.URL, "token", false), filepath.Join(t.TempDir(), "tmp"))
executor.storageRegistry = storage.NewRegistry(&agentTestStorageFactory{
providers: map[string]*agentTestStorageProvider{
"broken": {name: "broken", failUpload: true},
"ok": {name: "ok", objects: map[string][]byte{}},
},
})
if err := executor.ExecuteRunTask(context.Background(), 1, 99); err != nil {
t.Fatalf("ExecuteRunTask returned error: %v", err)
}
if len(updates) == 0 || finalUpdate.Status != "success" {
t.Fatalf("expected final success update, got updates=%#v final=%#v", updates, finalUpdate)
}
if finalUpdate.StorageTargetID != 12 {
t.Fatalf("expected first successful target 12, got %d", finalUpdate.StorageTargetID)
}
if len(finalUpdate.StorageUploadResults) != 2 {
t.Fatalf("expected two upload results, got %#v", finalUpdate.StorageUploadResults)
}
if finalUpdate.StorageUploadResults[0].Status != "failed" || finalUpdate.StorageUploadResults[1].Status != "success" {
t.Fatalf("unexpected upload results: %#v", finalUpdate.StorageUploadResults)
}
if finalUpdate.StoragePath == "" || finalUpdate.FileSize <= 0 || finalUpdate.Checksum == "" {
t.Fatalf("expected artifact metadata in final update, got %#v", finalUpdate)
}
}
func TestExecuteRunTaskReportsPerTargetUploadResultsWhenAllTargetsFail(t *testing.T) {
sourceDir := t.TempDir()
if err := os.WriteFile(filepath.Join(sourceDir, "index.html"), []byte("hello"), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
var finalUpdate RecordUpdate
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/agent/tasks/1":
writeAgentEnvelope(t, w, TaskSpec{
TaskID: 1,
Name: "site",
Type: "file",
SourcePath: sourceDir,
Compression: "gzip",
StorageTargets: []StorageTargetConfig{
{ID: 11, Name: "broken-a", Type: "agent_test_storage", Config: json.RawMessage(`{"name":"broken-a"}`)},
{ID: 12, Name: "broken-b", Type: "agent_test_storage", Config: json.RawMessage(`{"name":"broken-b"}`)},
},
})
case r.Method == http.MethodPost && r.URL.Path == "/api/agent/records/99":
var update RecordUpdate
if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
t.Fatalf("Decode update returned error: %v", err)
}
if update.Status != "" {
finalUpdate = update
}
writeAgentEnvelope(t, w, map[string]string{"status": "ok"})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
executor := NewExecutor(NewMasterClient(server.URL, "token", false), filepath.Join(t.TempDir(), "tmp"))
executor.storageRegistry = storage.NewRegistry(&agentTestStorageFactory{
providers: map[string]*agentTestStorageProvider{
"broken-a": {name: "broken-a", failUpload: true},
"broken-b": {name: "broken-b", failUpload: true},
},
})
if err := executor.ExecuteRunTask(context.Background(), 1, 99); err == nil {
t.Fatal("expected ExecuteRunTask to return upload failure")
}
if finalUpdate.Status != "failed" {
t.Fatalf("expected final failed update, got %#v", finalUpdate)
}
if len(finalUpdate.StorageUploadResults) != 2 {
t.Fatalf("expected failed update to keep per-target results, got %#v", finalUpdate.StorageUploadResults)
}
for _, item := range finalUpdate.StorageUploadResults {
if item.Status != "failed" || item.Error == "" {
t.Fatalf("unexpected upload result: %#v", item)
}
}
}
type agentTestStorageFactory struct {
providers map[string]*agentTestStorageProvider
}
func (f *agentTestStorageFactory) Type() storage.ProviderType {
return "agent_test_storage"
}
func (f *agentTestStorageFactory) New(_ context.Context, config map[string]any) (storage.StorageProvider, error) {
name, _ := config["name"].(string)
provider := f.providers[name]
if provider == nil {
return nil, fmt.Errorf("unknown provider %q", name)
}
return provider, nil
}
type agentTestStorageProvider struct {
name string
failUpload bool
objects map[string][]byte
}
func (p *agentTestStorageProvider) Type() storage.ProviderType { return "agent_test_storage" }
func (p *agentTestStorageProvider) TestConnection(context.Context) error {
return nil
}
func (p *agentTestStorageProvider) Upload(_ context.Context, objectKey string, reader io.Reader, _ int64, _ map[string]string) error {
if p.failUpload {
return fmt.Errorf("upload failed for %s", p.name)
}
data, err := io.ReadAll(reader)
if err != nil {
return err
}
if p.objects == nil {
p.objects = map[string][]byte{}
}
p.objects[objectKey] = data
return nil
}
func (p *agentTestStorageProvider) Download(_ context.Context, objectKey string) (io.ReadCloser, error) {
data, ok := p.objects[objectKey]
if !ok {
return nil, fmt.Errorf("object %s not found", objectKey)
}
return io.NopCloser(strings.NewReader(string(data))), nil
}
func (p *agentTestStorageProvider) Delete(_ context.Context, objectKey string) error {
delete(p.objects, objectKey)
return nil
}
func (p *agentTestStorageProvider) List(context.Context, string) ([]storage.ObjectInfo, error) {
return nil, nil
}
func writeAgentEnvelope(t *testing.T, w http.ResponseWriter, data any) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]any{"code": "OK", "data": data}); err != nil {
t.Fatalf("Encode response returned error: %v", err)
}
}

View File

@@ -82,7 +82,7 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
backupTaskService := service.NewBackupTaskService(backupTaskRepo, storageTargetRepo, configCipher)
backupTaskService.SetRecordsAndStorage(backupRecordRepo, storageRegistry)
// nodeRepo 在下方 Cluster 节点管理区块才实例化,这里延后注入
backupRunnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewSQLiteRunner(), backup.NewMySQLRunner(nil), backup.NewPostgreSQLRunner(nil), backup.NewSAPHANARunner(nil))
backupRunnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewSQLiteRunner(), backup.NewMySQLRunner(nil), backup.NewPostgreSQLRunner(nil), backup.NewSAPHANARunner(nil), backup.NewMongoDBRunner(nil))
logHub := backup.NewLogHub()
retentionService := backupretention.NewService(backupRecordRepo)
notifyRegistry := notify.NewRegistry(notify.NewEmailNotifier(), notify.NewWebhookNotifier(), notify.NewTelegramNotifier())
@@ -104,6 +104,7 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
restoreRecordRepo := repository.NewRestoreRecordRepository(db)
restoreLogHub := backup.NewLogHub()
dashboardService := service.NewDashboardService(backupTaskRepo, backupRecordRepo, storageTargetRepo)
reportService := service.NewReportService(backupTaskRepo, backupRecordRepo)
settingsService := service.NewSettingsService(systemConfigRepo)
// Audit
@@ -113,6 +114,8 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
schedulerService.SetAuditRecorder(auditService)
// 审计日志外输:启动时用当前 settings 初始化 webhook后续前端修改立即生效
settingsService.SetAuditWebhookConfigurer(ctx, auditService)
// 审计日志保留期清理:每 6h 读取 audit_retention_days 设置并清理超期日志0/缺省=永久保留)
auditService.StartRetentionMonitor(ctx, systemConfigRepo, 6*time.Hour)
// Database discovery集群依赖在 agentService 创建后注入)
databaseDiscoveryService := service.NewDatabaseDiscoveryService(backup.NewOSCommandExecutor())
@@ -131,6 +134,7 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
// Agent 协议服务:命令队列 + 任务下发 + 记录上报
agentCmdRepo := repository.NewAgentCommandRepository(db)
nodeService.SetAgentCommandRepository(agentCmdRepo)
agentService := service.NewAgentService(nodeRepo, backupTaskRepo, backupRecordRepo, storageTargetRepo, agentCmdRepo, configCipher)
agentService.SetRestoreRepository(restoreRecordRepo)
agentService.StartCommandTimeoutMonitor(ctx, 30*time.Second, 10*time.Minute)
@@ -240,7 +244,7 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
replicationService.SetMetrics(appMetrics)
metricsCollector := metrics.NewCollector(
appMetrics,
metrics.NewRepoSource(storageTargetRepo, backupRecordRepo, nodeRepo, backupTaskRepo),
metrics.NewRepoSource(storageTargetRepo, backupRecordRepo, nodeRepo, backupTaskRepo, agentCmdRepo),
30*time.Second,
)
metricsCollector.Start(ctx)
@@ -267,6 +271,7 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
ApiKeyService: apiKeyService,
NotificationService: notificationService,
DashboardService: dashboardService,
ReportService: reportService,
SettingsService: settingsService,
NodeService: nodeService,
AgentService: agentService,

View File

@@ -3,6 +3,7 @@ package backup
import (
"archive/tar"
"context"
"encoding/json"
"fmt"
"io"
"os"
@@ -52,6 +53,20 @@ func (r *FileRunner) Run(_ context.Context, task TaskSpec, writer LogWriter) (*R
defer tw.Close()
excludes := normalizeExcludePatterns(task.ExcludePatterns)
// 差异备份:基于上次全量清单仅打包新增/变更条目并记录删除;
// 全量备份记录完整清单manifest供后续差异比对。
differential := task.Differential && len(task.BaseManifest.Entries) > 0
baseIndex := map[string]ManifestEntry{}
seen := map[string]struct{}{}
var manifest *Manifest
if differential {
baseIndex = task.BaseManifest.index()
writer.WriteLine(fmt.Sprintf("差异备份模式:基线含 %d 个条目", len(baseIndex)))
} else {
manifest = &Manifest{Entries: make([]ManifestEntry, 0)}
}
totalFileCount := 0
totalDirCount := 0
@@ -88,6 +103,16 @@ func (r *FileRunner) Run(_ context.Context, task TaskSpec, writer LogWriter) (*R
return nil
}
entry := entryFromInfo(archiveName, currentInfo)
if differential {
seen[entry.Path] = struct{}{}
if !changedSince(baseIndex, entry) {
return nil // 自全量以来未变更,跳过
}
} else {
manifest.Entries = append(manifest.Entries, entry)
}
if currentInfo.IsDir() {
dirCount++
writer.WriteLine(fmt.Sprintf("📁 进入目录 %s", archiveName))
@@ -103,13 +128,19 @@ func (r *FileRunner) Run(_ context.Context, task TaskSpec, writer LogWriter) (*R
}
if currentInfo.Mode().IsRegular() {
file, err := os.Open(currentPath)
if err != nil {
return err
}
defer file.Close()
if _, err := io.CopyN(tw, file, currentInfo.Size()); err != nil && err != io.EOF {
return err
// 每个文件在独立作用域内打开并关闭,避免在大目录树中累积打开的文件句柄。
if copyErr := func() error {
file, err := os.Open(currentPath)
if err != nil {
return err
}
defer file.Close()
if _, err := io.CopyN(tw, file, currentInfo.Size()); err != nil && err != io.EOF {
return err
}
return nil
}(); copyErr != nil {
return copyErr
}
fileCount++
if fileCount%100 == 0 {
@@ -130,10 +161,16 @@ func (r *FileRunner) Run(_ context.Context, task TaskSpec, writer LogWriter) (*R
totalDirCount += dirCount
}
if len(sourcePaths) > 1 {
if differential {
deletions := deletedPaths(baseIndex, seen)
if err := writeDeletionsEntry(tw, deletions); err != nil {
return nil, err
}
writer.WriteLine(fmt.Sprintf("差异备份完成(%d 个目录、%d 个文件变更,删除 %d 项)", totalDirCount, totalFileCount, len(deletions)))
} else if len(sourcePaths) > 1 {
writer.WriteLine(fmt.Sprintf("全部源路径打包完成(共 %d 个目录,%d 个文件)", totalDirCount, totalFileCount))
}
return &RunResult{ArtifactPath: artifactPath, FileName: filepath.Base(artifactPath), TempDir: tempDir}, nil
return &RunResult{ArtifactPath: artifactPath, FileName: filepath.Base(artifactPath), TempDir: tempDir, Manifest: manifest}, nil
}
func (r *FileRunner) Restore(_ context.Context, task TaskSpec, artifactPath string, writer LogWriter) error {
@@ -148,9 +185,15 @@ func (r *FileRunner) Restore(_ context.Context, task TaskSpec, artifactPath stri
restoreSource = task.SourcePaths[0]
}
targetParent := filepath.Dir(filepath.Clean(strings.TrimSpace(restoreSource)))
// 恢复到指定位置:非空时归档解压到用户指定目录,而非原始源父目录。
if override := strings.TrimSpace(task.RestoreTargetPath); override != "" {
targetParent = filepath.Clean(override)
writer.WriteLine(fmt.Sprintf("恢复到指定目录:%s", targetParent))
}
if err := os.MkdirAll(targetParent, 0o755); err != nil {
return fmt.Errorf("create restore parent: %w", err)
}
var pendingDeletions []string
tr := tar.NewReader(artifactFile)
for {
header, err := tr.Next()
@@ -160,13 +203,27 @@ func (r *FileRunner) Restore(_ context.Context, task TaskSpec, artifactPath stri
if err != nil {
return fmt.Errorf("read tar entry: %w", err)
}
// 差异归档的删除清单不落地,留待提取完成后统一应用(避免被同批新增条目误删)。
if header.Name == deletionsEntryName {
data, readErr := io.ReadAll(tr)
if readErr != nil {
return fmt.Errorf("read deletions entry: %w", readErr)
}
if jsonErr := json.Unmarshal(data, &pendingDeletions); jsonErr != nil {
return fmt.Errorf("parse deletions entry: %w", jsonErr)
}
continue
}
cleanName := path.Clean(strings.TrimSpace(header.Name))
if cleanName == "." || cleanName == "" {
continue
}
targetPath := filepath.Clean(filepath.Join(targetParent, filepath.FromSlash(cleanName)))
parentWithSep := filepath.Clean(targetParent) + string(filepath.Separator)
if targetPath != filepath.Clean(targetParent) && !strings.HasPrefix(targetPath, parentWithSep) {
// 选择性恢复:仅提取被选中的文件/目录(及其子项)。
if len(task.SelectedPaths) > 0 && !pathSelected(cleanName, task.SelectedPaths) {
continue
}
targetPath, ok := resolveWithinParent(targetParent, cleanName)
if !ok {
return fmt.Errorf("tar entry escapes restore path")
}
switch header.Typeflag {
@@ -191,10 +248,94 @@ func (r *FileRunner) Restore(_ context.Context, task TaskSpec, artifactPath stri
}
}
}
// 选择性恢复时仅对选中范围应用删除,避免误删未选中的文件。
if len(task.SelectedPaths) > 0 {
pendingDeletions = filterSelectedPaths(pendingDeletions, task.SelectedPaths)
}
if err := applyDeletions(targetParent, pendingDeletions, writer); err != nil {
return err
}
writer.WriteLine("文件恢复完成")
return nil
}
// pathSelected 判断归档条目名是否落在选中集合内(精确匹配或位于选中目录之下)。
func pathSelected(name string, selected []string) bool {
for _, sel := range selected {
clean := path.Clean(strings.TrimSpace(sel))
if clean == "" || clean == "." {
continue
}
if name == clean || strings.HasPrefix(name, clean+"/") {
return true
}
}
return false
}
// filterSelectedPaths 仅保留落在选中集合内的路径。
func filterSelectedPaths(paths []string, selected []string) []string {
filtered := make([]string, 0, len(paths))
for _, p := range paths {
if pathSelected(path.Clean(strings.TrimSpace(p)), selected) {
filtered = append(filtered, p)
}
}
return filtered
}
// resolveWithinParent 将归档相对名安全解析为 targetParent 下的绝对路径;
// 越界(路径穿越)时返回 ok=false。提取与删除共用此校验杜绝逃逸。
func resolveWithinParent(targetParent, name string) (string, bool) {
targetPath := filepath.Clean(filepath.Join(targetParent, filepath.FromSlash(name)))
cleanParent := filepath.Clean(targetParent)
if targetPath == cleanParent {
return targetPath, true
}
if !strings.HasPrefix(targetPath, cleanParent+string(filepath.Separator)) {
return "", false
}
return targetPath, true
}
// writeDeletionsEntry 将差异备份的删除路径列表写入归档特殊条目。
func writeDeletionsEntry(tw *tar.Writer, deletions []string) error {
payload, err := json.Marshal(deletions)
if err != nil {
return fmt.Errorf("marshal deletions: %w", err)
}
header := &tar.Header{Name: deletionsEntryName, Mode: 0o600, Size: int64(len(payload)), Typeflag: tar.TypeReg}
if err := tw.WriteHeader(header); err != nil {
return fmt.Errorf("write deletions header: %w", err)
}
if _, err := tw.Write(payload); err != nil {
return fmt.Errorf("write deletions body: %w", err)
}
return nil
}
// applyDeletions 在基线恢复之上删除差异归档记录的路径(仅差异备份恢复时存在)。
// 每个路径经 resolveWithinParent 校验,越界即报错;目标不存在视为已删除。
func applyDeletions(targetParent string, deletions []string, writer LogWriter) error {
for _, name := range deletions {
clean := path.Clean(strings.TrimSpace(name))
if clean == "." || clean == "" {
continue
}
targetPath, ok := resolveWithinParent(targetParent, clean)
if !ok {
return fmt.Errorf("deletion entry escapes restore path")
}
if err := os.RemoveAll(targetPath); err != nil {
return fmt.Errorf("apply deletion %s: %w", clean, err)
}
}
if len(deletions) > 0 {
writer.WriteLine(fmt.Sprintf("已应用差异删除 %d 项", len(deletions)))
}
return nil
}
func normalizeExcludePatterns(items []string) []string {
result := make([]string, 0, len(items))
for _, item := range items {

View File

@@ -0,0 +1,182 @@
package backup
import (
"archive/tar"
"context"
"io"
"os"
"path/filepath"
"testing"
)
func diffWrite(t *testing.T, p, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", filepath.Dir(p), err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", p, err)
}
}
func diffAssertContent(t *testing.T, p, want string) {
t.Helper()
got, err := os.ReadFile(p)
if err != nil {
t.Fatalf("read %s: %v", p, err)
}
if string(got) != want {
t.Fatalf("%s content = %q, want %q", p, string(got), want)
}
}
func diffAssertAbsent(t *testing.T, p string) {
t.Helper()
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Fatalf("expected %s to be absent, stat err=%v", p, err)
}
}
func diffArchiveNames(t *testing.T, artifactPath string) map[string]bool {
t.Helper()
f, err := os.Open(artifactPath)
if err != nil {
t.Fatalf("open artifact: %v", err)
}
defer f.Close()
names := map[string]bool{}
tr := tar.NewReader(f)
for {
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("read tar: %v", err)
}
names[h.Name] = true
}
return names
}
// TestFileRunnerDifferentialRoundTrip 验证差异备份的端到端正确性:
// 全量 → 修改源(变更/删除/新增)→ 差异 → 链式恢复(全量+差异)→ 结果与修改后源一致。
func TestFileRunnerDifferentialRoundTrip(t *testing.T) {
work := t.TempDir()
src := filepath.Join(work, "src")
diffWrite(t, filepath.Join(src, "a.txt"), "alpha")
diffWrite(t, filepath.Join(src, "b.txt"), "bravo")
diffWrite(t, filepath.Join(src, "sub", "c.txt"), "charlie")
runner := NewFileRunner()
full, err := runner.Run(context.Background(), TaskSpec{Name: "diff", Type: "file", SourcePath: src, TempDir: t.TempDir()}, NopLogWriter{})
if err != nil {
t.Fatalf("full Run: %v", err)
}
if full.Manifest == nil || len(full.Manifest.Entries) == 0 {
t.Fatalf("full backup must produce a manifest, got %#v", full.Manifest)
}
// 变更 a.txt内容变长 → size 差异必被检出)、删除 b.txt、新增 d.txtsub/c.txt 不变
diffWrite(t, filepath.Join(src, "a.txt"), "ALPHA-modified-and-longer")
if err := os.Remove(filepath.Join(src, "b.txt")); err != nil {
t.Fatalf("remove b.txt: %v", err)
}
diffWrite(t, filepath.Join(src, "d.txt"), "delta")
diff, err := runner.Run(context.Background(), TaskSpec{Name: "diff", Type: "file", SourcePath: src, TempDir: t.TempDir(), Differential: true, BaseManifest: *full.Manifest}, NopLogWriter{})
if err != nil {
t.Fatalf("differential Run: %v", err)
}
if diff.Manifest != nil {
t.Fatalf("differential backup must not produce a manifest")
}
// 差异归档应包含变更/新增条目与删除清单,但不含未变更的 sub/c.txt
names := diffArchiveNames(t, diff.ArtifactPath)
if !names["src/a.txt"] || !names["src/d.txt"] {
t.Fatalf("differential archive missing changed/new entries: %v", names)
}
if names["src/sub/c.txt"] {
t.Fatalf("differential archive should not contain unchanged file sub/c.txt")
}
if !names[deletionsEntryName] {
t.Fatalf("differential archive missing deletions entry: %v", names)
}
// 链式恢复到全新目标
restoreRoot := t.TempDir()
restoreSrc := filepath.Join(restoreRoot, "src")
restoreTask := TaskSpec{Name: "diff", Type: "file", SourcePath: restoreSrc}
if err := runner.Restore(context.Background(), restoreTask, full.ArtifactPath, NopLogWriter{}); err != nil {
t.Fatalf("restore full: %v", err)
}
if err := runner.Restore(context.Background(), restoreTask, diff.ArtifactPath, NopLogWriter{}); err != nil {
t.Fatalf("restore differential: %v", err)
}
diffAssertContent(t, filepath.Join(restoreSrc, "a.txt"), "ALPHA-modified-and-longer")
diffAssertContent(t, filepath.Join(restoreSrc, "sub", "c.txt"), "charlie")
diffAssertContent(t, filepath.Join(restoreSrc, "d.txt"), "delta")
diffAssertAbsent(t, filepath.Join(restoreSrc, "b.txt"))
}
func TestPathSelected(t *testing.T) {
sel := []string{"src/a.txt", "src/sub"}
cases := map[string]bool{
"src/a.txt": true,
"src/sub": true,
"src/sub/c.txt": true, // 选中目录下的子项
"src/b.txt": false, // 未选中文件
"src/subother": false, // 前缀相近但非子项,不应误判
}
for name, want := range cases {
if got := pathSelected(name, sel); got != want {
t.Errorf("pathSelected(%q) = %v, want %v", name, got, want)
}
}
}
// TestFileRunnerSelectiveRestore 验证按需恢复:仅选中的文件与目录被还原,未选中的文件不出现。
func TestFileRunnerSelectiveRestore(t *testing.T) {
work := t.TempDir()
src := filepath.Join(work, "src")
diffWrite(t, filepath.Join(src, "a.txt"), "alpha")
diffWrite(t, filepath.Join(src, "b.txt"), "bravo")
diffWrite(t, filepath.Join(src, "sub", "c.txt"), "charlie")
runner := NewFileRunner()
full, err := runner.Run(context.Background(), TaskSpec{Name: "sel", Type: "file", SourcePath: src, TempDir: t.TempDir()}, NopLogWriter{})
if err != nil {
t.Fatalf("full Run: %v", err)
}
restoreRoot := t.TempDir()
restoreSrc := filepath.Join(restoreRoot, "src")
task := TaskSpec{Name: "sel", Type: "file", SourcePath: restoreSrc, SelectedPaths: []string{"src/a.txt", "src/sub"}}
if err := runner.Restore(context.Background(), task, full.ArtifactPath, NopLogWriter{}); err != nil {
t.Fatalf("selective Restore: %v", err)
}
diffAssertContent(t, filepath.Join(restoreSrc, "a.txt"), "alpha")
diffAssertContent(t, filepath.Join(restoreSrc, "sub", "c.txt"), "charlie") // 选中目录 → 子项一并恢复
diffAssertAbsent(t, filepath.Join(restoreSrc, "b.txt")) // 未选中 → 不恢复
}
// TestFileRunnerDifferentialWithoutBaseIsFull 验证无基线时差异请求回退为全量(产出清单、含全部文件)。
func TestFileRunnerDifferentialWithoutBaseIsFull(t *testing.T) {
src := filepath.Join(t.TempDir(), "src")
diffWrite(t, filepath.Join(src, "a.txt"), "alpha")
runner := NewFileRunner()
res, err := runner.Run(context.Background(), TaskSpec{Name: "diff", Type: "file", SourcePath: src, TempDir: t.TempDir(), Differential: true}, NopLogWriter{})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.Manifest == nil {
t.Fatalf("differential without base must fall back to full and produce a manifest")
}
if names := diffArchiveNames(t, res.ArtifactPath); !names["src/a.txt"] || names[deletionsEntryName] {
t.Fatalf("fallback-full archive unexpected: %v", names)
}
}

View File

@@ -0,0 +1,92 @@
package backup
import (
"encoding/json"
"os"
"path/filepath"
"sort"
)
// deletionsEntryName 是差异备份归档中记录「自全量以来被删除路径」的特殊条目名。
// 恢复时该条目不落地为文件,而是用于在基线之上删除对应路径。
const deletionsEntryName = ".backupx/deletions.json"
// ManifestEntry 记录一次全量备份中单个归档条目(文件或目录)的指纹,
// 供差异备份比对「自全量以来的变化」。Path 为归档内相对名slash 分隔,
// 与 tar header.Name 一致)。字段使用短键以压缩清单体积。
type ManifestEntry struct {
Path string `json:"p"`
Size int64 `json:"s"`
ModTimeNs int64 `json:"m"`
Mode uint32 `json:"o"`
IsDir bool `json:"d,omitempty"`
}
// Manifest 是一次全量备份的完整条目清单(文件与目录)。
type Manifest struct {
Entries []ManifestEntry `json:"entries"`
}
// EncodeManifest 将清单序列化为紧凑 JSON。
func EncodeManifest(m Manifest) ([]byte, error) {
return json.Marshal(m)
}
// DecodeManifest 反序列化清单;空输入返回空清单(视为「无基线」)。
func DecodeManifest(data []byte) (Manifest, error) {
m := Manifest{}
if len(data) == 0 {
return m, nil
}
if err := json.Unmarshal(data, &m); err != nil {
return Manifest{}, err
}
return m, nil
}
// index 构建 path -> entry 映射,便于差异比对 O(1) 查找。
func (m Manifest) index() map[string]ManifestEntry {
idx := make(map[string]ManifestEntry, len(m.Entries))
for _, e := range m.Entries {
idx[e.Path] = e
}
return idx
}
// entryFromInfo 由归档名与文件信息构造指纹条目。
func entryFromInfo(archiveName string, info os.FileInfo) ManifestEntry {
return ManifestEntry{
Path: filepath.ToSlash(archiveName),
Size: info.Size(),
ModTimeNs: info.ModTime().UnixNano(),
Mode: uint32(info.Mode().Perm()),
IsDir: info.IsDir(),
}
}
// changedSince 判断当前条目相对基线是否为「新增或变更」(即应纳入差异归档)。
// - 不在基线中 → 新增,纳入;
// - 已存在的目录 → 不携带数据,跳过(其下变更文件会各自判定);
// - 文件大小或 mtime 变化 → 变更纳入rsync 风格启发式)。
func changedSince(base map[string]ManifestEntry, cur ManifestEntry) bool {
prev, ok := base[cur.Path]
if !ok {
return true
}
if cur.IsDir {
return false
}
return prev.Size != cur.Size || prev.ModTimeNs != cur.ModTimeNs
}
// deletedPaths 返回基线中存在、但本次遍历未出现的路径(被删除的条目),按路径升序。
func deletedPaths(base map[string]ManifestEntry, seen map[string]struct{}) []string {
deleted := make([]string, 0)
for p := range base {
if _, ok := seen[p]; !ok {
deleted = append(deleted, p)
}
}
sort.Strings(deleted)
return deleted
}

View File

@@ -0,0 +1,79 @@
package backup
import (
"reflect"
"testing"
)
func TestEncodeDecodeManifestRoundTrip(t *testing.T) {
m := Manifest{Entries: []ManifestEntry{
{Path: "src/a.txt", Size: 10, ModTimeNs: 100, Mode: 0o644},
{Path: "src", Size: 0, ModTimeNs: 50, Mode: 0o755, IsDir: true},
}}
data, err := EncodeManifest(m)
if err != nil {
t.Fatalf("EncodeManifest: %v", err)
}
got, err := DecodeManifest(data)
if err != nil {
t.Fatalf("DecodeManifest: %v", err)
}
if !reflect.DeepEqual(got, m) {
t.Fatalf("roundtrip mismatch:\n got %#v\nwant %#v", got, m)
}
}
func TestDecodeManifestEmpty(t *testing.T) {
got, err := DecodeManifest(nil)
if err != nil {
t.Fatalf("DecodeManifest(nil): %v", err)
}
if len(got.Entries) != 0 {
t.Fatalf("expected empty manifest, got %#v", got)
}
}
func TestChangedSince(t *testing.T) {
base := Manifest{Entries: []ManifestEntry{
{Path: "a.txt", Size: 10, ModTimeNs: 100},
{Path: "dir", IsDir: true, ModTimeNs: 100},
}}.index()
cases := []struct {
name string
cur ManifestEntry
want bool
}{
{"unchanged file", ManifestEntry{Path: "a.txt", Size: 10, ModTimeNs: 100}, false},
{"size changed", ManifestEntry{Path: "a.txt", Size: 11, ModTimeNs: 100}, true},
{"mtime changed", ManifestEntry{Path: "a.txt", Size: 10, ModTimeNs: 200}, true},
{"new file", ManifestEntry{Path: "b.txt", Size: 1, ModTimeNs: 1}, true},
{"existing dir skipped", ManifestEntry{Path: "dir", IsDir: true, ModTimeNs: 999}, false},
{"new dir included", ManifestEntry{Path: "newdir", IsDir: true, ModTimeNs: 1}, true},
}
for _, tc := range cases {
if got := changedSince(base, tc.cur); got != tc.want {
t.Errorf("%s: changedSince=%v want %v", tc.name, got, tc.want)
}
}
}
func TestDeletedPaths(t *testing.T) {
base := Manifest{Entries: []ManifestEntry{
{Path: "a"}, {Path: "b"}, {Path: "c"},
}}.index()
seen := map[string]struct{}{"a": {}, "c": {}}
got := deletedPaths(base, seen)
want := []string{"b"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("deletedPaths=%v want %v", got, want)
}
}
func TestDeletedPathsNoneWhenAllSeen(t *testing.T) {
base := Manifest{Entries: []ManifestEntry{{Path: "a"}, {Path: "b"}}}.index()
seen := map[string]struct{}{"a": {}, "b": {}}
if got := deletedPaths(base, seen); len(got) != 0 {
t.Fatalf("expected no deletions, got %v", got)
}
}

View File

@@ -0,0 +1,119 @@
package backup
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// MongoDBRunner 通过 mongodump/mongorestore 备份与恢复 MongoDB 数据库。
// 采用 --archive 流式模式dump 写 stdout、restore 读 stdin与 MySQLRunner
// 的 mysqldump/mysql 管线保持一致;产物为未压缩的 mongo archive由备份管线统一压缩/加密。
type MongoDBRunner struct {
executor CommandExecutor
}
func NewMongoDBRunner(executor CommandExecutor) *MongoDBRunner {
if executor == nil {
executor = NewOSCommandExecutor()
}
return &MongoDBRunner{executor: executor}
}
func (r *MongoDBRunner) Type() string {
return "mongodb"
}
func (r *MongoDBRunner) Run(ctx context.Context, task TaskSpec, writer LogWriter) (*RunResult, error) {
if _, err := r.executor.LookPath("mongodump"); err != nil {
return nil, fmt.Errorf("未找到 mongodump 命令 (请确保服务器已安装 mongodb-database-tools)")
}
startedAt := task.StartedAt
if startedAt.IsZero() {
startedAt = time.Now().UTC()
}
tempDir, err := CreateTaskTempDir(task.Name, startedAt)
if err != nil {
return nil, err
}
fileName := BuildArtifactName(task.Name, startedAt, "archive")
artifactPath := filepath.Join(tempDir, fileName)
file, err := os.Create(artifactPath)
if err != nil {
return nil, fmt.Errorf("create mongodump archive file: %w", err)
}
defer file.Close()
args := mongoConnArgs(task.Database)
dbNames := normalizeDatabaseNames(task.Database.Names)
if len(dbNames) == 1 {
args = append(args, "--db", dbNames[0])
writer.WriteLine(fmt.Sprintf("备份数据库: %s", dbNames[0]))
} else {
writer.WriteLine("备份全部数据库")
}
args = append(args, "--archive") // 归档流式写入 stdout
writer.WriteLine(fmt.Sprintf("连接到 MongoDB: %s:%d", task.Database.Host, task.Database.Port))
stderrWriter := newLogLineWriter(writer, "mongodump")
writer.WriteLine("开始执行 mongodump")
if err := r.executor.Run(ctx, "mongodump", args, CommandOptions{Stdout: file, Stderr: stderrWriter}); err != nil {
return nil, fmt.Errorf("run mongodump: %w: %s", err, stderrWriter.collected())
}
info, err := file.Stat()
if err != nil {
return nil, fmt.Errorf("stat mongodump archive: %w", err)
}
if info.Size() == 0 {
return nil, fmt.Errorf("mongodump 产物为空,请检查数据库连接与权限")
}
writer.WriteLine(fmt.Sprintf("MongoDB 导出完成(文件大小: %s", formatFileSize(info.Size())))
return &RunResult{ArtifactPath: artifactPath, FileName: fileName, TempDir: tempDir, Size: info.Size(), StorageKey: BuildStorageKey("mongodb", startedAt, fileName)}, nil
}
func (r *MongoDBRunner) Restore(ctx context.Context, task TaskSpec, artifactPath string, writer LogWriter) error {
if _, err := r.executor.LookPath("mongorestore"); err != nil {
return fmt.Errorf("未找到 mongorestore 命令 (请确保服务器已安装 mongodb-database-tools)")
}
input, err := os.Open(filepath.Clean(artifactPath))
if err != nil {
return fmt.Errorf("open mongodb restore archive: %w", err)
}
defer input.Close()
args := mongoConnArgs(task.Database)
// --drop恢复前删除同名集合保证恢复后与归档一致与 mysql 恢复的整库覆盖语义对齐)。
args = append(args, "--drop", "--archive")
stderr := &bytes.Buffer{}
writer.WriteLine("开始执行 mongorestore")
if err := r.executor.Run(ctx, "mongorestore", args, CommandOptions{Stdin: input, Stderr: stderr}); err != nil {
return fmt.Errorf("run mongorestore: %w: %s", err, strings.TrimSpace(stderr.String()))
}
writer.WriteLine("MongoDB 恢复完成")
return nil
}
// mongoConnArgs 构造 mongodump/mongorestore 的连接与认证参数。
// 注意mongodb-database-tools 无类似 MYSQL_PWD 的密码环境变量,密码只能经 --password 传入;
// 认证库默认 admin绝大多数部署的管理账号所在库
func mongoConnArgs(db DatabaseSpec) []string {
args := make([]string, 0, 8)
if strings.TrimSpace(db.Host) != "" {
args = append(args, "--host", db.Host)
}
if db.Port > 0 {
args = append(args, "--port", strconv.Itoa(db.Port))
}
if strings.TrimSpace(db.User) != "" {
args = append(args, "--username", db.User, "--authenticationDatabase", "admin")
if strings.TrimSpace(db.Password) != "" {
args = append(args, "--password", db.Password)
}
}
return args
}

View File

@@ -0,0 +1,102 @@
package backup
import (
"context"
"errors"
"io"
"os"
"strings"
"testing"
)
func argIndex(args []string, target string) int {
for i, a := range args {
if a == target {
return i
}
}
return -1
}
func TestMongoDBRunnerRunUsesMongodump(t *testing.T) {
executor := &fakeCommandExecutor{runFunc: func(name string, args []string, options CommandOptions) error {
if options.Stdout != nil {
_, _ = io.WriteString(options.Stdout, "mongo archive bytes")
}
return nil
}}
runner := NewMongoDBRunner(executor)
result, err := runner.Run(context.Background(), TaskSpec{Name: "mongo", Type: "mongodb", Database: DatabaseSpec{Host: "127.0.0.1", Port: 27017, User: "admin", Password: "secret", Names: []string{"app"}}}, NopLogWriter{})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if executor.lastName != "mongodump" {
t.Fatalf("expected mongodump, got %s", executor.lastName)
}
args := executor.lastArgs
if argIndex(args, "--archive") < 0 {
t.Fatalf("expected --archive flag, got %#v", args)
}
if i := argIndex(args, "--db"); i < 0 || i+1 >= len(args) || args[i+1] != "app" {
t.Fatalf("expected --db app, got %#v", args)
}
if i := argIndex(args, "--username"); i < 0 || args[i+1] != "admin" {
t.Fatalf("expected --username admin, got %#v", args)
}
if argIndex(args, "--authenticationDatabase") < 0 || argIndex(args, "--password") < 0 {
t.Fatalf("expected auth args, got %#v", args)
}
if _, err := os.Stat(result.ArtifactPath); err != nil {
t.Fatalf("artifact file missing: %v", err)
}
if result.StorageKey == "" || !strings.HasSuffix(result.FileName, ".archive") {
t.Fatalf("unexpected result metadata: %#v", result)
}
}
func TestMongoDBRunnerRunBackupsAllWhenNoDatabase(t *testing.T) {
executor := &fakeCommandExecutor{runFunc: func(name string, args []string, options CommandOptions) error {
_, _ = io.WriteString(options.Stdout, "all dbs")
return nil
}}
runner := NewMongoDBRunner(executor)
_, err := runner.Run(context.Background(), TaskSpec{Name: "mongo", Type: "mongodb", Database: DatabaseSpec{Host: "127.0.0.1", Port: 27017}}, NopLogWriter{})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if argIndex(executor.lastArgs, "--db") >= 0 {
t.Fatalf("expected no --db when backing up all databases, got %#v", executor.lastArgs)
}
}
func TestMongoDBRunnerRunRejectsEmptyOutput(t *testing.T) {
executor := &fakeCommandExecutor{} // runFunc nil → writes nothing
runner := NewMongoDBRunner(executor)
_, err := runner.Run(context.Background(), TaskSpec{Name: "mongo", Type: "mongodb", Database: DatabaseSpec{Host: "127.0.0.1", Port: 27017, Names: []string{"app"}}}, NopLogWriter{})
if err == nil {
t.Fatal("expected error for empty mongodump output")
}
}
func TestMongoDBRunnerRestoreUsesMongorestore(t *testing.T) {
executor := &fakeCommandExecutor{}
runner := NewMongoDBRunner(executor)
artifact := filepathJoinTempFile(t, "dump.archive", "mongo archive bytes")
if err := runner.Restore(context.Background(), TaskSpec{Name: "mongo", Type: "mongodb", Database: DatabaseSpec{Host: "127.0.0.1", Port: 27017, User: "admin", Password: "secret"}}, artifact, NopLogWriter{}); err != nil {
t.Fatalf("Restore returned error: %v", err)
}
if executor.lastName != "mongorestore" {
t.Fatalf("expected mongorestore, got %s", executor.lastName)
}
if argIndex(executor.lastArgs, "--drop") < 0 || argIndex(executor.lastArgs, "--archive") < 0 {
t.Fatalf("expected --drop --archive, got %#v", executor.lastArgs)
}
}
func TestMongoDBRunnerRunReturnsLookupError(t *testing.T) {
runner := NewMongoDBRunner(&fakeCommandExecutor{lookupErr: errors.New("missing")})
_, err := runner.Run(context.Background(), TaskSpec{Name: "mongo", Type: "mongodb", Database: DatabaseSpec{Host: "127.0.0.1", Port: 27017, Names: []string{"app"}}}, NopLogWriter{})
if err == nil {
t.Fatal("expected error when mongodump is missing")
}
}

View File

@@ -0,0 +1,55 @@
package retention
import (
"testing"
"backupx/server/internal/model"
)
func retentionRecIDs(records []model.BackupRecord) []uint {
ids := make([]uint, 0, len(records))
for _, r := range records {
ids = append(ids, r.ID)
}
return ids
}
// 基线全量仍被「不在删除集合中的差异」依赖 → 必须保留,否则差异无法恢复。
func TestProtectDifferentialBasesKeepsBaseWithSurvivingDiff(t *testing.T) {
all := []model.BackupRecord{
{ID: 1, BackupKind: model.BackupKindFull},
{ID: 2, BackupKind: model.BackupKindDifferential, BaseRecordID: 1},
}
candidates := []model.BackupRecord{{ID: 1, BackupKind: model.BackupKindFull}}
if got := protectDifferentialBases(all, candidates); len(got) != 0 {
t.Fatalf("base with surviving diff must be protected, got %v", retentionRecIDs(got))
}
}
// 基线全量与其全部差异都在删除集合中 → 可一并删除(无残留差异失去基线)。
func TestProtectDifferentialBasesDeletesBaseWhenDiffAlsoDeleted(t *testing.T) {
all := []model.BackupRecord{
{ID: 1, BackupKind: model.BackupKindFull},
{ID: 2, BackupKind: model.BackupKindDifferential, BaseRecordID: 1},
}
candidates := []model.BackupRecord{
{ID: 1, BackupKind: model.BackupKindFull},
{ID: 2, BackupKind: model.BackupKindDifferential, BaseRecordID: 1},
}
if got := protectDifferentialBases(all, candidates); len(got) != 2 {
t.Fatalf("base+diff both expired should both be deleted, got %v", retentionRecIDs(got))
}
}
// 无差异备份时原样透传(不影响既有全量保留逻辑)。
func TestProtectDifferentialBasesNoDiffsPassThrough(t *testing.T) {
all := []model.BackupRecord{
{ID: 1, BackupKind: model.BackupKindFull},
{ID: 2, BackupKind: model.BackupKindFull},
}
candidates := []model.BackupRecord{{ID: 1, BackupKind: model.BackupKindFull}}
got := protectDifferentialBases(all, candidates)
if len(got) != 1 || got[0].ID != 1 {
t.Fatalf("no diffs should pass through unchanged, got %v", retentionRecIDs(got))
}
}

View File

@@ -3,6 +3,7 @@ package retention
import (
"context"
"fmt"
"sort"
"strings"
"time"
@@ -56,7 +57,15 @@ func (s *Service) Cleanup(ctx context.Context, task *model.BackupTask, provider
if err != nil {
return nil, fmt.Errorf("list successful records: %w", err)
}
candidates := selectRecordsToDelete(records, task.RetentionDays, task.MaxBackups, s.now())
var candidates []model.BackupRecord
if gfsEnabled(task) {
// GFS 策略:按天/周/月/年分层保留代表性备份,取代简单的天数/数量策略。
candidates = selectGFSToDelete(records, task.KeepDaily, task.KeepWeekly, task.KeepMonthly, task.KeepYearly)
} else {
candidates = selectRecordsToDelete(records, task.RetentionDays, task.MaxBackups, s.now())
}
// 差异链保护:保留仍被存活差异依赖的全量,避免删除基线后差异无法恢复。
candidates = protectDifferentialBases(records, candidates)
result := &CleanupResult{}
for _, record := range candidates {
if strings.TrimSpace(record.StoragePath) != "" {
@@ -90,7 +99,50 @@ func (s *Service) Cleanup(ctx context.Context, task *model.BackupTask, provider
return result, nil
}
// protectDifferentialBases 从删除候选中剔除「仍被存活差异依赖的全量」,
// 避免删除基线后其差异备份失去依据、无法恢复。全量仅当其全部差异都已过期/删除时才会被清理。
func protectDifferentialBases(all []model.BackupRecord, candidates []model.BackupRecord) []model.BackupRecord {
deleting := make(map[uint]struct{}, len(candidates))
for _, r := range candidates {
deleting[r.ID] = struct{}{}
}
protected := make(map[uint]struct{})
for _, r := range all {
if r.BackupKind != model.BackupKindDifferential || r.BaseRecordID == 0 {
continue
}
if _, beingDeleted := deleting[r.ID]; beingDeleted {
continue // 该差异本身也将被删除,无需保护其基线
}
protected[r.BaseRecordID] = struct{}{}
}
if len(protected) == 0 {
return candidates
}
filtered := make([]model.BackupRecord, 0, len(candidates))
for _, r := range candidates {
if r.BackupKind == model.BackupKindFull {
if _, keep := protected[r.ID]; keep {
continue
}
}
filtered = append(filtered, r)
}
return filtered
}
func selectRecordsToDelete(records []model.BackupRecord, retentionDays int, maxBackups int, now time.Time) []model.BackupRecord {
// 保留锁定(法律保留)的记录永不参与清理:先从候选集中剔除,
// 锁定备份既不被删除,也不占用 maxBackups 轮转名额。
if hasLocked(records) {
unlocked := make([]model.BackupRecord, 0, len(records))
for _, r := range records {
if !r.Locked {
unlocked = append(unlocked, r)
}
}
records = unlocked
}
selected := make(map[uint]model.BackupRecord)
if maxBackups > 0 && len(records) > maxBackups {
for _, record := range records[maxBackups:] {
@@ -113,3 +165,81 @@ func selectRecordsToDelete(records []model.BackupRecord, retentionDays int, maxB
}
return result
}
func hasLocked(records []model.BackupRecord) bool {
for i := range records {
if records[i].Locked {
return true
}
}
return false
}
// gfsEnabled 判定任务是否启用 GFS 分层保留(任一层级 > 0
func gfsEnabled(task *model.BackupTask) bool {
return task.KeepDaily > 0 || task.KeepWeekly > 0 || task.KeepMonthly > 0 || task.KeepYearly > 0
}
func recordTime(r *model.BackupRecord) time.Time {
if r.CompletedAt != nil {
return *r.CompletedAt
}
return r.StartedAt
}
func isoWeekKey(t time.Time) string {
y, w := t.ISOWeek()
return fmt.Sprintf("%d-W%02d", y, w)
}
// selectGFSToDelete 按 GFS祖父-父-子)策略选出应删除的记录。
//
// 规则:对每个层级(天/周/月/年),在按时间降序排列后,保留最近 keep 个不同周期中
// 每个周期最新的一份备份;各层级保留集合取并集即「保留集」,其余删除。
// 锁定(法律保留)的记录始终排除在删除候选之外。
func selectGFSToDelete(records []model.BackupRecord, daily, weekly, monthly, yearly int) []model.BackupRecord {
active := make([]model.BackupRecord, 0, len(records))
for i := range records {
if !records[i].Locked {
active = append(active, records[i])
}
}
sort.SliceStable(active, func(i, j int) bool {
return recordTime(&active[i]).After(recordTime(&active[j]))
})
keep := make(map[uint]bool, len(active))
keepTier := func(count int, key func(time.Time) string) {
if count <= 0 {
return
}
periods := 0
lastPeriod := ""
havePrev := false
for i := range active {
p := key(recordTime(&active[i]))
if havePrev && p == lastPeriod {
continue // 同周期已保留代表(最新一份)
}
if periods >= count {
break // 该层级已保留足够多的周期
}
keep[active[i].ID] = true
lastPeriod = p
havePrev = true
periods++
}
}
keepTier(daily, func(t time.Time) string { return t.Format("2006-01-02") })
keepTier(weekly, isoWeekKey)
keepTier(monthly, func(t time.Time) string { return t.Format("2006-01") })
keepTier(yearly, func(t time.Time) string { return t.Format("2006") })
del := make([]model.BackupRecord, 0)
for i := range active {
if !keep[active[i].ID] {
del = append(del, active[i])
}
}
return del
}

View File

@@ -45,6 +45,9 @@ func (r *fakeRecordRepository) ListByTask(_ context.Context, _ uint) ([]model.Ba
func (r *fakeRecordRepository) ListSuccessfulByTask(_ context.Context, _ uint) ([]model.BackupRecord, error) {
return r.records, nil
}
func (r *fakeRecordRepository) CountDependentDifferentials(context.Context, uint) (int64, error) {
return 0, nil
}
func (r *fakeRecordRepository) Count(context.Context) (int64, error) { return 0, nil }
func (r *fakeRecordRepository) CountSince(context.Context, time.Time) (int64, error) { return 0, nil }
func (r *fakeRecordRepository) CountSuccessSince(context.Context, time.Time) (int64, error) {
@@ -93,6 +96,105 @@ func TestSelectRecordsToDelete(t *testing.T) {
}
}
func gfsRecord(id uint, ts time.Time, locked bool) model.BackupRecord {
completed := ts
return model.BackupRecord{ID: id, StartedAt: ts, CompletedAt: &completed, Locked: locked}
}
func gfsDay(y, m, d, h int) time.Time {
return time.Date(y, time.Month(m), d, h, 0, 0, 0, time.UTC)
}
func deletedIDSet(records []model.BackupRecord) map[uint]bool {
out := make(map[uint]bool, len(records))
for i := range records {
out[records[i].ID] = true
}
return out
}
func assertDeleted(t *testing.T, del []model.BackupRecord, want ...uint) {
t.Helper()
got := deletedIDSet(del)
if len(got) != len(want) {
t.Fatalf("deleted set size = %d %v, want %d %v", len(got), got, len(want), want)
}
for _, id := range want {
if !got[id] {
t.Fatalf("expected id %d to be deleted; got %v", id, got)
}
}
}
// TestSelectGFSToDelete_DailyTier 验证按天分层:每天仅保留最新一份,且只保留最近 N 天。
func TestSelectGFSToDelete_DailyTier(t *testing.T) {
records := []model.BackupRecord{
gfsRecord(5, gfsDay(2026, 3, 7, 12), false), // 今天,最新 → 保留
gfsRecord(4, gfsDay(2026, 3, 7, 6), false), // 今天,较早 → 删除(非当天代表)
gfsRecord(3, gfsDay(2026, 3, 6, 12), false), // 昨天 → 保留
gfsRecord(2, gfsDay(2026, 3, 5, 12), false), // 前天 → 超出 daily=2 → 删除
gfsRecord(1, gfsDay(2026, 3, 4, 12), false), // 更早 → 删除
}
del := selectGFSToDelete(records, 2, 0, 0, 0)
assertDeleted(t, del, 4, 2, 1)
}
// TestSelectGFSToDelete_TierUnion 验证多层级取并集:月度层级保留日度层级会删除的旧备份。
func TestSelectGFSToDelete_TierUnion(t *testing.T) {
records := []model.BackupRecord{
gfsRecord(3, gfsDay(2026, 3, 7, 12), false), // 3 月(最新)
gfsRecord(2, gfsDay(2026, 2, 15, 12), false), // 2 月
gfsRecord(1, gfsDay(2026, 1, 15, 12), false), // 1 月
}
// daily=1 只留 ID3monthly=2 留最近两个月3 月=ID3、2 月=ID2。并集={3,2},删除 ID1。
del := selectGFSToDelete(records, 1, 0, 2, 0)
assertDeleted(t, del, 1)
}
// TestSelectGFSToDelete_SkipsLocked 验证锁定记录即使超出所有层级也永不删除。
func TestSelectGFSToDelete_SkipsLocked(t *testing.T) {
records := []model.BackupRecord{
gfsRecord(3, gfsDay(2026, 3, 7, 12), false),
gfsRecord(2, gfsDay(2026, 3, 6, 12), false),
gfsRecord(1, gfsDay(2020, 1, 1, 12), true), // 远超 daily=1 但已锁定 → 不删
}
del := selectGFSToDelete(records, 1, 0, 0, 0)
assertDeleted(t, del, 2) // 仅 ID2 被删ID1 锁定豁免ID3 为当日代表
}
func TestGFSEnabled(t *testing.T) {
if gfsEnabled(&model.BackupTask{}) {
t.Fatal("empty GFS config should be disabled")
}
if !gfsEnabled(&model.BackupTask{KeepWeekly: 4}) {
t.Fatal("KeepWeekly>0 should enable GFS")
}
}
// TestSelectRecordsToDelete_SkipsLocked 验证保留锁定(法律保留)的记录永不被选中删除,
// 即使它既超过保留期、又超过 maxBackups 名额。
func TestSelectRecordsToDelete_SkipsLocked(t *testing.T) {
now := time.Date(2026, 3, 7, 16, 0, 0, 0, time.UTC)
completedNew := now.Add(-24 * time.Hour)
completedOld := now.Add(-15 * 24 * time.Hour)
records := []model.BackupRecord{
{ID: 3, CompletedAt: &completedNew},
{ID: 2, CompletedAt: &completedNew},
{ID: 1, CompletedAt: &completedOld, Locked: true}, // 超期但锁定 → 不应删除
}
selected := selectRecordsToDelete(records, 7, 2, now)
for _, r := range selected {
if r.ID == 1 {
t.Fatalf("locked record #1 must never be selected for deletion: %#v", selected)
}
}
// 锁定记录不占 maxBackups 名额:未锁定仅 2 条maxBackups=2 → 无超额删除,
// 且无未锁定记录超期 → 选中集为空。
if len(selected) != 0 {
t.Fatalf("expected no deletions (locked excluded), got %#v", selected)
}
}
func TestCleanupDeletesExpiredRecords(t *testing.T) {
now := time.Date(2026, 3, 7, 16, 0, 0, 0, time.UTC)
completedNew := now.Add(-24 * time.Hour)

View File

@@ -36,6 +36,15 @@ type TaskSpec struct {
MaxBackups int
StartedAt time.Time
TempDir string
// Differential 为 true 时执行差异备份:仅打包自 BaseManifest 以来新增/变更的条目,
// 并记录被删除的路径。仅文件类型任务支持BaseManifest 为空时回退为全量。
Differential bool
BaseManifest Manifest
// SelectedPaths 非空时仅恢复这些归档相对路径(及其子项),用于按需(选择性)恢复;仅文件类型生效。
SelectedPaths []string
// RestoreTargetPath 仅用于恢复:非空时,文件类型恢复将归档解压到该目录,
// 而非默认的原始源路径父目录。用于「恢复到指定位置」(迁移/测试/并排恢复)。
RestoreTargetPath string
}
type RunResult struct {
@@ -44,6 +53,8 @@ type RunResult struct {
TempDir string
Size int64
StorageKey string
// Manifest 为全量备份产出的条目清单,供后续差异备份比对;差异备份运行时为 nil。
Manifest *Manifest
}
type LogEvent struct {
@@ -62,7 +73,7 @@ type ProgressInfo struct {
BytesSent int64 `json:"bytesSent"`
TotalBytes int64 `json:"totalBytes"`
Percent float64 `json:"percent"`
SpeedBps float64 `json:"speedBps"` // bytes/sec
SpeedBps float64 `json:"speedBps"` // bytes/sec
TargetName string `json:"targetName"`
}

View File

@@ -21,6 +21,10 @@ type ServerConfig struct {
Port int `mapstructure:"port"`
Mode string `mapstructure:"mode"`
ExternalURL string `mapstructure:"external_url"`
// WebRoot 指向前端构建产物目录。留空时后端会按部署惯例自动探测
// ./web、./web/dist、/opt/backupx/web 等)。探测命中后后端直接托管
// 前端 SPA无需额外的 nginx 反向代理即可访问 Web 控制台。
WebRoot string `mapstructure:"web_root"`
}
type DatabaseConfig struct {
@@ -138,6 +142,7 @@ func applyDefaults(v *viper.Viper) {
v.SetDefault("server.port", 8340)
v.SetDefault("server.mode", "release")
v.SetDefault("server.external_url", "")
v.SetDefault("server.web_root", "")
v.SetDefault("database.path", "./data/backupx.db")
v.SetDefault("security.jwt_expire", "24h")
v.SetDefault("backup.temp_dir", "/tmp/backupx")

View File

@@ -52,6 +52,20 @@ func (h *BackupRecordHandler) Get(c *gin.Context) {
response.Success(c, item)
}
// Contents 返回备份记录的文件清单(内容浏览,只读)。
func (h *BackupRecordHandler) Contents(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
contents, err := h.service.ListContents(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, contents)
}
func (h *BackupRecordHandler) StreamLogs(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
@@ -137,7 +151,14 @@ func (h *BackupRecordHandler) Restore(c *gin.Context) {
if subject, exists := c.Get(contextUserSubjectKey); exists {
triggeredBy = strings.TrimSpace(fmt.Sprintf("%v", subject))
}
detail, err := h.restoreService.Start(c.Request.Context(), id, triggeredBy)
// 可选请求体selectedPaths 按需选择性恢复targetPath 恢复到指定目录(仅文件类型本机恢复)。
// 无 body 时为整体恢复到原始路径。
var body struct {
SelectedPaths []string `json:"selectedPaths"`
TargetPath string `json:"targetPath"`
}
_ = c.ShouldBindJSON(&body)
detail, err := h.restoreService.StartSelective(c.Request.Context(), id, body.SelectedPaths, strings.TrimSpace(body.TargetPath), triggeredBy)
if err != nil {
response.Error(c, err)
return
@@ -161,6 +182,32 @@ func (h *BackupRecordHandler) Delete(c *gin.Context) {
response.Success(c, gin.H{"deleted": true})
}
// SetLock 设置/解除备份记录的保留锁定(法律保留)。
func (h *BackupRecordHandler) SetLock(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
var input struct {
Locked bool `json:"locked"`
}
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, apperror.BadRequest("BACKUP_RECORD_LOCK_INVALID", "锁定参数不合法", err))
return
}
detail, err := h.service.SetLock(c.Request.Context(), id, input.Locked)
if err != nil {
response.Error(c, err)
return
}
action, desc := "unlock", fmt.Sprintf("解除备份记录保留锁定 (ID: %d)", id)
if input.Locked {
action, desc = "lock", fmt.Sprintf("设置备份记录保留锁定 (ID: %d)", id)
}
recordAudit(c, h.auditService, "backup_record", action, "backup_record", fmt.Sprintf("%d", id), "", desc)
response.Success(c, detail)
}
func (h *BackupRecordHandler) BatchDelete(c *gin.Context) {
var input struct {
IDs []uint `json:"ids" binding:"required,min=1"`
@@ -216,7 +263,7 @@ func writeSSEEvent(writer io.Writer, event backup.LogEvent) error {
}
func parseUintString(value string) (uint, bool) {
parsed, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64)
parsed, err := strconv.ParseUint(strings.TrimSpace(value), 10, 0)
if err != nil {
return 0, false
}

View File

@@ -0,0 +1,95 @@
package http
import (
"encoding/csv"
"fmt"
"strconv"
"strings"
"time"
"backupx/server/internal/service"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
type ReportHandler struct {
service *service.ReportService
}
func NewReportHandler(reportService *service.ReportService) *ReportHandler {
return &ReportHandler{service: reportService}
}
func reportDays(c *gin.Context) int {
days := 30
if v := strings.TrimSpace(c.Query("days")); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
days = parsed
}
}
return days
}
// Compliance 返回 JSON 合规报表(按任务的备份合规证据 + 汇总)。
func (h *ReportHandler) Compliance(c *gin.Context) {
payload, err := h.service.ComplianceReport(c.Request.Context(), reportDays(c))
if err != nil {
response.Error(c, err)
return
}
response.Success(c, payload)
}
// ComplianceCSV 把合规报表导出为 CSV供审计归档。带 UTF-8 BOM 以便 Excel 正确识别中文。
func (h *ReportHandler) ComplianceCSV(c *gin.Context) {
report, err := h.service.ComplianceReport(c.Request.Context(), reportDays(c))
if err != nil {
response.Error(c, err)
return
}
filename := fmt.Sprintf("backupx-compliance-%s.csv", report.GeneratedAt.Format("20060102-150405"))
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", "attachment; filename="+filename)
_, _ = c.Writer.WriteString("\ufeff") // UTF-8 BOM
w := csv.NewWriter(c.Writer)
_ = w.Write([]string{
"任务ID", "任务名", "类型", "启用", "节点", "加密", "保留天数", "SLA(RPO小时)",
"周期内运行", "成功", "失败", "成功率", "最近状态", "最近运行(UTC)", "最近成功(UTC)", "保护字节数", "合规判定",
})
for _, row := range report.Tasks {
_ = w.Write([]string{
strconv.FormatUint(uint64(row.TaskID), 10),
row.TaskName,
row.Type,
boolCN(row.Enabled),
row.NodeName,
boolCN(row.Encrypted),
strconv.Itoa(row.RetentionDays),
strconv.Itoa(row.SLAHoursRPO),
strconv.Itoa(row.TotalRuns),
strconv.Itoa(row.Successes),
strconv.Itoa(row.Failures),
fmt.Sprintf("%.2f%%", row.SuccessRate*100),
row.LastStatus,
fmtTimePtr(row.LastRunAt),
fmtTimePtr(row.LastSuccessAt),
strconv.FormatInt(row.ProtectedBytes, 10),
row.Risk,
})
}
w.Flush()
}
func boolCN(b bool) string {
if b {
return "是"
}
return "否"
}
func fmtTimePtr(t *time.Time) string {
if t == nil {
return ""
}
return t.UTC().Format("2006-01-02 15:04:05")
}

View File

@@ -41,6 +41,7 @@ type RouterDependencies struct {
ApiKeyService *service.ApiKeyService
NotificationService *service.NotificationService
DashboardService *service.DashboardService
ReportService *service.ReportService
SettingsService *service.SettingsService
NodeService *service.NodeService
AgentService *service.AgentService
@@ -166,9 +167,11 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
backupRecords.GET("/:id", backupRecordHandler.Get)
backupRecords.GET("/:id/logs/stream", backupRecordHandler.StreamLogs)
backupRecords.GET("/:id/download", backupRecordHandler.Download)
backupRecords.GET("/:id/contents", backupRecordHandler.Contents)
backupRecords.POST("/:id/restore", RequireNotViewer(), backupRecordHandler.Restore)
backupRecords.POST("/batch-delete", RequireNotViewer(), backupRecordHandler.BatchDelete)
backupRecords.DELETE("/:id", RequireNotViewer(), backupRecordHandler.Delete)
backupRecords.PUT("/:id/lock", RequireNotViewer(), backupRecordHandler.SetLock)
// 恢复记录独立命名空间:列表/详情/SSE 日志流。
// 创建恢复仍然走 POST /backup/records/:id/restore以源备份记录为触发点
@@ -211,6 +214,15 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
// 基于备份记录的验证入口:与 restore 对称
backupRecords.POST("/:id/verify", RequireNotViewer(), verificationHandler.TriggerByRecord)
}
// 企业合规报表:按任务的可导出备份合规证据(区别于 Dashboard 的实时聚合视图)。
if deps.ReportService != nil {
reportHandler := NewReportHandler(deps.ReportService)
reports := api.Group("/reports")
reports.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
reports.GET("/compliance", reportHandler.Compliance)
reports.GET("/compliance/export", reportHandler.ComplianceCSV)
}
dashboard := api.Group("/dashboard")
dashboard.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
dashboard.GET("/stats", dashboardHandler.Stats)
@@ -292,7 +304,10 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
nodes.POST("", RequireRole("admin"), nodeHandler.Create)
nodes.PUT("/:id", RequireRole("admin"), nodeHandler.Update)
nodes.DELETE("/:id", RequireRole("admin"), nodeHandler.Delete)
nodes.GET("/:id/fs/list", nodeHandler.ListDirectory)
// 文件浏览会枚举节点文件系统目录(含 /etc、/root 等),属敏感读操作:
// 限制为非 vieweradmin/operator与"创建备份任务需选源路径"的权限对齐,
// 避免只读 viewer 借此探查服务器目录结构。
nodes.GET("/:id/fs/list", RequireNotViewer(), nodeHandler.ListDirectory)
nodes.POST("/batch", RequireRole("admin"), nodeHandler.BatchCreate)
nodes.POST("/:id/install-tokens", RequireRole("admin"), nodeHandler.CreateInstallToken)
nodes.POST("/:id/rotate-token", RequireRole("admin"), nodeHandler.RotateToken)
@@ -353,9 +368,25 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
engine.GET("/api/install/:token/compose.yml", installHandler.Compose)
}
engine.NoRoute(func(c *gin.Context) {
// 未匹配路由处理:
// - 找到前端产物目录时,托管 SPA静态文件 + index.html 回退),
// 使后端在无 nginx 反向代理时也能直接提供 Web 控制台issue #62
// - 未找到前端目录时退化为纯 API 服务,统一返回结构化 JSON 404。
apiNotFound := func(c *gin.Context) {
response.Error(c, apperror.New(stdhttp.StatusNotFound, "NOT_FOUND", "接口不存在", errors.New("route not found")))
})
}
if webRoot := resolveWebRoot(deps.Config.Server.WebRoot); webRoot != "" {
if deps.Logger != nil {
deps.Logger.Info("serving web frontend", zap.String("web_root", webRoot))
}
engine.NoRoute(spaFileServer(webRoot, apiNotFound))
} else {
if deps.Logger != nil {
deps.Logger.Warn("web frontend directory not found; serving API only",
zap.String("hint", "set server.web_root in config, or place the built frontend at ./web"))
}
engine.NoRoute(apiNotFound)
}
return engine
}

View File

@@ -0,0 +1,86 @@
package http
import (
stdhttp "net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
// resolveWebRoot 返回前端静态资源目录。优先使用显式配置的路径,
// 否则按部署惯例依次探测常见位置,返回首个包含 index.html 的目录。
// 返回空字符串表示未找到前端产物,此时后端退化为纯 API 服务。
func resolveWebRoot(configured string) string {
candidates := []string{
configured,
"./web/dist", // 源码树根目录构建产物(优先于 ./web避免命中前端源码模板
"./web", // systemdWorkingDirectory=/opt/backupx → /opt/backupx/web容器 WORKDIR=/app → /app/web
"../web/dist", // 从 server/ 目录运行make dev-server
"/opt/backupx/web",
"/app/web",
}
for _, dir := range candidates {
if strings.TrimSpace(dir) == "" {
continue
}
if hasIndexHTML(dir) {
if abs, err := filepath.Abs(dir); err == nil {
return abs
}
return dir
}
}
return ""
}
func hasIndexHTML(dir string) bool {
info, err := os.Stat(filepath.Join(dir, "index.html"))
return err == nil && !info.IsDir()
}
// isReservedBackendPath 判断请求是否命中后端保留前缀API、探针、安装脚本
// 这些路径即使未匹配到具体路由,也应返回结构化 JSON 404而不是回退到
// 前端 index.html —— 否则反向代理/安装脚本会把 HTML 当成接口响应(参考 issue #46
func isReservedBackendPath(p string) bool {
switch p {
case "/health", "/ready", "/metrics", "/api", "/install":
return true
}
return strings.HasPrefix(p, "/api/") || strings.HasPrefix(p, "/install/")
}
// spaFileServer 构造 SPA 静态资源处理器,用作 gin 的 NoRoute 回退:
// - 后端保留前缀返回 apiNotFoundJSON 404
// - 其余 GET/HEAD 请求若在 webRoot 内命中真实文件则直接返回该文件;
// - 未命中文件的路径回退到 index.html交由前端路由处理history 模式刷新)。
func spaFileServer(webRoot string, apiNotFound gin.HandlerFunc) gin.HandlerFunc {
indexPath := filepath.Join(webRoot, "index.html")
return func(c *gin.Context) {
reqPath := c.Request.URL.Path
if isReservedBackendPath(reqPath) {
apiNotFound(c)
return
}
if c.Request.Method != stdhttp.MethodGet && c.Request.Method != stdhttp.MethodHead {
apiNotFound(c)
return
}
// 防目录穿越:以 webRoot 为根清理路径,确保最终目标仍位于 webRoot 内。
clean := filepath.Clean("/" + strings.TrimPrefix(reqPath, "/"))
target := filepath.Join(webRoot, clean)
if rel, err := filepath.Rel(webRoot, target); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
apiNotFound(c)
return
}
if info, err := os.Stat(target); err == nil && !info.IsDir() {
c.File(target)
return
}
// 前端 SPA 路由(/dashboard、/tasks 等)回退到 index.html。
c.File(indexPath)
}
}

View File

@@ -0,0 +1,175 @@
package http
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
)
func TestResolveWebRoot(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Run("explicit configured dir with index.html", func(t *testing.T) {
dir := t.TempDir()
writeIndex(t, dir)
got := resolveWebRoot(dir)
abs, _ := filepath.Abs(dir)
if got != abs {
t.Fatalf("resolveWebRoot(%q) = %q, want %q", dir, got, abs)
}
})
t.Run("configured dir without index.html falls through to none", func(t *testing.T) {
dir := t.TempDir() // no index.html, and no conventional ./web in CWD during test
if got := resolveWebRoot(dir); got != "" {
// 允许 CWD 恰好存在约定目录的环境,但临时目录本身不应被选中。
abs, _ := filepath.Abs(dir)
if got == abs {
t.Fatalf("expected dir without index.html to be skipped, got %q", got)
}
}
})
t.Run("empty configured uses auto-detect order", func(t *testing.T) {
// 切到一个仅含 ./web/dist/index.html 的临时工作目录,验证自动探测。
root := t.TempDir()
distDir := filepath.Join(root, "web", "dist")
if err := os.MkdirAll(distDir, 0o755); err != nil {
t.Fatal(err)
}
writeIndex(t, distDir)
restore := chdir(t, root)
defer restore()
// 以 chdir 之后的实际工作目录为基准计算期望值,避免 macOS 上
// /var → /private/var 符号链接导致字符串不一致。
wd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
want := filepath.Join(wd, "web", "dist")
got := resolveWebRoot("")
if got != want {
t.Fatalf("auto-detect = %q, want %q", got, want)
}
})
}
func TestIsReservedBackendPath(t *testing.T) {
reserved := []string{"/health", "/ready", "/metrics", "/api", "/install", "/api/", "/api/system/info", "/install/abc", "/install/abc/compose.yml"}
for _, p := range reserved {
if !isReservedBackendPath(p) {
t.Errorf("isReservedBackendPath(%q) = false, want true", p)
}
}
notReserved := []string{"/", "/dashboard", "/assets/app.js", "/installer", "/apidocs", "/favicon.ico"}
for _, p := range notReserved {
if isReservedBackendPath(p) {
t.Errorf("isReservedBackendPath(%q) = true, want false", p)
}
}
}
func TestSpaFileServer(t *testing.T) {
gin.SetMode(gin.TestMode)
webRoot := t.TempDir()
writeIndexContent(t, webRoot, "<!doctype html><title>BackupX</title>")
assetsDir := filepath.Join(webRoot, "assets")
if err := os.MkdirAll(assetsDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(assetsDir, "app.js"), []byte("console.log(1)"), 0o644); err != nil {
t.Fatal(err)
}
apiNotFoundHit := false
apiNotFound := func(c *gin.Context) {
apiNotFoundHit = true
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND"})
}
engine := gin.New()
engine.NoRoute(spaFileServer(webRoot, apiNotFound))
cases := []struct {
name string
method string
path string
wantStatus int
wantBody string // 子串;为空表示不校验
wantAPI404 bool
}{
{name: "root serves index", method: http.MethodGet, path: "/", wantStatus: 200, wantBody: "BackupX"},
{name: "spa route falls back to index", method: http.MethodGet, path: "/dashboard", wantStatus: 200, wantBody: "BackupX"},
{name: "real asset served", method: http.MethodGet, path: "/assets/app.js", wantStatus: 200, wantBody: "console.log"},
{name: "api path returns json 404", method: http.MethodGet, path: "/api/garbage", wantStatus: 404, wantAPI404: true},
{name: "health returns json 404 via reserved", method: http.MethodGet, path: "/health", wantStatus: 404, wantAPI404: true},
{name: "non-GET on spa path is api 404", method: http.MethodPost, path: "/dashboard", wantStatus: 404, wantAPI404: true},
{name: "directory falls back to index", method: http.MethodGet, path: "/assets/", wantStatus: 200, wantBody: "BackupX"},
// 含 ".." 的请求路径被 net/http 在文件服务层直接拒绝400 invalid URL path
// 绝不会泄露 webRoot 之外的文件;这是在 filepath.Rel 校验之上的纵深防御。
{name: "traversal rejected, never serves passwd", method: http.MethodGet, path: "/../../etc/passwd", wantStatus: 400, wantBody: "invalid URL path"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
apiNotFoundHit = false
req := httptest.NewRequest(tc.method, tc.path, nil)
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
if rec.Code != tc.wantStatus {
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, tc.wantStatus, rec.Body.String())
}
if tc.wantBody != "" && !contains(rec.Body.String(), tc.wantBody) {
t.Fatalf("body %q does not contain %q", rec.Body.String(), tc.wantBody)
}
if tc.wantAPI404 != apiNotFoundHit {
t.Fatalf("apiNotFoundHit = %v, want %v", apiNotFoundHit, tc.wantAPI404)
}
})
}
}
func writeIndex(t *testing.T, dir string) {
t.Helper()
writeIndexContent(t, dir, "<!doctype html><title>BackupX</title>")
}
func writeIndexContent(t *testing.T, dir, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func chdir(t *testing.T, dir string) func() {
t.Helper()
orig, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
return func() { _ = os.Chdir(orig) }
}
func contains(s, sub string) bool {
return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0)
}
func indexOf(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}

View File

@@ -199,7 +199,7 @@ func (h *StorageTargetHandler) GoogleDriveProfile(c *gin.Context) {
func parseUintParam(c *gin.Context, key string) (uint, bool) {
value := strings.TrimSpace(c.Param(key))
parsed, err := strconv.ParseUint(value, 10, 64)
parsed, err := strconv.ParseUint(value, 10, 0)
if err != nil {
response.Error(c, apperror.BadRequest("INVALID_ID", fmt.Sprintf("参数 %s 不合法", key), err))
return 0, false

View File

@@ -1,6 +1,8 @@
package installscript
import (
"os"
"path/filepath"
"strings"
"testing"
@@ -85,11 +87,38 @@ func TestRenderScriptDocker(t *testing.T) {
if !strings.Contains(got, "awuqing/backupx:${AGENT_VERSION}") {
t.Errorf("docker script missing image tag reference:\n%s", got)
}
if !strings.Contains(got, `"awuqing/backupx:${AGENT_VERSION}" agent`) {
t.Errorf("docker script must start image in agent mode:\n%s", got)
}
if !strings.Contains(got, `-e "BACKUPX_AGENT_TEMP_DIR=/var/lib/backupx-agent/tmp"`) {
t.Errorf("docker script missing temp dir env:\n%s", got)
}
if !strings.Contains(got, `docker logs --tail=100 backupx-agent`) {
t.Errorf("docker script missing diagnostic log command:\n%s", got)
}
if !strings.Contains(got, `grep -q '"status":"online"'`) {
t.Errorf("docker script missing online probe:\n%s", got)
}
if strings.Contains(got, "systemctl daemon-reload") {
t.Errorf("docker script should not reference systemctl:\n%s", got)
}
}
func TestDockerEntrypointForwardsAgentSubcommand(t *testing.T) {
entrypointPath := filepath.Join("..", "..", "..", "deploy", "docker", "entrypoint.sh")
got, err := os.ReadFile(entrypointPath)
if err != nil {
t.Fatalf("read docker entrypoint: %v", err)
}
script := string(got)
if !strings.Contains(script, `"${1:-}" = "agent"`) {
t.Fatalf("entrypoint must detect the agent subcommand before starting server:\n%s", script)
}
if !strings.Contains(script, `exec /app/bin/backupx "$@"`) {
t.Fatalf("entrypoint must exec backupx with forwarded args:\n%s", script)
}
}
func TestRenderComposeYaml(t *testing.T) {
ctx := testCtx
ctx.Mode = model.InstallModeDocker
@@ -100,9 +129,15 @@ func TestRenderComposeYaml(t *testing.T) {
if !strings.Contains(got, "image: awuqing/backupx:v1.7.0") {
t.Errorf("compose missing image:\n%s", got)
}
if !strings.Contains(got, `command: ["agent"]`) {
t.Errorf("compose must start image in agent mode:\n%s", got)
}
if !strings.Contains(got, `BACKUPX_AGENT_TOKEN: "deadbeefcafebabe0123456789abcdef0123456789abcdef0123456789abcdef"`) {
t.Errorf("compose missing token env:\n%s", got)
}
if !strings.Contains(got, `BACKUPX_AGENT_TEMP_DIR: "/var/lib/backupx-agent/tmp"`) {
t.Errorf("compose missing temp dir env:\n%s", got)
}
if !strings.Contains(got, "/var/lib/backupx-agent:/var/lib/backupx-agent") {
t.Errorf("compose missing agent data volume:\n%s", got)
}

View File

@@ -9,5 +9,6 @@ services:
environment:
BACKUPX_AGENT_MASTER: "{{.MasterURL}}"
BACKUPX_AGENT_TOKEN: "{{.AgentToken}}"
BACKUPX_AGENT_TEMP_DIR: "/var/lib/backupx-agent/tmp"
volumes:
- /var/lib/backupx-agent:/var/lib/backupx-agent

View File

@@ -70,6 +70,8 @@ Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}"
ExecStart=${INSTALL_PREFIX}/backupx agent --temp-dir /var/lib/backupx-agent/tmp
Restart=on-failure
RestartSec=10s
# Agent 需以 root 运行以读取任意源数据;与单机服务端保持一致的资源/句柄上限。
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
@@ -109,7 +111,20 @@ docker rm -f backupx-agent >/dev/null 2>&1 || true
docker run -d --name backupx-agent --restart=unless-stopped \
-e "BACKUPX_AGENT_MASTER=${MASTER_URL}" \
-e "BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}" \
-e "BACKUPX_AGENT_TEMP_DIR=/var/lib/backupx-agent/tmp" \
-v /var/lib/backupx-agent:/var/lib/backupx-agent \
"awuqing/backupx:${AGENT_VERSION}" agent
echo "✓ 容器已启动"
echo "✓ 容器已启动,等待节点上线"
for i in $(seq 1 15); do
sleep 2
if curl -fsSL -H "X-Agent-Token: ${AGENT_TOKEN}" "${MASTER_URL}/api/v1/agent/self" 2>/dev/null \
| grep -q '"status":"online"'; then
echo "✓ 节点已上线"
exit 0
fi
done
echo "⚠ 30s 内未收到上线心跳,请检查容器状态、网络与 Master URL。"
echo "排查命令docker ps -a --filter name=backupx-agent"
echo "排查命令docker logs --tail=100 backupx-agent"
exit 2
{{end}}

View File

@@ -13,16 +13,18 @@ type SampleSource interface {
ListStorageTargets(ctx context.Context) ([]model.StorageTarget, error)
StorageUsage(ctx context.Context) ([]repository.BackupStorageUsageItem, error)
ListNodes(ctx context.Context) ([]model.Node, error)
AgentQueueSummaries(ctx context.Context) (map[uint]repository.AgentCommandQueueSummary, error)
CountSLABreach(ctx context.Context) (int, error)
}
// repoSource 把 repository 适配到 SampleSource。
type repoSource struct {
targets repository.StorageTargetRepository
records repository.BackupRecordRepository
nodes repository.NodeRepository
tasks repository.BackupTaskRepository
now func() time.Time
targets repository.StorageTargetRepository
records repository.BackupRecordRepository
nodes repository.NodeRepository
tasks repository.BackupTaskRepository
commands repository.AgentCommandRepository
now func() time.Time
}
// NewRepoSource 用仓储实例构造 SampleSource。
@@ -31,13 +33,15 @@ func NewRepoSource(
records repository.BackupRecordRepository,
nodes repository.NodeRepository,
tasks repository.BackupTaskRepository,
commands repository.AgentCommandRepository,
) SampleSource {
return &repoSource{
targets: targets,
records: records,
nodes: nodes,
tasks: tasks,
now: func() time.Time { return time.Now().UTC() },
targets: targets,
records: records,
nodes: nodes,
tasks: tasks,
commands: commands,
now: func() time.Time { return time.Now().UTC() },
}
}
@@ -53,6 +57,13 @@ func (s *repoSource) ListNodes(ctx context.Context) ([]model.Node, error) {
return s.nodes.List(ctx)
}
func (s *repoSource) AgentQueueSummaries(ctx context.Context) (map[uint]repository.AgentCommandQueueSummary, error) {
if s.commands == nil {
return nil, nil
}
return s.commands.NodeQueueSummaries(ctx)
}
// CountSLABreach 统计当前违反 RPO 的任务:
// - 任务启用且配置了 SLAHoursRPO > 0
// - 最近一次成功备份距今超出 SLA 时间窗,或从未成功过
@@ -136,7 +147,9 @@ func (c *Collector) collect(ctx context.Context) {
}
// 节点在线状态role 约定为 master / agent
if nodes, err := c.source.ListNodes(ctx); err == nil {
queueByNode, _ := c.source.AgentQueueSummaries(ctx)
c.metrics.ResetNodeOnline()
c.metrics.ResetAgentQueue()
for i := range nodes {
n := &nodes[i]
role := "agent"
@@ -144,6 +157,8 @@ func (c *Collector) collect(ctx context.Context) {
role = "master"
}
c.metrics.SetNodeOnline(n.Name, role, n.Status == model.NodeStatusOnline)
queue := queueByNode[n.ID]
c.metrics.SetAgentQueue(n.Name, role, queue.Depth, queue.Running, queue.Timeouts)
}
}
if breach, err := c.source.CountSLABreach(ctx); err == nil {

View File

@@ -31,6 +31,12 @@ type Metrics struct {
StorageUsedBytes *prometheus.GaugeVec
// 节点在线状态labels: node_name, rolevalue: 0/1
NodeOnline *prometheus.GaugeVec
// Agent 命令队列深度labels: node_name, role
AgentCommandQueueDepth *prometheus.GaugeVec
// Agent 正在执行的长命令数labels: node_name, role
AgentCommandRunning *prometheus.GaugeVec
// Agent 命令超时累计数快照labels: node_name, role
AgentCommandTimeoutTotal *prometheus.GaugeVec
// 验证演练结果labels: status
VerifyRunTotal *prometheus.CounterVec
// 恢复操作结果labels: status
@@ -78,6 +84,18 @@ func New(version string) *Metrics {
Name: "backupx_node_online",
Help: "集群节点在线状态1 在线 / 0 离线)",
}, []string{"node_name", "role"}),
AgentCommandQueueDepth: prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: "backupx_agent_command_queue_depth",
Help: "Agent 当前 pending/dispatched 命令总数",
}, []string{"node_name", "role"}),
AgentCommandRunning: prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: "backupx_agent_command_running",
Help: "Agent 当前正在执行的长命令数",
}, []string{"node_name", "role"}),
AgentCommandTimeoutTotal: prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: "backupx_agent_command_timeout_total",
Help: "Agent 已超时命令数快照",
}, []string{"node_name", "role"}),
VerifyRunTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "backupx_verify_run_total",
Help: "备份验证演练执行总数",
@@ -106,6 +124,9 @@ func New(version string) *Metrics {
m.TaskRunningGauge,
m.StorageUsedBytes,
m.NodeOnline,
m.AgentCommandQueueDepth,
m.AgentCommandRunning,
m.AgentCommandTimeoutTotal,
m.VerifyRunTotal,
m.RestoreRunTotal,
m.ReplicationRunTotal,
@@ -208,6 +229,24 @@ func (m *Metrics) ResetNodeOnline() {
m.NodeOnline.Reset()
}
func (m *Metrics) SetAgentQueue(name, role string, depth, running, timeoutCount int) {
if m == nil {
return
}
m.AgentCommandQueueDepth.WithLabelValues(name, role).Set(float64(depth))
m.AgentCommandRunning.WithLabelValues(name, role).Set(float64(running))
m.AgentCommandTimeoutTotal.WithLabelValues(name, role).Set(float64(timeoutCount))
}
func (m *Metrics) ResetAgentQueue() {
if m == nil {
return
}
m.AgentCommandQueueDepth.Reset()
m.AgentCommandRunning.Reset()
m.AgentCommandTimeoutTotal.Reset()
}
// ResetStorageUsed 清空存储目标 gauge。
func (m *Metrics) ResetStorageUsed() {
if m == nil {

View File

@@ -41,9 +41,11 @@ func TestObserveTaskRun_NilReceiverIsSafe(t *testing.T) {
m.DecTaskRunning()
m.SetStorageUsed("a", "s3", 1)
m.SetNodeOnline("n1", "master", true)
m.SetAgentQueue("n1", "agent", 2, 1, 3)
m.SetSLABreach(3)
m.ResetNodeOnline()
m.ResetStorageUsed()
m.ResetAgentQueue()
// no panic -> pass
}
@@ -51,6 +53,7 @@ func TestHandler_ExposesBackupxMetrics(t *testing.T) {
m := New("0.0.0-test")
m.ObserveTaskRun("file", "success", 1.0, 2048)
m.SetNodeOnline("n1", "master", true)
m.SetAgentQueue("edge-a", "agent", 3, 1, 2)
m.SetSLABreach(1)
recorder := httptest.NewRecorder()
@@ -66,6 +69,9 @@ func TestHandler_ExposesBackupxMetrics(t *testing.T) {
"backupx_task_run_total",
"backupx_task_run_duration_seconds",
"backupx_node_online",
"backupx_agent_command_queue_depth",
"backupx_agent_command_running",
"backupx_agent_command_timeout_total",
"backupx_sla_breach_tasks",
"backupx_app_info",
} {

View File

@@ -8,28 +8,43 @@ const (
BackupRecordStatusFailed = "failed"
)
const (
// BackupKindFull 全量备份BackupKindDifferential 差异备份(仅含自基线全量以来的变更)。
BackupKindFull = "full"
BackupKindDifferential = "differential"
)
type BackupRecord struct {
ID uint `gorm:"primaryKey" json:"id"`
TaskID uint `gorm:"column:task_id;index;not null" json:"taskId"`
Task BackupTask `json:"task,omitempty"`
StorageTargetID uint `gorm:"column:storage_target_id;index;not null" json:"storageTargetId"`
StorageTarget StorageTarget `json:"storageTarget,omitempty"`
ID uint `gorm:"primaryKey" json:"id"`
TaskID uint `gorm:"column:task_id;index;not null" json:"taskId"`
Task BackupTask `json:"task,omitempty"`
StorageTargetID uint `gorm:"column:storage_target_id;index;not null" json:"storageTargetId"`
StorageTarget StorageTarget `json:"storageTarget,omitempty"`
// NodeID 执行该次备份的节点0 = 本机 Master。用于集群中识别 local_disk 类型
// 存储的归属节点,避免 Master 端试图跨节点访问远程 Agent 的本地存储。
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
Status string `gorm:"size:20;index;not null" json:"status"`
FileName string `gorm:"column:file_name;size:255" json:"fileName"`
FileSize int64 `gorm:"column:file_size;not null;default:0" json:"fileSize"`
Checksum string `gorm:"column:checksum;size:64" json:"checksum"`
StoragePath string `gorm:"column:storage_path;size:500" json:"storagePath"`
StorageUploadResults string `gorm:"column:storage_upload_results;type:text" json:"-"`
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"`
LogContent string `gorm:"column:log_content;type:text" json:"logContent"`
StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"`
CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
Status string `gorm:"size:20;index;not null" json:"status"`
FileName string `gorm:"column:file_name;size:255" json:"fileName"`
FileSize int64 `gorm:"column:file_size;not null;default:0" json:"fileSize"`
Checksum string `gorm:"column:checksum;size:64" json:"checksum"`
StoragePath string `gorm:"column:storage_path;size:500" json:"storagePath"`
StorageUploadResults string `gorm:"column:storage_upload_results;type:text" json:"-"`
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
// Locked 保留锁定(法律保留):为 true 时该备份不参与保留期/数量自动清理,
// 且禁止手动删除,直到显式解锁。用于保护合规快照、迁移前基线等关键备份。
Locked bool `gorm:"column:locked;not null;default:false;index" json:"locked"`
// BackupKind 备份类型full全量/ differential差异
BackupKind string `gorm:"column:backup_kind;size:16;not null;default:'full';index" json:"backupKind"`
// BaseRecordID 差异备份所基于的全量备份记录 ID全量记录为 0
BaseRecordID uint `gorm:"column:base_record_id;index;not null;default:0" json:"baseRecordId"`
// Manifest 全量备份的条目清单JSON供后续差异备份比对差异记录为空。
Manifest string `gorm:"column:manifest;type:text" json:"-"`
ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"`
LogContent string `gorm:"column:log_content;type:text" json:"logContent"`
StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"`
CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (BackupRecord) TableName() string {

View File

@@ -8,6 +8,13 @@ const (
BackupTaskTypeSQLite = "sqlite"
BackupTaskTypePostgreSQL = "postgresql"
BackupTaskTypeSAPHANA = "saphana"
BackupTaskTypeMongoDB = "mongodb"
)
const (
// BackupModeFull 全量模式默认BackupModeDifferential 差异模式(仅文件类型本机任务)。
BackupModeFull = "full"
BackupModeDifferential = "differential"
)
const (
@@ -18,42 +25,53 @@ const (
)
type BackupTask struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:100;uniqueIndex;not null" json:"name"`
Type string `gorm:"size:20;index;not null" json:"type"`
Enabled bool `gorm:"not null;default:true" json:"enabled"`
CronExpr string `gorm:"column:cron_expr;size:64" json:"cronExpr"`
SourcePath string `gorm:"column:source_path;size:500" json:"sourcePath"`
SourcePaths string `gorm:"column:source_paths;type:text" json:"sourcePaths"`
ExcludePatterns string `gorm:"column:exclude_patterns;type:text" json:"excludePatterns"`
DBHost string `gorm:"column:db_host;size:255" json:"dbHost"`
DBPort int `gorm:"column:db_port" json:"dbPort"`
DBUser string `gorm:"column:db_user;size:100" json:"dbUser"`
DBPasswordCiphertext string `gorm:"column:db_password_ciphertext;type:text" json:"-"`
DBName string `gorm:"column:db_name;size:255" json:"dbName"`
DBPath string `gorm:"column:db_path;size:500" json:"dbPath"`
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:100;uniqueIndex;not null" json:"name"`
Type string `gorm:"size:20;index;not null" json:"type"`
Enabled bool `gorm:"not null;default:true" json:"enabled"`
CronExpr string `gorm:"column:cron_expr;size:64" json:"cronExpr"`
SourcePath string `gorm:"column:source_path;size:500" json:"sourcePath"`
SourcePaths string `gorm:"column:source_paths;type:text" json:"sourcePaths"`
ExcludePatterns string `gorm:"column:exclude_patterns;type:text" json:"excludePatterns"`
DBHost string `gorm:"column:db_host;size:255" json:"dbHost"`
DBPort int `gorm:"column:db_port" json:"dbPort"`
DBUser string `gorm:"column:db_user;size:100" json:"dbUser"`
DBPasswordCiphertext string `gorm:"column:db_password_ciphertext;type:text" json:"-"`
DBName string `gorm:"column:db_name;size:255" json:"dbName"`
DBPath string `gorm:"column:db_path;size:500" json:"dbPath"`
// ExtraConfig 类型特有的扩展配置JSON如 SAP HANA 的 backupLevel / backupChannels 等
ExtraConfig string `gorm:"column:extra_config;type:text" json:"extraConfig"`
StorageTargetID uint `gorm:"column:storage_target_id;index;not null" json:"storageTargetId"` // deprecated: 保留兼容
StorageTarget StorageTarget `json:"storageTarget,omitempty"` // deprecated: 保留兼容
StorageTargets []StorageTarget `gorm:"many2many:backup_task_storage_targets" json:"storageTargets,omitempty"`
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
Node Node `json:"node,omitempty"`
ExtraConfig string `gorm:"column:extra_config;type:text" json:"extraConfig"`
StorageTargetID uint `gorm:"column:storage_target_id;index;not null" json:"storageTargetId"` // deprecated: 保留兼容
StorageTarget StorageTarget `json:"storageTarget,omitempty"` // deprecated: 保留兼容
StorageTargets []StorageTarget `gorm:"many2many:backup_task_storage_targets" json:"storageTargets,omitempty"`
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
Node Node `json:"node,omitempty"`
// NodePoolTag 节点池标签(可选)。非空且 NodeID=0 时,调度器会从 Node.Labels 包含该 tag
// 的在线节点中动态挑选一台执行(按运行中任务数最少原则),失败会 best-effort 切换到下一个候选。
// 典型场景NodePoolTag="db" 让 MySQL 备份任务在任意标有 "db" 的数据库节点执行。
NodePoolTag string `gorm:"column:node_pool_tag;size:64;index" json:"nodePoolTag"`
Tags string `gorm:"column:tags;size:500" json:"tags"`
RetentionDays int `gorm:"column:retention_days;not null;default:30" json:"retentionDays"`
Compression string `gorm:"size:10;not null;default:'gzip'" json:"compression"`
Encrypt bool `gorm:"not null;default:false" json:"encrypt"`
MaxBackups int `gorm:"column:max_backups;not null;default:10" json:"maxBackups"`
LastRunAt *time.Time `gorm:"column:last_run_at" json:"lastRunAt,omitempty"`
LastStatus string `gorm:"column:last_status;size:20;not null;default:'idle'" json:"lastStatus"`
NodePoolTag string `gorm:"column:node_pool_tag;size:64;index" json:"nodePoolTag"`
Tags string `gorm:"column:tags;size:500" json:"tags"`
RetentionDays int `gorm:"column:retention_days;not null;default:30" json:"retentionDays"`
Compression string `gorm:"size:10;not null;default:'gzip'" json:"compression"`
Encrypt bool `gorm:"not null;default:false" json:"encrypt"`
MaxBackups int `gorm:"column:max_backups;not null;default:10" json:"maxBackups"`
// BackupMode 备份模式full全量默认/ differential差异。差异仅支持本机文件任务。
BackupMode string `gorm:"column:backup_mode;size:16;not null;default:'full'" json:"backupMode"`
// DiffFullIntervalDays 差异模式下强制全量的间隔(天):最近全量超过该天数则本次自动改为全量,
// 限制差异链跨度与单个差异体积。默认 7。
DiffFullIntervalDays int `gorm:"column:diff_full_interval_days;not null;default:7" json:"diffFullIntervalDays"`
// GFS祖父-父-子)保留:分别保留最近 N 天 / M 周 / K 月 / Y 年的代表性备份(每周期保留最新一份)。
// 任一 > 0 即启用 GFS取代 RetentionDays/MaxBackups 简单策略;全为 0 时维持简单策略(向后兼容)。
KeepDaily int `gorm:"column:keep_daily;not null;default:0" json:"keepDaily"`
KeepWeekly int `gorm:"column:keep_weekly;not null;default:0" json:"keepWeekly"`
KeepMonthly int `gorm:"column:keep_monthly;not null;default:0" json:"keepMonthly"`
KeepYearly int `gorm:"column:keep_yearly;not null;default:0" json:"keepYearly"`
LastRunAt *time.Time `gorm:"column:last_run_at" json:"lastRunAt,omitempty"`
LastStatus string `gorm:"column:last_status;size:20;not null;default:'idle'" json:"lastStatus"`
// 验证(恢复演练)配置 — 定期自动校验备份可恢复性
VerifyEnabled bool `gorm:"column:verify_enabled;not null;default:false" json:"verifyEnabled"`
VerifyCronExpr string `gorm:"column:verify_cron_expr;size:64" json:"verifyCronExpr"`
VerifyMode string `gorm:"column:verify_mode;size:20;not null;default:'quick'" json:"verifyMode"`
VerifyEnabled bool `gorm:"column:verify_enabled;not null;default:false" json:"verifyEnabled"`
VerifyCronExpr string `gorm:"column:verify_cron_expr;size:64" json:"verifyCronExpr"`
VerifyMode string `gorm:"column:verify_mode;size:20;not null;default:'quick'" json:"verifyMode"`
// SLA 配置 — RPO期望最长未备份间隔与告警阈值
SLAHoursRPO int `gorm:"column:sla_hours_rpo;not null;default:0" json:"slaHoursRpo"`
AlertOnConsecutiveFails int `gorm:"column:alert_on_consecutive_fails;not null;default:1" json:"alertOnConsecutiveFails"`
@@ -68,9 +86,9 @@ type BackupTask struct {
// 语义:上游任务成功后自动触发本任务,形成工作流(如 DB 备份完成 → 归档压缩)。
// 调度器继续按本任务自己的 cron 触发,仅"自动触发"路径响应依赖完成事件。
// 循环依赖检查在 service 层完成,避免配置阶段即出错。
DependsOnTaskIDs string `gorm:"column:depends_on_task_ids;size:500" json:"dependsOnTaskIds"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DependsOnTaskIDs string `gorm:"column:depends_on_task_ids;size:500" json:"dependsOnTaskIds"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (BackupTask) TableName() string {

View File

@@ -10,22 +10,46 @@ const (
NodeStatusOffline = "offline"
)
// OfflineGracePeriod 节点心跳超时判定阈值:超过该时长未心跳的远程节点视为离线。
// Agent 默认 15s 心跳一次,预留 3 次重试空间。
const OfflineGracePeriod = 45 * time.Second
// EffectiveStatus 返回节点的「实时」在线状态。
//
// 存储字段 Status 由心跳置 online、由后台离线监控置 offline二者之间存在最长一个
// 监控周期的滞后窗口;期间 List/Get/调度器可能读到过期的 "online",进而把任务下发
// 给一台刚刚失联的节点。本方法直接以 LastSeen 推导:远程节点若超过 OfflineGracePeriod
// 未心跳即视为 offline消除该滞后导致的误判。本机节点恒以存储状态为准它就是 Master
// 自身,不依赖心跳)。
func (n *Node) EffectiveStatus(now time.Time) string {
if n == nil {
return NodeStatusOffline
}
if n.IsLocal {
return n.Status
}
if now.Sub(n.LastSeen) > OfflineGracePeriod {
return NodeStatusOffline
}
return n.Status
}
// Node represents a managed server node in the cluster.
// The default "local" node is auto-created for single-machine backward compatibility.
type Node struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:128;uniqueIndex;not null" json:"name"`
Hostname string `gorm:"size:255" json:"hostname"`
IPAddress string `gorm:"column:ip_address;size:64" json:"ipAddress"`
Token string `gorm:"size:128;uniqueIndex;not null" json:"-"`
Status string `gorm:"size:20;not null;default:'offline'" json:"status"`
IsLocal bool `gorm:"not null;default:false" json:"isLocal"`
OS string `gorm:"size:64" json:"os"`
Arch string `gorm:"size:32" json:"arch"`
AgentVer string `gorm:"column:agent_version;size:32" json:"agentVersion"`
LastSeen time.Time `gorm:"column:last_seen" json:"lastSeen"`
PrevToken string `gorm:"size:128;index" json:"-"`
PrevTokenExpires *time.Time `gorm:"column:prev_token_expires" json:"-"`
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:128;uniqueIndex;not null" json:"name"`
Hostname string `gorm:"size:255" json:"hostname"`
IPAddress string `gorm:"column:ip_address;size:64" json:"ipAddress"`
Token string `gorm:"size:128;uniqueIndex;not null" json:"-"`
Status string `gorm:"size:20;not null;default:'offline'" json:"status"`
IsLocal bool `gorm:"not null;default:false" json:"isLocal"`
OS string `gorm:"size:64" json:"os"`
Arch string `gorm:"size:32" json:"arch"`
AgentVer string `gorm:"column:agent_version;size:32" json:"agentVersion"`
LastSeen time.Time `gorm:"column:last_seen" json:"lastSeen"`
PrevToken string `gorm:"size:128;index" json:"-"`
PrevTokenExpires *time.Time `gorm:"column:prev_token_expires" json:"-"`
// MaxConcurrent 该节点允许的最大并发任务数0=不限制,沿用全局 cfg.Backup.MaxConcurrent
// 用于大集群中限制单节点资源占用:例如小内存 Agent 节点可配 1避免多个大备份同时跑挤爆。
MaxConcurrent int `gorm:"column:max_concurrent;not null;default:0" json:"maxConcurrent"`

View File

@@ -0,0 +1,58 @@
package model
import (
"testing"
"time"
)
func TestNodeEffectiveStatus(t *testing.T) {
now := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC)
cases := []struct {
name string
node *Node
want string
}{
{
name: "remote fresh heartbeat → stored online",
node: &Node{IsLocal: false, Status: NodeStatusOnline, LastSeen: now.Add(-10 * time.Second)},
want: NodeStatusOnline,
},
{
name: "remote stale heartbeat but stored online → derived offline",
node: &Node{IsLocal: false, Status: NodeStatusOnline, LastSeen: now.Add(-90 * time.Second)},
want: NodeStatusOffline,
},
{
name: "remote just past grace period → offline",
node: &Node{IsLocal: false, Status: NodeStatusOnline, LastSeen: now.Add(-(OfflineGracePeriod + time.Second))},
want: NodeStatusOffline,
},
{
name: "remote within grace period → online",
node: &Node{IsLocal: false, Status: NodeStatusOnline, LastSeen: now.Add(-(OfflineGracePeriod - time.Second))},
want: NodeStatusOnline,
},
{
name: "local node ignores LastSeen → stored online",
node: &Node{IsLocal: true, Status: NodeStatusOnline, LastSeen: now.Add(-24 * time.Hour)},
want: NodeStatusOnline,
},
{
name: "remote stored offline stays offline",
node: &Node{IsLocal: false, Status: NodeStatusOffline, LastSeen: now.Add(-5 * time.Second)},
want: NodeStatusOffline,
},
{
name: "nil node → offline",
node: nil,
want: NodeStatusOffline,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.node.EffectiveStatus(now); got != tc.want {
t.Fatalf("EffectiveStatus = %q, want %q", got, tc.want)
}
})
}
}

View File

@@ -11,21 +11,23 @@ const (
)
type RestoreRecord struct {
ID uint `gorm:"primaryKey" json:"id"`
BackupRecordID uint `gorm:"column:backup_record_id;index;not null" json:"backupRecordId"`
BackupRecord BackupRecord `json:"backupRecord,omitempty"`
TaskID uint `gorm:"column:task_id;index;not null" json:"taskId"`
Task BackupTask `json:"task,omitempty"`
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
Status string `gorm:"size:20;index;not null" json:"status"`
ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"`
LogContent string `gorm:"column:log_content;type:text" json:"logContent"`
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"`
CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"`
TriggeredBy string `gorm:"column:triggered_by;size:100" json:"triggeredBy"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID uint `gorm:"primaryKey" json:"id"`
BackupRecordID uint `gorm:"column:backup_record_id;index;not null" json:"backupRecordId"`
BackupRecord BackupRecord `json:"backupRecord,omitempty"`
TaskID uint `gorm:"column:task_id;index;not null" json:"taskId"`
Task BackupTask `json:"task,omitempty"`
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
// TargetPath 恢复到指定目录(仅文件类型本机恢复);空 = 恢复到原始源路径。
TargetPath string `gorm:"column:target_path;size:500" json:"targetPath"`
Status string `gorm:"size:20;index;not null" json:"status"`
ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"`
LogContent string `gorm:"column:log_content;type:text" json:"logContent"`
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"`
CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"`
TriggeredBy string `gorm:"column:triggered_by;size:100" json:"triggeredBy"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (RestoreRecord) TableName() string {

View File

@@ -37,13 +37,12 @@ func (n *EmailNotifier) Send(_ context.Context, config map[string]any, message M
from := strings.TrimSpace(asString(config["from"]))
toList := splitCommaValues(asString(config["to"]))
address := host + ":" + strconv.Itoa(port)
headers := []string{"From: " + from, "To: " + strings.Join(toList, ", "), "Subject: " + message.Title, "MIME-Version: 1.0", "Content-Type: text/plain; charset=UTF-8", "", message.Body}
var auth smtp.Auth
if username != "" {
auth = smtp.PlainAuth("", username, password, host)
}
rawMessage := []byte(strings.Join(headers, "\r\n"))
rawMessage := buildRawMessage(from, toList, message)
if port == 465 {
tlsConfig := &tls.Config{ServerName: host}
@@ -86,3 +85,31 @@ func (n *EmailNotifier) Send(_ context.Context, config map[string]any, message M
return smtp.SendMail(address, auth, from, toList, rawMessage)
}
// buildRawMessage 构造 RFC 5322 邮件原文。所有头部值都会剔除 CR/LF
// 防止 SMTP 头注入:备份任务名等用户可控内容会进入 Subject若包含
// 换行符可被用来注入额外头部(如 Bcc或伪造正文。正文本身不做处理
// 允许包含换行。
func buildRawMessage(from string, toList []string, message Message) []byte {
sanitizedTo := make([]string, 0, len(toList))
for _, addr := range toList {
if s := sanitizeHeaderValue(addr); s != "" {
sanitizedTo = append(sanitizedTo, s)
}
}
headers := []string{
"From: " + sanitizeHeaderValue(from),
"To: " + strings.Join(sanitizedTo, ", "),
"Subject: " + sanitizeHeaderValue(message.Title),
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=UTF-8",
"",
message.Body,
}
return []byte(strings.Join(headers, "\r\n"))
}
// sanitizeHeaderValue 移除头部值中的 CR 与 LF消除头注入向量。
func sanitizeHeaderValue(value string) string {
return strings.NewReplacer("\r", "", "\n", "").Replace(strings.TrimSpace(value))
}

View File

@@ -0,0 +1,55 @@
package notify
import (
"strings"
"testing"
)
// TestBuildRawMessageStripsHeaderInjection 验证用户可控内容(如备份任务名
// 进入 Subject中的 CR/LF 被剔除,无法注入额外头部或伪造正文。
func TestBuildRawMessageStripsHeaderInjection(t *testing.T) {
msg := Message{
Title: "备份失败\r\nBcc: attacker@evil.com\r\n\r\n伪造正文",
Body: "正文第一行\n正文第二行",
}
raw := string(buildRawMessage("sender@example.com", []string{"ops@example.com"}, msg))
parts := strings.SplitN(raw, "\r\n\r\n", 2)
if len(parts) != 2 {
t.Fatalf("缺少头部/正文分隔符,原文=%q", raw)
}
headerBlock, body := parts[0], parts[1]
// 头部区不得出现独立的注入头行。
for _, line := range strings.Split(headerBlock, "\r\n") {
if strings.HasPrefix(line, "Bcc:") {
t.Fatalf("检测到头注入:出现独立 Bcc 头行 %q", line)
}
}
// 头部区应恰好是固定的 5 行From/To/Subject/MIME-Version/Content-Type
if got := len(strings.Split(headerBlock, "\r\n")); got != 5 {
t.Fatalf("头部行数=%d期望 5headerBlock=%q", got, headerBlock)
}
// 正文必须保持原样(正文中的 \n 合法,不应被处理)。
if body != "正文第一行\n正文第二行" {
t.Fatalf("正文被篡改:%q", body)
}
// Subject 行必须包含原始标题文本CRLF 被移除后拼接在同一行)。
if !strings.Contains(headerBlock, "Subject: 备份失败Bcc: attacker@evil.com伪造正文") {
t.Fatalf("Subject 行不符合预期:%q", headerBlock)
}
}
func TestSanitizeHeaderValue(t *testing.T) {
cases := map[string]string{
" normal ": "normal",
"a\r\nb": "ab",
"x\ny\rz": "xyz",
"no-control-chars": "no-control-chars",
}
for in, want := range cases {
if got := sanitizeHeaderValue(in); got != want {
t.Errorf("sanitizeHeaderValue(%q) = %q, want %q", in, got, want)
}
}
}

View File

@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
@@ -21,9 +22,18 @@ func (n *WebhookNotifier) Type() string { return "webhook" }
func (n *WebhookNotifier) SensitiveFields() []string { return []string{"secret"} }
func (n *WebhookNotifier) Validate(config map[string]any) error {
if strings.TrimSpace(asString(config["url"])) == "" {
raw := strings.TrimSpace(asString(config["url"]))
if raw == "" {
return fmt.Errorf("webhook url is required")
}
parsed, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("webhook url is invalid: %w", err)
}
// 仅允许 http/https杜绝 file://、gopher:// 等可被用于 SSRF 的协议。
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return fmt.Errorf("webhook url must use http or https scheme")
}
return nil
}

View File

@@ -35,6 +35,28 @@ type AgentCommandRepository interface {
// ListPendingByNode 列出某节点下的所有 pending/dispatched 命令。
// 用于删除节点或节点离线时的清理。
ListPendingByNode(ctx context.Context, nodeID uint) ([]model.AgentCommand, error)
NodeQueueSummaries(ctx context.Context) (map[uint]AgentCommandQueueSummary, error)
}
type AgentCommandQueueSummary struct {
NodeID uint `json:"nodeId"`
Pending int `json:"pending"`
Dispatched int `json:"dispatched"`
Running int `json:"running"`
Depth int `json:"depth"`
Timeouts int `json:"timeouts"`
LastError string `json:"lastError,omitempty"`
OldestActiveAt *time.Time `json:"oldestActiveAt,omitempty"`
}
type agentCommandTimeoutCount struct {
NodeID uint
Count int
}
type agentCommandLastError struct {
NodeID uint
ErrorMessage string
}
type GormAgentCommandRepository struct {
@@ -186,3 +208,114 @@ func (r *GormAgentCommandRepository) ListPendingByNode(ctx context.Context, node
}
return items, nil
}
func (r *GormAgentCommandRepository) NodeQueueSummaries(ctx context.Context) (map[uint]AgentCommandQueueSummary, error) {
summaries, err := r.activeQueueSummaries(ctx)
if err != nil {
return nil, err
}
if err := r.applyTerminalQueueStats(ctx, summaries); err != nil {
return nil, err
}
return summaries, nil
}
func (r *GormAgentCommandRepository) activeQueueSummaries(ctx context.Context) (map[uint]AgentCommandQueueSummary, error) {
var items []model.AgentCommand
if err := r.db.WithContext(ctx).
Where("status IN ?", []string{
model.AgentCommandStatusPending,
model.AgentCommandStatusDispatched,
}).
Order("node_id asc, id asc").
Find(&items).Error; err != nil {
return nil, err
}
summaries := make(map[uint]AgentCommandQueueSummary)
for i := range items {
cmd := &items[i]
summary := summaries[cmd.NodeID]
summary.NodeID = cmd.NodeID
switch cmd.Status {
case model.AgentCommandStatusPending:
summary.Pending++
summary.Depth++
summary.OldestActiveAt = oldestTime(summary.OldestActiveAt, &cmd.CreatedAt)
case model.AgentCommandStatusDispatched:
summary.Dispatched++
summary.Depth++
if isLongRunningAgentCommand(cmd.Type) {
summary.Running++
}
summary.OldestActiveAt = oldestTime(summary.OldestActiveAt, cmd.DispatchedAt)
}
summaries[cmd.NodeID] = summary
}
return summaries, nil
}
func (r *GormAgentCommandRepository) applyTerminalQueueStats(ctx context.Context, summaries map[uint]AgentCommandQueueSummary) error {
var timeoutCounts []agentCommandTimeoutCount
if err := r.db.WithContext(ctx).
Model(&model.AgentCommand{}).
Select("node_id, COUNT(*) AS count").
Where("status = ?", model.AgentCommandStatusTimeout).
Group("node_id").
Scan(&timeoutCounts).Error; err != nil {
return err
}
for _, item := range timeoutCounts {
summary := summaries[item.NodeID]
summary.NodeID = item.NodeID
summary.Timeouts = item.Count
summaries[item.NodeID] = summary
}
terminalStatuses := []string{
model.AgentCommandStatusFailed,
model.AgentCommandStatusTimeout,
}
latestByNode := r.db.WithContext(ctx).
Model(&model.AgentCommand{}).
Select("node_id, MAX(COALESCE(completed_at, updated_at, created_at)) AS last_error_at").
Where("status IN ? AND error_message <> ''", terminalStatuses).
Group("node_id")
var lastErrors []agentCommandLastError
if err := r.db.WithContext(ctx).
Table("agent_commands AS cmd").
Select("cmd.node_id, cmd.error_message").
Joins("JOIN (?) latest ON latest.node_id = cmd.node_id AND latest.last_error_at = COALESCE(cmd.completed_at, cmd.updated_at, cmd.created_at)", latestByNode).
Where("cmd.status IN ? AND cmd.error_message <> ''", terminalStatuses).
Order("cmd.node_id asc, cmd.id desc").
Scan(&lastErrors).Error; err != nil {
return err
}
seenLastError := make(map[uint]struct{}, len(lastErrors))
for _, item := range lastErrors {
if _, ok := seenLastError[item.NodeID]; ok {
continue
}
summary := summaries[item.NodeID]
summary.NodeID = item.NodeID
summary.LastError = item.ErrorMessage
summaries[item.NodeID] = summary
seenLastError[item.NodeID] = struct{}{}
}
return nil
}
func oldestTime(current *time.Time, candidate *time.Time) *time.Time {
if candidate == nil {
return current
}
if current == nil || candidate.Before(*current) {
value := *candidate
return &value
}
return current
}
func isLongRunningAgentCommand(commandType string) bool {
return commandType == model.AgentCommandTypeRunTask || commandType == model.AgentCommandTypeRestoreRecord
}

View File

@@ -218,3 +218,44 @@ func TestAgentCommandRepository_ListStaleActiveIncludesPendingAndDispatched(t *t
t.Fatalf("unexpected stale active order/items: %#v", items)
}
}
func TestAgentCommandRepository_NodeQueueSummaries(t *testing.T) {
db := newTestDB(t)
repo := NewAgentCommandRepository(db)
ctx := context.Background()
old := time.Now().UTC().Add(-20 * time.Minute)
recent := time.Now().UTC().Add(-2 * time.Minute)
dispatchedAt := time.Now().UTC().Add(-5 * time.Minute)
completedAt := time.Now().UTC().Add(-1 * time.Minute)
commands := []*model.AgentCommand{
{NodeID: 1, Type: model.AgentCommandTypeRunTask, Status: model.AgentCommandStatusPending, CreatedAt: old},
{NodeID: 1, Type: model.AgentCommandTypeRestoreRecord, Status: model.AgentCommandStatusPending, CreatedAt: recent},
{NodeID: 1, Type: model.AgentCommandTypeRunTask, Status: model.AgentCommandStatusDispatched, DispatchedAt: &dispatchedAt},
{NodeID: 1, Type: model.AgentCommandTypeRunTask, Status: model.AgentCommandStatusFailed, ErrorMessage: "boom", CompletedAt: &completedAt},
{NodeID: 1, Type: model.AgentCommandTypeRunTask, Status: model.AgentCommandStatusTimeout, ErrorMessage: "late", CompletedAt: &recent},
{NodeID: 2, Type: model.AgentCommandTypeRunTask, Status: model.AgentCommandStatusPending, CreatedAt: old},
}
for _, cmd := range commands {
if err := repo.Create(ctx, cmd); err != nil {
t.Fatalf("Create returned error: %v", err)
}
}
summaries, err := repo.NodeQueueSummaries(ctx)
if err != nil {
t.Fatalf("NodeQueueSummaries returned error: %v", err)
}
nodeOne := summaries[1]
if nodeOne.Pending != 2 || nodeOne.Dispatched != 1 || nodeOne.Running != 1 || nodeOne.Depth != 3 {
t.Fatalf("unexpected node 1 summary: %#v", nodeOne)
}
if nodeOne.Timeouts != 1 || nodeOne.LastError != "boom" {
t.Fatalf("expected terminal timeout and latest error in summary, got %#v", nodeOne)
}
if nodeOne.OldestActiveAt == nil || !nodeOne.OldestActiveAt.Equal(old) {
t.Fatalf("expected oldest active at %s, got %#v", old, nodeOne.OldestActiveAt)
}
if nodeTwo := summaries[2]; nodeTwo.Pending != 1 || nodeTwo.Depth != 1 || nodeTwo.Timeouts != 0 || nodeTwo.LastError != "" {
t.Fatalf("unexpected node 2 summary: %#v", nodeTwo)
}
}

View File

@@ -29,6 +29,8 @@ type AuditLogRepository interface {
Create(ctx context.Context, log *model.AuditLog) error
List(ctx context.Context, opts AuditLogListOptions) (*AuditLogListResult, error)
ListAll(ctx context.Context, opts AuditLogListOptions) ([]model.AuditLog, error)
// DeleteBefore 删除 created_at 早于 cutoff 的审计日志,返回删除行数。用于保留期清理。
DeleteBefore(ctx context.Context, cutoff time.Time) (int64, error)
}
type gormAuditLogRepository struct {
@@ -43,6 +45,11 @@ func (r *gormAuditLogRepository) Create(_ context.Context, log *model.AuditLog)
return r.db.Create(log).Error
}
func (r *gormAuditLogRepository) DeleteBefore(ctx context.Context, cutoff time.Time) (int64, error) {
result := r.db.WithContext(ctx).Where("created_at < ?", cutoff).Delete(&model.AuditLog{})
return result.RowsAffected, result.Error
}
func (r *gormAuditLogRepository) List(_ context.Context, opts AuditLogListOptions) (*AuditLogListResult, error) {
query := r.buildQuery(opts)
var total int64

View File

@@ -0,0 +1,68 @@
package repository
import (
"context"
"testing"
"time"
"backupx/server/internal/model"
)
// 列表查询应省略 Manifest 列(避免拖出大 JSON而 FindByID 仍保留(内容浏览/差异基线需要)。
func TestListSuccessfulByTaskOmitsManifest(t *testing.T) {
ctx := context.Background()
repo := newBackupRecordTestRepository(t)
rec := &model.BackupRecord{TaskID: 1, StorageTargetID: 1, Status: "success", BackupKind: model.BackupKindFull, Manifest: `{"entries":[{"p":"x"}]}`, StartedAt: time.Now().UTC()}
if err := repo.Create(ctx, rec); err != nil {
t.Fatalf("create: %v", err)
}
items, err := repo.ListSuccessfulByTask(ctx, 1)
if err != nil {
t.Fatalf("ListSuccessfulByTask: %v", err)
}
if len(items) == 0 {
t.Fatal("expected at least one record")
}
for _, it := range items {
if it.Manifest != "" {
t.Fatalf("ListSuccessfulByTask must omit Manifest, got %q", it.Manifest)
}
}
full, err := repo.FindByID(ctx, rec.ID)
if err != nil || full == nil {
t.Fatalf("FindByID: %v / %v", full, err)
}
if full.Manifest == "" {
t.Fatal("FindByID must retain Manifest (browse/diff depend on it)")
}
}
// 仅统计「成功且依赖给定全量」的差异备份:失败的或依赖其他全量的不计入。
func TestCountDependentDifferentials(t *testing.T) {
ctx := context.Background()
repo := newBackupRecordTestRepository(t)
now := time.Now().UTC()
base := &model.BackupRecord{TaskID: 1, StorageTargetID: 1, Status: "success", BackupKind: model.BackupKindFull, StartedAt: now}
if err := repo.Create(ctx, base); err != nil {
t.Fatalf("create base: %v", err)
}
mk := func(status, kind string, baseID uint) {
if err := repo.Create(ctx, &model.BackupRecord{TaskID: 1, StorageTargetID: 1, Status: status, BackupKind: kind, BaseRecordID: baseID, StartedAt: now}); err != nil {
t.Fatalf("create dependent: %v", err)
}
}
mk(model.BackupRecordStatusSuccess, model.BackupKindDifferential, base.ID) // 计入
mk(model.BackupRecordStatusSuccess, model.BackupKindDifferential, base.ID) // 计入
mk(model.BackupRecordStatusFailed, model.BackupKindDifferential, base.ID) // 失败 → 不计
mk(model.BackupRecordStatusSuccess, model.BackupKindDifferential, 99999) // 依赖其他全量 → 不计
n, err := repo.CountDependentDifferentials(ctx, base.ID)
if err != nil {
t.Fatalf("CountDependentDifferentials: %v", err)
}
if n != 2 {
t.Fatalf("want 2 dependent successful differentials, got %d", n)
}
}

View File

@@ -40,6 +40,7 @@ type BackupRecordRepository interface {
ListRecent(context.Context, int) ([]model.BackupRecord, error)
ListByTask(context.Context, uint) ([]model.BackupRecord, error)
ListSuccessfulByTask(context.Context, uint) ([]model.BackupRecord, error)
CountDependentDifferentials(context.Context, uint) (int64, error)
Count(context.Context) (int64, error)
CountSince(context.Context, time.Time) (int64, error)
CountSuccessSince(context.Context, time.Time) (int64, error)
@@ -57,7 +58,8 @@ func NewBackupRecordRepository(db *gorm.DB) *GormBackupRecordRepository {
}
func (r *GormBackupRecordRepository) List(ctx context.Context, options BackupRecordListOptions) ([]model.BackupRecord, error) {
query := r.db.WithContext(ctx).Model(&model.BackupRecord{}).Preload("Task").Preload("Task.StorageTarget").Order("started_at desc")
// Omit("Manifest"):列表不需要可能很大的清单 JSON避免每行拖出该 TEXT 列。
query := r.db.WithContext(ctx).Model(&model.BackupRecord{}).Omit("Manifest").Preload("Task").Preload("Task.StorageTarget").Order("started_at desc")
if options.TaskID != nil {
query = query.Where("task_id = ?", *options.TaskID)
}
@@ -125,7 +127,7 @@ func (r *GormBackupRecordRepository) ListRecent(ctx context.Context, limit int)
limit = 10
}
var items []model.BackupRecord
if err := r.db.WithContext(ctx).Preload("Task").Preload("Task.StorageTarget").Order("started_at desc").Limit(limit).Find(&items).Error; err != nil {
if err := r.db.WithContext(ctx).Omit("Manifest").Preload("Task").Preload("Task.StorageTarget").Order("started_at desc").Limit(limit).Find(&items).Error; err != nil {
return nil, err
}
return items, nil
@@ -133,7 +135,7 @@ func (r *GormBackupRecordRepository) ListRecent(ctx context.Context, limit int)
func (r *GormBackupRecordRepository) ListByTask(ctx context.Context, taskID uint) ([]model.BackupRecord, error) {
var items []model.BackupRecord
if err := r.db.WithContext(ctx).Where("task_id = ?", taskID).Order("id desc").Find(&items).Error; err != nil {
if err := r.db.WithContext(ctx).Omit("Manifest").Where("task_id = ?", taskID).Order("id desc").Find(&items).Error; err != nil {
return nil, err
}
return items, nil
@@ -141,12 +143,21 @@ func (r *GormBackupRecordRepository) ListByTask(ctx context.Context, taskID uint
func (r *GormBackupRecordRepository) ListSuccessfulByTask(ctx context.Context, taskID uint) ([]model.BackupRecord, error) {
var items []model.BackupRecord
if err := r.db.WithContext(ctx).Where("task_id = ? AND status = ?", taskID, "success").Order("completed_at desc, id desc").Find(&items).Error; err != nil {
if err := r.db.WithContext(ctx).Omit("Manifest").Where("task_id = ? AND status = ?", taskID, "success").Order("completed_at desc, id desc").Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
// CountDependentDifferentials 统计依赖某全量记录(作为基线)的成功差异备份数量。
func (r *GormBackupRecordRepository) CountDependentDifferentials(ctx context.Context, baseID uint) (int64, error) {
var count int64
err := r.db.WithContext(ctx).Model(&model.BackupRecord{}).
Where("base_record_id = ? AND backup_kind = ? AND status = ?", baseID, model.BackupKindDifferential, model.BackupRecordStatusSuccess).
Count(&count).Error
return count, err
}
func (r *GormBackupRecordRepository) Count(ctx context.Context) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&model.BackupRecord{}).Count(&count).Error; err != nil {

View File

@@ -167,7 +167,8 @@ func (s *Service) syncTaskLocked(task *model.BackupTask) error {
// 集群感知:若任务绑定了离线的远程节点,跳过本轮触发避免堆积 failed 记录
if taskNodeID > 0 && s.nodes != nil {
node, err := s.nodes.FindByID(context.Background(), taskNodeID)
if err == nil && node != nil && !node.IsLocal && node.Status != model.NodeStatusOnline {
// 用实时推导的状态判定,避免后台监控刷新前把任务下发给刚失联的节点。
if err == nil && node != nil && !node.IsLocal && node.EffectiveStatus(time.Now().UTC()) != model.NodeStatusOnline {
if s.logger != nil {
s.logger.Warn("skip scheduled run: target node offline",
zap.Uint("task_id", taskID), zap.String("task_name", taskName),

View File

@@ -230,13 +230,15 @@ func (s *AgentService) ensureTaskSpecAccess(ctx context.Context, node *model.Nod
// AgentRecordUpdate Agent 上报备份记录的最终状态。
type AgentRecordUpdate struct {
Status string `json:"status"` // running | success | failed
FileName string `json:"fileName,omitempty"`
FileSize int64 `json:"fileSize,omitempty"`
Checksum string `json:"checksum,omitempty"`
StoragePath string `json:"storagePath,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
LogAppend string `json:"logAppend,omitempty"` // 增量日志,追加到 record.log_content
Status string `json:"status"` // running | success | failed
FileName string `json:"fileName,omitempty"`
FileSize int64 `json:"fileSize,omitempty"`
Checksum string `json:"checksum,omitempty"`
StoragePath string `json:"storagePath,omitempty"`
StorageTargetID uint `json:"storageTargetId,omitempty"`
StorageUploadResults []StorageUploadResultItem `json:"storageUploadResults,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
LogAppend string `json:"logAppend,omitempty"` // 增量日志,追加到 record.log_content
}
// UpdateRecord 更新备份记录的状态/日志。Agent 在执行过程中可多次调用。
@@ -273,6 +275,14 @@ func (s *AgentService) UpdateRecord(ctx context.Context, node *model.Node, recor
if update.StoragePath != "" {
record.StoragePath = update.StoragePath
}
if update.StorageTargetID > 0 {
record.StorageTargetID = update.StorageTargetID
}
if len(update.StorageUploadResults) > 0 {
if resultsJSON, marshalErr := json.Marshal(update.StorageUploadResults); marshalErr == nil {
record.StorageUploadResults = string(resultsJSON)
}
}
if update.ErrorMessage != "" {
record.ErrorMessage = update.ErrorMessage
}
@@ -294,7 +304,10 @@ func (s *AgentService) UpdateRecord(ctx context.Context, node *model.Node, recor
// 同步更新任务的 last_status
if update.Status == model.BackupRecordStatusSuccess || update.Status == model.BackupRecordStatusFailed {
task.LastStatus = update.Status
_ = s.taskRepo.Update(ctx, task)
task.LastRunAt = &record.StartedAt
if err := s.taskRepo.Update(ctx, task); err != nil {
return fmt.Errorf("update backup task summary: %w", err)
}
}
return nil
}

View File

@@ -2,7 +2,9 @@ package service
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
"time"
@@ -93,10 +95,15 @@ func TestAgentServicePooledTaskUsesRecordNodeForSpecAndRecordUpdates(t *testing.
}
if err := svc.UpdateRecord(ctx, owner, 1, AgentRecordUpdate{
Status: model.BackupRecordStatusSuccess,
FileName: "backup.tar.gz",
FileSize: 123,
StoragePath: "tasks/1/backup.tar.gz",
Status: model.BackupRecordStatusSuccess,
FileName: "backup.tar.gz",
FileSize: 123,
StoragePath: "tasks/1/backup.tar.gz",
StorageTargetID: 2,
StorageUploadResults: []StorageUploadResultItem{
{StorageTargetID: 1, StorageTargetName: "first", Status: "failed", Error: "boom"},
{StorageTargetID: 2, StorageTargetName: "second", Status: "success", StoragePath: "tasks/1/backup.tar.gz", FileSize: 123},
},
}); err != nil {
t.Fatalf("owner UpdateRecord returned error: %v", err)
}
@@ -107,11 +114,60 @@ func TestAgentServicePooledTaskUsesRecordNodeForSpecAndRecordUpdates(t *testing.
if updated.Status != model.BackupRecordStatusSuccess || updated.NodeID != owner.ID {
t.Fatalf("unexpected updated record: %#v", updated)
}
if updated.StorageTargetID != 2 {
t.Fatalf("expected successful storage target id 2, got %d", updated.StorageTargetID)
}
if !strings.Contains(updated.StorageUploadResults, `"storageTargetName":"second"`) {
t.Fatalf("expected upload results to be persisted, got %q", updated.StorageUploadResults)
}
if err := svc.UpdateRecord(ctx, other, 1, AgentRecordUpdate{LogAppend: "bad"}); err == nil {
t.Fatal("expected non-owner node to be forbidden from record update")
}
}
func TestAgentServiceUpdateRecordRefreshesTaskSummaryOnTerminalStatus(t *testing.T) {
for _, status := range []string{model.BackupRecordStatusSuccess, model.BackupRecordStatusFailed} {
t.Run(status, func(t *testing.T) {
svc, _, records, _, owner, _ := newAgentServicePoolTestHarness(t)
ctx := context.Background()
record, err := records.FindByID(ctx, 1)
if err != nil {
t.Fatalf("FindByID record returned error: %v", err)
}
if err := svc.UpdateRecord(ctx, owner, record.ID, AgentRecordUpdate{Status: status}); err != nil {
t.Fatalf("UpdateRecord returned error: %v", err)
}
task, err := svc.taskRepo.FindByID(ctx, record.TaskID)
if err != nil {
t.Fatalf("FindByID task returned error: %v", err)
}
if task.LastStatus != status {
t.Fatalf("expected task LastStatus %q, got %q", status, task.LastStatus)
}
if task.LastRunAt == nil || !task.LastRunAt.Equal(record.StartedAt) {
t.Fatalf("expected task LastRunAt to match record startedAt %s, got %#v", record.StartedAt, task.LastRunAt)
}
})
}
}
func TestAgentServiceUpdateRecordReturnsTaskSummaryUpdateError(t *testing.T) {
svc, _, _, _, owner, _ := newAgentServicePoolTestHarness(t)
ctx := context.Background()
expectedErr := errors.New("task update failed")
svc.taskRepo = &failingUpdateTaskRepo{
BackupTaskRepository: svc.taskRepo,
err: expectedErr,
}
err := svc.UpdateRecord(ctx, owner, 1, AgentRecordUpdate{Status: model.BackupRecordStatusSuccess})
if !errors.Is(err, expectedErr) {
t.Fatalf("expected task update error %v, got %v", expectedErr, err)
}
}
func TestAgentServiceProcessStaleCommandsFailsPendingRunTaskRecord(t *testing.T) {
svc, _, records, commands, owner, _ := newAgentServicePoolTestHarness(t)
ctx := context.Background()
@@ -587,3 +643,12 @@ func setBackupRecordUpdatedAt(db *gorm.DB, id uint, updatedAt time.Time) error {
func setRestoreRecordUpdatedAt(db *gorm.DB, id uint, updatedAt time.Time) error {
return db.Model(&model.RestoreRecord{}).Where("id = ?", id).UpdateColumn("updated_at", updatedAt).Error
}
type failingUpdateTaskRepo struct {
repository.BackupTaskRepository
err error
}
func (r *failingUpdateTaskRepo) Update(context.Context, *model.BackupTask) error {
return r.err
}

View File

@@ -0,0 +1,86 @@
package service
import (
"context"
"path/filepath"
"testing"
"time"
"backupx/server/internal/config"
"backupx/server/internal/database"
"backupx/server/internal/logger"
"backupx/server/internal/model"
"backupx/server/internal/repository"
)
func TestAuditRetention(t *testing.T) {
baseDir := t.TempDir()
log, err := logger.New(config.LogConfig{Level: "error"})
if err != nil {
t.Fatal(err)
}
db, err := database.Open(config.DatabaseConfig{Path: filepath.Join(baseDir, "a.db")}, log)
if err != nil {
t.Fatal(err)
}
auditRepo := repository.NewAuditLogRepository(db)
configRepo := repository.NewSystemConfigRepository(db)
svc := NewAuditService(auditRepo)
ctx := context.Background()
// seed 写一条审计日志并把 created_at 强制改到 daysAgo 天前。
seed := func(daysAgo int) {
rec := &model.AuditLog{Username: "t", Category: "test", Action: "seed"}
if err := auditRepo.Create(ctx, rec); err != nil {
t.Fatalf("create: %v", err)
}
ts := time.Now().UTC().AddDate(0, 0, -daysAgo)
if err := db.Model(&model.AuditLog{}).Where("id = ?", rec.ID).Update("created_at", ts).Error; err != nil {
t.Fatalf("backdate: %v", err)
}
}
count := func() int64 {
var n int64
db.Model(&model.AuditLog{}).Count(&n)
return n
}
seed(100)
seed(40)
seed(0)
if count() != 3 {
t.Fatalf("expected 3 seeded, got %d", count())
}
// days<=0 不清理。
if n, _ := svc.PurgeOlderThan(ctx, 0); n != 0 || count() != 3 {
t.Fatalf("days<=0 must not purge (n=%d count=%d)", n, count())
}
// 保留 50 天 → 删除 100 天前那条。
n, err := svc.PurgeOlderThan(ctx, 50)
if err != nil {
t.Fatalf("purge: %v", err)
}
if n != 1 || count() != 2 {
t.Fatalf("expected 1 purged / 2 remaining, got n=%d count=%d", n, count())
}
// 设置驱动retention=10 天 → 再删 40 天前那条。
if err := configRepo.Upsert(ctx, &model.SystemConfig{Key: SettingKeyAuditRetentionDays, Value: "10"}); err != nil {
t.Fatalf("upsert setting: %v", err)
}
svc.runRetentionOnce(ctx, configRepo)
if count() != 1 {
t.Fatalf("expected 1 remaining after retention=10, got %d", count())
}
// retention=0 → 永久保留,不再删除。
if err := configRepo.Upsert(ctx, &model.SystemConfig{Key: SettingKeyAuditRetentionDays, Value: "0"}); err != nil {
t.Fatalf("upsert setting: %v", err)
}
svc.runRetentionOnce(ctx, configRepo)
if count() != 1 {
t.Fatalf("retention=0 must keep all, got %d", count())
}
}

View File

@@ -10,6 +10,7 @@ import (
"fmt"
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"
@@ -51,6 +52,59 @@ func NewAuditService(repo repository.AuditLogRepository) *AuditService {
}
}
// PurgeOlderThan 删除早于 days 天前的审计日志返回删除条数。days<=0 时不清理。
func (s *AuditService) PurgeOlderThan(ctx context.Context, days int) (int64, error) {
if days <= 0 {
return 0, nil
}
cutoff := time.Now().UTC().AddDate(0, 0, -days)
return s.repo.DeleteBefore(ctx, cutoff)
}
// StartRetentionMonitor 启动后台审计保留期清理:按 interval 周期读取
// audit_retention_days 设置,>0 时删除超期审计日志。缺省/0 表示永久保留
// 向后兼容默认不删任何历史。ctx 取消后退出。
func (s *AuditService) StartRetentionMonitor(ctx context.Context, configs repository.SystemConfigRepository, interval time.Duration) {
if s == nil || configs == nil {
return
}
if interval <= 0 {
interval = 6 * time.Hour
}
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
s.runRetentionOnce(ctx, configs) // 启动后立即跑一次
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.runRetentionOnce(ctx, configs)
}
}
}()
}
func (s *AuditService) runRetentionOnce(ctx context.Context, configs repository.SystemConfigRepository) {
cfg, err := configs.GetByKey(ctx, SettingKeyAuditRetentionDays)
if err != nil || cfg == nil {
return
}
days, err := strconv.Atoi(strings.TrimSpace(cfg.Value))
if err != nil || days <= 0 {
return
}
deleted, err := s.PurgeOlderThan(ctx, days)
if err != nil {
log.Printf("[audit] retention purge failed: %v", err)
return
}
if deleted > 0 {
log.Printf("[audit] retention purge: deleted %d logs older than %d days", deleted, days)
}
}
// SetWebhook 动态配置审计事件转发 URL 与签名密钥。
// - url 为空字符串时禁用转发
// - secret 非空时对 payload 计算 HMAC-SHA256作为 X-BackupX-Signature header

View File

@@ -46,6 +46,10 @@ func (r *fakeAuditRepo) ListAll(context.Context, repository.AuditLogListOptions)
return nil, nil
}
func (r *fakeAuditRepo) DeleteBefore(context.Context, time.Time) (int64, error) {
return 0, nil
}
func TestAuditService_WebhookDeliversSignedPayload(t *testing.T) {
var hits atomic.Int32
var got struct {

View File

@@ -293,7 +293,7 @@ func (s *AuthService) verifyLoginMFA(ctx context.Context, user *model.User, inpu
}
func (s *AuthService) userBySubject(ctx context.Context, subject string) (*model.User, error) {
userID, err := strconv.ParseUint(subject, 10, 64)
userID, err := strconv.ParseUint(subject, 10, 0)
if err != nil {
return nil, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效用户身份", err)
}

View File

@@ -52,6 +52,11 @@ type StorageUploadResultItem struct {
Error string `json:"error,omitempty"`
}
const (
uploadMaxAttempts = 3
uploadRetryBackoff = 10 * time.Second
)
type DownloadedArtifact struct {
FileName string
Reader io.ReadCloser
@@ -96,6 +101,7 @@ type BackupExecutionService struct {
retries int // rclone 底层重试次数
bandwidthLimit string // rclone 带宽限制(全局默认,节点配置可覆盖)
metrics *metrics.Metrics
taskLocks sync.Map
}
// SetMetrics 注入 Prometheus 采集器。nil 时所有埋点退化为 no-op。
@@ -270,6 +276,21 @@ func (s *BackupExecutionService) DeleteRecord(ctx context.Context, recordID uint
if record == nil {
return apperror.New(404, "BACKUP_RECORD_NOT_FOUND", "备份记录不存在", fmt.Errorf("backup record %d not found", recordID))
}
if record.Locked {
return apperror.BadRequest("BACKUP_RECORD_LOCKED",
"该备份已保留锁定(法律保留),请先解锁再删除", nil)
}
// 差异链保护:禁止删除仍被差异备份依赖的全量,否则这些差异将无法恢复(与保留清理的保护一致)。
if record.BackupKind == model.BackupKindFull {
deps, depErr := s.records.CountDependentDifferentials(ctx, record.ID)
if depErr != nil {
return apperror.Internal("BACKUP_RECORD_DELETE_FAILED", "无法检查差异备份依赖", depErr)
}
if deps > 0 {
return apperror.BadRequest("BACKUP_RECORD_HAS_DEPENDENTS",
fmt.Sprintf("该全量备份仍有 %d 个差异备份依赖它,删除会导致这些差异无法恢复。请先删除相关差异备份或等待其过期。", deps), nil)
}
}
if remote, err := s.deleteRemoteLocalDiskObject(ctx, record); err != nil {
return err
} else if !remote && strings.TrimSpace(record.StoragePath) != "" {
@@ -326,28 +347,8 @@ func (s *BackupExecutionService) deleteRemoteLocalDiskObject(ctx context.Context
// provider 指向的是 Master 本机的同名路径,访问会静默取错文件或 404。明确拒绝
// 让用户知情,避免假成功。
func (s *BackupExecutionService) validateClusterAccessible(ctx context.Context, record *model.BackupRecord) error {
if record == nil || record.NodeID == 0 {
return nil
}
// 检查是否为远程节点
if s.nodeRepo == nil {
return nil
}
node, err := s.nodeRepo.FindByID(ctx, record.NodeID)
if err != nil || node == nil || node.IsLocal {
return nil
}
// 检查存储类型是否为 local_disk跨节点不可达
target, err := s.targets.FindByID(ctx, record.StorageTargetID)
if err != nil || target == nil {
return nil
}
if strings.EqualFold(target.Type, "local_disk") {
return apperror.BadRequest("BACKUP_RECORD_CROSS_NODE_LOCAL_DISK",
fmt.Sprintf("该备份位于节点 %s 的本地磁盘local_diskMaster 无法跨节点访问。请登录该节点或改用云存储后再操作。", node.Name),
nil)
}
return nil
return validateCrossNodeLocalDisk(ctx, s.nodeRepo, s.targets, record,
"BACKUP_RECORD_CROSS_NODE_LOCAL_DISK", "访问。请登录该节点或改用云存储后再操作")
}
func (s *BackupExecutionService) startTask(ctx context.Context, id uint, async bool) (*BackupRecordDetail, error) {
@@ -358,6 +359,11 @@ func (s *BackupExecutionService) startTask(ctx context.Context, id uint, async b
if task == nil {
return nil, apperror.New(404, "BACKUP_TASK_NOT_FOUND", "备份任务不存在", fmt.Errorf("backup task %d not found", id))
}
unlock := s.acquireTaskStartLock(task.ID)
defer unlock()
if err := s.ensureTaskNotRunning(ctx, task); err != nil {
return nil, err
}
// 维护窗口校验:手动执行同样尊重窗口,避免业务高峰期误触发。
if strings.TrimSpace(task.MaintenanceWindows) != "" {
windows := backup.ParseMaintenanceWindows(task.MaintenanceWindows)
@@ -427,6 +433,27 @@ func (s *BackupExecutionService) startTask(ctx context.Context, id uint, async b
return s.getRecordDetail(ctx, record.ID)
}
func (s *BackupExecutionService) acquireTaskStartLock(taskID uint) func() {
value, _ := s.taskLocks.LoadOrStore(taskID, &sync.Mutex{})
mu := value.(*sync.Mutex)
mu.Lock()
return mu.Unlock
}
func (s *BackupExecutionService) ensureTaskNotRunning(ctx context.Context, task *model.BackupTask) error {
taskID := task.ID
items, err := s.records.List(ctx, repository.BackupRecordListOptions{TaskID: &taskID, Status: model.BackupRecordStatusRunning})
if err != nil {
return apperror.Internal("BACKUP_RECORD_LIST_FAILED", "无法检查任务运行状态", err)
}
if len(items) == 0 {
return nil
}
return apperror.BadRequest("BACKUP_TASK_ALREADY_RUNNING",
fmt.Sprintf("任务「%s」正在运行记录 #%d请等待完成后再触发。", task.Name, items[0].ID),
nil)
}
// shouldNotify 按任务的告警策略决定是否发送本次通知。
// 成功结果:始终发送(方便用户确认备份状态)。
// 失败结果:仅当"最近 N 条记录(含本次)均为 failed"时发送N = AlertOnConsecutiveFails。
@@ -561,14 +588,46 @@ func (s *BackupExecutionService) isRemoteNode(ctx context.Context, nodeID uint)
// resolveRemoteNode 返回 NodeID 对应的远程节点指针,或 nil 表示本机执行。
// 相比 isRemoteNode它让调用方能读取节点状态在线/离线)做进一步判断。
func (s *BackupExecutionService) resolveRemoteNode(ctx context.Context, nodeID uint) *model.Node {
if s.nodeRepo == nil || s.agentDispatcher == nil || nodeID == 0 {
return nil
return resolveRemoteExecutionNode(ctx, s.nodeRepo, s.agentDispatcher != nil, nodeID)
}
// resolveDifferentialBase 为差异备份解析基线全量仅本机NodeID=0文件任务且 BackupMode=differential 时生效。
// 返回最近一次「成功、含清单、未超过 DiffFullIntervalDays」的全量记录 ID 及其清单;
// 无合适基线(首次备份 / 最近全量已过期 / 清单缺失)时 ok=false调用方回退为全量。
func (s *BackupExecutionService) resolveDifferentialBase(ctx context.Context, task *model.BackupTask) (uint, backup.Manifest, bool) {
if task.Type != model.BackupTaskTypeFile || task.NodeID != 0 || !strings.EqualFold(task.BackupMode, model.BackupModeDifferential) {
return 0, backup.Manifest{}, false
}
node, err := s.nodeRepo.FindByID(ctx, nodeID)
if err != nil || node == nil || node.IsLocal {
return nil
records, err := s.records.ListSuccessfulByTask(ctx, task.ID)
if err != nil {
return 0, backup.Manifest{}, false
}
return node
intervalDays := task.DiffFullIntervalDays
if intervalDays <= 0 {
intervalDays = 7
}
cutoff := time.Now().Add(-time.Duration(intervalDays) * 24 * time.Hour)
for i := range records {
rec := records[i]
if rec.BackupKind != model.BackupKindFull {
continue
}
// 最近的全量已超过强制全量间隔 → 触发新全量,限制差异链跨度与单个差异体积。
if rec.StartedAt.Before(cutoff) {
return 0, backup.Manifest{}, false
}
// 列表查询已省略 Manifest 列这里按需单独加载最近全量的清单FindByID 含 Manifest
full, ferr := s.records.FindByID(ctx, rec.ID)
if ferr != nil || full == nil || strings.TrimSpace(full.Manifest) == "" {
return 0, backup.Manifest{}, false
}
manifest, decErr := backup.DecodeManifest([]byte(full.Manifest))
if decErr != nil || len(manifest.Entries) == 0 {
return 0, backup.Manifest{}, false
}
return rec.ID, manifest, true
}
return 0, backup.Manifest{}, false
}
func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.BackupTask, recordID uint, startedAt time.Time) {
@@ -595,6 +654,10 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
var storagePath string
selectedStorageTargetID := task.StorageTargetID
var uploadResults []StorageUploadResultItem
// 差异备份链信息:实际类型(全量/差异)、基线全量 ID、全量清单 JSON。
backupKind := model.BackupKindFull
var baseRecordID uint
var manifestJSON string
completeRecord := func() {
if finalizeErr := s.finalizeRecord(ctx, task, recordID, startedAt, status, errMessage, logger.String(), fileName, fileSize, checksum, storagePath, selectedStorageTargetID); finalizeErr != nil {
logger.Errorf("写回备份记录失败:%v", finalizeErr)
@@ -610,6 +673,17 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
}
}
}
// 持久化差异链信息:全量记录其清单(供后续差异比对),差异记录其基线全量 ID。
if status == model.BackupRecordStatusSuccess && (backupKind != model.BackupKindFull || baseRecordID != 0 || manifestJSON != "") {
if record, findErr := s.records.FindByID(ctx, recordID); findErr == nil && record != nil {
record.BackupKind = backupKind
record.BaseRecordID = baseRecordID
record.Manifest = manifestJSON
if updErr := s.records.Update(ctx, record); updErr != nil {
logger.Warnf("写回差异链信息失败:%v", updErr)
}
}
}
if s.shouldNotify(ctx, task, status) {
if err := s.notifier.NotifyBackupResult(ctx, BackupExecutionNotification{Task: task, Record: &model.BackupRecord{ID: recordID, TaskID: task.ID, Status: status, FileName: fileName, FileSize: fileSize, StoragePath: storagePath, ErrorMessage: errMessage, StartedAt: startedAt}, Error: buildOptionalError(errMessage)}); err != nil {
logger.Warnf("发送备份通知失败:%v", err)
@@ -627,6 +701,13 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
logger.Errorf("构建任务运行时配置失败:%v", err)
return
}
// 差异备份:解析基线全量,命中则切换为差异模式(仅本机文件任务)。
if baseID, baseManifest, ok := s.resolveDifferentialBase(ctx, task); ok {
spec.Differential = true
spec.BaseManifest = baseManifest
baseRecordID = baseID
logger.Infof("差异备份模式:基于全量备份 #%d 仅打包变更", baseID)
}
runner, err := s.runnerRegistry.Runner(spec.Type)
if err != nil {
errMessage = err.Error()
@@ -640,6 +721,17 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
return
}
defer os.RemoveAll(result.TempDir)
// 依据运行器产出判定实际类型:产出清单 → 全量(记录清单供后续差异比对);否则为差异。
if result.Manifest != nil {
backupKind = model.BackupKindFull
if data, encErr := backup.EncodeManifest(*result.Manifest); encErr == nil {
manifestJSON = string(data)
} else {
logger.Warnf("备份清单序列化失败(不影响本次备份,但将禁用后续差异):%v", encErr)
}
} else {
backupKind = model.BackupKindDifferential
}
finalPath := result.ArtifactPath
if strings.EqualFold(task.Compression, "gzip") && !strings.HasSuffix(strings.ToLower(finalPath), ".gz") {
logger.Infof("开始压缩备份文件")
@@ -650,6 +742,15 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
return
}
finalPath = compressedPath
} else if strings.EqualFold(task.Compression, "zstd") && !strings.HasSuffix(strings.ToLower(finalPath), ".zst") {
logger.Infof("开始压缩备份文件zstd")
compressedPath, compressErr := compress.ZstdFile(finalPath)
if compressErr != nil {
errMessage = compressErr.Error()
logger.Errorf("压缩备份文件失败:%v", compressErr)
return
}
finalPath = compressedPath
}
if task.Encrypt {
logger.Infof("开始加密备份文件")
@@ -678,6 +779,11 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
logger.Errorf("没有关联的存储目标")
return
}
storageUsage, err := s.storageUsageSnapshot(ctx)
if err != nil {
logger.Warnf("读取存储目标用量失败,跳过本次软配额校验:%v", err)
storageUsage = map[uint]int64{}
}
// 并行上传到所有目标
uploadResults = make([]StorageUploadResultItem, len(targetIDs))
@@ -701,15 +807,7 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
}
// 软限额校验QuotaBytes > 0 时,已累计 + 本次 > 配额 → 拒绝上传
if target != nil && target.QuotaBytes > 0 {
currentUsed := int64(0)
if items, err := s.records.StorageUsage(ctx); err == nil {
for _, it := range items {
if it.StorageTargetID == targetID {
currentUsed = it.TotalSize
break
}
}
}
currentUsed := storageUsage[targetID]
if currentUsed+fileSize > target.QuotaBytes {
quotaMsg := fmt.Sprintf("超出存储目标 %s 的配额(%d + %d > %d", targetName, currentUsed, fileSize, target.QuotaBytes)
uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: quotaMsg}
@@ -718,15 +816,18 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
}
}
logger.Infof("开始上传备份到存储目标:%s", targetName)
// 上传级重试:最多 3 次,指数退避10s, 30s, 90s
maxAttempts := 3
// 上传级重试:最多 3 次,等待时间随 context 取消及时退出。
var lastUploadErr error
var hr *hashingReader
for attempt := 1; attempt <= maxAttempts; attempt++ {
for attempt := 1; attempt <= uploadMaxAttempts; attempt++ {
if attempt > 1 {
backoff := time.Duration(attempt*attempt) * 10 * time.Second
backoff := time.Duration(attempt-1) * uploadRetryBackoff
logger.Warnf("存储目标 %s 第 %d 次重试(等待 %v%v", targetName, attempt, backoff, lastUploadErr)
time.Sleep(backoff)
if waitErr := waitForUploadRetry(ctx, backoff); waitErr != nil {
uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: waitErr.Error()}
logger.Warnf("存储目标 %s 上传重试已取消:%v", targetName, waitErr)
return
}
}
artifact, openErr := os.Open(finalPath)
if openErr != nil {
@@ -756,7 +857,7 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
}
if lastUploadErr != nil {
uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: lastUploadErr.Error()}
logger.Warnf("存储目标 %s 上传失败(已重试 %d 次):%v", targetName, maxAttempts, lastUploadErr)
logger.Warnf("存储目标 %s 上传失败(已重试 %d 次):%v", targetName, uploadMaxAttempts, lastUploadErr)
return
}
// 完整性校验:对比实际传输字节数
@@ -881,6 +982,32 @@ func (s *BackupExecutionService) finalizeRecord(ctx context.Context, task *model
return s.tasks.Update(ctx, task)
}
func (s *BackupExecutionService) storageUsageSnapshot(ctx context.Context) (map[uint]int64, error) {
items, err := s.records.StorageUsage(ctx)
if err != nil {
return nil, fmt.Errorf("storage usage snapshot: %w", err)
}
usage := make(map[uint]int64, len(items))
for _, item := range items {
usage[item.StorageTargetID] = item.TotalSize
}
return usage, nil
}
func waitForUploadRetry(ctx context.Context, delay time.Duration) error {
if delay <= 0 {
return nil
}
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func (s *BackupExecutionService) resolveProvider(ctx context.Context, targetID uint) (storage.StorageProvider, error) {
return s.resolveProviderForNode(ctx, targetID, 0)
}
@@ -894,78 +1021,11 @@ func (s *BackupExecutionService) resolveProviderForNode(ctx context.Context, tar
LowLevelRetries: s.retries,
BandwidthLimit: s.effectiveBandwidth(ctx, nodeID),
})
target, err := s.targets.FindByID(ctx, targetID)
if err != nil {
return nil, apperror.Internal("BACKUP_STORAGE_TARGET_GET_FAILED", "无法获取存储目标详情", err)
}
if target == nil {
return nil, apperror.BadRequest("BACKUP_STORAGE_TARGET_INVALID", "关联的存储目标不存在", nil)
}
configMap := map[string]any{}
if err := s.cipher.DecryptJSON(target.ConfigCiphertext, &configMap); err != nil {
return nil, apperror.Internal("BACKUP_STORAGE_TARGET_DECRYPT_FAILED", "无法解密存储目标配置", err)
}
provider, err := s.storageRegistry.Create(ctx, target.Type, configMap)
if err != nil {
return nil, err
}
return provider, nil
return resolveStorageProvider(ctx, s.targets, s.storageRegistry, s.cipher, targetID)
}
func (s *BackupExecutionService) buildTaskSpec(task *model.BackupTask, startedAt time.Time) (backup.TaskSpec, error) {
excludePatterns := []string{}
if strings.TrimSpace(task.ExcludePatterns) != "" {
if err := json.Unmarshal([]byte(task.ExcludePatterns), &excludePatterns); err != nil {
return backup.TaskSpec{}, apperror.Internal("BACKUP_TASK_DECODE_FAILED", "无法解析排除规则", err)
}
}
password := ""
if strings.TrimSpace(task.DBPasswordCiphertext) != "" {
plain, err := s.cipher.Decrypt(task.DBPasswordCiphertext)
if err != nil {
return backup.TaskSpec{}, apperror.Internal("BACKUP_TASK_DECRYPT_FAILED", "无法解密数据库密码", err)
}
password = string(plain)
}
sourcePaths := []string{}
if strings.TrimSpace(task.SourcePaths) != "" {
if err := json.Unmarshal([]byte(task.SourcePaths), &sourcePaths); err != nil {
return backup.TaskSpec{}, apperror.Internal("BACKUP_TASK_DECODE_FAILED", "无法解析源路径配置", err)
}
}
dbSpec := backup.DatabaseSpec{
Host: task.DBHost,
Port: task.DBPort,
User: task.DBUser,
Password: password,
Names: []string{task.DBName},
Path: task.DBPath,
}
// 解析 ExtraConfig 填充类型特有字段(目前主要用于 SAP HANA
if strings.TrimSpace(task.ExtraConfig) != "" {
extra := map[string]any{}
if err := json.Unmarshal([]byte(task.ExtraConfig), &extra); err != nil {
return backup.TaskSpec{}, apperror.Internal("BACKUP_TASK_DECODE_FAILED", "无法解析扩展配置", err)
}
applyHANAExtraConfig(&dbSpec, extra)
}
return backup.TaskSpec{
ID: task.ID,
Name: task.Name,
Type: task.Type,
SourcePath: task.SourcePath,
SourcePaths: sourcePaths,
ExcludePatterns: excludePatterns,
StorageTargetID: task.StorageTargetID,
StorageTargetType: "",
Compression: task.Compression,
Encrypt: task.Encrypt,
RetentionDays: task.RetentionDays,
MaxBackups: task.MaxBackups,
StartedAt: startedAt,
TempDir: s.tempDir,
Database: dbSpec,
}, nil
return buildBackupTaskSpec(s.cipher, task, startedAt, s.tempDir)
}
// applyHANAExtraConfig 从 ExtraConfig map 中提取 SAP HANA 字段填入 DatabaseSpec。
@@ -1007,22 +1067,7 @@ func (s *BackupExecutionService) loadRecordProvider(ctx context.Context, recordI
}
func (s *BackupExecutionService) prepareArtifactForRestore(artifactPath string) (string, error) {
currentPath := artifactPath
if strings.HasSuffix(strings.ToLower(currentPath), ".enc") {
decryptedPath, err := backupcrypto.DecryptFile(s.cipher.Key(), currentPath)
if err != nil {
return "", err
}
currentPath = decryptedPath
}
if strings.HasSuffix(strings.ToLower(currentPath), ".gz") {
decompressedPath, err := compress.GunzipFile(currentPath)
if err != nil {
return "", err
}
currentPath = decompressedPath
}
return currentPath, nil
return prepareBackupArtifact(s.cipher, artifactPath, nil)
}
func (s *BackupExecutionService) getRecordDetail(ctx context.Context, recordID uint) (*BackupRecordDetail, error) {

View File

@@ -2,11 +2,13 @@ package service
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
@@ -40,9 +42,11 @@ func (f *testStorageFactory) New(_ context.Context, config map[string]any) (stor
}
type testStorageProvider struct {
name string
failUpload bool
objects map[string][]byte
name string
failUpload bool
blockUpload <-chan struct{}
onUpload func()
objects map[string][]byte
}
func (p *testStorageProvider) Type() storage.ProviderType { return "test_storage" }
@@ -50,6 +54,12 @@ func (p *testStorageProvider) TestConnection(context.Context) error {
return nil
}
func (p *testStorageProvider) Upload(_ context.Context, objectKey string, reader io.Reader, _ int64, _ map[string]string) error {
if p.blockUpload != nil {
<-p.blockUpload
}
if p.onUpload != nil {
p.onUpload()
}
if p.failUpload {
return fmt.Errorf("upload failed for %s", p.name)
}
@@ -193,6 +203,39 @@ func TestBackupExecutionServiceNodePoolSelectionDoesNotPersistTaskNodeID(t *test
}
}
func TestBackupExecutionServiceRejectsDuplicateRunningTask(t *testing.T) {
executionService, _, tasks, _, records, _, _ := newExecutionTestServices(t)
ctx := context.Background()
task, err := tasks.FindByID(ctx, 1)
if err != nil {
t.Fatalf("FindByID task returned error: %v", err)
}
startedAt := time.Now().UTC()
running := &model.BackupRecord{
TaskID: task.ID,
StorageTargetID: task.StorageTargetID,
NodeID: 0,
Status: model.BackupRecordStatusRunning,
StartedAt: startedAt,
}
if err := records.Create(ctx, running); err != nil {
t.Fatalf("Create running record returned error: %v", err)
}
_, err = executionService.RunTaskByIDSync(ctx, task.ID)
if err == nil || !strings.Contains(err.Error(), "正在运行") {
t.Fatalf("expected duplicate running task to be rejected, got %v", err)
}
items, err := records.List(ctx, repository.BackupRecordListOptions{Status: model.BackupRecordStatusRunning})
if err != nil {
t.Fatalf("List running records returned error: %v", err)
}
if len(items) != 1 || items[0].ID != running.ID {
t.Fatalf("expected only the original running record, got %#v", items)
}
}
func TestBackupExecutionServiceDeleteRecordDispatchesRemoteLocalDiskCleanup(t *testing.T) {
executionService, _, tasks, _, records, _, _ := newExecutionTestServices(t)
ctx := context.Background()
@@ -334,6 +377,155 @@ func TestBackupExecutionServiceRecordsFirstSuccessfulStorageTarget(t *testing.T)
}
}
func TestBackupExecutionServiceUploadRetryStopsWhenContextCancelled(t *testing.T) {
executionService, _, tasks, targets, records, _, _ := newExecutionTestServices(t)
ctx, cancel := context.WithCancel(context.Background())
var cancelOnce sync.Once
failing := &testStorageProvider{
name: "failing",
failUpload: true,
onUpload: func() {
cancelOnce.Do(cancel)
},
}
executionService.storageRegistry = storage.NewRegistry(&testStorageFactory{providers: map[string]*testStorageProvider{
"failing": failing,
}})
cipher := codec.NewConfigCipher("execution-secret")
failingConfig, err := cipher.EncryptJSON(map[string]any{"name": "failing"})
if err != nil {
t.Fatalf("EncryptJSON returned error: %v", err)
}
if err := targets.Update(ctx, &model.StorageTarget{
ID: 1,
Name: "local",
Type: "test_storage",
Enabled: true,
ConfigCiphertext: failingConfig,
ConfigVersion: 1,
LastTestStatus: "unknown",
}); err != nil {
t.Fatalf("Update target returned error: %v", err)
}
task, err := tasks.FindByID(ctx, 1)
if err != nil {
t.Fatalf("FindByID task returned error: %v", err)
}
startedAt := time.Now().UTC()
record := &model.BackupRecord{
TaskID: task.ID,
StorageTargetID: task.StorageTargetID,
Status: model.BackupRecordStatusRunning,
StartedAt: startedAt,
}
if err := records.Create(ctx, record); err != nil {
t.Fatalf("Create record returned error: %v", err)
}
done := make(chan struct{})
go func() {
executionService.executeTask(ctx, task, record.ID, startedAt)
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("expected cancelled upload retry to stop without waiting for backoff sleep")
}
}
func TestBackupExecutionServiceReadsStorageUsageOnceForMultiTargetQuotaChecks(t *testing.T) {
executionService, _, tasks, targets, records, _, _ := newExecutionTestServices(t)
ctx := context.Background()
first := &testStorageProvider{name: "first", objects: map[string][]byte{}}
second := &testStorageProvider{name: "second", objects: map[string][]byte{}}
executionService.storageRegistry = storage.NewRegistry(&testStorageFactory{providers: map[string]*testStorageProvider{
"first": first,
"second": second,
}})
cipher := codec.NewConfigCipher("execution-secret")
firstConfig, err := cipher.EncryptJSON(map[string]any{"name": "first"})
if err != nil {
t.Fatalf("EncryptJSON first returned error: %v", err)
}
secondConfig, err := cipher.EncryptJSON(map[string]any{"name": "second"})
if err != nil {
t.Fatalf("EncryptJSON second returned error: %v", err)
}
if err := targets.Update(ctx, &model.StorageTarget{ID: 1, Name: "local", Type: "test_storage", Enabled: true, ConfigCiphertext: firstConfig, ConfigVersion: 1, LastTestStatus: "unknown", QuotaBytes: 1 << 30}); err != nil {
t.Fatalf("Update first target returned error: %v", err)
}
if err := targets.Create(ctx, &model.StorageTarget{Name: "second", Type: "test_storage", Enabled: true, ConfigCiphertext: secondConfig, ConfigVersion: 1, LastTestStatus: "unknown", QuotaBytes: 1 << 30}); err != nil {
t.Fatalf("Create second target returned error: %v", err)
}
task, err := tasks.FindByID(ctx, 1)
if err != nil {
t.Fatalf("FindByID task returned error: %v", err)
}
task.StorageTargets = []model.StorageTarget{{ID: 1}, {ID: 2}}
if err := tasks.Update(ctx, task); err != nil {
t.Fatalf("Update task returned error: %v", err)
}
executionService.records = &storageUsageCountingRecordRepo{BackupRecordRepository: records}
detail, err := executionService.RunTaskByIDSync(ctx, task.ID)
if err != nil {
t.Fatalf("RunTaskByIDSync returned error: %v", err)
}
if detail.Status != model.BackupRecordStatusSuccess {
t.Fatalf("expected success, got %#v", detail)
}
countingRepo := executionService.records.(*storageUsageCountingRecordRepo)
if countingRepo.usageCalls != 1 {
t.Fatalf("expected StorageUsage to be called once for quota snapshot, got %d", countingRepo.usageCalls)
}
if len(first.objects) != 1 || len(second.objects) != 1 {
t.Fatalf("expected both targets to receive upload, got first=%d second=%d", len(first.objects), len(second.objects))
}
}
func TestBackupExecutionServiceContinuesWhenStorageUsageSnapshotFails(t *testing.T) {
executionService, _, _, targets, records, _, _ := newExecutionTestServices(t)
ctx := context.Background()
provider := &testStorageProvider{name: "primary", objects: map[string][]byte{}}
executionService.storageRegistry = storage.NewRegistry(&testStorageFactory{providers: map[string]*testStorageProvider{
"primary": provider,
}})
cipher := codec.NewConfigCipher("execution-secret")
configCiphertext, err := cipher.EncryptJSON(map[string]any{"name": "primary"})
if err != nil {
t.Fatalf("EncryptJSON returned error: %v", err)
}
if err := targets.Update(ctx, &model.StorageTarget{
ID: 1,
Name: "local",
Type: "test_storage",
Enabled: true,
ConfigCiphertext: configCiphertext,
ConfigVersion: 1,
LastTestStatus: "unknown",
QuotaBytes: 1 << 30,
}); err != nil {
t.Fatalf("Update target returned error: %v", err)
}
executionService.records = &storageUsageFailingRecordRepo{
BackupRecordRepository: records,
err: errStorageUsageFailed,
}
detail, err := executionService.RunTaskByIDSync(ctx, 1)
if err != nil {
t.Fatalf("RunTaskByIDSync returned error: %v", err)
}
if detail.Status != model.BackupRecordStatusSuccess {
t.Fatalf("expected success despite soft quota usage snapshot error, got %#v", detail)
}
if len(provider.objects) != 1 {
t.Fatalf("expected upload to proceed, got %d uploaded objects", len(provider.objects))
}
}
func TestBackupRecordServiceRestore(t *testing.T) {
executionService, recordService, _, _, _, sourceDir, _ := newExecutionTestServices(t)
detail, err := executionService.RunTaskByIDSync(context.Background(), 1)
@@ -354,3 +546,27 @@ func TestBackupRecordServiceRestore(t *testing.T) {
t.Fatalf("unexpected restored content: %s", string(content))
}
}
type storageUsageCountingRecordRepo struct {
repository.BackupRecordRepository
mu sync.Mutex
usageCalls int
}
func (r *storageUsageCountingRecordRepo) StorageUsage(ctx context.Context) ([]repository.BackupStorageUsageItem, error) {
r.mu.Lock()
r.usageCalls++
r.mu.Unlock()
return r.BackupRecordRepository.StorageUsage(ctx)
}
type storageUsageFailingRecordRepo struct {
repository.BackupRecordRepository
err error
}
func (r *storageUsageFailingRecordRepo) StorageUsage(context.Context) ([]repository.BackupStorageUsageItem, error) {
return nil, r.err
}
var errStorageUsageFailed = errors.New("storage usage failed")

View File

@@ -0,0 +1,104 @@
package service
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"backupx/server/internal/backup"
"backupx/server/internal/config"
"backupx/server/internal/database"
"backupx/server/internal/logger"
"backupx/server/internal/model"
"backupx/server/internal/repository"
"backupx/server/internal/storage"
"backupx/server/internal/storage/codec"
storageRclone "backupx/server/internal/storage/rclone"
)
func newLockTestHarness(t *testing.T) (*BackupRecordService, *BackupExecutionService) {
t.Helper()
baseDir := t.TempDir()
sourceDir := filepath.Join(baseDir, "data")
storeDir := filepath.Join(baseDir, "store")
if err := os.MkdirAll(sourceDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "f.txt"), []byte("lock-data"), 0o644); err != nil {
t.Fatal(err)
}
log, err := logger.New(config.LogConfig{Level: "error"})
if err != nil {
t.Fatal(err)
}
db, err := database.Open(config.DatabaseConfig{Path: filepath.Join(baseDir, "backupx.db")}, log)
if err != nil {
t.Fatal(err)
}
cipher := codec.NewConfigCipher("lock-secret")
targets := repository.NewStorageTargetRepository(db)
tasks := repository.NewBackupTaskRepository(db)
records := repository.NewBackupRecordRepository(db)
cfg, err := cipher.EncryptJSON(map[string]any{"basePath": storeDir})
if err != nil {
t.Fatal(err)
}
if err := targets.Create(context.Background(), &model.StorageTarget{Name: "s", Type: string(storage.ProviderTypeLocalDisk), Enabled: true, ConfigCiphertext: cfg, ConfigVersion: 1, LastTestStatus: "unknown"}); err != nil {
t.Fatal(err)
}
task := &model.BackupTask{Name: "lock-task", Type: "file", Enabled: true, SourcePath: sourceDir, StorageTargetID: 1, NodeID: 0, RetentionDays: 30, Compression: "gzip", MaxBackups: 10, LastStatus: "idle"}
if err := tasks.Create(context.Background(), task); err != nil {
t.Fatal(err)
}
logHub := backup.NewLogHub()
runnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewSQLiteRunner(), backup.NewMySQLRunner(nil), backup.NewPostgreSQLRunner(nil))
storageRegistry := storage.NewRegistry(storageRclone.NewLocalDiskFactory())
execution := NewBackupExecutionService(tasks, records, targets, storageRegistry, runnerRegistry, logHub, nil, cipher, nil, baseDir, 2, 10, "")
recordService := NewBackupRecordService(records, execution, logHub)
return recordService, execution
}
// TestBackupRecordLock_BlocksDeletion 验证保留锁定后手动删除被拒绝,解锁后可删除。
func TestBackupRecordLock_BlocksDeletion(t *testing.T) {
recordService, execution := newLockTestHarness(t)
ctx := context.Background()
bd, err := execution.RunTaskByIDSync(ctx, 1)
if err != nil {
t.Fatalf("RunTaskByIDSync: %v", err)
}
if bd.Status != "success" {
t.Fatalf("backup not success: %s", bd.Status)
}
// 锁定。
detail, err := recordService.SetLock(ctx, bd.ID, true)
if err != nil {
t.Fatalf("SetLock(true): %v", err)
}
if !detail.Locked {
t.Fatal("expected detail.Locked = true")
}
// 锁定状态下删除应被拒绝。
if err := execution.DeleteRecord(ctx, bd.ID); err == nil {
t.Fatal("expected delete of locked record to be rejected")
} else if !strings.Contains(err.Error(), "保留锁定") {
t.Fatalf("unexpected delete error: %v", err)
}
// 记录仍然存在。
if got, _ := recordService.Get(ctx, bd.ID); got == nil {
t.Fatal("locked record must still exist after rejected delete")
}
// 解锁后可删除。
if _, err := recordService.SetLock(ctx, bd.ID, false); err != nil {
t.Fatalf("SetLock(false): %v", err)
}
if err := execution.DeleteRecord(ctx, bd.ID); err != nil {
t.Fatalf("delete after unlock should succeed: %v", err)
}
}

View File

@@ -3,6 +3,7 @@ package service
import (
"context"
"encoding/json"
"sort"
"strings"
"time"
@@ -36,13 +37,15 @@ type BackupRecordSummary struct {
ErrorMessage string `json:"errorMessage"`
StartedAt time.Time `json:"startedAt"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
Locked bool `json:"locked"`
BackupKind string `json:"backupKind"`
}
type BackupRecordDetail struct {
BackupRecordSummary
LogContent string `json:"logContent"`
LogEvents []backup.LogEvent `json:"logEvents,omitempty"`
StorageUploadResults []StorageUploadResultItem `json:"storageUploadResults,omitempty"`
StorageUploadResults []StorageUploadResultItem `json:"storageUploadResults,omitempty"`
}
type BackupRecordService struct {
@@ -78,6 +81,64 @@ func (s *BackupRecordService) Get(ctx context.Context, id uint) (*BackupRecordDe
return toBackupRecordDetail(item, s.logHub), nil
}
// BackupContentEntry 描述备份内单个条目(文件或目录),用于内容浏览。
type BackupContentEntry struct {
Path string `json:"path"`
Size int64 `json:"size"`
IsDir bool `json:"isDir"`
}
// BackupRecordContents 是一次备份的内容清单视图。
type BackupRecordContents struct {
RecordID uint `json:"recordId"`
Total int `json:"total"`
Truncated bool `json:"truncated"`
BasedOnFull uint `json:"basedOnFull,omitempty"` // 差异记录时,清单取自该基线全量
Entries []BackupContentEntry `json:"entries"`
}
const backupContentsMaxEntries = 10000
// ListContents 返回某备份记录的文件清单(仅文件类型的新全量备份会记录清单)。
// 差异记录回退到其基线全量的清单,近似展示恢复后的目录结构。无清单时返回明确错误。
func (s *BackupRecordService) ListContents(ctx context.Context, id uint) (*BackupRecordContents, error) {
item, err := s.records.FindByID(ctx, id)
if err != nil {
return nil, apperror.Internal("BACKUP_RECORD_GET_FAILED", "无法获取备份记录", err)
}
if item == nil {
return nil, apperror.New(404, "BACKUP_RECORD_NOT_FOUND", "备份记录不存在", nil)
}
manifestJSON := item.Manifest
basedOnFull := uint(0)
if strings.TrimSpace(manifestJSON) == "" && item.BaseRecordID != 0 {
if base, baseErr := s.records.FindByID(ctx, item.BaseRecordID); baseErr == nil && base != nil {
manifestJSON = base.Manifest
basedOnFull = base.ID
}
}
if strings.TrimSpace(manifestJSON) == "" {
return nil, apperror.New(422, "BACKUP_CONTENTS_UNAVAILABLE", "该备份未记录文件清单(仅文件类型的新全量备份支持内容浏览),请重新执行一次全量备份后再试。", nil)
}
manifest, decErr := backup.DecodeManifest([]byte(manifestJSON))
if decErr != nil {
return nil, apperror.Internal("BACKUP_CONTENTS_DECODE_FAILED", "解析备份清单失败", decErr)
}
entries := manifest.Entries
sort.Slice(entries, func(i, j int) bool { return entries[i].Path < entries[j].Path })
total := len(entries)
truncated := false
if total > backupContentsMaxEntries {
entries = entries[:backupContentsMaxEntries]
truncated = true
}
result := &BackupRecordContents{RecordID: item.ID, Total: total, Truncated: truncated, BasedOnFull: basedOnFull, Entries: make([]BackupContentEntry, 0, len(entries))}
for _, e := range entries {
result.Entries = append(result.Entries, BackupContentEntry{Path: e.Path, Size: e.Size, IsDir: e.IsDir})
}
return result, nil
}
func (s *BackupRecordService) SubscribeLogs(ctx context.Context, id uint, buffer int) (<-chan backup.LogEvent, func(), error) {
item, err := s.records.FindByID(ctx, id)
if err != nil {
@@ -102,6 +163,25 @@ func (s *BackupRecordService) Delete(ctx context.Context, id uint) error {
return s.execution.DeleteRecord(ctx, id)
}
// SetLock 设置或解除备份记录的保留锁定(法律保留)。
// 锁定后该记录免于保留期/数量自动清理,且禁止手动删除,直至显式解锁。
func (s *BackupRecordService) SetLock(ctx context.Context, id uint, locked bool) (*BackupRecordDetail, error) {
item, err := s.records.FindByID(ctx, id)
if err != nil {
return nil, apperror.Internal("BACKUP_RECORD_GET_FAILED", "无法获取备份记录详情", err)
}
if item == nil {
return nil, apperror.New(404, "BACKUP_RECORD_NOT_FOUND", "备份记录不存在", nil)
}
if item.Locked != locked {
item.Locked = locked
if err := s.records.Update(ctx, item); err != nil {
return nil, apperror.Internal("BACKUP_RECORD_LOCK_FAILED", "无法更新备份锁定状态", err)
}
}
return toBackupRecordDetail(item, s.logHub), nil
}
func toBackupRecordSummary(item *model.BackupRecord) BackupRecordSummary {
return BackupRecordSummary{
ID: item.ID,
@@ -118,6 +198,8 @@ func toBackupRecordSummary(item *model.BackupRecord) BackupRecordSummary {
ErrorMessage: item.ErrorMessage,
StartedAt: item.StartedAt,
CompletedAt: item.CompletedAt,
Locked: item.Locked,
BackupKind: item.BackupKind,
}
}

View File

@@ -21,7 +21,7 @@ const backupTaskMaskedValue = "********"
type BackupTaskUpsertInput struct {
Name string `json:"name" binding:"required,min=1,max=100"`
Type string `json:"type" binding:"required,oneof=file mysql sqlite postgresql pgsql saphana"`
Type string `json:"type" binding:"required,oneof=file mysql sqlite postgresql pgsql saphana mongodb"`
Enabled bool `json:"enabled"`
CronExpr string `json:"cronExpr" binding:"max=64"`
SourcePath string `json:"sourcePath" binding:"max=500"`
@@ -33,16 +33,16 @@ type BackupTaskUpsertInput struct {
DBPassword string `json:"dbPassword" binding:"max=255"`
DBName string `json:"dbName" binding:"max=255"`
DBPath string `json:"dbPath" binding:"max=500"`
StorageTargetID uint `json:"storageTargetId"` // deprecated: 向后兼容
StorageTargetIDs []uint `json:"storageTargetIds"` // 新增:多存储目标
NodeID uint `json:"nodeId"` // 执行节点0 = 本机 Master 或节点池)
StorageTargetID uint `json:"storageTargetId"` // deprecated: 向后兼容
StorageTargetIDs []uint `json:"storageTargetIds"` // 新增:多存储目标
NodeID uint `json:"nodeId"` // 执行节点0 = 本机 Master 或节点池)
// NodePoolTag 节点池标签。NodeID=0 且本字段非空时,调度器动态从 Labels 命中的在线节点中选负载最低者。
NodePoolTag string `json:"nodePoolTag" binding:"max=64"`
Tags string `json:"tags" binding:"max=500"` // 逗号分隔标签
RetentionDays int `json:"retentionDays"`
Compression string `json:"compression" binding:"omitempty,oneof=gzip none"`
Encrypt bool `json:"encrypt"`
MaxBackups int `json:"maxBackups"`
NodePoolTag string `json:"nodePoolTag" binding:"max=64"`
Tags string `json:"tags" binding:"max=500"` // 逗号分隔标签
RetentionDays int `json:"retentionDays"`
Compression string `json:"compression" binding:"omitempty,oneof=gzip zstd none"`
Encrypt bool `json:"encrypt"`
MaxBackups int `json:"maxBackups"`
// ExtraConfig 类型特有扩展配置(如 SAP HANA 的 backupLevel/backupChannels
ExtraConfig map[string]any `json:"extraConfig"`
// 验证(恢复演练)配置
@@ -52,6 +52,14 @@ type BackupTaskUpsertInput struct {
// SLA 配置
SLAHoursRPO int `json:"slaHoursRpo"`
AlertOnConsecutiveFails int `json:"alertOnConsecutiveFails"`
// GFS 分层保留(任一 > 0 启用,取代 RetentionDays/MaxBackups
KeepDaily int `json:"keepDaily"`
KeepWeekly int `json:"keepWeekly"`
KeepMonthly int `json:"keepMonthly"`
KeepYearly int `json:"keepYearly"`
// BackupMode 备份模式full默认/ differential差异仅文件类型本机任务
BackupMode string `json:"backupMode" binding:"omitempty,oneof=full differential"`
DiffFullIntervalDays int `json:"diffFullIntervalDays"`
// 备份复制目标存储 ID 列表3-2-1 规则)
ReplicationTargetIDs []uint `json:"replicationTargetIds"`
// 维护窗口CSV详见 backup/window.go
@@ -70,8 +78,8 @@ type BackupTaskSummary struct {
Type string `json:"type"`
Enabled bool `json:"enabled"`
CronExpr string `json:"cronExpr"`
StorageTargetID uint `json:"storageTargetId"` // deprecated: 取第一个
StorageTargetName string `json:"storageTargetName"` // deprecated: 取第一个
StorageTargetID uint `json:"storageTargetId"` // deprecated: 取第一个
StorageTargetName string `json:"storageTargetName"` // deprecated: 取第一个
StorageTargetIDs []uint `json:"storageTargetIds"`
StorageTargetNames []string `json:"storageTargetNames"`
NodeID uint `json:"nodeId"`
@@ -90,11 +98,17 @@ type BackupTaskSummary struct {
VerifyMode string `json:"verifyMode"`
SLAHoursRPO int `json:"slaHoursRpo"`
AlertOnConsecutiveFails int `json:"alertOnConsecutiveFails"`
KeepDaily int `json:"keepDaily"`
KeepWeekly int `json:"keepWeekly"`
KeepMonthly int `json:"keepMonthly"`
KeepYearly int `json:"keepYearly"`
BackupMode string `json:"backupMode"`
DiffFullIntervalDays int `json:"diffFullIntervalDays"`
// 备份复制目标3-2-1
ReplicationTargetIDs []uint `json:"replicationTargetIds"`
MaintenanceWindows string `json:"maintenanceWindows"`
DependsOnTaskIDs []uint `json:"dependsOnTaskIds"`
UpdatedAt time.Time `json:"updatedAt"`
ReplicationTargetIDs []uint `json:"replicationTargetIds"`
MaintenanceWindows string `json:"maintenanceWindows"`
DependsOnTaskIDs []uint `json:"dependsOnTaskIds"`
UpdatedAt time.Time `json:"updatedAt"`
}
type BackupTaskDetail struct {
@@ -488,6 +502,7 @@ func (s *BackupTaskService) validateInput(ctx context.Context, existing *model.B
return apperror.BadRequest("BACKUP_STORAGE_TARGET_INVALID", fmt.Sprintf("关联的存储目标 %d 不存在", tid), nil)
}
}
var fixedNode *model.Node
if input.NodeID > 0 && s.nodes != nil {
node, err := s.nodes.FindByID(ctx, input.NodeID)
if err != nil {
@@ -496,12 +511,25 @@ func (s *BackupTaskService) validateInput(ctx context.Context, existing *model.B
if node == nil {
return apperror.BadRequest("BACKUP_TASK_INVALID", "所选执行节点不存在", nil)
}
fixedNode = node
}
// 节点池与固定节点互斥:固定节点已确定执行位置,不再动态调度
if input.NodeID > 0 && strings.TrimSpace(input.NodePoolTag) != "" {
return apperror.BadRequest("BACKUP_TASK_INVALID",
"固定执行节点与节点池标签只能选其一", nil)
}
if input.Encrypt && (strings.TrimSpace(input.NodePoolTag) != "" || (fixedNode != nil && !fixedNode.IsLocal)) {
return apperror.BadRequest("BACKUP_TASK_REMOTE_ENCRYPT_UNSUPPORTED",
"远程节点暂不支持加密备份。请关闭加密,或将任务固定在 Master 本机执行。", nil)
}
if strings.EqualFold(strings.TrimSpace(input.BackupMode), model.BackupModeDifferential) {
if input.Type != model.BackupTaskTypeFile {
return apperror.BadRequest("BACKUP_TASK_DIFF_UNSUPPORTED", "差异备份仅支持文件目录类型任务", nil)
}
if strings.TrimSpace(input.NodePoolTag) != "" || (fixedNode != nil && !fixedNode.IsLocal) {
return apperror.BadRequest("BACKUP_TASK_DIFF_REMOTE_UNSUPPORTED", "差异备份当前仅支持本机 Master 执行,请将任务固定在本机或改用全量备份。", nil)
}
}
if input.RetentionDays < 0 {
return apperror.BadRequest("BACKUP_TASK_INVALID", "保留天数不能小于 0", nil)
}
@@ -563,7 +591,7 @@ func validateTaskTypeSpecificFields(input BackupTaskUpsertInput, passwordRequire
if !hasSourcePaths {
return apperror.BadRequest("BACKUP_TASK_INVALID", "文件备份必须填写源路径", nil)
}
case "mysql", "postgresql", "saphana":
case "mysql", "postgresql", "saphana", "mongodb":
if strings.TrimSpace(input.DBHost) == "" {
return apperror.BadRequest("BACKUP_TASK_INVALID", "数据库主机不能为空", nil)
}
@@ -639,38 +667,44 @@ func (s *BackupTaskService) buildTask(existing *model.BackupTask, input BackupTa
return nil, apperror.BadRequest("BACKUP_TASK_INVALID", "扩展配置格式不合法", err)
}
item := &model.BackupTask{
Name: strings.TrimSpace(input.Name),
Type: normalizeBackupTaskType(input.Type),
Enabled: input.Enabled,
CronExpr: strings.TrimSpace(input.CronExpr),
SourcePath: primarySourcePath,
SourcePaths: sourcePathsJSON,
ExcludePatterns: excludePatterns,
DBHost: strings.TrimSpace(input.DBHost),
DBPort: input.DBPort,
DBUser: strings.TrimSpace(input.DBUser),
DBPasswordCiphertext: passwordCiphertext,
DBName: strings.TrimSpace(input.DBName),
DBPath: strings.TrimSpace(input.DBPath),
ExtraConfig: extraConfigJSON,
StorageTargetID: primaryTargetID,
StorageTargets: storageTargets,
NodeID: input.NodeID,
NodePoolTag: strings.TrimSpace(input.NodePoolTag),
Tags: strings.TrimSpace(input.Tags),
RetentionDays: input.RetentionDays,
Compression: compression,
Encrypt: input.Encrypt,
MaxBackups: maxBackups,
LastStatus: "idle",
VerifyEnabled: input.VerifyEnabled,
VerifyCronExpr: strings.TrimSpace(input.VerifyCronExpr),
VerifyMode: normalizeVerifyMode(input.VerifyMode),
SLAHoursRPO: maxInt(0, input.SLAHoursRPO),
Name: strings.TrimSpace(input.Name),
Type: normalizeBackupTaskType(input.Type),
Enabled: input.Enabled,
CronExpr: strings.TrimSpace(input.CronExpr),
SourcePath: primarySourcePath,
SourcePaths: sourcePathsJSON,
ExcludePatterns: excludePatterns,
DBHost: strings.TrimSpace(input.DBHost),
DBPort: input.DBPort,
DBUser: strings.TrimSpace(input.DBUser),
DBPasswordCiphertext: passwordCiphertext,
DBName: strings.TrimSpace(input.DBName),
DBPath: strings.TrimSpace(input.DBPath),
ExtraConfig: extraConfigJSON,
StorageTargetID: primaryTargetID,
StorageTargets: storageTargets,
NodeID: input.NodeID,
NodePoolTag: strings.TrimSpace(input.NodePoolTag),
Tags: strings.TrimSpace(input.Tags),
RetentionDays: input.RetentionDays,
Compression: compression,
Encrypt: input.Encrypt,
MaxBackups: maxBackups,
LastStatus: "idle",
VerifyEnabled: input.VerifyEnabled,
VerifyCronExpr: strings.TrimSpace(input.VerifyCronExpr),
VerifyMode: normalizeVerifyMode(input.VerifyMode),
SLAHoursRPO: maxInt(0, input.SLAHoursRPO),
AlertOnConsecutiveFails: alertThreshold(input.AlertOnConsecutiveFails),
ReplicationTargetIDs: encodeUintCSV(input.ReplicationTargetIDs),
MaintenanceWindows: strings.TrimSpace(input.MaintenanceWindows),
DependsOnTaskIDs: encodeUintCSV(input.DependsOnTaskIDs),
KeepDaily: maxInt(0, input.KeepDaily),
KeepWeekly: maxInt(0, input.KeepWeekly),
KeepMonthly: maxInt(0, input.KeepMonthly),
KeepYearly: maxInt(0, input.KeepYearly),
BackupMode: normalizeBackupMode(input.BackupMode, input.Type),
DiffFullIntervalDays: diffFullInterval(input.DiffFullIntervalDays),
ReplicationTargetIDs: encodeUintCSV(input.ReplicationTargetIDs),
MaintenanceWindows: strings.TrimSpace(input.MaintenanceWindows),
DependsOnTaskIDs: encodeUintCSV(input.DependsOnTaskIDs),
}
if existing != nil {
item.LastRunAt = existing.LastRunAt
@@ -736,34 +770,40 @@ func toBackupTaskSummary(item *model.BackupTask) BackupTaskSummary {
primaryName = targetNames[0]
}
return BackupTaskSummary{
ID: item.ID,
Name: item.Name,
Type: normalizeBackupTaskType(item.Type),
Enabled: item.Enabled,
CronExpr: item.CronExpr,
StorageTargetID: primaryID,
StorageTargetName: primaryName,
StorageTargetIDs: targetIDs,
StorageTargetNames: targetNames,
NodeID: item.NodeID,
NodeName: item.Node.Name,
NodePoolTag: item.NodePoolTag,
Tags: item.Tags,
RetentionDays: item.RetentionDays,
Compression: item.Compression,
Encrypt: item.Encrypt,
MaxBackups: item.MaxBackups,
LastRunAt: item.LastRunAt,
LastStatus: item.LastStatus,
ID: item.ID,
Name: item.Name,
Type: normalizeBackupTaskType(item.Type),
Enabled: item.Enabled,
CronExpr: item.CronExpr,
StorageTargetID: primaryID,
StorageTargetName: primaryName,
StorageTargetIDs: targetIDs,
StorageTargetNames: targetNames,
NodeID: item.NodeID,
NodeName: item.Node.Name,
NodePoolTag: item.NodePoolTag,
Tags: item.Tags,
RetentionDays: item.RetentionDays,
Compression: item.Compression,
Encrypt: item.Encrypt,
MaxBackups: item.MaxBackups,
LastRunAt: item.LastRunAt,
LastStatus: item.LastStatus,
VerifyEnabled: item.VerifyEnabled,
VerifyCronExpr: item.VerifyCronExpr,
VerifyMode: item.VerifyMode,
SLAHoursRPO: item.SLAHoursRPO,
AlertOnConsecutiveFails: item.AlertOnConsecutiveFails,
KeepDaily: item.KeepDaily,
KeepWeekly: item.KeepWeekly,
KeepMonthly: item.KeepMonthly,
KeepYearly: item.KeepYearly,
BackupMode: item.BackupMode,
DiffFullIntervalDays: item.DiffFullIntervalDays,
ReplicationTargetIDs: parseUintCSV(item.ReplicationTargetIDs),
MaintenanceWindows: item.MaintenanceWindows,
DependsOnTaskIDs: parseUintCSV(item.DependsOnTaskIDs),
UpdatedAt: item.UpdatedAt,
UpdatedAt: item.UpdatedAt,
}
}
@@ -895,6 +935,22 @@ func decodeExtraConfig(value string) (map[string]any, error) {
return result, nil
}
// normalizeBackupMode 归一化备份模式:仅文件类型可启用差异,其余一律全量(双保险,防绕过校验)。
func normalizeBackupMode(mode, taskType string) string {
if strings.EqualFold(strings.TrimSpace(mode), model.BackupModeDifferential) && normalizeBackupTaskType(taskType) == model.BackupTaskTypeFile {
return model.BackupModeDifferential
}
return model.BackupModeFull
}
// diffFullInterval 归一化差异模式下的强制全量间隔(天),非正值回退默认 7。
func diffFullInterval(days int) int {
if days <= 0 {
return 7
}
return days
}
func normalizeBackupTaskType(value string) string {
normalized := strings.TrimSpace(strings.ToLower(value))
if normalized == "pgsql" {

View File

@@ -3,6 +3,7 @@ package service
import (
"context"
"path/filepath"
"strings"
"testing"
"backupx/server/internal/config"
@@ -29,6 +30,82 @@ func newBackupTaskServiceForTest(t *testing.T) (*BackupTaskService, repository.S
return service, targets, tasks
}
func TestBackupTaskServiceRejectsEncryptedRemoteTasks(t *testing.T) {
ctx := context.Background()
service, targets, _ := newBackupTaskServiceForTest(t)
service.SetNodeRepository(&nodeRepoStub{nodes: []model.Node{
{ID: 41, Name: "master", Token: "master-token", Status: model.NodeStatusOnline, IsLocal: true},
{ID: 42, Name: "edge", Token: "edge-token", Status: model.NodeStatusOnline, IsLocal: false},
}})
if err := targets.Create(ctx, &model.StorageTarget{Name: "local", Type: "local_disk", Enabled: true, ConfigCiphertext: "ciphertext", ConfigVersion: 1, LastTestStatus: "unknown"}); err != nil {
t.Fatalf("seed storage target error: %v", err)
}
_, err := service.Create(ctx, BackupTaskUpsertInput{
Name: "encrypted-node-pool",
Type: "file",
Enabled: true,
SourcePath: "/srv/site",
StorageTargetID: 1,
NodePoolTag: "db",
RetentionDays: 30,
Compression: "gzip",
MaxBackups: 10,
Encrypt: true,
})
if err == nil || !strings.Contains(err.Error(), "远程节点暂不支持加密备份") {
t.Fatalf("expected encrypted node-pool task to be rejected, got %v", err)
}
created, err := service.Create(ctx, BackupTaskUpsertInput{
Name: "local-encrypted",
Type: "file",
Enabled: true,
SourcePath: "/srv/site",
StorageTargetID: 1,
RetentionDays: 30,
Compression: "gzip",
MaxBackups: 10,
Encrypt: true,
})
if err != nil {
t.Fatalf("Create local encrypted task returned error: %v", err)
}
localNodeTask, err := service.Create(ctx, BackupTaskUpsertInput{
Name: "local-node-encrypted",
Type: "file",
Enabled: true,
SourcePath: "/srv/site",
StorageTargetID: 1,
NodeID: 41,
RetentionDays: 30,
Compression: "gzip",
MaxBackups: 10,
Encrypt: true,
})
if err != nil {
t.Fatalf("Create encrypted task pinned to local node returned error: %v", err)
}
if localNodeTask.NodeID != 41 || !localNodeTask.Encrypt {
t.Fatalf("expected encrypted task to keep local node, got %#v", localNodeTask)
}
_, err = service.Update(ctx, created.ID, BackupTaskUpsertInput{
Name: created.Name,
Type: created.Type,
Enabled: true,
SourcePath: "/srv/site",
StorageTargetID: 1,
NodeID: 42,
RetentionDays: 30,
Compression: "gzip",
MaxBackups: 10,
Encrypt: true,
})
if err == nil || !strings.Contains(err.Error(), "远程节点暂不支持加密备份") {
t.Fatalf("expected encrypted fixed-node update to be rejected, got %v", err)
}
}
func TestBackupTaskServiceCreateAndGet(t *testing.T) {
ctx := context.Background()
service, targets, _ := newBackupTaskServiceForTest(t)

View File

@@ -0,0 +1,214 @@
package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/backup"
"backupx/server/internal/model"
"backupx/server/internal/repository"
"backupx/server/internal/storage"
"backupx/server/internal/storage/codec"
"backupx/server/pkg/compress"
backupcrypto "backupx/server/pkg/crypto"
)
// 本文件集中放置「备份执行 / 恢复 / 验证 / 复制」四个执行服务共享的执行期辅助逻辑。
//
// 历史上这些函数(解密存储配置创建 provider、按后缀解密解压归档、判定远程节点、
// 跨节点 local_disk 保护、构建任务执行规格)在四个服务里各复制了一份,差异仅在
// 字段名与少量错误码/日志文案。重复实现既增加维护成本,也容易出现"改了一处忘了
// 另一处"的不一致缺陷。这里抽取为单一实现,各服务通过薄封装方法委托调用,调用方
// 无需改动。
// fileSHA256 计算文件内容的 SHA-256小写 hex与备份上传时记录到
// BackupRecord.Checksum 的格式一致,用于恢复/复制前的完整性校验。
func fileSHA256(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// verifyArtifactChecksum 校验下载到本地的备份对象与记录的 SHA-256 是否一致。
// expected 为空时跳过(兼容早期未记录 checksum 的备份);不一致返回结构化错误,
// 调用方应据此中止恢复,避免还原已损坏或被篡改的数据。
func verifyArtifactChecksum(path, expected string) error {
expected = strings.TrimSpace(expected)
if expected == "" {
return nil
}
actual, err := fileSHA256(path)
if err != nil {
return apperror.Internal("BACKUP_CHECKSUM_READ_FAILED", "无法读取备份文件计算校验和", err)
}
if !strings.EqualFold(actual, expected) {
// 包装错误同样使用中文并附上期望/实际哈希apperror.Error() 会优先返回包装错误,
// 而恢复记录的 ErrorMessage 取自 err.Error(),需保证对用户可读。
return apperror.BadRequest("BACKUP_CHECKSUM_MISMATCH",
"备份文件完整性校验失败SHA-256 不匹配,文件可能已损坏或被篡改",
fmt.Errorf("备份文件完整性校验失败SHA-256 不匹配(期望 %s实际 %s文件可能已损坏或被篡改", expected, actual))
}
return nil
}
// resolveStorageProvider 查询存储目标、解密其配置并创建 provider。
func resolveStorageProvider(ctx context.Context, targets repository.StorageTargetRepository, registry *storage.Registry, cipher *codec.ConfigCipher, targetID uint) (storage.StorageProvider, error) {
target, err := targets.FindByID(ctx, targetID)
if err != nil {
return nil, apperror.Internal("BACKUP_STORAGE_TARGET_GET_FAILED", "无法获取存储目标详情", err)
}
if target == nil {
return nil, apperror.BadRequest("BACKUP_STORAGE_TARGET_INVALID", "关联的存储目标不存在", nil)
}
configMap := map[string]any{}
if err := cipher.DecryptJSON(target.ConfigCiphertext, &configMap); err != nil {
return nil, apperror.Internal("BACKUP_STORAGE_TARGET_DECRYPT_FAILED", "无法解密存储目标配置", err)
}
return registry.Create(ctx, target.Type, configMap)
}
// prepareBackupArtifact 按文件后缀依次解密(.enc)与解压(.gz),返回最终可读路径。
// logger 可为 nil此时静默执行
func prepareBackupArtifact(cipher *codec.ConfigCipher, artifactPath string, logger *backup.ExecutionLogger) (string, error) {
current := artifactPath
if strings.HasSuffix(strings.ToLower(current), ".enc") {
if logger != nil {
logger.Infof("检测到加密后缀,开始解密")
}
decrypted, err := backupcrypto.DecryptFile(cipher.Key(), current)
if err != nil {
return "", err
}
current = decrypted
}
if strings.HasSuffix(strings.ToLower(current), ".gz") {
if logger != nil {
logger.Infof("检测到 gzip 压缩,开始解压")
}
decompressed, err := compress.GunzipFile(current)
if err != nil {
return "", err
}
current = decompressed
}
if strings.HasSuffix(strings.ToLower(current), ".zst") {
if logger != nil {
logger.Infof("检测到 zstd 压缩,开始解压")
}
decompressed, err := compress.UnzstdFile(current)
if err != nil {
return "", err
}
current = decompressed
}
return current, nil
}
// resolveRemoteExecutionNode 返回远程(非本机)节点指针,用于判定任务应下发给
// Agent 还是在 Master 本地执行。clusterEnabled 通常为「该服务是否注入了 Agent
// 下发能力」。本机 / 未启用集群 / nodeID=0 / 未找到时返回 nil走本地执行
func resolveRemoteExecutionNode(ctx context.Context, nodeRepo repository.NodeRepository, clusterEnabled bool, nodeID uint) *model.Node {
if nodeRepo == nil || !clusterEnabled || nodeID == 0 {
return nil
}
node, err := nodeRepo.FindByID(ctx, nodeID)
if err != nil || node == nil || node.IsLocal {
return nil
}
return node
}
// validateCrossNodeLocalDisk 跨节点 local_disk 保护:若备份记录归属某远程节点,
// 且其存储目标是 local_disk数据位于该节点本地磁盘Master 无法跨节点访问,
// 直接返回错误。errCode/opName 由各服务定制,以给出贴合场景的提示文案。
func validateCrossNodeLocalDisk(ctx context.Context, nodeRepo repository.NodeRepository, targets repository.StorageTargetRepository, record *model.BackupRecord, errCode, opName string) error {
if record == nil || record.NodeID == 0 || nodeRepo == nil {
return nil
}
node, err := nodeRepo.FindByID(ctx, record.NodeID)
if err != nil || node == nil || node.IsLocal {
return nil
}
target, err := targets.FindByID(ctx, record.StorageTargetID)
if err != nil || target == nil {
return nil
}
if strings.EqualFold(target.Type, "local_disk") {
return apperror.BadRequest(errCode,
fmt.Sprintf("备份位于节点 %s 的本地磁盘local_diskMaster 无法跨节点%s。", node.Name, opName),
nil)
}
return nil
}
// buildBackupTaskSpec 由备份任务构建执行规格:解析排除规则/源路径、解密 DB 密码、
// 套用 ExtraConfigSAP HANA 等类型特有字段)。被备份执行与恢复服务共享。
func buildBackupTaskSpec(cipher *codec.ConfigCipher, task *model.BackupTask, startedAt time.Time, tempDir string) (backup.TaskSpec, error) {
excludePatterns := []string{}
if strings.TrimSpace(task.ExcludePatterns) != "" {
if err := json.Unmarshal([]byte(task.ExcludePatterns), &excludePatterns); err != nil {
return backup.TaskSpec{}, apperror.Internal("BACKUP_TASK_DECODE_FAILED", "无法解析排除规则", err)
}
}
password := ""
if strings.TrimSpace(task.DBPasswordCiphertext) != "" {
plain, err := cipher.Decrypt(task.DBPasswordCiphertext)
if err != nil {
return backup.TaskSpec{}, apperror.Internal("BACKUP_TASK_DECRYPT_FAILED", "无法解密数据库密码", err)
}
password = string(plain)
}
sourcePaths := []string{}
if strings.TrimSpace(task.SourcePaths) != "" {
if err := json.Unmarshal([]byte(task.SourcePaths), &sourcePaths); err != nil {
return backup.TaskSpec{}, apperror.Internal("BACKUP_TASK_DECODE_FAILED", "无法解析源路径配置", err)
}
}
dbSpec := backup.DatabaseSpec{
Host: task.DBHost,
Port: task.DBPort,
User: task.DBUser,
Password: password,
Names: []string{task.DBName},
Path: task.DBPath,
}
// 解析 ExtraConfig 填充类型特有字段(目前主要用于 SAP HANA
if strings.TrimSpace(task.ExtraConfig) != "" {
extra := map[string]any{}
if err := json.Unmarshal([]byte(task.ExtraConfig), &extra); err != nil {
return backup.TaskSpec{}, apperror.Internal("BACKUP_TASK_DECODE_FAILED", "无法解析扩展配置", err)
}
applyHANAExtraConfig(&dbSpec, extra)
}
return backup.TaskSpec{
ID: task.ID,
Name: task.Name,
Type: task.Type,
SourcePath: task.SourcePath,
SourcePaths: sourcePaths,
ExcludePatterns: excludePatterns,
StorageTargetID: task.StorageTargetID,
Compression: task.Compression,
Encrypt: task.Encrypt,
RetentionDays: task.RetentionDays,
MaxBackups: task.MaxBackups,
StartedAt: startedAt,
TempDir: tempDir,
Database: dbSpec,
}, nil
}

View File

@@ -0,0 +1,59 @@
package service
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestFileSHA256(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "f.bin")
if err := os.WriteFile(p, []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
// echo -n hello | sha256sum
const want = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
got, err := fileSHA256(p)
if err != nil {
t.Fatalf("fileSHA256: %v", err)
}
if got != want {
t.Fatalf("fileSHA256 = %q, want %q", got, want)
}
}
func TestVerifyArtifactChecksum(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "artifact.tar.gz")
if err := os.WriteFile(p, []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
const sum = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
t.Run("empty expected skips (backward compat)", func(t *testing.T) {
if err := verifyArtifactChecksum(p, ""); err != nil {
t.Fatalf("empty expected should skip, got %v", err)
}
})
t.Run("matching checksum passes (case-insensitive)", func(t *testing.T) {
if err := verifyArtifactChecksum(p, strings.ToUpper(sum)); err != nil {
t.Fatalf("matching checksum should pass, got %v", err)
}
})
t.Run("mismatch is rejected", func(t *testing.T) {
err := verifyArtifactChecksum(p, "deadbeef")
if err == nil {
t.Fatal("mismatch should error")
}
if !strings.Contains(err.Error(), "完整性校验失败") {
t.Fatalf("unexpected error message: %v", err)
}
})
t.Run("missing file errors", func(t *testing.T) {
if err := verifyArtifactChecksum(filepath.Join(dir, "nope"), sum); err == nil {
t.Fatal("missing file should error")
}
})
}

View File

@@ -36,6 +36,19 @@ type NodeSummary struct {
BandwidthLimit string `json:"bandwidthLimit"`
Labels string `json:"labels"`
CreatedAt time.Time `json:"createdAt"`
Queue NodeQueue `json:"queue"`
RunningTasks int `json:"runningTasks"`
LastError string `json:"lastError,omitempty"`
Health string `json:"health"`
}
type NodeQueue struct {
Pending int `json:"pending"`
Dispatched int `json:"dispatched"`
Depth int `json:"depth"`
Timeouts int `json:"timeouts"`
OldestActiveAt *time.Time `json:"oldestActiveAt,omitempty"`
OldestActiveAgeS int `json:"oldestActiveAgeSeconds"`
}
// NodeCreateInput is the input for creating a new remote node.
@@ -54,10 +67,11 @@ type NodeUpdateInput struct {
// NodeService manages the cluster nodes.
type NodeService struct {
repo repository.NodeRepository
taskRepo repository.BackupTaskRepository
agentRPC NodeAgentRPC
version string
repo repository.NodeRepository
taskRepo repository.BackupTaskRepository
agentRPC NodeAgentRPC
cmdRepo repository.AgentCommandRepository
version string
}
// NodeAgentRPC 抽象 Agent 远程调用能力(避免 service 内循环依赖)。
@@ -81,6 +95,10 @@ func (s *NodeService) SetAgentRPC(rpc NodeAgentRPC) {
s.agentRPC = rpc
}
func (s *NodeService) SetAgentCommandRepository(cmdRepo repository.AgentCommandRepository) {
s.cmdRepo = cmdRepo
}
// EnsureLocalNode creates the default "local" node if it does not exist.
func (s *NodeService) EnsureLocalNode(ctx context.Context) error {
existing, err := s.repo.FindLocal(ctx)
@@ -120,24 +138,10 @@ func (s *NodeService) List(ctx context.Context) ([]NodeSummary, error) {
if err != nil {
return nil, err
}
queueByNode := s.loadQueueSummaries(ctx)
result := make([]NodeSummary, len(nodes))
for i, n := range nodes {
result[i] = NodeSummary{
ID: n.ID,
Name: n.Name,
Hostname: n.Hostname,
IPAddress: n.IPAddress,
Status: n.Status,
IsLocal: n.IsLocal,
OS: n.OS,
Arch: n.Arch,
AgentVersion: n.AgentVer,
LastSeen: n.LastSeen,
MaxConcurrent: n.MaxConcurrent,
BandwidthLimit: n.BandwidthLimit,
Labels: n.Labels,
CreatedAt: n.CreatedAt,
}
result[i] = s.toNodeSummary(&n, queueByNode[n.ID])
}
return result, nil
}
@@ -150,12 +154,31 @@ func (s *NodeService) Get(ctx context.Context, id uint) (*NodeSummary, error) {
if node == nil {
return nil, apperror.New(http.StatusNotFound, "NODE_NOT_FOUND", "节点不存在", nil)
}
return &NodeSummary{
queueByNode := s.loadQueueSummaries(ctx)
summary := s.toNodeSummary(node, queueByNode[node.ID])
return &summary, nil
}
func (s *NodeService) loadQueueSummaries(ctx context.Context) map[uint]repository.AgentCommandQueueSummary {
if s.cmdRepo == nil {
return nil
}
summaries, err := s.cmdRepo.NodeQueueSummaries(ctx)
if err != nil {
return nil
}
return summaries
}
func (s *NodeService) toNodeSummary(node *model.Node, queue repository.AgentCommandQueueSummary) NodeSummary {
// 以 LastSeen 实时推导状态,避免读到后台监控尚未刷新的过期 "online"。
effStatus := node.EffectiveStatus(time.Now().UTC())
summary := NodeSummary{
ID: node.ID,
Name: node.Name,
Hostname: node.Hostname,
IPAddress: node.IPAddress,
Status: node.Status,
Status: effStatus,
IsLocal: node.IsLocal,
OS: node.OS,
Arch: node.Arch,
@@ -165,7 +188,31 @@ func (s *NodeService) Get(ctx context.Context, id uint) (*NodeSummary, error) {
BandwidthLimit: node.BandwidthLimit,
Labels: node.Labels,
CreatedAt: node.CreatedAt,
}, nil
Queue: NodeQueue{
Pending: queue.Pending,
Dispatched: queue.Dispatched,
Depth: queue.Depth,
Timeouts: queue.Timeouts,
OldestActiveAt: queue.OldestActiveAt,
},
RunningTasks: queue.Running,
LastError: queue.LastError,
Health: nodeHealth(effStatus, queue),
}
if queue.OldestActiveAt != nil {
summary.Queue.OldestActiveAgeS = int(time.Since(*queue.OldestActiveAt).Seconds())
}
return summary
}
func nodeHealth(status string, queue repository.AgentCommandQueueSummary) string {
if status != model.NodeStatusOnline {
return "offline"
}
if queue.Timeouts > 0 || strings.TrimSpace(queue.LastError) != "" {
return "degraded"
}
return "healthy"
}
// Create registers a new remote node and returns its authentication token.
@@ -258,9 +305,9 @@ func (s *NodeService) ListDirectory(ctx context.Context, nodeID uint, path strin
return result, nil
}
// OfflineThreshold 节点被判定为离线的心跳超时阈值
// Agent 默认 15s 心跳一次45s 未见视为离线,预留 3 次重试空间
const OfflineThreshold = 45 * time.Second
// OfflineThreshold 节点被判定为离线的心跳超时阈值,与 model.EffectiveStatus 共用同一阈值,
// 保证「后台监控持久化的 offline」与「读路径实时推导的 offline」判定一致
const OfflineThreshold = model.OfflineGracePeriod
// StartOfflineMonitor 启动后台 goroutine定期把超时未心跳的节点标记为离线。
// 传入的 ctx 被取消后退出。

View File

@@ -23,6 +23,9 @@ func openNodeServiceDB(t *testing.T) *gorm.DB {
if err := db.AutoMigrate(&model.Node{}); err != nil {
t.Fatalf("migrate: %v", err)
}
if err := db.AutoMigrate(&model.AgentCommand{}); err != nil {
t.Fatalf("migrate agent commands: %v", err)
}
return db
}
@@ -157,3 +160,48 @@ func TestRotateTokenNotFound(t *testing.T) {
t.Fatalf("expected not found error")
}
}
func TestNodeServiceListIncludesQueueHealthSummary(t *testing.T) {
db := openNodeServiceDB(t)
nodeRepo := repository.NewNodeRepository(db)
cmdRepo := repository.NewAgentCommandRepository(db)
svc := NewNodeService(nodeRepo, "test")
svc.SetAgentCommandRepository(cmdRepo)
ctx := context.Background()
node := &model.Node{
Name: "edge-a",
Token: "edge-token",
Status: model.NodeStatusOnline,
IsLocal: false,
LastSeen: time.Now().UTC(),
}
if err := nodeRepo.Create(ctx, node); err != nil {
t.Fatalf("Create node returned error: %v", err)
}
old := time.Now().UTC().Add(-time.Minute)
if err := cmdRepo.Create(ctx, &model.AgentCommand{NodeID: node.ID, Type: model.AgentCommandTypeRunTask, Status: model.AgentCommandStatusPending, CreatedAt: old}); err != nil {
t.Fatalf("Create pending command returned error: %v", err)
}
completedAt := time.Now().UTC()
if err := cmdRepo.Create(ctx, &model.AgentCommand{NodeID: node.ID, Type: model.AgentCommandTypeRunTask, Status: model.AgentCommandStatusTimeout, ErrorMessage: "agent timeout", CompletedAt: &completedAt}); err != nil {
t.Fatalf("Create timeout command returned error: %v", err)
}
items, err := svc.List(ctx)
if err != nil {
t.Fatalf("List returned error: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected one node, got %#v", items)
}
got := items[0]
if got.Queue.Pending != 1 || got.Queue.Depth != 1 || got.Queue.Timeouts != 1 {
t.Fatalf("unexpected queue summary: %#v", got.Queue)
}
if got.Health != "degraded" || got.LastError != "agent timeout" {
t.Fatalf("expected terminal command errors to degrade healthy node, got %#v", got)
}
if got.Queue.OldestActiveAt == nil || got.Queue.OldestActiveAgeS <= 0 {
t.Fatalf("expected oldest active metadata, got %#v", got.Queue)
}
}

View File

@@ -82,22 +82,22 @@ func (s *ReplicationService) SetEventDispatcher(dispatcher EventDispatcher) {
// ReplicationRecordSummary 列表项。
type ReplicationRecordSummary struct {
ID uint `json:"id"`
BackupRecordID uint `json:"backupRecordId"`
TaskID uint `json:"taskId"`
SourceTargetID uint `json:"sourceTargetId"`
SourceTargetName string `json:"sourceTargetName"`
DestTargetID uint `json:"destTargetId"`
DestTargetName string `json:"destTargetName"`
Status string `json:"status"`
StoragePath string `json:"storagePath"`
FileSize int64 `json:"fileSize"`
Checksum string `json:"checksum"`
ErrorMessage string `json:"errorMessage"`
DurationSeconds int `json:"durationSeconds"`
TriggeredBy string `json:"triggeredBy"`
StartedAt time.Time `json:"startedAt"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
ID uint `json:"id"`
BackupRecordID uint `json:"backupRecordId"`
TaskID uint `json:"taskId"`
SourceTargetID uint `json:"sourceTargetId"`
SourceTargetName string `json:"sourceTargetName"`
DestTargetID uint `json:"destTargetId"`
DestTargetName string `json:"destTargetName"`
Status string `json:"status"`
StoragePath string `json:"storagePath"`
FileSize int64 `json:"fileSize"`
Checksum string `json:"checksum"`
ErrorMessage string `json:"errorMessage"`
DurationSeconds int `json:"durationSeconds"`
TriggeredBy string `json:"triggeredBy"`
StartedAt time.Time `json:"startedAt"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
}
type ReplicationRecordListInput struct {
@@ -262,39 +262,13 @@ func (s *ReplicationService) executeReplication(ctx context.Context, repID uint)
}
func (s *ReplicationService) resolveProvider(ctx context.Context, targetID uint) (storage.StorageProvider, error) {
target, err := s.targets.FindByID(ctx, targetID)
if err != nil {
return nil, apperror.Internal("STORAGE_TARGET_GET_FAILED", "无法获取存储目标", err)
}
if target == nil {
return nil, apperror.BadRequest("STORAGE_TARGET_INVALID", "存储目标不存在", nil)
}
configMap := map[string]any{}
if err := s.cipher.DecryptJSON(target.ConfigCiphertext, &configMap); err != nil {
return nil, apperror.Internal("STORAGE_TARGET_DECRYPT_FAILED", "无法解密存储配置", err)
}
return s.storageRegistry.Create(ctx, target.Type, configMap)
return resolveStorageProvider(ctx, s.targets, s.storageRegistry, s.cipher, targetID)
}
// validateClusterAccessible 拒绝跨节点 local_disk 源Master 无法拉取)
// validateClusterAccessible 拒绝跨节点 local_disk 源Master 无法拉取)
func (s *ReplicationService) validateClusterAccessible(ctx context.Context, record *model.BackupRecord) error {
if record == nil || record.NodeID == 0 || s.nodeRepo == nil {
return nil
}
node, err := s.nodeRepo.FindByID(ctx, record.NodeID)
if err != nil || node == nil || node.IsLocal {
return nil
}
target, err := s.targets.FindByID(ctx, record.StorageTargetID)
if err != nil || target == nil {
return nil
}
if strings.EqualFold(target.Type, "local_disk") {
return apperror.BadRequest("REPLICATION_CROSS_NODE_LOCAL_DISK",
fmt.Sprintf("备份位于节点 %s 的本地磁盘local_diskMaster 无法跨节点复制。请改用云存储作为主备份。", node.Name),
nil)
}
return nil
return validateCrossNodeLocalDisk(ctx, s.nodeRepo, s.targets, record,
"REPLICATION_CROSS_NODE_LOCAL_DISK", "复制。请改用云存储作为主备份")
}
func (s *ReplicationService) dispatchFailed(ctx context.Context, rep *model.ReplicationRecord, message string) {
@@ -304,12 +278,12 @@ func (s *ReplicationService) dispatchFailed(ctx context.Context, rep *model.Repl
title := "BackupX 备份复制失败"
body := fmt.Sprintf("备份记录:#%d\n源 → 目标:#%d → #%d\n错误%s", rep.BackupRecordID, rep.SourceTargetID, rep.DestTargetID, message)
fields := map[string]any{
"replicationId": rep.ID,
"backupRecordId": rep.BackupRecordID,
"taskId": rep.TaskID,
"sourceTargetId": rep.SourceTargetID,
"destTargetId": rep.DestTargetID,
"error": message,
"replicationId": rep.ID,
"backupRecordId": rep.BackupRecordID,
"taskId": rep.TaskID,
"sourceTargetId": rep.SourceTargetID,
"destTargetId": rep.DestTargetID,
"error": message,
}
_ = s.eventDispatcher.DispatchEvent(ctx, model.NotificationEventReplicationFailed, title, body, fields)
}

View File

@@ -0,0 +1,154 @@
package service
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"backupx/server/internal/backup"
"backupx/server/internal/config"
"backupx/server/internal/database"
"backupx/server/internal/logger"
"backupx/server/internal/model"
"backupx/server/internal/repository"
"backupx/server/internal/storage"
"backupx/server/internal/storage/codec"
storageRclone "backupx/server/internal/storage/rclone"
)
type replicationTestHarness struct {
repl *ReplicationService
execution *BackupExecutionService
records repository.BackupRecordRepository
destDir string
srcDir string
}
func newReplicationTestHarness(t *testing.T) *replicationTestHarness {
t.Helper()
baseDir := t.TempDir()
sourceData := filepath.Join(baseDir, "data")
srcStore := filepath.Join(baseDir, "src-store")
destStore := filepath.Join(baseDir, "dest-store")
if err := os.MkdirAll(sourceData, 0o755); err != nil {
t.Fatalf("mkdir data: %v", err)
}
if err := os.WriteFile(filepath.Join(sourceData, "index.html"), []byte("hello-replicate"), 0o644); err != nil {
t.Fatalf("write data: %v", err)
}
log, err := logger.New(config.LogConfig{Level: "error"})
if err != nil {
t.Fatalf("logger.New: %v", err)
}
db, err := database.Open(config.DatabaseConfig{Path: filepath.Join(baseDir, "backupx.db")}, log)
if err != nil {
t.Fatalf("database.Open: %v", err)
}
cipher := codec.NewConfigCipher("replicate-secret")
targets := repository.NewStorageTargetRepository(db)
tasks := repository.NewBackupTaskRepository(db)
records := repository.NewBackupRecordRepository(db)
replications := repository.NewReplicationRecordRepository(db)
nodes := repository.NewNodeRepository(db)
mkTarget := func(name, basePath string) {
cfg, err := cipher.EncryptJSON(map[string]any{"basePath": basePath})
if err != nil {
t.Fatalf("EncryptJSON: %v", err)
}
if err := targets.Create(context.Background(), &model.StorageTarget{Name: name, Type: string(storage.ProviderTypeLocalDisk), Enabled: true, ConfigCiphertext: cfg, ConfigVersion: 1, LastTestStatus: "unknown"}); err != nil {
t.Fatalf("create target %s: %v", name, err)
}
}
mkTarget("src", srcStore) // ID 1
mkTarget("dest", destStore) // ID 2
task := &model.BackupTask{Name: "repl-test", Type: "file", Enabled: true, SourcePath: sourceData, StorageTargetID: 1, NodeID: 0, RetentionDays: 30, Compression: "gzip", MaxBackups: 10, LastStatus: "idle"}
if err := tasks.Create(context.Background(), task); err != nil {
t.Fatalf("create task: %v", err)
}
logHub := backup.NewLogHub()
runnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewSQLiteRunner(), backup.NewMySQLRunner(nil), backup.NewPostgreSQLRunner(nil))
storageRegistry := storage.NewRegistry(storageRclone.NewLocalDiskFactory())
execution := NewBackupExecutionService(tasks, records, targets, storageRegistry, runnerRegistry, logHub, nil, cipher, nil, baseDir, 2, 10, "")
repl := NewReplicationService(replications, records, targets, nodes, storageRegistry, cipher, baseDir, 2)
return &replicationTestHarness{repl: repl, execution: execution, records: records, destDir: destStore, srcDir: srcStore}
}
func countFiles(t *testing.T, dir string) int {
t.Helper()
n := 0
_ = filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error {
if err == nil && info != nil && !info.IsDir() {
n++
}
return nil
})
return n
}
// TestReplicationService_MirrorsToDestTarget 覆盖正常路径:把成功备份从源存储复制到目标存储,
// 目标出现对象、源保留(复制非移动),记录终态为 success。
func TestReplicationService_MirrorsToDestTarget(t *testing.T) {
h := newReplicationTestHarness(t)
ctx := context.Background()
backupDetail, err := h.execution.RunTaskByIDSync(ctx, 1)
if err != nil {
t.Fatalf("RunTaskByIDSync: %v", err)
}
if backupDetail.Status != "success" {
t.Fatalf("expected backup success, got %s", backupDetail.Status)
}
if countFiles(t, h.destDir) != 0 {
t.Fatalf("dest store should be empty before replication")
}
done := make(chan struct{})
h.repl.async = func(job func()) {
go func() { job(); close(done) }()
}
summary, err := h.repl.Start(ctx, backupDetail.ID, 2, "tester")
if err != nil {
t.Fatalf("replication Start: %v", err)
}
select {
case <-done:
case <-time.After(15 * time.Second):
t.Fatal("replication did not complete in time")
}
final, err := h.repl.Get(ctx, summary.ID)
if err != nil {
t.Fatalf("Get: %v", err)
}
if final.Status != model.ReplicationStatusSuccess {
t.Fatalf("expected replication success, got %s (err=%s)", final.Status, final.ErrorMessage)
}
if countFiles(t, h.destDir) == 0 {
t.Fatal("dest store should contain the replicated object")
}
if countFiles(t, h.srcDir) == 0 {
t.Fatal("source object must remain after replication (copy, not move)")
}
}
// TestReplicationService_RejectsSameTarget 校验:目标与源相同时同步拒绝。
func TestReplicationService_RejectsSameTarget(t *testing.T) {
h := newReplicationTestHarness(t)
ctx := context.Background()
backupDetail, err := h.execution.RunTaskByIDSync(ctx, 1)
if err != nil {
t.Fatalf("RunTaskByIDSync: %v", err)
}
// 备份写到 target 1以 target 1 作为复制目标应被拒绝。
if _, err := h.repl.Start(ctx, backupDetail.ID, 1, "tester"); err == nil {
t.Fatal("expected error when dest target equals source")
} else if !strings.Contains(err.Error(), "目标存储无效或与源相同") {
t.Fatalf("unexpected error: %v", err)
}
}

View File

@@ -0,0 +1,199 @@
package service
import (
"context"
"time"
"backupx/server/internal/apperror"
"backupx/server/internal/model"
"backupx/server/internal/repository"
)
// ReportService 生成企业合规报表。区别于 Dashboard 的实时聚合视图,
// 本服务产出「按任务、可导出、可归档」的时间点合规证据(供 SOC2 / ISO27001 等审计)。
type ReportService struct {
tasks repository.BackupTaskRepository
records repository.BackupRecordRepository
}
func NewReportService(tasks repository.BackupTaskRepository, records repository.BackupRecordRepository) *ReportService {
return &ReportService{tasks: tasks, records: records}
}
// ComplianceTaskRow 单个备份任务的合规明细行。
type ComplianceTaskRow struct {
TaskID uint `json:"taskId"`
TaskName string `json:"taskName"`
Type string `json:"type"`
Enabled bool `json:"enabled"`
NodeName string `json:"nodeName"`
CronExpr string `json:"cronExpr"`
Encrypted bool `json:"encrypted"`
RetentionDays int `json:"retentionDays"`
SLAHoursRPO int `json:"slaHoursRpo"`
TotalRuns int `json:"totalRuns"`
Successes int `json:"successes"`
Failures int `json:"failures"`
SuccessRate float64 `json:"successRate"`
LastStatus string `json:"lastStatus"`
LastRunAt *time.Time `json:"lastRunAt,omitempty"`
LastSuccessAt *time.Time `json:"lastSuccessAt,omitempty"`
ProtectedBytes int64 `json:"protectedBytes"`
Compliant bool `json:"compliant"`
Risk string `json:"risk"` // ok | at_risk | not_applicable
}
// ComplianceSummary 报表汇总。
type ComplianceSummary struct {
TotalTasks int `json:"totalTasks"`
EnabledTasks int `json:"enabledTasks"`
CompliantTasks int `json:"compliantTasks"`
AtRiskTasks int `json:"atRiskTasks"`
EncryptedTasks int `json:"encryptedTasks"`
OverallSuccessRate float64 `json:"overallSuccessRate"`
TotalProtectedB int64 `json:"totalProtectedBytes"`
}
// ComplianceReport 完整合规报表。
type ComplianceReport struct {
GeneratedAt time.Time `json:"generatedAt"`
RangeDays int `json:"rangeDays"`
Summary ComplianceSummary `json:"summary"`
Tasks []ComplianceTaskRow `json:"tasks"`
}
const (
reportMinDays = 1
reportMaxDays = 365
)
// ComplianceReport 生成最近 days 天的合规报表。
func (s *ReportService) ComplianceReport(ctx context.Context, days int) (*ComplianceReport, error) {
if days < reportMinDays || days > reportMaxDays {
return nil, apperror.BadRequest("REPORT_RANGE_INVALID",
"统计天数需在 1-365 之间", nil)
}
tasks, err := s.tasks.List(ctx, repository.BackupTaskListOptions{})
if err != nil {
return nil, apperror.Internal("REPORT_TASKS_FAILED", "无法获取任务列表", err)
}
now := time.Now().UTC()
since := now.AddDate(0, 0, -days)
report := &ComplianceReport{GeneratedAt: now, RangeDays: days, Tasks: make([]ComplianceTaskRow, 0, len(tasks))}
var totalRuns, totalSuccess int
for i := range tasks {
task := tasks[i]
records, err := s.records.ListByTask(ctx, task.ID)
if err != nil {
return nil, apperror.Internal("REPORT_RECORDS_FAILED", "无法获取任务备份记录", err)
}
row := s.buildTaskRow(&task, records, now, since)
report.Tasks = append(report.Tasks, row)
report.Summary.TotalTasks++
if task.Enabled {
report.Summary.EnabledTasks++
}
if task.Encrypt {
report.Summary.EncryptedTasks++
}
switch row.Risk {
case "ok":
report.Summary.CompliantTasks++
case "at_risk":
report.Summary.AtRiskTasks++
}
report.Summary.TotalProtectedB += row.ProtectedBytes
totalRuns += row.TotalRuns
totalSuccess += row.Successes
}
if totalRuns > 0 {
report.Summary.OverallSuccessRate = roundRate(float64(totalSuccess) / float64(totalRuns))
}
return report, nil
}
func (s *ReportService) buildTaskRow(task *model.BackupTask, records []model.BackupRecord, now, since time.Time) ComplianceTaskRow {
row := ComplianceTaskRow{
TaskID: task.ID,
TaskName: task.Name,
Type: task.Type,
Enabled: task.Enabled,
NodeName: task.Node.Name,
CronExpr: task.CronExpr,
Encrypted: task.Encrypt,
RetentionDays: task.RetentionDays,
SLAHoursRPO: task.SLAHoursRPO,
LastStatus: "none",
}
var lastRun, lastSuccess *model.BackupRecord
for i := range records {
rec := &records[i]
// 统计窗口内的运行情况(按 StartedAt 落在 [since, now])。
if !rec.StartedAt.Before(since) {
row.TotalRuns++
switch rec.Status {
case model.BackupRecordStatusSuccess:
row.Successes++
case model.BackupRecordStatusFailed:
row.Failures++
}
}
// 最近一次运行 / 最近一次成功(不限窗口,反映当前保护态)。
if lastRun == nil || rec.StartedAt.After(lastRun.StartedAt) {
lastRun = rec
}
if rec.Status == model.BackupRecordStatusSuccess {
if lastSuccess == nil || rec.StartedAt.After(lastSuccess.StartedAt) {
lastSuccess = rec
}
}
}
if row.TotalRuns > 0 {
row.SuccessRate = roundRate(float64(row.Successes) / float64(row.TotalRuns))
}
if lastRun != nil {
row.LastStatus = lastRun.Status
started := lastRun.StartedAt
row.LastRunAt = &started
}
if lastSuccess != nil {
when := lastSuccess.StartedAt
if lastSuccess.CompletedAt != nil {
when = *lastSuccess.CompletedAt
}
row.LastSuccessAt = &when
row.ProtectedBytes = lastSuccess.FileSize
}
row.Compliant, row.Risk = evaluateCompliance(task, lastSuccess, now)
return row
}
// evaluateCompliance 判定任务合规性:
// - 禁用任务not_applicable不计入合规/风险)。
// - 从未成功at_risk。
// - 配置了 SLARPO 小时):最近成功在 RPO 内为 ok否则 at_risk。
// - 未配置 SLA只要存在成功备份即视为 ok。
func evaluateCompliance(task *model.BackupTask, lastSuccess *model.BackupRecord, now time.Time) (bool, string) {
if !task.Enabled {
return false, "not_applicable"
}
if lastSuccess == nil {
return false, "at_risk"
}
if task.SLAHoursRPO > 0 {
when := lastSuccess.StartedAt
if lastSuccess.CompletedAt != nil {
when = *lastSuccess.CompletedAt
}
if now.Sub(when).Hours() > float64(task.SLAHoursRPO) {
return false, "at_risk"
}
}
return true, "ok"
}
func roundRate(v float64) float64 {
return float64(int(v*10000+0.5)) / 10000
}

View File

@@ -0,0 +1,136 @@
package service
import (
"context"
"os"
"path/filepath"
"testing"
"backupx/server/internal/backup"
"backupx/server/internal/config"
"backupx/server/internal/database"
"backupx/server/internal/logger"
"backupx/server/internal/model"
"backupx/server/internal/repository"
"backupx/server/internal/storage"
"backupx/server/internal/storage/codec"
storageRclone "backupx/server/internal/storage/rclone"
)
func newReportTestHarness(t *testing.T) (*ReportService, *BackupExecutionService) {
t.Helper()
baseDir := t.TempDir()
sourceDir := filepath.Join(baseDir, "data")
storeDir := filepath.Join(baseDir, "store")
if err := os.MkdirAll(sourceDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "f.txt"), []byte("report-data"), 0o644); err != nil {
t.Fatal(err)
}
log, err := logger.New(config.LogConfig{Level: "error"})
if err != nil {
t.Fatal(err)
}
db, err := database.Open(config.DatabaseConfig{Path: filepath.Join(baseDir, "backupx.db")}, log)
if err != nil {
t.Fatal(err)
}
cipher := codec.NewConfigCipher("report-secret")
targets := repository.NewStorageTargetRepository(db)
tasks := repository.NewBackupTaskRepository(db)
records := repository.NewBackupRecordRepository(db)
cfg, err := cipher.EncryptJSON(map[string]any{"basePath": storeDir})
if err != nil {
t.Fatal(err)
}
if err := targets.Create(context.Background(), &model.StorageTarget{Name: "s", Type: string(storage.ProviderTypeLocalDisk), Enabled: true, ConfigCiphertext: cfg, ConfigVersion: 1, LastTestStatus: "unknown"}); err != nil {
t.Fatal(err)
}
task := &model.BackupTask{Name: "rep-task", Type: "file", Enabled: true, SourcePath: sourceDir, StorageTargetID: 1, NodeID: 0, RetentionDays: 30, Compression: "gzip", MaxBackups: 10, LastStatus: "idle"}
if err := tasks.Create(context.Background(), task); err != nil {
t.Fatal(err)
}
runnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewSQLiteRunner(), backup.NewMySQLRunner(nil), backup.NewPostgreSQLRunner(nil))
storageRegistry := storage.NewRegistry(storageRclone.NewLocalDiskFactory())
execution := NewBackupExecutionService(tasks, records, targets, storageRegistry, runnerRegistry, backup.NewLogHub(), nil, cipher, nil, baseDir, 2, 10, "")
return NewReportService(tasks, records), execution
}
func findRow(rows []ComplianceTaskRow, taskID uint) *ComplianceTaskRow {
for i := range rows {
if rows[i].TaskID == taskID {
return &rows[i]
}
}
return nil
}
func TestComplianceReport_ReflectsBackupOutcome(t *testing.T) {
report, execution := newReportTestHarness(t)
ctx := context.Background()
// 备份前:任务启用但从未成功 → at_risk。
before, err := report.ComplianceReport(ctx, 30)
if err != nil {
t.Fatalf("ComplianceReport: %v", err)
}
row := findRow(before.Tasks, 1)
if row == nil {
t.Fatal("task row missing before backup")
}
if row.Risk != "at_risk" || row.Compliant {
t.Fatalf("expected at_risk before any success, got risk=%s compliant=%v", row.Risk, row.Compliant)
}
if before.Summary.AtRiskTasks != 1 || before.Summary.CompliantTasks != 0 {
t.Fatalf("unexpected summary before: %+v", before.Summary)
}
// 跑一次成功备份。
bd, err := execution.RunTaskByIDSync(ctx, 1)
if err != nil {
t.Fatalf("RunTaskByIDSync: %v", err)
}
if bd.Status != "success" {
t.Fatalf("backup not success: %s", bd.Status)
}
// 备份后:合规、成功率 1.0、保护字节数 > 0。
after, err := report.ComplianceReport(ctx, 30)
if err != nil {
t.Fatalf("ComplianceReport after: %v", err)
}
row = findRow(after.Tasks, 1)
if row == nil {
t.Fatal("task row missing after backup")
}
if !row.Compliant || row.Risk != "ok" {
t.Fatalf("expected ok/compliant after success, got risk=%s compliant=%v", row.Risk, row.Compliant)
}
if row.TotalRuns != 1 || row.Successes != 1 || row.Failures != 0 {
t.Fatalf("unexpected counts: runs=%d ok=%d fail=%d", row.TotalRuns, row.Successes, row.Failures)
}
if row.SuccessRate != 1 {
t.Fatalf("expected success rate 1.0, got %v", row.SuccessRate)
}
if row.LastStatus != "success" || row.LastSuccessAt == nil || row.ProtectedBytes <= 0 {
t.Fatalf("unexpected last/protected: status=%s lastSuccess=%v bytes=%d", row.LastStatus, row.LastSuccessAt, row.ProtectedBytes)
}
if after.Summary.CompliantTasks != 1 || after.Summary.AtRiskTasks != 0 || after.Summary.OverallSuccessRate != 1 {
t.Fatalf("unexpected summary after: %+v", after.Summary)
}
if after.Summary.TotalProtectedB <= 0 {
t.Fatalf("expected protected bytes > 0, got %d", after.Summary.TotalProtectedB)
}
}
func TestComplianceReport_RejectsInvalidRange(t *testing.T) {
report, _ := newReportTestHarness(t)
ctx := context.Background()
if _, err := report.ComplianceReport(ctx, 0); err == nil {
t.Fatal("expected error for days=0")
}
if _, err := report.ComplianceReport(ctx, 9999); err == nil {
t.Fatal("expected error for days>365")
}
}

View File

@@ -16,8 +16,6 @@ import (
"backupx/server/internal/repository"
"backupx/server/internal/storage"
"backupx/server/internal/storage/codec"
"backupx/server/pkg/compress"
backupcrypto "backupx/server/pkg/crypto"
)
// RestoreService 管理恢复记录生命周期并在集群中路由执行。
@@ -122,6 +120,13 @@ type RestoreRecordDetail struct {
// 若任务绑定远程节点:入队 AgentCommand 后立即返回(状态为 running
// 若本地:异步执行并立即返回。
func (s *RestoreService) Start(ctx context.Context, backupRecordID uint, triggeredBy string) (*RestoreRecordDetail, error) {
return s.StartSelective(ctx, backupRecordID, nil, "", triggeredBy)
}
// StartSelective 启动恢复。两个可选项均仅适用于本机文件备份:
// - selectedPaths 非空时仅恢复选中的文件/目录(及其子项),用于按需(选择性)恢复;
// - targetPath 非空时把归档恢复到该绝对目录而非原始源路径父目录(迁移/测试/并排恢复)。
func (s *RestoreService) StartSelective(ctx context.Context, backupRecordID uint, selectedPaths []string, targetPath string, triggeredBy string) (*RestoreRecordDetail, error) {
record, err := s.records.FindByID(ctx, backupRecordID)
if err != nil {
return nil, apperror.Internal("BACKUP_RECORD_GET_FAILED", "无法获取备份记录", err)
@@ -139,13 +144,37 @@ func (s *RestoreService) Start(ctx context.Context, backupRecordID uint, trigger
if task == nil {
return nil, apperror.New(404, "BACKUP_TASK_NOT_FOUND", "关联的备份任务不存在", fmt.Errorf("backup task %d not found", record.TaskID))
}
if len(selectedPaths) > 0 {
if task.Type != model.BackupTaskTypeFile {
return nil, apperror.BadRequest("RESTORE_SELECTIVE_UNSUPPORTED", "按需(选择性)恢复仅支持文件类型备份", nil)
}
if s.resolveRemoteNode(ctx, s.resolveRestoreNodeID(record, task)) != nil {
return nil, apperror.BadRequest("RESTORE_SELECTIVE_REMOTE_UNSUPPORTED", "按需恢复当前仅支持本机 Master 执行", nil)
}
}
startedAt := s.now()
restoreNodeID := s.resolveRestoreNodeID(record, task)
// 恢复到指定目录:仅文件类型 + 本机执行支持;需为绝对路径。
targetPath = strings.TrimSpace(targetPath)
if targetPath != "" {
if task.Type != model.BackupTaskTypeFile {
return nil, apperror.BadRequest("RESTORE_TARGET_UNSUPPORTED", "仅文件类型备份支持恢复到指定目录", nil)
}
if !filepath.IsAbs(targetPath) {
return nil, apperror.BadRequest("RESTORE_TARGET_INVALID", "恢复目录必须是绝对路径", nil)
}
if s.isRemoteNode(ctx, restoreNodeID) {
return nil, apperror.BadRequest("RESTORE_TARGET_REMOTE_UNSUPPORTED", "远程节点恢复暂不支持指定目录,请在该节点本地操作", nil)
}
}
restore := &model.RestoreRecord{
BackupRecordID: backupRecordID,
TaskID: record.TaskID,
NodeID: restoreNodeID,
TargetPath: targetPath,
Status: model.RestoreRecordStatusRunning,
StartedAt: startedAt,
TriggeredBy: strings.TrimSpace(triggeredBy),
@@ -180,7 +209,7 @@ func (s *RestoreService) Start(ctx context.Context, backupRecordID uint, trigger
// 本地节点:异步执行
run := func() {
s.executeLocally(context.Background(), restore.ID, task, record)
s.executeLocally(context.Background(), restore.ID, task, record, selectedPaths, targetPath)
}
s.async(run)
return s.getDetail(ctx, restore.ID)
@@ -203,18 +232,11 @@ func (s *RestoreService) isRemoteNode(ctx context.Context, nodeID uint) bool {
// resolveRemoteNode 返回远程节点指针(含 Status用于离线判定。
func (s *RestoreService) resolveRemoteNode(ctx context.Context, nodeID uint) *model.Node {
if s.nodeRepo == nil || s.dispatcher == nil || nodeID == 0 {
return nil
}
node, err := s.nodeRepo.FindByID(ctx, nodeID)
if err != nil || node == nil || node.IsLocal {
return nil
}
return node
return resolveRemoteExecutionNode(ctx, s.nodeRepo, s.dispatcher != nil, nodeID)
}
// executeLocally 在 Master 本地执行恢复。
func (s *RestoreService) executeLocally(ctx context.Context, restoreID uint, task *model.BackupTask, backupRecord *model.BackupRecord) {
func (s *RestoreService) executeLocally(ctx context.Context, restoreID uint, task *model.BackupTask, backupRecord *model.BackupRecord, selectedPaths []string, targetPath string) {
s.semaphore <- struct{}{}
defer func() { <-s.semaphore }()
@@ -232,10 +254,27 @@ func (s *RestoreService) executeLocally(ctx context.Context, restoreID uint, tas
}()
logger.Infof("开始在本地执行恢复(备份记录 #%d", backupRecord.ID)
provider, providerErr := s.resolveProvider(ctx, backupRecord.StorageTargetID)
if providerErr != nil {
errMessage = providerErr.Error()
logger.Errorf("创建存储客户端失败:%v", providerErr)
spec, specErr := s.buildTaskSpec(task, backupRecord.StartedAt)
if specErr != nil {
errMessage = specErr.Error()
logger.Errorf("构建恢复规格失败:%v", specErr)
return
}
if len(selectedPaths) > 0 {
spec.SelectedPaths = selectedPaths
logger.Infof("按需恢复:仅恢复选中的 %d 个路径", len(selectedPaths))
}
// 恢复到指定目录(已在 StartSelective 校验为文件类型+绝对路径+本机);
// 应用于恢复链中的每个归档(全量铺底与差异覆盖均落到该目录)。
if targetPath != "" {
spec.RestoreTargetPath = targetPath
logger.Infof("恢复到指定目录:%s", targetPath)
}
runner, runnerErr := s.runnerRegistry.Runner(spec.Type)
if runnerErr != nil {
errMessage = runnerErr.Error()
logger.Errorf("不支持的备份类型:%v", runnerErr)
return
}
@@ -252,52 +291,89 @@ func (s *RestoreService) executeLocally(ctx context.Context, restoreID uint, tas
}
defer os.RemoveAll(tempDir)
fileName := backupRecord.FileName
if strings.TrimSpace(fileName) == "" {
fileName = filepath.Base(backupRecord.StoragePath)
}
artifactPath := filepath.Join(tempDir, filepath.Base(fileName))
logger.Infof("开始下载备份文件:%s", backupRecord.StoragePath)
reader, downloadErr := provider.Download(ctx, backupRecord.StoragePath)
if downloadErr != nil {
errMessage = downloadErr.Error()
logger.Errorf("下载备份文件失败:%v", downloadErr)
// 恢复链:全量 → [自身];差异 → [基线全量, 自身],按序应用(全量铺底,差异覆盖并删除)。
chain, chainErr := s.buildRestoreChain(ctx, backupRecord)
if chainErr != nil {
errMessage = chainErr.Error()
logger.Errorf("%v", chainErr)
return
}
if writeErr := writeReaderToFile(artifactPath, reader); writeErr != nil {
errMessage = writeErr.Error()
logger.Errorf("写入恢复文件失败:%v", writeErr)
return
}
preparedPath, prepareErr := s.prepareArtifact(artifactPath, logger)
if prepareErr != nil {
errMessage = prepareErr.Error()
logger.Errorf("准备恢复文件失败:%v", prepareErr)
return
}
spec, specErr := s.buildTaskSpec(task, backupRecord.StartedAt)
if specErr != nil {
errMessage = specErr.Error()
logger.Errorf("构建恢复规格失败:%v", specErr)
return
}
runner, runnerErr := s.runnerRegistry.Runner(spec.Type)
if runnerErr != nil {
errMessage = runnerErr.Error()
logger.Errorf("不支持的备份类型:%v", runnerErr)
return
}
logger.Infof("开始执行 %s 恢复", spec.Type)
if restoreErr := runner.Restore(ctx, spec, preparedPath, logger); restoreErr != nil {
errMessage = restoreErr.Error()
logger.Errorf("恢复执行失败:%v", restoreErr)
return
logger.Infof("开始执行 %s 恢复(恢复链含 %d 个备份)", spec.Type, len(chain))
for idx := range chain {
rec := chain[idx]
if len(chain) > 1 {
logger.Infof("恢复链 [%d/%d]:应用备份记录 #%d%s", idx+1, len(chain), rec.ID, backupKindLabel(rec.BackupKind))
}
if err := s.restoreArtifact(ctx, &rec, spec, runner, tempDir, logger); err != nil {
errMessage = err.Error()
logger.Errorf("恢复执行失败:%v", err)
return
}
}
status = model.RestoreRecordStatusSuccess
logger.Infof("恢复执行成功")
}
// restoreArtifact 下载、完整性校验、解密解压并通过 runner 应用单个备份记录的归档。
// 每个记录使用独立子目录,避免恢复链中基线/差异的同名归档相互覆盖。
func (s *RestoreService) restoreArtifact(ctx context.Context, record *model.BackupRecord, spec backup.TaskSpec, runner backup.BackupRunner, parentTempDir string, logger *backup.ExecutionLogger) error {
provider, err := s.resolveProvider(ctx, record.StorageTargetID)
if err != nil {
return fmt.Errorf("创建存储客户端失败:%w", err)
}
recDir, err := os.MkdirTemp(parentTempDir, fmt.Sprintf("rec-%d-*", record.ID))
if err != nil {
return fmt.Errorf("创建恢复子目录失败:%w", err)
}
fileName := record.FileName
if strings.TrimSpace(fileName) == "" {
fileName = filepath.Base(record.StoragePath)
}
artifactPath := filepath.Join(recDir, filepath.Base(fileName))
logger.Infof("开始下载备份文件:%s", record.StoragePath)
reader, err := provider.Download(ctx, record.StoragePath)
if err != nil {
return fmt.Errorf("下载备份文件失败:%w", err)
}
if err := writeReaderToFile(artifactPath, reader); err != nil {
return fmt.Errorf("写入恢复文件失败:%w", err)
}
// 完整性校验:解密/解压前比对 SHA-256早期无 checksum 的备份跳过(向后兼容)。
if record.Checksum != "" {
if err := verifyArtifactChecksum(artifactPath, record.Checksum); err != nil {
return fmt.Errorf("完整性校验失败:%w", err)
}
}
preparedPath, err := s.prepareArtifact(artifactPath, logger)
if err != nil {
return fmt.Errorf("准备恢复文件失败:%w", err)
}
return runner.Restore(ctx, spec, preparedPath, logger)
}
// buildRestoreChain 返回恢复某记录所需、按应用顺序排列的记录链:
// 全量 → [自身];差异 → [基线全量, 自身]。基线缺失/不可用时报错,杜绝残缺恢复。
func (s *RestoreService) buildRestoreChain(ctx context.Context, record *model.BackupRecord) ([]model.BackupRecord, error) {
if record.BackupKind != model.BackupKindDifferential || record.BaseRecordID == 0 {
return []model.BackupRecord{*record}, nil
}
base, err := s.records.FindByID(ctx, record.BaseRecordID)
if err != nil || base == nil {
return nil, fmt.Errorf("差异备份的基线全量 #%d 不存在,无法恢复", record.BaseRecordID)
}
if base.Status != model.BackupRecordStatusSuccess || strings.TrimSpace(base.StoragePath) == "" {
return nil, fmt.Errorf("差异备份的基线全量 #%d 不可用,无法恢复", record.BaseRecordID)
}
return []model.BackupRecord{*base, *record}, nil
}
func backupKindLabel(kind string) string {
if kind == model.BackupKindDifferential {
return "差异"
}
return "全量"
}
// dispatchRestoreEvent 按终态向事件总线派发 restore_success 或 restore_failed。
// eventDispatcher 未注入时静默忽略,保持向后兼容。
func (s *RestoreService) dispatchRestoreEvent(ctx context.Context, restoreID uint, status, errMessage string, task *model.BackupTask) {
@@ -335,97 +411,19 @@ func (s *RestoreService) dispatchRestoreEvent(ctx context.Context, restoreID uin
_ = s.eventDispatcher.DispatchEvent(ctx, eventType, title, body, fields)
}
// resolveProvider 复用 BackupExecutionService 的逻辑(解密 → 创建 provider
// resolveProvider 解密存储目标配置并创建 provider(共享实现)。
func (s *RestoreService) resolveProvider(ctx context.Context, targetID uint) (storage.StorageProvider, error) {
target, err := s.targets.FindByID(ctx, targetID)
if err != nil {
return nil, apperror.Internal("BACKUP_STORAGE_TARGET_GET_FAILED", "无法获取存储目标详情", err)
}
if target == nil {
return nil, apperror.BadRequest("BACKUP_STORAGE_TARGET_INVALID", "关联的存储目标不存在", nil)
}
configMap := map[string]any{}
if err := s.cipher.DecryptJSON(target.ConfigCiphertext, &configMap); err != nil {
return nil, apperror.Internal("BACKUP_STORAGE_TARGET_DECRYPT_FAILED", "无法解密存储目标配置", err)
}
return s.storageRegistry.Create(ctx, target.Type, configMap)
return resolveStorageProvider(ctx, s.targets, s.storageRegistry, s.cipher, targetID)
}
// prepareArtifact 根据文件后缀依次解密、解压。
// prepareArtifact 根据文件后缀依次解密、解压(共享实现)
func (s *RestoreService) prepareArtifact(artifactPath string, logger *backup.ExecutionLogger) (string, error) {
currentPath := artifactPath
if strings.HasSuffix(strings.ToLower(currentPath), ".enc") {
logger.Infof("检测到加密后缀,开始解密")
decrypted, err := backupcrypto.DecryptFile(s.cipher.Key(), currentPath)
if err != nil {
return "", err
}
currentPath = decrypted
}
if strings.HasSuffix(strings.ToLower(currentPath), ".gz") {
logger.Infof("检测到 gzip 压缩,开始解压")
decompressed, err := compress.GunzipFile(currentPath)
if err != nil {
return "", err
}
currentPath = decompressed
}
return currentPath, nil
return prepareBackupArtifact(s.cipher, artifactPath, logger)
}
// buildTaskSpec 复刻 BackupExecutionService.buildTaskSpec 的核心逻辑
// buildTaskSpec 由任务构建执行规格(共享实现)
func (s *RestoreService) buildTaskSpec(task *model.BackupTask, startedAt time.Time) (backup.TaskSpec, error) {
excludePatterns := []string{}
if strings.TrimSpace(task.ExcludePatterns) != "" {
if err := json.Unmarshal([]byte(task.ExcludePatterns), &excludePatterns); err != nil {
return backup.TaskSpec{}, apperror.Internal("BACKUP_TASK_DECODE_FAILED", "无法解析排除规则", err)
}
}
password := ""
if strings.TrimSpace(task.DBPasswordCiphertext) != "" {
plain, err := s.cipher.Decrypt(task.DBPasswordCiphertext)
if err != nil {
return backup.TaskSpec{}, apperror.Internal("BACKUP_TASK_DECRYPT_FAILED", "无法解密数据库密码", err)
}
password = string(plain)
}
sourcePaths := []string{}
if strings.TrimSpace(task.SourcePaths) != "" {
if err := json.Unmarshal([]byte(task.SourcePaths), &sourcePaths); err != nil {
return backup.TaskSpec{}, apperror.Internal("BACKUP_TASK_DECODE_FAILED", "无法解析源路径配置", err)
}
}
dbSpec := backup.DatabaseSpec{
Host: task.DBHost,
Port: task.DBPort,
User: task.DBUser,
Password: password,
Names: []string{task.DBName},
Path: task.DBPath,
}
if strings.TrimSpace(task.ExtraConfig) != "" {
extra := map[string]any{}
if err := json.Unmarshal([]byte(task.ExtraConfig), &extra); err != nil {
return backup.TaskSpec{}, apperror.Internal("BACKUP_TASK_DECODE_FAILED", "无法解析扩展配置", err)
}
applyHANAExtraConfig(&dbSpec, extra)
}
return backup.TaskSpec{
ID: task.ID,
Name: task.Name,
Type: task.Type,
SourcePath: task.SourcePath,
SourcePaths: sourcePaths,
ExcludePatterns: excludePatterns,
StorageTargetID: task.StorageTargetID,
Compression: task.Compression,
Encrypt: task.Encrypt,
RetentionDays: task.RetentionDays,
MaxBackups: task.MaxBackups,
StartedAt: startedAt,
TempDir: s.tempDir,
Database: dbSpec,
}, nil
return buildBackupTaskSpec(s.cipher, task, startedAt, s.tempDir)
}
// finalize 只更新状态和错误信息,不写 log用于失败的 dispatch 路径)。
@@ -539,6 +537,8 @@ type AgentRestoreSpec struct {
Storage AgentStorageTargetConfig `json:"storage"`
StoragePath string `json:"storagePath"`
FileName string `json:"fileName"`
// Checksum 源备份对象的 SHA-256小写 hexAgent 在还原前据此校验完整性。
Checksum string `json:"checksum,omitempty"`
}
// AgentRestoreUpdate Agent 回传的增量更新。
@@ -625,6 +625,7 @@ func (s *RestoreService) GetAgentRestoreSpec(ctx context.Context, node *model.No
},
StoragePath: backupRecord.StoragePath,
FileName: backupRecord.FileName,
Checksum: backupRecord.Checksum,
}, nil
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
@@ -197,6 +198,132 @@ func TestRestoreServiceStart_LocalNodeExecutesInline(t *testing.T) {
}
}
// TestRestoreServiceStart_RejectsCorruptedBackup 验证恢复在还原前做 SHA-256 完整性
// 校验:若已存储的备份对象被损坏/篡改,恢复必须失败且不触碰源数据。
func TestRestoreServiceStart_RejectsCorruptedBackup(t *testing.T) {
h := newRestoreTestHarness(t, false)
ctx := context.Background()
backupDetail, err := h.execution.RunTaskByIDSync(ctx, 1)
if err != nil {
t.Fatalf("RunTaskByIDSync: %v", err)
}
if backupDetail.Status != "success" {
t.Fatalf("expected backup success, got %s", backupDetail.Status)
}
// 破坏已存储的备份对象:追加垃圾字节,使其 SHA-256 与记录不符。
corrupted := false
if walkErr := filepath.Walk(h.storageDir, func(p string, info os.FileInfo, walkErr error) error {
if walkErr != nil || info.IsDir() {
return walkErr
}
f, openErr := os.OpenFile(p, os.O_APPEND|os.O_WRONLY, 0o644)
if openErr != nil {
return openErr
}
defer f.Close()
if _, writeErr := f.WriteString("corrupt"); writeErr != nil {
return writeErr
}
corrupted = true
return nil
}); walkErr != nil {
t.Fatalf("corrupt walk: %v", walkErr)
}
if !corrupted {
t.Fatal("did not find a stored backup object to corrupt")
}
if err := os.RemoveAll(h.sourceDir); err != nil {
t.Fatalf("remove source: %v", err)
}
done := make(chan struct{})
h.service.async = func(job func()) {
go func() { job(); close(done) }()
}
detail, err := h.service.Start(ctx, backupDetail.ID, "tester")
if err != nil {
t.Fatalf("Start: %v", err)
}
select {
case <-done:
case <-time.After(15 * time.Second):
t.Fatalf("restore did not complete in time")
}
final, err := h.service.Get(ctx, detail.ID)
if err != nil {
t.Fatalf("Get final: %v", err)
}
if final.Status != model.RestoreRecordStatusFailed {
t.Fatalf("expected restore to FAIL on corrupted backup, got %s (err=%s)", final.Status, final.ErrorMessage)
}
if !strings.Contains(final.ErrorMessage, "完整性校验失败") && !strings.Contains(final.ErrorMessage, "SHA-256") {
t.Fatalf("expected checksum failure message, got %q", final.ErrorMessage)
}
// 校验阶段即中止,不应触碰源数据。
if _, statErr := os.Stat(filepath.Join(h.sourceDir, "index.html")); statErr == nil {
t.Fatal("source must not be restored when checksum verification fails")
}
}
// TestRestoreServiceStart_RestoresToAlternatePath 验证恢复到指定目录:归档落在指定目录,
// 且相对路径被拒绝。
func TestRestoreServiceStart_RestoresToAlternatePath(t *testing.T) {
h := newRestoreTestHarness(t, false)
ctx := context.Background()
backupDetail, err := h.execution.RunTaskByIDSync(ctx, 1)
if err != nil {
t.Fatalf("RunTaskByIDSync: %v", err)
}
if backupDetail.Status != "success" {
t.Fatalf("expected backup success, got %s", backupDetail.Status)
}
altDir := filepath.Join(t.TempDir(), "restore-here")
// 相对路径应被拒绝(且不创建恢复记录)。
if _, relErr := h.service.StartSelective(ctx, backupDetail.ID, nil, "relative/path", "tester"); relErr == nil {
t.Fatal("relative target path should be rejected")
}
done := make(chan struct{})
h.service.async = func(job func()) {
go func() {
job()
close(done)
}()
}
detail, err := h.service.StartSelective(ctx, backupDetail.ID, nil, altDir, "tester")
if err != nil {
t.Fatalf("StartSelective(altDir): %v", err)
}
select {
case <-done:
case <-time.After(15 * time.Second):
t.Fatal("restore did not complete in time")
}
final, err := h.service.Get(ctx, detail.ID)
if err != nil {
t.Fatalf("Get: %v", err)
}
if final.Status != model.RestoreRecordStatusSuccess {
t.Fatalf("expected success, got %s (err=%s)", final.Status, final.ErrorMessage)
}
// 源目录 basename 为 "source",归档解压到 altDir/source/index.html。
got, err := os.ReadFile(filepath.Join(altDir, "source", "index.html"))
if err != nil {
t.Fatalf("read restored file at alternate path: %v", err)
}
if string(got) != "hello-restore" {
t.Fatalf("unexpected restored content at alt path: %q", string(got))
}
}
func TestRestoreServiceStart_RemoteNodeEnqueuesCommand(t *testing.T) {
h := newRestoreTestHarness(t, true)
ctx := context.Background()
@@ -307,6 +434,7 @@ func TestRestoreServiceAgentRestoreAccessUsesRestoreRecordNode(t *testing.T) {
Status: model.BackupRecordStatusSuccess,
FileName: "remote.tar.gz",
StoragePath: "file/2026/05/09/remote.tar.gz",
Checksum: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
StartedAt: startedAt,
CompletedAt: &completedAt,
}
@@ -332,6 +460,10 @@ func TestRestoreServiceAgentRestoreAccessUsesRestoreRecordNode(t *testing.T) {
if spec.RestoreRecordID != restore.ID || spec.StoragePath != backupRecord.StoragePath {
t.Fatalf("unexpected restore spec: %#v", spec)
}
// Agent 端完整性校验依赖 spec 透传源备份 checksum。
if spec.Checksum != backupRecord.Checksum {
t.Fatalf("expected spec.Checksum=%q, got %q", backupRecord.Checksum, spec.Checksum)
}
if _, err := h.service.GetAgentRestoreSpec(ctx, other, restore.ID); err == nil {
t.Fatal("expected non-owner node to be forbidden from restore spec")
}

View File

@@ -47,6 +47,8 @@ const (
SettingKeyBandwidthLimit = "bandwidth_limit"
SettingKeyAuditWebhookURL = "audit_webhook_url"
SettingKeyAuditWebhookSecret = "audit_webhook_secret"
// SettingKeyAuditRetentionDays 审计日志保留天数0/缺省=永久保留)。
SettingKeyAuditRetentionDays = "audit_retention_days"
)
var settingsKeys = []string{
@@ -57,6 +59,7 @@ var settingsKeys = []string{
SettingKeyBandwidthLimit,
SettingKeyAuditWebhookURL,
SettingKeyAuditWebhookSecret,
SettingKeyAuditRetentionDays,
}
func (s *SettingsService) GetAll(ctx context.Context) (map[string]string, error) {

View File

@@ -15,8 +15,6 @@ import (
"backupx/server/internal/repository"
"backupx/server/internal/storage"
"backupx/server/internal/storage/codec"
"backupx/server/pkg/compress"
backupcrypto "backupx/server/pkg/crypto"
)
// VerificationService 管理备份验证(恢复演练)记录生命周期。
@@ -92,11 +90,11 @@ func (v *VerificationEventNotifier) NotifyVerificationResult(ctx context.Context
title := "BackupX 备份验证失败"
body := fmt.Sprintf("任务:%s\n验证记录#%d\n错误%s", taskName, record.ID, record.ErrorMessage)
fields := map[string]any{
"taskId": record.TaskID,
"taskName": taskName,
"verifyId": record.ID,
"taskId": record.TaskID,
"taskName": taskName,
"verifyId": record.ID,
"backupRecordId": record.BackupRecordID,
"error": record.ErrorMessage,
"error": record.ErrorMessage,
}
return v.dispatcher.DispatchEvent(ctx, model.NotificationEventVerifyFailed, title, body, fields)
}
@@ -243,23 +241,8 @@ func (s *VerificationService) Start(ctx context.Context, backupRecordID uint, mo
// validateClusterAccessible 复刻 BackupExecutionService 的跨节点 local_disk 保护。
// 避免 Master 端在错误机器下载/校验到假数据。
func (s *VerificationService) validateClusterAccessible(ctx context.Context, record *model.BackupRecord) error {
if record == nil || record.NodeID == 0 || s.nodeRepo == nil {
return nil
}
node, err := s.nodeRepo.FindByID(ctx, record.NodeID)
if err != nil || node == nil || node.IsLocal {
return nil
}
target, err := s.targets.FindByID(ctx, record.StorageTargetID)
if err != nil || target == nil {
return nil
}
if strings.EqualFold(target.Type, "local_disk") {
return apperror.BadRequest("VERIFY_CROSS_NODE_LOCAL_DISK",
fmt.Sprintf("备份位于节点 %s 的本地磁盘local_diskMaster 无法跨节点验证。", node.Name),
nil)
}
return nil
return validateCrossNodeLocalDisk(ctx, s.nodeRepo, s.targets, record,
"VERIFY_CROSS_NODE_LOCAL_DISK", "验证")
}
// executeLocally 异步执行验证:下载 → 解密 → 解压 → 按类型校验。
@@ -333,14 +316,26 @@ func (s *VerificationService) executeLocally(ctx context.Context, verID uint, ta
logger.Errorf("写入沙箱失败:%v", err)
return
}
// 完整性校验:先比对下载对象的 SHA-256外层对象与备份记录一致再做结构校验。
// 与恢复路径保持一致的强度;早期无 checksum 的备份跳过(向后兼容)。
if backupRecord.Checksum != "" {
logger.Infof("校验备份完整性SHA-256")
if csErr := verifyArtifactChecksum(artifactPath, backupRecord.Checksum); csErr != nil {
errMessage = csErr.Error()
summary = "完整性校验失败"
logger.Errorf("完整性校验失败:%v", csErr)
return
}
logger.Infof("完整性校验通过")
}
preparedPath, err := s.prepareArtifact(artifactPath, logger)
if err != nil {
errMessage = err.Error()
logger.Errorf("准备归档失败:%v", err)
return
}
// 按任务类型分派校验
report, verifyErr := s.verifyByType(task.Type, preparedPath, backupRecord.Checksum, logger)
// 按任务类型做结构校验(外层 SHA-256 已在上方单独校验)。
report, verifyErr := s.verifyByType(task.Type, preparedPath, logger)
if verifyErr != nil {
errMessage = verifyErr.Error()
if report != nil && report.Detail != "" {
@@ -358,28 +353,11 @@ func (s *VerificationService) executeLocally(ctx context.Context, verID uint, ta
// prepareArtifact 按后缀解密/解压,返回可读路径。
func (s *VerificationService) prepareArtifact(artifactPath string, logger *backup.ExecutionLogger) (string, error) {
current := artifactPath
if strings.HasSuffix(strings.ToLower(current), ".enc") {
logger.Infof("检测到加密后缀,开始解密")
decrypted, err := backupcrypto.DecryptFile(s.cipher.Key(), current)
if err != nil {
return "", err
}
current = decrypted
}
if strings.HasSuffix(strings.ToLower(current), ".gz") {
logger.Infof("检测到 gzip解压")
decompressed, err := compress.GunzipFile(current)
if err != nil {
return "", err
}
current = decompressed
}
return current, nil
return prepareBackupArtifact(s.cipher, artifactPath, logger)
}
// verifyByType 按任务类型分派到对应 Verify* 策略
func (s *VerificationService) verifyByType(taskType, artifactPath, checksum string, logger *backup.ExecutionLogger) (*backup.VerifyReport, error) {
// verifyByType 按任务类型分派到对应 Verify* 结构校验策略(不含 SHA-256 比对)
func (s *VerificationService) verifyByType(taskType, artifactPath string, logger *backup.ExecutionLogger) (*backup.VerifyReport, error) {
switch strings.ToLower(strings.TrimSpace(taskType)) {
case "file":
logger.Infof("执行文件归档校验")

View File

@@ -0,0 +1,159 @@
package service
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"backupx/server/internal/backup"
"backupx/server/internal/config"
"backupx/server/internal/database"
"backupx/server/internal/logger"
"backupx/server/internal/model"
"backupx/server/internal/repository"
"backupx/server/internal/storage"
"backupx/server/internal/storage/codec"
storageRclone "backupx/server/internal/storage/rclone"
)
type verifyTestHarness struct {
verify *VerificationService
execution *BackupExecutionService
records repository.BackupRecordRepository
storageDir string
}
func newVerifyTestHarness(t *testing.T) *verifyTestHarness {
t.Helper()
baseDir := t.TempDir()
sourceDir := filepath.Join(baseDir, "source")
storageDir := filepath.Join(baseDir, "storage")
if err := os.MkdirAll(sourceDir, 0o755); err != nil {
t.Fatalf("mkdir source: %v", err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "index.html"), []byte("hello-verify"), 0o644); err != nil {
t.Fatalf("write source: %v", err)
}
log, err := logger.New(config.LogConfig{Level: "error"})
if err != nil {
t.Fatalf("logger.New: %v", err)
}
db, err := database.Open(config.DatabaseConfig{Path: filepath.Join(baseDir, "backupx.db")}, log)
if err != nil {
t.Fatalf("database.Open: %v", err)
}
cipher := codec.NewConfigCipher("verify-secret")
targets := repository.NewStorageTargetRepository(db)
tasks := repository.NewBackupTaskRepository(db)
records := repository.NewBackupRecordRepository(db)
verifications := repository.NewVerificationRecordRepository(db)
nodes := repository.NewNodeRepository(db)
targetCipher, err := cipher.EncryptJSON(map[string]any{"basePath": storageDir})
if err != nil {
t.Fatalf("EncryptJSON: %v", err)
}
if err := targets.Create(context.Background(), &model.StorageTarget{Name: "local", Type: string(storage.ProviderTypeLocalDisk), Enabled: true, ConfigCiphertext: targetCipher, ConfigVersion: 1, LastTestStatus: "unknown"}); err != nil {
t.Fatalf("create target: %v", err)
}
task := &model.BackupTask{Name: "verify-test", Type: "file", Enabled: true, SourcePath: sourceDir, StorageTargetID: 1, NodeID: 0, RetentionDays: 30, Compression: "gzip", MaxBackups: 10, LastStatus: "idle"}
if err := tasks.Create(context.Background(), task); err != nil {
t.Fatalf("create task: %v", err)
}
logHub := backup.NewLogHub()
runnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewSQLiteRunner(), backup.NewMySQLRunner(nil), backup.NewPostgreSQLRunner(nil))
storageRegistry := storage.NewRegistry(storageRclone.NewLocalDiskFactory())
execution := NewBackupExecutionService(tasks, records, targets, storageRegistry, runnerRegistry, logHub, nil, cipher, nil, baseDir, 2, 10, "")
verify := NewVerificationService(verifications, records, tasks, targets, nodes, storageRegistry, backup.NewLogHub(), cipher, baseDir, 2)
return &verifyTestHarness{verify: verify, execution: execution, records: records, storageDir: storageDir}
}
// runVerify 同步执行一次验证并返回终态记录。
func (h *verifyTestHarness) runVerify(t *testing.T, backupRecordID uint) *VerificationRecordDetail {
t.Helper()
ctx := context.Background()
done := make(chan struct{})
h.verify.async = func(job func()) {
go func() { job(); close(done) }()
}
detail, err := h.verify.Start(ctx, backupRecordID, "quick", "tester")
if err != nil {
t.Fatalf("verify Start: %v", err)
}
select {
case <-done:
case <-time.After(15 * time.Second):
t.Fatal("verify did not complete in time")
}
final, err := h.verify.Get(ctx, detail.ID)
if err != nil {
t.Fatalf("verify Get: %v", err)
}
return final
}
// TestVerificationService_Success 覆盖正常路径对一个有效gzip 压缩)的备份做验证应通过。
// 同时回归保护 #77——新增的 SHA-256 校验不得误伤合法的压缩备份。
func TestVerificationService_Success(t *testing.T) {
h := newVerifyTestHarness(t)
backupDetail, err := h.execution.RunTaskByIDSync(context.Background(), 1)
if err != nil {
t.Fatalf("RunTaskByIDSync: %v", err)
}
if backupDetail.Status != "success" {
t.Fatalf("expected backup success, got %s", backupDetail.Status)
}
final := h.runVerify(t, backupDetail.ID)
if final.Status != model.VerificationRecordStatusSuccess {
t.Fatalf("expected verify success, got %s (err=%s)", final.Status, final.ErrorMessage)
}
}
// TestVerificationService_RejectsCorruptedBackup 验证 #77 的完整性校验:
// 存储对象被损坏时验证必须失败并给出 checksum 失败信息。
func TestVerificationService_RejectsCorruptedBackup(t *testing.T) {
h := newVerifyTestHarness(t)
backupDetail, err := h.execution.RunTaskByIDSync(context.Background(), 1)
if err != nil {
t.Fatalf("RunTaskByIDSync: %v", err)
}
if backupDetail.Status != "success" {
t.Fatalf("expected backup success, got %s", backupDetail.Status)
}
// 破坏已存储的备份对象,使其 SHA-256 与记录不符。
corrupted := false
if walkErr := filepath.Walk(h.storageDir, func(p string, info os.FileInfo, walkErr error) error {
if walkErr != nil || info.IsDir() {
return walkErr
}
f, openErr := os.OpenFile(p, os.O_APPEND|os.O_WRONLY, 0o644)
if openErr != nil {
return openErr
}
defer f.Close()
if _, writeErr := f.WriteString("corrupt"); writeErr != nil {
return writeErr
}
corrupted = true
return nil
}); walkErr != nil {
t.Fatalf("corrupt walk: %v", walkErr)
}
if !corrupted {
t.Fatal("did not find a stored backup object to corrupt")
}
final := h.runVerify(t, backupDetail.ID)
if final.Status != model.VerificationRecordStatusFailed {
t.Fatalf("expected verify to FAIL on corrupted backup, got %s", final.Status)
}
if !strings.Contains(final.ErrorMessage, "完整性校验失败") && !strings.Contains(final.ErrorMessage, "SHA-256") {
t.Fatalf("expected checksum failure message, got %q", final.ErrorMessage)
}
}

View File

@@ -0,0 +1,65 @@
package compress
import (
"fmt"
"io"
"os"
"strings"
"github.com/klauspost/compress/zstd"
)
// ZstdFile 将文件压缩为 .zstzstd返回压缩产物路径。
// 相比 gzipzstd 在相近 CPU 开销下提供更高压缩率与显著更快的解压速度。
func ZstdFile(sourcePath string) (string, error) {
source, err := os.Open(sourcePath)
if err != nil {
return "", fmt.Errorf("open source file: %w", err)
}
defer source.Close()
targetPath := sourcePath + ".zst"
target, err := os.Create(targetPath)
if err != nil {
return "", fmt.Errorf("create zstd file: %w", err)
}
defer target.Close()
writer, err := zstd.NewWriter(target)
if err != nil {
return "", fmt.Errorf("create zstd writer: %w", err)
}
if _, err := io.Copy(writer, source); err != nil {
_ = writer.Close()
return "", fmt.Errorf("zstd source file: %w", err)
}
if err := writer.Close(); err != nil {
return "", fmt.Errorf("close zstd writer: %w", err)
}
return targetPath, nil
}
// UnzstdFile 解压 .zst 文件,返回解压产物路径。
func UnzstdFile(sourcePath string) (string, error) {
source, err := os.Open(sourcePath)
if err != nil {
return "", fmt.Errorf("open zstd file: %w", err)
}
defer source.Close()
reader, err := zstd.NewReader(source)
if err != nil {
return "", fmt.Errorf("create zstd reader: %w", err)
}
defer reader.Close()
targetPath := strings.TrimSuffix(sourcePath, ".zst")
if targetPath == sourcePath {
targetPath += ".out"
}
target, err := os.Create(targetPath)
if err != nil {
return "", fmt.Errorf("create target file: %w", err)
}
defer target.Close()
if _, err := io.Copy(target, reader); err != nil {
return "", fmt.Errorf("unzstd file: %w", err)
}
return targetPath, nil
}

View File

@@ -0,0 +1,40 @@
package compress
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestZstdRoundTrip(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "data.txt")
content := []byte("hello zstd roundtrip 差异压缩测试 " + strings.Repeat("payload-", 2000))
if err := os.WriteFile(src, content, 0o644); err != nil {
t.Fatalf("write source: %v", err)
}
compressed, err := ZstdFile(src)
if err != nil {
t.Fatalf("ZstdFile: %v", err)
}
if !strings.HasSuffix(compressed, ".zst") {
t.Fatalf("expected .zst suffix, got %s", compressed)
}
// 删除原文件,确保后续读到的是解压结果而非残留原文件。
if err := os.Remove(src); err != nil {
t.Fatalf("remove source: %v", err)
}
out, err := UnzstdFile(compressed)
if err != nil {
t.Fatalf("UnzstdFile: %v", err)
}
got, err := os.ReadFile(out)
if err != nil {
t.Fatalf("read decompressed: %v", err)
}
if !bytes.Equal(got, content) {
t.Fatalf("roundtrip mismatch: got %d bytes, want %d bytes", len(got), len(content))
}
}

89
web/package-lock.json generated
View File

@@ -9,7 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@arco-design/web-react": "^2.66.0",
"axios": "^1.8.4",
"axios": "^1.16.0",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"i18next": "^25.8.14",
@@ -29,7 +29,7 @@
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^26.0.0",
"typescript": "^5.7.3",
"vite": "^6.2.1",
"vite": "^6.4.2",
"vitest": "^3.0.8"
}
},
@@ -127,7 +127,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -476,7 +475,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -500,7 +498,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1458,7 +1455,8 @@
"resolved": "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -1536,7 +1534,6 @@
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -1554,7 +1551,6 @@
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -1566,7 +1562,6 @@
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^18.0.0"
}
@@ -1723,6 +1718,7 @@
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@@ -1733,6 +1729,7 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -1767,14 +1764,14 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.6",
"resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
"proxy-from-env": "^2.1.0"
}
},
"node_modules/b-tween": {
@@ -1822,7 +1819,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -2080,7 +2076,8 @@
"resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/dom-helpers": {
"version": "5.2.1",
@@ -2317,9 +2314,9 @@
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
@@ -2543,7 +2540,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4"
},
@@ -2604,7 +2600,6 @@
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
@@ -2666,9 +2661,9 @@
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/loose-envify": {
@@ -2706,6 +2701,7 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -2853,12 +2849,11 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -2867,9 +2862,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"dev": true,
"funding": [
{
@@ -2901,6 +2896,7 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -2915,7 +2911,8 @@
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/prop-types": {
"version": "15.8.1",
@@ -2935,10 +2932,13 @@
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/punycode": {
"version": "2.3.1",
@@ -2955,7 +2955,6 @@
"resolved": "https://registry.npmmirror.com/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -2980,7 +2979,6 @@
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -3434,7 +3432,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -3529,18 +3526,16 @@
"resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -3793,9 +3788,9 @@
}
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"dev": true,
"license": "MIT",
"engines": {

View File

@@ -11,7 +11,7 @@
},
"dependencies": {
"@arco-design/web-react": "^2.66.0",
"axios": "^1.8.4",
"axios": "^1.16.0",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"i18next": "^25.8.14",
@@ -31,7 +31,7 @@
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^26.0.0",
"typescript": "^5.7.3",
"vite": "^6.2.1",
"vite": "^6.4.2",
"vitest": "^3.0.8"
}
}

View File

@@ -0,0 +1,125 @@
import { Alert, Button, Input, Modal, Spin, Table, Tag, Typography } from '@arco-design/web-react'
import { useEffect, useMemo, useState } from 'react'
import { getBackupRecordContents } from '../../services/backup-records'
import type { BackupRecordContentEntry, BackupRecordContents } from '../../types/backup-records'
import { resolveErrorMessage } from '../../utils/error'
import { formatBytes } from '../../utils/format'
interface BackupRecordContentsModalProps {
visible: boolean
recordId?: number
onClose: () => void
// onRestoreSelected 提供时启用按需恢复:勾选条目后回调选中的归档路径。
onRestoreSelected?: (paths: string[]) => void
}
// BackupRecordContentsModal 浏览某次备份捕获的文件清单(只读)。
// 数据来源于全量备份记录的清单,无需下载归档,秒级展示并支持按路径筛选。
export function BackupRecordContentsModal({ visible, recordId, onClose, onRestoreSelected }: BackupRecordContentsModalProps) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [contents, setContents] = useState<BackupRecordContents | null>(null)
const [keyword, setKeyword] = useState('')
const [selectedKeys, setSelectedKeys] = useState<string[]>([])
useEffect(() => {
if (!visible || !recordId) {
return
}
let active = true
setLoading(true)
setError('')
setKeyword('')
setContents(null)
setSelectedKeys([])
void (async () => {
try {
const data = await getBackupRecordContents(recordId)
if (active) {
setContents(data)
}
} catch (e) {
if (active) {
setError(resolveErrorMessage(e, '加载备份内容失败'))
}
} finally {
if (active) {
setLoading(false)
}
}
})()
return () => {
active = false
}
}, [visible, recordId])
const filtered = useMemo(() => {
const entries = contents?.entries ?? []
const kw = keyword.trim().toLowerCase()
if (!kw) {
return entries
}
return entries.filter((e) => e.path.toLowerCase().includes(kw))
}, [contents, keyword])
return (
<Modal visible={visible} title="备份内容" footer={null} onCancel={onClose} unmountOnExit style={{ width: 760 }}>
{loading ? (
<Spin style={{ display: 'block', textAlign: 'center', padding: 40 }} />
) : error ? (
<Alert type="warning" content={error} />
) : contents ? (
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{contents.total}
{contents.truncated ? `(清单较大,仅展示前 ${contents.entries.length} 个)` : ''}
{contents.basedOnFull ? `;差异备份,清单取自基线全量 #${contents.basedOnFull}` : ''}
</Typography.Text>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', margin: '8px 0' }}>
<Input.Search allowClear placeholder="按路径筛选" value={keyword} onChange={setKeyword} style={{ flex: 1 }} />
{onRestoreSelected && (
<Button type="primary" status="warning" disabled={selectedKeys.length === 0} onClick={() => onRestoreSelected(selectedKeys)}>
{selectedKeys.length}
</Button>
)}
</div>
<Table
size="small"
rowKey="path"
data={filtered}
rowSelection={
onRestoreSelected
? { type: 'checkbox', selectedRowKeys: selectedKeys, onChange: (keys) => setSelectedKeys(keys as string[]) }
: undefined
}
pagination={{ pageSize: 50, sizeCanChange: false }}
scroll={{ y: 420 }}
columns={[
{
title: '路径',
dataIndex: 'path',
render: (_: unknown, row: BackupRecordContentEntry) => (
<span>
{row.isDir ? (
<Tag size="small" color="arcoblue">
</Tag>
) : null}{' '}
{row.path}
</span>
),
},
{
title: '大小',
dataIndex: 'size',
width: 120,
align: 'right',
render: (_: unknown, row: BackupRecordContentEntry) => (row.isDir ? '-' : formatBytes(row.size)),
},
]}
/>
</div>
) : null}
</Modal>
)
}

View File

@@ -12,6 +12,7 @@ import type { BackupTaskDetail } from '../../types/backup-tasks'
import { resolveErrorMessage } from '../../utils/error'
import { formatBytes, formatDateTime, formatDuration } from '../../utils/format'
import { RestoreConfirmModal } from '../restore-records/RestoreConfirmModal'
import { BackupRecordContentsModal } from './BackupRecordContentsModal'
interface BackupRecordLogDrawerProps {
visible: boolean
@@ -53,6 +54,7 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
const [restoreLoading, setRestoreLoading] = useState(false)
const [restorePreparing, setRestorePreparing] = useState(false)
const [verifyLoading, setVerifyLoading] = useState(false)
const [contentsVisible, setContentsVisible] = useState(false)
useEffect(() => {
if (!visible || !recordId) {
@@ -191,13 +193,13 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
}
}
async function handleConfirmRestore() {
async function handleConfirmRestore(targetPath?: string) {
if (!recordId) {
return
}
setRestoreLoading(true)
try {
const restore = await startRestoreFromBackup(recordId)
const restore = await startRestoreFromBackup(recordId, undefined, targetPath)
Message.success('恢复已启动,正在打开日志')
setRestoreModalVisible(false)
setRestoreTask(null)
@@ -211,6 +213,26 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
}
}
// handleSelectiveRestore 按需恢复:仅还原内容浏览中勾选的文件/目录到原位置。
async function handleSelectiveRestore(paths: string[]) {
if (!recordId || paths.length === 0) {
return
}
if (!window.confirm(`确定将选中的 ${paths.length} 项恢复到原位置吗?这会覆盖目标位置的现有文件,不可撤销。`)) {
return
}
try {
const restore = await startRestoreFromBackup(recordId, paths)
Message.success('按需恢复已启动,正在打开日志')
setContentsVisible(false)
await onChanged?.()
navigate(`/restore/records?restoreId=${restore.id}`)
onCancel()
} catch (restoreError) {
Message.error(resolveErrorMessage(restoreError, '启动按需恢复失败'))
}
}
async function handleDelete() {
if (!recordId) {
return
@@ -268,6 +290,7 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
<Button loading={acting} onClick={handleDownload}>
</Button>
<Button onClick={() => setContentsVisible(true)}></Button>
{writable && (
<Button
type="primary"
@@ -322,7 +345,13 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
setRestoreModalVisible(false)
setRestoreTask(null)
}}
onConfirm={() => void handleConfirmRestore()}
onConfirm={(targetPath) => void handleConfirmRestore(targetPath)}
/>
<BackupRecordContentsModal
visible={contentsVisible}
recordId={recordId}
onClose={() => setContentsVisible(false)}
onRestoreSelected={writable && record?.status === 'success' ? (paths) => void handleSelectiveRestore(paths) : undefined}
/>
</Drawer>
)

View File

@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from 'react'
import { CronInput } from '../CronInput'
import type { StorageTargetDetail, StorageTargetPayload, StorageTargetSummary } from '../../types/storage-targets'
import type { StorageConnectionTestResult } from '../../types/storage-targets'
import type { BackupTaskDetail, BackupTaskPayload, BackupTaskType } from '../../types/backup-tasks'
import type { BackupMode, BackupTaskDetail, BackupTaskPayload, BackupTaskType } from '../../types/backup-tasks'
import type { NodeSummary } from '../../types/nodes'
import { DatabasePicker } from '../common/DatabasePicker'
import { DirectoryPicker } from '../common/DirectoryPicker'
@@ -65,6 +65,12 @@ function createEmptyDraft(storageTargets?: StorageTargetSummary[]): BackupTaskPa
compression: 'gzip',
encrypt: false,
maxBackups: 10,
keepDaily: 0,
keepWeekly: 0,
keepMonthly: 0,
keepYearly: 0,
backupMode: 'full',
diffFullIntervalDays: 7,
extraConfig: undefined,
verifyEnabled: false,
verifyCronExpr: '',
@@ -134,6 +140,12 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
compression: initialValue.compression,
encrypt: initialValue.encrypt,
maxBackups: initialValue.maxBackups,
keepDaily: initialValue.keepDaily ?? 0,
keepWeekly: initialValue.keepWeekly ?? 0,
keepMonthly: initialValue.keepMonthly ?? 0,
keepYearly: initialValue.keepYearly ?? 0,
backupMode: initialValue.backupMode ?? 'full',
diffFullIntervalDays: initialValue.diffFullIntervalDays ?? 7,
extraConfig: initialValue.extraConfig,
verifyEnabled: initialValue.verifyEnabled ?? false,
verifyCronExpr: initialValue.verifyCronExpr ?? '',
@@ -190,11 +202,11 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
sourcePath: value === 'file' ? current.sourcePath : '',
sourcePaths: value === 'file' ? current.sourcePaths : [''],
excludePatterns: value === 'file' ? current.excludePatterns : [],
dbHost: value === 'mysql' || value === 'postgresql' || value === 'saphana' ? current.dbHost : '',
dbPort: value === 'mysql' || value === 'postgresql' || value === 'saphana' ? current.dbPort || getDefaultPort(value) : 0,
dbUser: value === 'mysql' || value === 'postgresql' || value === 'saphana' ? current.dbUser : '',
dbPassword: value === 'mysql' || value === 'postgresql' || value === 'saphana' ? current.dbPassword : '',
dbName: value === 'mysql' || value === 'postgresql' || value === 'saphana' ? current.dbName : '',
dbHost: isDatabaseBackupTask(value) ? current.dbHost : '',
dbPort: isDatabaseBackupTask(value) ? current.dbPort || getDefaultPort(value) : 0,
dbUser: isDatabaseBackupTask(value) ? current.dbUser : '',
dbPassword: isDatabaseBackupTask(value) ? current.dbPassword : '',
dbName: isDatabaseBackupTask(value) ? current.dbName : '',
dbPath: value === 'sqlite' ? current.dbPath : '',
// 切换到 SAP HANA 时初始化扩展配置;切换到其他类型时清空
extraConfig: value === 'saphana'
@@ -580,6 +592,34 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
<Typography.Text></Typography.Text>
<Select value={draft.compression} options={backupCompressionOptions as unknown as { label: string; value: string }[]} onChange={(value) => updateDraft({ compression: value as BackupTaskPayload['compression'] })} />
</div>
{isFileBackupTask(draft.type) && (
<div>
<Typography.Text></Typography.Text>
<Select
value={draft.backupMode}
options={[
{ label: '全量备份', value: 'full' },
{ label: '差异备份(仅文件、本机)', value: 'differential' },
]}
onChange={(value) => updateDraft({ backupMode: value as BackupMode })}
/>
{draft.backupMode === 'differential' && (
<div style={{ marginTop: 8 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
+
</Typography.Text>
<InputNumber
style={{ width: '100%', marginTop: 4 }}
placeholder="强制全量间隔(天)"
prefix="全量间隔(天)"
min={1}
value={draft.diffFullIntervalDays}
onChange={(value) => updateDraft({ diffFullIntervalDays: Number(value ?? 7) })}
/>
</div>
)}
</div>
)}
<div>
<Typography.Text></Typography.Text>
<InputNumber style={{ width: '100%' }} value={draft.retentionDays} min={0} onChange={(value) => updateDraft({ retentionDays: Number(value ?? 0) })} />
@@ -588,6 +628,25 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
<Typography.Text></Typography.Text>
<InputNumber style={{ width: '100%' }} value={draft.maxBackups} min={0} onChange={(value) => updateDraft({ maxBackups: Number(value ?? 0) })} />
</div>
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
GFS &gt; 0 ////
</Typography.Text>
<Grid.Row gutter={8} style={{ marginTop: 4 }}>
<Grid.Col span={6}>
<InputNumber style={{ width: '100%' }} placeholder="日" prefix="日" min={0} value={draft.keepDaily} onChange={(value) => updateDraft({ keepDaily: Number(value ?? 0) })} />
</Grid.Col>
<Grid.Col span={6}>
<InputNumber style={{ width: '100%' }} placeholder="周" prefix="周" min={0} value={draft.keepWeekly} onChange={(value) => updateDraft({ keepWeekly: Number(value ?? 0) })} />
</Grid.Col>
<Grid.Col span={6}>
<InputNumber style={{ width: '100%' }} placeholder="月" prefix="月" min={0} value={draft.keepMonthly} onChange={(value) => updateDraft({ keepMonthly: Number(value ?? 0) })} />
</Grid.Col>
<Grid.Col span={6}>
<InputNumber style={{ width: '100%' }} placeholder="年" prefix="年" min={0} value={draft.keepYearly} onChange={(value) => updateDraft({ keepYearly: Number(value ?? 0) })} />
</Grid.Col>
</Grid.Row>
</div>
<div>
<Typography.Text></Typography.Text>
<Input

View File

@@ -6,10 +6,12 @@ export const backupTaskTypeOptions = [
{ label: 'SQLite', value: 'sqlite' },
{ label: 'PostgreSQL', value: 'postgresql' },
{ label: 'SAP HANA', value: 'saphana' },
{ label: 'MongoDB', value: 'mongodb' },
] as const
export const backupCompressionOptions = [
{ label: 'Gzip 压缩', value: 'gzip' },
{ label: 'Zstd 压缩(更快/更小)', value: 'zstd' },
{ label: '不压缩', value: 'none' },
] as const
@@ -25,6 +27,8 @@ export function getBackupTaskTypeLabel(type: BackupTaskType) {
return 'PostgreSQL'
case 'saphana':
return 'SAP HANA'
case 'mongodb':
return 'MongoDB'
default:
return type
}
@@ -67,7 +71,7 @@ export function isSQLiteBackupTask(type: BackupTaskType) {
}
export function isDatabaseBackupTask(type: BackupTaskType) {
return type === 'mysql' || type === 'postgresql' || type === 'saphana'
return type === 'mysql' || type === 'postgresql' || type === 'saphana' || type === 'mongodb'
}
export function getDefaultPort(type: BackupTaskType) {
@@ -78,13 +82,22 @@ export function getDefaultPort(type: BackupTaskType) {
return 5432
case 'saphana':
return 30015
case 'mongodb':
return 27017
default:
return 0
}
}
export function getCompressionLabel(compression: BackupCompression) {
return compression === 'gzip' ? 'Gzip' : '无'
switch (compression) {
case 'gzip':
return 'Gzip'
case 'zstd':
return 'Zstd'
default:
return '无'
}
}
/** SAP HANA 备份级别选项 */

View File

@@ -1,4 +1,5 @@
import { Alert, Descriptions, Modal, Space, Tag, Typography } from '@arco-design/web-react'
import { Alert, Descriptions, Input, Modal, Space, Tag, Typography } from '@arco-design/web-react'
import { useState } from 'react'
import type { BackupRecordDetail } from '../../types/backup-records'
import type { BackupTaskDetail } from '../../types/backup-tasks'
@@ -8,21 +9,26 @@ interface RestoreConfirmModalProps {
backupRecord: BackupRecordDetail | null
task: BackupTaskDetail | null
onCancel: () => void
onConfirm: () => void
onConfirm: (targetPath?: string) => void
}
// RestoreConfirmModal 展示即将恢复的备份摘要与覆盖风险,强制用户二次确认。
// 恢复是破坏性操作:会覆盖任务配置的源路径/数据库,不可撤销。
// 文件类型 + 本机恢复时,允许指定「恢复到其他目录」以避免覆盖原位置。
export function RestoreConfirmModal({ visible, loading, backupRecord, task, onCancel, onConfirm }: RestoreConfirmModalProps) {
const [targetPath, setTargetPath] = useState('')
if (!backupRecord || !task) {
return (
<Modal visible={visible} title="确认恢复" onCancel={onCancel} onOk={onConfirm} confirmLoading={loading} unmountOnExit>
<Modal visible={visible} title="确认恢复" onCancel={onCancel} onOk={() => onConfirm()} confirmLoading={loading} unmountOnExit>
<Alert type="info" content="正在加载任务与备份信息..." />
</Modal>
)
}
const restoreTarget = renderRestoreTarget(task)
const isLocal = !task.nodeId || task.nodeId === 0
const allowAltPath = task.type === 'file' && isLocal
const nodeLabel = task.nodeId && task.nodeId > 0
? (task.nodeName ? `${task.nodeName}(远程节点)` : `节点 #${task.nodeId}`)
: '本机 Master'
@@ -35,8 +41,9 @@ export function RestoreConfirmModal({ visible, loading, backupRecord, task, onCa
cancelText="取消"
okButtonProps={{ status: 'danger', loading }}
onCancel={onCancel}
onOk={onConfirm}
onOk={() => onConfirm(targetPath.trim() || undefined)}
unmountOnExit
afterClose={() => setTargetPath('')}
>
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
<Alert
@@ -51,9 +58,23 @@ export function RestoreConfirmModal({ visible, loading, backupRecord, task, onCa
{ label: '类型', value: <Tag color="arcoblue" bordered>{task.type.toUpperCase()}</Tag> },
{ label: '执行节点', value: nodeLabel },
{ label: '源备份', value: backupRecord.fileName || '-' },
{ label: '恢复目标', value: restoreTarget },
{ label: '恢复目标', value: targetPath.trim() ? <Typography.Text code>{targetPath.trim()}</Typography.Text> : restoreTarget },
]}
/>
{allowAltPath && (
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
</Typography.Text>
<Input
style={{ marginTop: 4 }}
allowClear
value={targetPath}
placeholder="/path/to/restore-here"
onChange={(value) => setTargetPath(value)}
/>
</div>
)}
</Space>
</Modal>
)
@@ -80,7 +101,7 @@ function renderRestoreTarget(task: BackupTaskDetail) {
if (task.type === 'sqlite') {
return <Typography.Text code>{task.dbPath || '-'}</Typography.Text>
}
if (task.type === 'mysql' || task.type === 'postgresql' || task.type === 'saphana') {
if (task.type === 'mysql' || task.type === 'postgresql' || task.type === 'saphana' || task.type === 'mongodb') {
return (
<Typography.Text>
{task.dbUser}@{task.dbHost}:{task.dbPort} / <Typography.Text code>{task.dbName || '-'}</Typography.Text>

View File

@@ -20,6 +20,7 @@ import {
IconCloud,
IconDesktop,
IconList,
IconFilePdf,
} from '@arco-design/web-react/icon'
import { useState } from 'react'
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
@@ -106,6 +107,7 @@ interface MenuItemConfig {
const menuItems: MenuItemConfig[] = [
{ key: '/dashboard', label: '仪表盘', icon: <IconDashboard /> },
{ key: '/reports', label: '合规报表', icon: <IconFilePdf /> },
{ key: '/backup/tasks', label: '备份任务', icon: <IconFile /> },
{ key: '/backup/records', label: '备份记录', icon: <IconHistory /> },
{ key: '/restore/records', label: '恢复记录', icon: <IconRefresh /> },

View File

@@ -1,7 +1,10 @@
import { Button, DatePicker, Input, Message, PageHeader, Select, Space, Table, Tag, Typography } from '@arco-design/web-react'
import { Button, DatePicker, Input, InputNumber, Message, PageHeader, Select, Space, Table, Tag, Typography } from '@arco-design/web-react'
import type { ColumnProps } from '@arco-design/web-react/es/Table'
import { useCallback, useEffect, useState } from 'react'
import { exportAuditLogs, listAuditLogs } from '../../services/audit'
import { fetchSettings, updateSettings } from '../../services/system'
import { useAuthStore } from '../../stores/auth'
import { isAdmin } from '../../utils/permissions'
import type { AuditLog } from '../../types/audit'
import { formatDateTime } from '../../utils/format'
import { resolveErrorMessage } from '../../utils/error'
@@ -112,6 +115,9 @@ export function AuditLogsPage() {
const [dateRange, setDateRange] = useState<string[] | null>(null)
const [page, setPage] = useState(1)
const [exporting, setExporting] = useState(false)
const admin = isAdmin(useAuthStore((state) => state.user))
const [retentionDays, setRetentionDays] = useState(0)
const [savingRetention, setSavingRetention] = useState(false)
const fetchData = useCallback(async (currentPage: number) => {
setLoading(true)
@@ -139,6 +145,31 @@ export function AuditLogsPage() {
void fetchData(page)
}, [page, fetchData])
// 管理员加载当前审计日志保留期设置。
useEffect(() => {
if (!admin) return
void (async () => {
try {
const settings = await fetchSettings()
setRetentionDays(Number(settings.audit_retention_days ?? 0) || 0)
} catch {
/* 读取失败时保持默认 0永久保留不打断主流程 */
}
})()
}, [admin])
async function handleSaveRetention() {
setSavingRetention(true)
try {
await updateSettings({ audit_retention_days: String(retentionDays) })
Message.success(retentionDays > 0 ? `已设置:保留最近 ${retentionDays}` : '已设置:永久保留')
} catch (e) {
Message.error(resolveErrorMessage(e, '保存保留期失败'))
} finally {
setSavingRetention(false)
}
}
async function handleExport() {
setExporting(true)
try {
@@ -171,6 +202,27 @@ export function AuditLogsPage() {
style={{ paddingBottom: 0 }}
title="审计日志"
subTitle="记录系统中所有关键操作,保障数据操作链可溯源。支持高级筛选与 CSV 导出(最多 10000 行)。"
extra={
admin ? (
<Space>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
</Typography.Text>
<InputNumber
style={{ width: 150 }}
min={0}
max={3650}
value={retentionDays}
onChange={(value) => setRetentionDays(Number(value) || 0)}
suffix="天"
placeholder="0=永久"
/>
<Button type="primary" size="small" loading={savingRetention} onClick={() => void handleSaveRetention()}>
</Button>
</Space>
) : undefined
}
/>
{error ? <Typography.Text type="error">{error}</Typography.Text> : null}
<Space wrap>

View File

@@ -2,7 +2,7 @@ import { Button, Card, Empty, Message, Select, Space, Table, Tag, Typography } f
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { BackupRecordLogDrawer } from '../../components/backup-records/BackupRecordLogDrawer'
import { listBackupRecords } from '../../services/backup-records'
import { listBackupRecords, setBackupRecordLock } from '../../services/backup-records'
import { listBackupTasks } from '../../services/backup-tasks'
import type { BackupRecordStatus, BackupRecordSummary } from '../../types/backup-records'
import type { BackupTaskSummary } from '../../types/backup-tasks'
@@ -33,6 +33,7 @@ export function BackupRecordsPage() {
const [tasks, setTasks] = useState<BackupTaskSummary[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [lockBusyId, setLockBusyId] = useState<number | null>(null)
const selectedTaskId = Number(searchParams.get('taskId') ?? 0) || undefined
const selectedRecordId = Number(searchParams.get('recordId') ?? 0) || undefined
@@ -64,6 +65,19 @@ export function BackupRecordsPage() {
void loadData()
}, [loadData])
async function handleToggleLock(record: BackupRecordSummary) {
setLockBusyId(record.id)
try {
await setBackupRecordLock(record.id, !record.locked)
Message.success(record.locked ? '已解除保留锁定' : '已锁定:该备份将豁免保留清理与删除')
await loadData()
} catch (e) {
Message.error(resolveErrorMessage(e, '操作失败'))
} finally {
setLockBusyId(null)
}
}
function updateSearchParam(key: 'taskId' | 'status' | 'recordId', value?: string) {
const nextParams = new URLSearchParams(searchParams)
if (!value || value === '0') {
@@ -96,7 +110,11 @@ export function BackupRecordsPage() {
dataIndex: 'fileName',
render: (_: unknown, record: BackupRecordSummary) => (
<Space direction="vertical" size={2}>
<Typography.Text>{record.fileName || '-'}</Typography.Text>
<Space size={4}>
<Typography.Text>{record.fileName || '-'}</Typography.Text>
{record.locked && <Tag color="orange" size="small" bordered></Tag>}
{record.backupKind === 'differential' && <Tag color="purple" size="small" bordered></Tag>}
</Space>
<Typography.Text type="secondary">{formatBytes(record.fileSize)}</Typography.Text>
{record.checksum && (
<Typography.Text type="secondary" copyable style={{ fontSize: 11 }}>
@@ -129,11 +147,24 @@ export function BackupRecordsPage() {
{
title: '操作',
dataIndex: 'actions',
width: 120,
width: 180,
render: (_: unknown, record: BackupRecordSummary) => (
<Button size="small" type="text" onClick={() => updateSearchParam('recordId', String(record.id))}>
</Button>
<Space size={4}>
<Button size="small" type="text" onClick={() => updateSearchParam('recordId', String(record.id))}>
</Button>
{record.status === 'success' && (
<Button
size="small"
type="text"
status={record.locked ? 'warning' : 'default'}
loading={lockBusyId === record.id}
onClick={() => void handleToggleLock(record)}
>
{record.locked ? '解锁' : '锁定'}
</Button>
)}
</Space>
),
},
]

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { UserInfo } from '../../services/auth'
import { canManageNodes } from './NodesPage'
import { canManageNodes, formatQueueAge, getNodeHealthView } from './NodesPage'
import type { NodeSummary } from '../../types/nodes'
function user(role: string): UserInfo {
return {
@@ -19,3 +20,58 @@ describe('canManageNodes', () => {
expect(canManageNodes(null)).toBe(false)
})
})
describe('node diagnostics helpers', () => {
it('formats queue age and health status from backend summaries', () => {
const node: NodeSummary = {
id: 1,
name: 'edge-a',
hostname: '',
ipAddress: '',
status: 'online',
isLocal: false,
os: 'linux',
arch: 'amd64',
agentVersion: 'v1',
lastSeen: '2026-05-12T00:00:00Z',
createdAt: '2026-05-12T00:00:00Z',
health: 'degraded',
lastError: 'agent timeout',
runningTasks: 1,
queue: {
pending: 2,
dispatched: 1,
depth: 3,
timeouts: 1,
oldestActiveAgeSeconds: 125,
},
}
expect(formatQueueAge(node.queue?.oldestActiveAgeSeconds)).toBe('2m')
expect(getNodeHealthView(node)).toEqual({
text: '异常',
badgeStatus: 'warning',
tagColor: 'orangered',
tooltip: 'agent timeout',
})
})
it('treats offline nodes as offline even without queue errors', () => {
const node = {
id: 2,
name: 'edge-b',
hostname: '',
ipAddress: '',
status: 'offline',
isLocal: false,
os: '',
arch: '',
agentVersion: '',
lastSeen: '',
createdAt: '',
} satisfies NodeSummary
expect(formatQueueAge(0)).toBe('-')
expect(getNodeHealthView(node).text).toBe('离线')
})
})

View File

@@ -20,6 +20,28 @@ export function canManageNodes(user: UserInfo | null | undefined): boolean {
return isAdmin(user)
}
export function formatQueueAge(seconds?: number): string {
if (!seconds || seconds <= 0) return '-'
if (seconds < 60) return `${seconds}s`
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
return `${Math.floor(seconds / 3600)}h`
}
export function getNodeHealthView(node: NodeSummary) {
if (node.status !== 'online' || node.health === 'offline') {
return { text: '离线', badgeStatus: 'default' as const, tagColor: 'gray', tooltip: '节点未在线' }
}
if (node.health === 'degraded' || node.queue?.timeouts || node.lastError) {
return {
text: '异常',
badgeStatus: 'warning' as const,
tagColor: 'orangered',
tooltip: node.lastError || '存在超时或失败的 Agent 命令',
}
}
return { text: '健康', badgeStatus: 'success' as const, tagColor: 'green', tooltip: 'Agent 心跳与队列状态正常' }
}
export default function NodesPage() {
const [nodes, setNodes] = useState<NodeSummary[]>([])
const [loading, setLoading] = useState(false)
@@ -122,10 +144,18 @@ export default function NodesPage() {
),
},
{
title: '状态', dataIndex: 'status', width: 100,
render: (status: string) => status === 'online'
? <Badge status="success" text="在线" />
: <Badge status="default" text="离线" />,
title: '健康', dataIndex: 'health', width: 150,
render: (_: string, record: NodeSummary) => {
const health = getNodeHealthView(record)
return (
<Tooltip content={health.tooltip}>
<Space size={6}>
<Badge status={health.badgeStatus} />
<Tag color={health.tagColor}>{health.text}</Tag>
</Space>
</Tooltip>
)
},
},
{ title: '主机名', dataIndex: 'hostname', render: (v: string) => v || '-' },
{ title: 'IP 地址', dataIndex: 'ipAddress', render: (v: string) => v || '-' },
@@ -138,6 +168,27 @@ export default function NodesPage() {
title: 'Agent 版本', dataIndex: 'agentVersion', width: 140,
render: (v: string) => renderAgentVersion(v, masterVersion),
},
{
title: '队列', dataIndex: 'queue', width: 160,
render: (_: unknown, record: NodeSummary) => {
const queue = record.queue
if (!queue || queue.depth === 0) {
return <Text type="secondary"></Text>
}
return (
<Tooltip content={`pending ${queue.pending} / dispatched ${queue.dispatched} / oldest ${formatQueueAge(queue.oldestActiveAgeSeconds)}`}>
<Space size={4}>
<Tag color="arcoblue"> {queue.depth}</Tag>
{queue.timeouts > 0 && <Tag color="orangered"> {queue.timeouts}</Tag>}
</Space>
</Tooltip>
)
},
},
{
title: '运行中', dataIndex: 'runningTasks', width: 90,
render: (v: number | undefined) => v && v > 0 ? <Tag color="green">{v}</Tag> : <Text type="secondary">0</Text>,
},
{
title: '标签 / 节点池', dataIndex: 'labels', width: 180,
render: (v: string) => {

View File

@@ -0,0 +1,185 @@
import { Button, Card, Grid, Message, Select, Space, Statistic, Table, Tag, Typography } from '@arco-design/web-react'
import { IconDownload, IconRefresh } from '@arco-design/web-react/icon'
import { useCallback, useEffect, useState } from 'react'
import { downloadComplianceCSV, fetchComplianceReport } from '../../services/reports'
import type { ComplianceReport, ComplianceRisk, ComplianceTaskRow } from '../../types/reports'
import { resolveErrorMessage } from '../../utils/error'
import { formatBytes, formatDateTime, formatPercent } from '../../utils/format'
const { Row, Col } = Grid
const { Title, Text } = Typography
const rangeOptions = [
{ label: '近 7 天', value: 7 },
{ label: '近 30 天', value: 30 },
{ label: '近 90 天', value: 90 },
{ label: '近 180 天', value: 180 },
{ label: '近 365 天', value: 365 },
]
function riskTag(risk: ComplianceRisk) {
switch (risk) {
case 'ok':
return <Tag color="green"></Tag>
case 'at_risk':
return <Tag color="red"></Tag>
default:
return <Tag color="gray"></Tag>
}
}
export function ReportsPage() {
const [days, setDays] = useState(30)
const [report, setReport] = useState<ComplianceReport | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [exporting, setExporting] = useState(false)
const loadData = useCallback(async (range: number) => {
setLoading(true)
try {
const data = await fetchComplianceReport(range)
setReport(data)
setError('')
} catch (e) {
setError(resolveErrorMessage(e, '加载合规报表失败'))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void loadData(days)
}, [days, loadData])
async function handleExport() {
setExporting(true)
try {
await downloadComplianceCSV(days)
Message.success('已导出 CSV')
} catch (e) {
Message.error(resolveErrorMessage(e, '导出失败'))
} finally {
setExporting(false)
}
}
const summary = report?.summary
const columns = [
{
title: '任务',
dataIndex: 'taskName',
render: (_: unknown, row: ComplianceTaskRow) => (
<Space direction="vertical" size={2}>
<Text style={{ fontWeight: 600 }}>{row.taskName}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{row.type} · {row.nodeName || '本机'}
</Text>
</Space>
),
},
{ title: '状态', dataIndex: 'risk', render: (_: unknown, row: ComplianceTaskRow) => riskTag(row.risk) },
{
title: '成功率',
dataIndex: 'successRate',
render: (value: number, row: ComplianceTaskRow) => (row.totalRuns > 0 ? formatPercent(value) : '—'),
},
{
title: '周期内(成功/失败)',
dataIndex: 'totalRuns',
render: (_: unknown, row: ComplianceTaskRow) => `${row.successes} / ${row.failures}`,
},
{
title: '最近成功',
dataIndex: 'lastSuccessAt',
render: (value?: string) => (value ? formatDateTime(value) : <Text type="secondary"></Text>),
},
{ title: '保护量', dataIndex: 'protectedBytes', render: (value: number) => formatBytes(value) },
{
title: '加密',
dataIndex: 'encrypted',
render: (value: boolean) =>
value ? <Tag color="arcoblue" size="small"></Tag> : <Text type="secondary"></Text>,
},
{ title: 'SLA(RPO)', dataIndex: 'slaHoursRpo', render: (value: number) => (value > 0 ? `${value}h` : '—') },
]
const statCards = [
{ title: '受保护任务', value: summary?.enabledTasks ?? 0, suffix: `/ ${summary?.totalTasks ?? 0}` },
{ title: '合规任务', value: summary?.compliantTasks ?? 0, color: 'rgb(var(--green-6))' },
{ title: '风险任务', value: summary?.atRiskTasks ?? 0, color: 'rgb(var(--red-6))' },
{ title: '已加密任务', value: summary?.encryptedTasks ?? 0 },
]
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
<div>
<Title heading={5} style={{ margin: 0 }}>
</Title>
<Text type="secondary" style={{ fontSize: 12 }}>
{report
? `生成于 ${formatDateTime(report.generatedAt)} · 统计窗口 ${report.rangeDays}`
: '按任务的备份合规证据,可导出归档以供审计'}
</Text>
</div>
<Space>
<Select value={days} onChange={(value) => setDays(value as number)} options={rangeOptions} style={{ width: 130 }} />
<Button icon={<IconRefresh />} onClick={() => void loadData(days)} loading={loading}>
</Button>
<Button type="primary" icon={<IconDownload />} onClick={() => void handleExport()} loading={exporting} disabled={loading}>
CSV
</Button>
</Space>
</div>
<Row gutter={16}>
{statCards.map((card) => (
<Col span={6} key={card.title}>
<Card>
<Statistic
title={card.title}
value={card.value}
suffix={card.suffix}
groupSeparator
styleValue={card.color ? { color: card.color } : undefined}
/>
</Card>
</Col>
))}
</Row>
<Row gutter={16}>
<Col span={12}>
<Card>
<Statistic title="整体成功率" value={summary ? Number((summary.overallSuccessRate * 100).toFixed(1)) : 0} suffix="%" />
</Card>
</Col>
<Col span={12}>
<Card>
<Statistic title="受保护数据总量" value={formatBytes(summary?.totalProtectedBytes)} />
</Card>
</Col>
</Row>
{error ? (
<Card>
<Text type="error">{error}</Text>
</Card>
) : (
<Card>
<Table
rowKey="taskId"
loading={loading}
columns={columns}
data={report?.tasks ?? []}
pagination={{ pageSize: 20, sizeCanChange: true }}
border={false}
/>
</Card>
)}
</Space>
)
}

View File

@@ -15,6 +15,7 @@ import { GoogleDriveCallbackPage } from '../pages/storage-targets/GoogleDriveCal
import { StorageTargetsPage } from '../pages/storage-targets/StorageTargetsPage'
import { SettingsPage } from '../pages/settings/SettingsPage'
import { AuditLogsPage } from '../pages/audit/AuditLogsPage'
import { ReportsPage } from '../pages/reports/ReportsPage'
import NodesPage from '../pages/nodes/NodesPage'
import { ProtectedRoute } from './ProtectedRoute'
@@ -32,6 +33,7 @@ export function RouterView() {
>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="reports" element={<ReportsPage />} />
<Route path="backup/tasks" element={<BackupTasksPage />} />
<Route path="backup/records" element={<BackupRecordsPage />} />
<Route path="restore/records" element={<RestoreRecordsPage />} />

View File

@@ -1,5 +1,5 @@
import { http, getAccessToken, type ApiEnvelope, unwrapApiEnvelope } from './http'
import type { BackupLogEvent, BackupRecordDetail, BackupRecordListFilter, BackupRecordSummary } from '../types/backup-records'
import type { BackupLogEvent, BackupRecordContents, BackupRecordDetail, BackupRecordListFilter, BackupRecordSummary } from '../types/backup-records'
import { resolveErrorMessage } from '../utils/error'
interface RecordLogStreamHandlers {
@@ -69,6 +69,12 @@ export async function getBackupRecord(id: number) {
return unwrapApiEnvelope(response.data)
}
// getBackupRecordContents 获取备份记录的文件清单(内容浏览,只读)。
export async function getBackupRecordContents(id: number) {
const response = await http.get<ApiEnvelope<BackupRecordContents>>(`/backup/records/${id}/contents`)
return unwrapApiEnvelope(response.data)
}
export async function downloadBackupRecord(id: number) {
const response = await http.get<Blob>(`/backup/records/${id}/download`, { responseType: 'blob' })
return {
@@ -89,6 +95,12 @@ export async function deleteBackupRecord(id: number) {
return unwrapApiEnvelope(response.data)
}
// setBackupRecordLock 设置/解除备份记录的保留锁定(法律保留)。
export async function setBackupRecordLock(id: number, locked: boolean) {
const response = await http.put<ApiEnvelope<BackupRecordDetail>>(`/backup/records/${id}/lock`, { locked })
return unwrapApiEnvelope(response.data)
}
export function streamBackupRecordLogs(recordId: number, handlers: RecordLogStreamHandlers) {
const controller = new AbortController()

View File

@@ -0,0 +1,24 @@
import { http, type ApiEnvelope, unwrapApiEnvelope } from './http'
import type { ComplianceReport } from '../types/reports'
export async function fetchComplianceReport(days = 30) {
const response = await http.get<ApiEnvelope<ComplianceReport>>('/reports/compliance', { params: { days } })
return unwrapApiEnvelope(response.data)
}
// downloadComplianceCSV 通过带认证的 http 客户端拉取 CSV blob 并触发浏览器下载。
export async function downloadComplianceCSV(days = 30) {
const response = await http.get('/reports/compliance/export', {
params: { days },
responseType: 'blob',
})
const blob = new Blob([response.data], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `backupx-compliance-${days}d.csv`
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}

View File

@@ -39,9 +39,19 @@ export async function getRestoreRecord(id: number) {
return unwrapApiEnvelope(response.data)
}
// startRestoreFromBackup 通过源备份记录启动恢复。返回新建的恢复记录详情。
export async function startRestoreFromBackup(backupRecordId: number) {
const response = await http.post<ApiEnvelope<RestoreRecordDetail>>(`/backup/records/${backupRecordId}/restore`)
// startRestoreFromBackup 通过源备份记录启动恢复。两个可选项互不影响:
// - selectedPaths 非空时为按需(选择性)恢复,仅还原选中的文件/目录;
// - targetPath 非空时把文件归档恢复到该绝对目录而非原始路径(仅文件类型本机恢复)。
// 返回新建的恢复记录详情。
export async function startRestoreFromBackup(backupRecordId: number, selectedPaths?: string[], targetPath?: string) {
const body: { selectedPaths?: string[]; targetPath?: string } = {}
if (selectedPaths && selectedPaths.length > 0) {
body.selectedPaths = selectedPaths
}
if (targetPath && targetPath.trim()) {
body.targetPath = targetPath.trim()
}
const response = await http.post<ApiEnvelope<RestoreRecordDetail>>(`/backup/records/${backupRecordId}/restore`, body)
return unwrapApiEnvelope(response.data)
}

Some files were not shown because too many files have changed in this diff Show More