@shellus/way 0.6.10 → 0.6.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -1
- package/dist/cli.js +520 -73
- package/package.json +9 -11
- package/repositories.yaml.example +1 -0
- package/rules.yaml.example +20 -0
package/README.md
CHANGED
|
@@ -39,6 +39,8 @@
|
|
|
39
39
|
|
|
40
40
|
定期清理旧快照,防止存储膨胀。
|
|
41
41
|
|
|
42
|
+
正式 `way gc` 在删除快照前执行普通 `restic unlock`,仅回收 restic 判定失效的锁,不使用 `--remove-all`。解锁失败时停止清理;`--dry-run` 不解锁。活跃锁仍由 restic 保护。
|
|
43
|
+
|
|
42
44
|
### 7. 同步不等于备份
|
|
43
45
|
|
|
44
46
|
同步工具(如 Syncthing)会将误操作实时传播到所有节点。真正的备份必须具备版本历史和回滚能力。
|
|
@@ -52,6 +54,7 @@
|
|
|
52
54
|
- 支持项目级 schedule 配置(不同项目不同频率)
|
|
53
55
|
- 使用 node-cron 实现精确到分钟的调度
|
|
54
56
|
- systemd service 管理常驻进程(自动重启)
|
|
57
|
+
- 支持把本地仓库中各项目的最新快照定期复制到远程仓库
|
|
55
58
|
|
|
56
59
|
### 9. 多层冗余原则
|
|
57
60
|
|
|
@@ -68,7 +71,7 @@
|
|
|
68
71
|
## 依赖
|
|
69
72
|
|
|
70
73
|
- Linux x64 独立发行包无需预装 Node.js、npm、Bun 或 restic
|
|
71
|
-
- npm 安装方式需要 Node.js >=
|
|
74
|
+
- npm 安装方式需要 Node.js >= 22.12
|
|
72
75
|
- Linux x64 平台内置 [restic](https://restic.net/) 0.18.1,其他平台需自行安装 restic
|
|
73
76
|
|
|
74
77
|
`way` 查找 restic 的顺序:
|
|
@@ -137,6 +140,15 @@ way systemd install
|
|
|
137
140
|
way systemd status
|
|
138
141
|
```
|
|
139
142
|
|
|
143
|
+
Windows 使用系统自带的任务计划程序守护 `way daemon`,不依赖 NSSM 或其他常驻工具:
|
|
144
|
+
|
|
145
|
+
```powershell
|
|
146
|
+
way windows-service install
|
|
147
|
+
way windows-service status
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`windows-service install` 会创建以 `SYSTEM` 身份在开机后运行的原生任务,并配置失败重启;注册和删除任务需要在提升权限的 PowerShell 中执行。任务仅守护 daemon,实际备份频率仍由 `rules.yaml` 的项目级 `schedule` 决定。
|
|
151
|
+
|
|
140
152
|
### 4. 手动执行备份
|
|
141
153
|
|
|
142
154
|
```bash
|
|
@@ -159,6 +171,8 @@ way restore data --host old-host --target /tmp/restore --dry-run # 从指定 ho
|
|
|
159
171
|
way restore data --target /tmp/restore --delete # 删除目标中快照不存在的文件
|
|
160
172
|
way gc # 按 retention 策略清理旧快照
|
|
161
173
|
way gc --dry-run # 模拟清理(不实际删除)
|
|
174
|
+
way replicate # 执行全部仓库复制任务
|
|
175
|
+
way replicate local-to-s3 --init # 首次初始化目标仓库并执行复制
|
|
162
176
|
|
|
163
177
|
# daemon 模式(推荐)
|
|
164
178
|
way daemon # 启动常驻进程,按配置定时执行
|
|
@@ -169,6 +183,12 @@ way systemd show # 显示 systemd 配置
|
|
|
169
183
|
way systemd status # 查看服务状态
|
|
170
184
|
way systemd uninstall # 卸载服务
|
|
171
185
|
|
|
186
|
+
# Windows 原生守护任务管理
|
|
187
|
+
way windows-service install # 注册开机启动、SYSTEM 身份运行的 daemon 守护任务
|
|
188
|
+
way windows-service show # 显示将要写入的任务与启动脚本
|
|
189
|
+
way windows-service status # 查看任务状态
|
|
190
|
+
way windows-service uninstall # 删除任务与启动脚本
|
|
191
|
+
|
|
172
192
|
# 显式透传 restic(way 只设置环境变量)
|
|
173
193
|
way restic snapshots # → restic snapshots
|
|
174
194
|
way restic check # → restic check
|
|
@@ -190,9 +210,12 @@ graph LR
|
|
|
190
210
|
C --> D[node-cron 定时触发]
|
|
191
211
|
D --> E[执行 restic backup]
|
|
192
212
|
E --> F[推送 Uptime Kuma]
|
|
213
|
+
D --> K[选择各项目最新快照]
|
|
214
|
+
K --> L[restic copy 到远程仓库]
|
|
193
215
|
|
|
194
216
|
G[systemd service] --> H[启动 daemon]
|
|
195
217
|
H --> I[进程崩溃自动重启]
|
|
218
|
+
J[Windows Task Scheduler] --> H
|
|
196
219
|
```
|
|
197
220
|
|
|
198
221
|
---
|
|
@@ -221,9 +244,32 @@ WAY_DIR=/path/to/config way restic snapshots
|
|
|
221
244
|
- **projects.*.hooks**: 项目级备份钩子,支持 `before_backup` 和 `after_backup`
|
|
222
245
|
- **uptime_kuma.push_url**: 全局 Uptime Kuma Push 地址,作为项目未配置通知地址时的回退
|
|
223
246
|
- **projects.*.uptime_kuma.push_url**: 项目级 Uptime Kuma Push 地址,优先于全局地址
|
|
247
|
+
- **replications**: 仓库复制任务,包含源仓库、目标仓库、调度和目标仓库独立保留策略
|
|
224
248
|
- **maintenance**: 维护任务配置(prune、check)
|
|
249
|
+
- **maintenance.prune.uptime_kuma.push_url**: daemon 定时 prune 的 Uptime Kuma Push 地址,未配置时回退到全局地址;prune 成功推送 UP,失败推送带原因的 DOWN
|
|
225
250
|
- **global_excludes**: 全局排除规则
|
|
226
251
|
|
|
252
|
+
`defaults.retention` 支持两种互斥模式:
|
|
253
|
+
|
|
254
|
+
- 计数保留:`keep_daily`、`keep_weekly`、`keep_monthly`、`keep_yearly`,按 restic 分组规则保留周期快照。
|
|
255
|
+
- 严格范围保留:`keep_hosts` 与 `max_age_days` 必须同时设置。Way 以执行时的当前时间计算截止点,只保留白名单主机在最近指定天数内的快照;其他快照使用明确 ID 删除。该模式不会使用 restic 相对最新快照计算的 `keep-within` 语义。
|
|
256
|
+
|
|
257
|
+
两种模式不得混用。严格范围保留示例:
|
|
258
|
+
|
|
259
|
+
```yaml
|
|
260
|
+
defaults:
|
|
261
|
+
retention:
|
|
262
|
+
keep_hosts: [backup-host]
|
|
263
|
+
max_age_days: 7
|
|
264
|
+
|
|
265
|
+
maintenance:
|
|
266
|
+
prune:
|
|
267
|
+
schedule: "0 4 * * *"
|
|
268
|
+
retry_lock: "30m"
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
`maintenance.prune.retry_lock` 会作为 restic 全局 `--retry-lock` 参数传递。正式启用严格范围保留前必须先执行 `way gc --dry-run`,核对输出的保留和删除快照列表。
|
|
272
|
+
|
|
227
273
|
项目级钩子用于在 restic 备份前后执行一致性快照、校验或清理脚本:
|
|
228
274
|
|
|
229
275
|
```yaml
|
|
@@ -325,6 +371,8 @@ way backup data_deps
|
|
|
325
371
|
|
|
326
372
|
`schedule` 支持 node-cron 字符串或 `false`。`false` 表示不创建自动调度任务,只能通过 `way backup <project>` 或 `way backup` 手动触发。项目未设置 `schedule` 时继承 `defaults.schedule`;如果全局和项目都未设置,则不自动调度。
|
|
327
373
|
|
|
374
|
+
daemon 的调度心跳最多容忍 5 秒延迟;事件循环短暂阻塞后仍会补执行本轮任务,超过窗口则由 node-cron 报告为 missed execution。
|
|
375
|
+
|
|
328
376
|
| 格式 | 说明 | 示例 |
|
|
329
377
|
|------|------|------|
|
|
330
378
|
| `"0 */2 * * *"` | 间隔表达式 | 每 2 小时 |
|
|
@@ -333,6 +381,27 @@ way backup data_deps
|
|
|
333
381
|
| `"*/30 * * * *"` | 分钟间隔 | 每 30 分钟 |
|
|
334
382
|
| `false` | 禁用自动调度 | 只手动备份 |
|
|
335
383
|
|
|
384
|
+
**仓库复制示例**:
|
|
385
|
+
|
|
386
|
+
```yaml
|
|
387
|
+
replications:
|
|
388
|
+
local-to-s3:
|
|
389
|
+
from: local
|
|
390
|
+
to: s3
|
|
391
|
+
snapshot_policy: latest-per-project
|
|
392
|
+
schedule: "30 5 * * *"
|
|
393
|
+
prune_schedule: "30 6 * * 0"
|
|
394
|
+
retention:
|
|
395
|
+
keep_daily: 30
|
|
396
|
+
keep_weekly: 12
|
|
397
|
+
keep_monthly: 12
|
|
398
|
+
keep_yearly: 3
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
复制任务读取源仓库中所有带 `way:<project>` 标签的快照,每个项目只选择时间最新的一份传给 `restic copy`。因此本地可以高频备份,而远程只接收每日复制时刻的项目最新状态;重复运行会跳过已经复制的源快照。目标仓库的 `prune_schedule` 和 `retention` 独立于默认仓库。
|
|
402
|
+
|
|
403
|
+
目标仓库第一次使用时执行 `way replicate <name> --init`。该命令通过 `--copy-chunker-params` 继承源仓库的分块参数,再执行首次复制;初始化后日常任务只运行 `way replicate <name>`。复制源当前必须是本地仓库,源与目标可以使用不同的 restic 密码。
|
|
404
|
+
|
|
336
405
|
#### 排除规则通配符语法
|
|
337
406
|
|
|
338
407
|
restic 使用 Go 的 filepath.Match 语法:
|
|
@@ -357,10 +426,24 @@ repositories:
|
|
|
357
426
|
path: /backup/repo
|
|
358
427
|
credentials:
|
|
359
428
|
password: your-password # 直接明文
|
|
429
|
+
|
|
430
|
+
s3:
|
|
431
|
+
type: s3
|
|
432
|
+
endpoint: s3.example.com
|
|
433
|
+
bucket: my-backup-bucket
|
|
434
|
+
region: region-1
|
|
435
|
+
options:
|
|
436
|
+
bucket_lookup: dns
|
|
437
|
+
credentials:
|
|
438
|
+
password: your-restic-password
|
|
439
|
+
access_key_id: your-access-key
|
|
440
|
+
secret_access_key: your-secret-key
|
|
360
441
|
```
|
|
361
442
|
|
|
362
443
|
建议设置文件权限:`chmod 600 ~/.way/repositories.yaml`
|
|
363
444
|
|
|
445
|
+
`repositories.yaml` 和 `rules.yaml` 支持 YAML merge anchor(`<<`);Way 使用 YAML 1.2 Core Schema 并显式启用 merge tag,不会同时启用 YAML 1.1 的日期自动转换等额外类型。
|
|
446
|
+
|
|
364
447
|
## 配置备份
|
|
365
448
|
|
|
366
449
|
`~/.way/` 目录包含所有配置和凭证,建议整体备份到安全位置:
|
package/dist/cli.js
CHANGED
|
@@ -2,22 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
|
-
import
|
|
6
|
-
import
|
|
5
|
+
import fs8 from "fs";
|
|
6
|
+
import path9 from "path";
|
|
7
7
|
|
|
8
8
|
// src/core/config.ts
|
|
9
9
|
import fs from "fs";
|
|
10
10
|
import path from "path";
|
|
11
|
-
import yaml from "js-yaml";
|
|
11
|
+
import * as yaml from "js-yaml";
|
|
12
|
+
var configSchema = yaml.CORE_SCHEMA.withTags(yaml.mergeTag);
|
|
12
13
|
function loadConfig(wayDir, remoteName) {
|
|
13
14
|
const repoFile = path.join(wayDir, "repositories.yaml");
|
|
14
|
-
const repoConfig = yaml.load(fs.readFileSync(repoFile, "utf8"));
|
|
15
|
+
const repoConfig = yaml.load(fs.readFileSync(repoFile, "utf8"), { schema: configSchema });
|
|
15
16
|
const repoName = remoteName === "default" ? repoConfig.default : remoteName;
|
|
16
17
|
const repository = repoConfig.repositories[repoName];
|
|
17
18
|
if (!repository) throw new Error(`Repository not found: ${repoName}`);
|
|
18
19
|
const rulesFile = path.join(wayDir, "rules.yaml");
|
|
19
|
-
const rules = yaml.load(fs.readFileSync(rulesFile, "utf8"));
|
|
20
|
-
|
|
20
|
+
const rules = yaml.load(fs.readFileSync(rulesFile, "utf8"), { schema: configSchema });
|
|
21
|
+
const legacySchedule = "schedule" in rules ? rules.schedule : void 0;
|
|
22
|
+
if (typeof legacySchedule === "object" && legacySchedule !== null && "backup" in legacySchedule) {
|
|
21
23
|
throw new Error("\u68C0\u6D4B\u5230 v0.4.x \u65E7\u914D\u7F6E\u683C\u5F0F\uFF0C\u8BF7\u8FD0\u884C\u8FC1\u79FB\u811A\u672C\uFF1Anpx --yes @shellus/way@latest migrate-to-v0.5.sh \u6216\u624B\u52A8\u53C2\u8003 rules.yaml.example \u66F4\u65B0\u914D\u7F6E");
|
|
22
24
|
}
|
|
23
25
|
return { repository, rules };
|
|
@@ -91,19 +93,20 @@ function resolveResticBin(options = {}) {
|
|
|
91
93
|
}
|
|
92
94
|
|
|
93
95
|
// src/core/restic.ts
|
|
94
|
-
function
|
|
95
|
-
const env = {};
|
|
96
|
+
function buildRepositoryLocation(repo) {
|
|
96
97
|
switch (repo.type) {
|
|
97
98
|
case "s3":
|
|
98
|
-
|
|
99
|
-
break;
|
|
99
|
+
return `s3:https://${repo.endpoint}/${repo.bucket}`;
|
|
100
100
|
case "local":
|
|
101
|
-
|
|
102
|
-
break;
|
|
101
|
+
return repo.path;
|
|
103
102
|
case "sftp":
|
|
104
|
-
|
|
105
|
-
break;
|
|
103
|
+
return `sftp:${repo.host}:${repo.path}`;
|
|
106
104
|
}
|
|
105
|
+
}
|
|
106
|
+
function buildResticEnv(repo) {
|
|
107
|
+
const env = {
|
|
108
|
+
RESTIC_REPOSITORY: buildRepositoryLocation(repo)
|
|
109
|
+
};
|
|
107
110
|
if (repo.credentials.password) env.RESTIC_PASSWORD = repo.credentials.password;
|
|
108
111
|
if (repo.credentials.access_key_id) env.AWS_ACCESS_KEY_ID = repo.credentials.access_key_id;
|
|
109
112
|
if (repo.credentials.secret_access_key) env.AWS_SECRET_ACCESS_KEY = repo.credentials.secret_access_key;
|
|
@@ -178,6 +181,12 @@ function collectIncludeDirs(paths, includeDirs, options = {}) {
|
|
|
178
181
|
}
|
|
179
182
|
return Array.from(new Set(matches));
|
|
180
183
|
}
|
|
184
|
+
function normalizeResticPath(value, platform = process.platform) {
|
|
185
|
+
if (platform !== "win32") return value;
|
|
186
|
+
const match = value.match(/^([A-Za-z]):[\\/](.*)$/);
|
|
187
|
+
if (!match) return value.replace(/\\/g, "/");
|
|
188
|
+
return `/${match[1].toUpperCase()}/${match[2].replace(/\\/g, "/")}`;
|
|
189
|
+
}
|
|
181
190
|
function buildRestoreArgs(name, project, options) {
|
|
182
191
|
const args = [
|
|
183
192
|
"restore",
|
|
@@ -186,7 +195,7 @@ function buildRestoreArgs(name, project, options) {
|
|
|
186
195
|
];
|
|
187
196
|
if (options.host) args.push(`--host=${options.host}`);
|
|
188
197
|
args.push(`--target=${options.target}`);
|
|
189
|
-
for (const
|
|
198
|
+
for (const path10 of options.includePaths || project.paths) args.push(`--include=${normalizeResticPath(path10, options.platform)}`);
|
|
190
199
|
if (options.dryRun) args.push("--dry-run");
|
|
191
200
|
if (options.delete) args.push("--delete");
|
|
192
201
|
if (options.verbose) args.push("--verbose=2");
|
|
@@ -197,11 +206,24 @@ function buildS3Options(repo) {
|
|
|
197
206
|
if (repo.options?.bucket_lookup) {
|
|
198
207
|
options.push("-o", `s3.bucket-lookup=${repo.options.bucket_lookup}`);
|
|
199
208
|
}
|
|
209
|
+
if (repo.region) {
|
|
210
|
+
options.push("-o", `s3.region=${repo.region}`);
|
|
211
|
+
}
|
|
200
212
|
return options;
|
|
201
213
|
}
|
|
202
214
|
async function execRestic(args, env, s3Options = []) {
|
|
215
|
+
await runRestic(args, env, s3Options, false);
|
|
216
|
+
}
|
|
217
|
+
async function execResticCapture(args, env, s3Options = []) {
|
|
218
|
+
return runRestic(args, env, s3Options, true);
|
|
219
|
+
}
|
|
220
|
+
async function runRestic(args, env, s3Options, capture) {
|
|
203
221
|
try {
|
|
204
|
-
await execa(resolveResticBin(), [...s3Options, ...args], {
|
|
222
|
+
const result = await execa(resolveResticBin(), [...s3Options, ...args], {
|
|
223
|
+
env: { ...process.env, ...env },
|
|
224
|
+
...capture ? { stdout: "pipe", stderr: "inherit" } : { stdio: "inherit" }
|
|
225
|
+
});
|
|
226
|
+
return typeof result.stdout === "string" ? result.stdout : "";
|
|
205
227
|
} catch (error) {
|
|
206
228
|
if (error.code === "ENOENT") {
|
|
207
229
|
console.error("Error: restic not found. Linux x64 packages include restic; other platforms must install it first.");
|
|
@@ -212,11 +234,28 @@ async function execRestic(args, env, s3Options = []) {
|
|
|
212
234
|
}
|
|
213
235
|
}
|
|
214
236
|
|
|
237
|
+
// src/core/uptime-kuma.ts
|
|
238
|
+
var MAX_MESSAGE_LENGTH = 200;
|
|
239
|
+
async function notifyUptimeKuma(push, pushUrl) {
|
|
240
|
+
const msg = push.msg.length > MAX_MESSAGE_LENGTH ? `${push.msg.slice(0, MAX_MESSAGE_LENGTH - 3)}...` : push.msg;
|
|
241
|
+
const url = `${pushUrl}?status=${push.status}&msg=${encodeURIComponent(msg)}&ping=${push.ping}`;
|
|
242
|
+
try {
|
|
243
|
+
const response = await fetch(url);
|
|
244
|
+
if (response.ok) {
|
|
245
|
+
console.log(`Uptime Kuma notified: status=${push.status}, ping=${push.ping}ms`);
|
|
246
|
+
} else {
|
|
247
|
+
console.error("Uptime Kuma notification failed");
|
|
248
|
+
}
|
|
249
|
+
} catch (error) {
|
|
250
|
+
console.error("Uptime Kuma notification failed:", error);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
215
254
|
// src/commands/backup.ts
|
|
216
255
|
import fs4 from "fs";
|
|
217
256
|
import os from "os";
|
|
218
257
|
import path4 from "path";
|
|
219
|
-
import {
|
|
258
|
+
import { execa as execa2 } from "execa";
|
|
220
259
|
async function backup(options) {
|
|
221
260
|
const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
|
|
222
261
|
const config = loadConfig(wayDir, options.remote);
|
|
@@ -300,7 +339,11 @@ async function backup(options) {
|
|
|
300
339
|
if (failed.length > 0) console.log("Failed:", failed.join(", "));
|
|
301
340
|
if (!dryRun) {
|
|
302
341
|
for (const [pushUrl, result] of groupUptimeKumaResults(projectResults)) {
|
|
303
|
-
await notifyUptimeKuma(
|
|
342
|
+
await notifyUptimeKuma({
|
|
343
|
+
status: result.failed.length > 0 ? "down" : "up",
|
|
344
|
+
msg: `Succeeded: ${result.succeeded.length}, Failed: ${result.failed.length}`,
|
|
345
|
+
ping: result.duration
|
|
346
|
+
}, pushUrl);
|
|
304
347
|
}
|
|
305
348
|
}
|
|
306
349
|
return { succeeded, failed, duration };
|
|
@@ -377,7 +420,7 @@ async function runProjectHooks(hooks, context) {
|
|
|
377
420
|
continue;
|
|
378
421
|
}
|
|
379
422
|
console.log(`Running ${context.label} hook for ${context.projectName}: ${normalized.run}`);
|
|
380
|
-
const subprocess =
|
|
423
|
+
const subprocess = execa2(normalized.run, {
|
|
381
424
|
shell: true,
|
|
382
425
|
stdio: "inherit",
|
|
383
426
|
timeout: parseTimeout(normalized.timeout),
|
|
@@ -404,25 +447,48 @@ async function runProjectHooks(hooks, context) {
|
|
|
404
447
|
}
|
|
405
448
|
}
|
|
406
449
|
}
|
|
407
|
-
async function notifyUptimeKuma(result, pushUrl) {
|
|
408
|
-
const status = result.failed.length > 0 ? "down" : "up";
|
|
409
|
-
const msg = `Succeeded: ${result.succeeded.length}, Failed: ${result.failed.length}`;
|
|
410
|
-
const url = `${pushUrl}?status=${status}&msg=${encodeURIComponent(msg)}&ping=${result.duration}`;
|
|
411
|
-
try {
|
|
412
|
-
const response = await fetch(url);
|
|
413
|
-
if (response.ok) {
|
|
414
|
-
console.log(`Uptime Kuma notified: status=${status}, ping=${result.duration}ms`);
|
|
415
|
-
} else {
|
|
416
|
-
console.error("Uptime Kuma notification failed");
|
|
417
|
-
}
|
|
418
|
-
} catch (error) {
|
|
419
|
-
console.error("Uptime Kuma notification failed:", error);
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
450
|
|
|
423
451
|
// src/commands/restore.ts
|
|
452
|
+
import path5 from "path";
|
|
453
|
+
function isWithin(parent, child, pathApi) {
|
|
454
|
+
const relative = pathApi.relative(parent, child);
|
|
455
|
+
return relative === "" || !relative.startsWith("..") && !pathApi.isAbsolute(relative);
|
|
456
|
+
}
|
|
457
|
+
function findCommonParent(paths, pathApi) {
|
|
458
|
+
let candidate = paths[0];
|
|
459
|
+
while (!paths.every((item) => isWithin(candidate, item, pathApi))) {
|
|
460
|
+
const parent = pathApi.dirname(candidate);
|
|
461
|
+
if (parent === candidate) return candidate;
|
|
462
|
+
candidate = parent;
|
|
463
|
+
}
|
|
464
|
+
return candidate;
|
|
465
|
+
}
|
|
466
|
+
function buildWindowsRestorePlans(project, target, snapshot = "latest") {
|
|
467
|
+
const pathApi = path5.win32;
|
|
468
|
+
const groups = /* @__PURE__ */ new Map();
|
|
469
|
+
for (const sourcePath of project.paths) {
|
|
470
|
+
const root = pathApi.parse(sourcePath).root.toLowerCase();
|
|
471
|
+
const paths = groups.get(root) || [];
|
|
472
|
+
paths.push(sourcePath);
|
|
473
|
+
groups.set(root, paths);
|
|
474
|
+
}
|
|
475
|
+
return Array.from(groups.values()).map((paths) => {
|
|
476
|
+
const parent = findCommonParent(paths.map((item) => pathApi.dirname(item)), pathApi);
|
|
477
|
+
const drive = parent[0].toUpperCase();
|
|
478
|
+
const rest = parent.slice(2).replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
|
479
|
+
const snapshotPath = rest ? `/${drive}/${rest}` : `/${drive}`;
|
|
480
|
+
const includePaths = paths.map((item) => `/${pathApi.relative(parent, item).replace(/\\/g, "/")}`);
|
|
481
|
+
const targetPath = pathApi.join(target, ...snapshotPath.split("/").filter(Boolean));
|
|
482
|
+
return {
|
|
483
|
+
snapshot: `${snapshot}:${snapshotPath}`,
|
|
484
|
+
target: targetPath,
|
|
485
|
+
includePaths: Array.from(new Set(includePaths))
|
|
486
|
+
};
|
|
487
|
+
});
|
|
488
|
+
}
|
|
424
489
|
async function restore(options) {
|
|
425
490
|
if (!options.target) throw new Error("--target is required");
|
|
491
|
+
const target = options.target;
|
|
426
492
|
const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
|
|
427
493
|
const config = loadConfig(wayDir, options.remote);
|
|
428
494
|
const env = buildResticEnv(config.repository);
|
|
@@ -440,15 +506,19 @@ async function restore(options) {
|
|
|
440
506
|
}
|
|
441
507
|
console.log(`=== Restoring: ${projectName} ===`);
|
|
442
508
|
try {
|
|
443
|
-
const
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
509
|
+
const plans = process.platform === "win32" && project.paths.every((item) => /^[A-Za-z]:[\\/]/.test(item)) ? buildWindowsRestorePlans(project, target, options.snapshot) : [{ snapshot: options.snapshot, target, includePaths: void 0 }];
|
|
510
|
+
for (const plan of plans) {
|
|
511
|
+
const args = buildRestoreArgs(projectName, project, {
|
|
512
|
+
target: plan.target,
|
|
513
|
+
snapshot: plan.snapshot,
|
|
514
|
+
host: options.host,
|
|
515
|
+
dryRun: options.dryRun,
|
|
516
|
+
delete: options.delete,
|
|
517
|
+
verbose: options.verbose,
|
|
518
|
+
includePaths: plan.includePaths
|
|
519
|
+
});
|
|
520
|
+
await execRestic(args, env, s3Options);
|
|
521
|
+
}
|
|
452
522
|
succeeded.push(projectName);
|
|
453
523
|
} catch (error) {
|
|
454
524
|
console.error(`Failed to restore ${projectName}:`, error);
|
|
@@ -463,18 +533,108 @@ async function restore(options) {
|
|
|
463
533
|
}
|
|
464
534
|
|
|
465
535
|
// src/commands/gc.ts
|
|
536
|
+
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
537
|
+
function hasLegacyPolicy(retention) {
|
|
538
|
+
return [
|
|
539
|
+
retention.keep_daily,
|
|
540
|
+
retention.keep_weekly,
|
|
541
|
+
retention.keep_monthly,
|
|
542
|
+
retention.keep_yearly
|
|
543
|
+
].some((value) => value !== void 0);
|
|
544
|
+
}
|
|
545
|
+
function isStrictPolicy(retention) {
|
|
546
|
+
return retention.keep_hosts !== void 0 || retention.max_age_days !== void 0;
|
|
547
|
+
}
|
|
548
|
+
function validateStrictPolicy(retention) {
|
|
549
|
+
if (!retention.keep_hosts?.length) {
|
|
550
|
+
throw new Error("Strict retention requires at least one defaults.retention.keep_hosts entry");
|
|
551
|
+
}
|
|
552
|
+
if (!Number.isFinite(retention.max_age_days) || retention.max_age_days <= 0) {
|
|
553
|
+
throw new Error("Strict retention requires defaults.retention.max_age_days to be a positive number");
|
|
554
|
+
}
|
|
555
|
+
if (hasLegacyPolicy(retention)) {
|
|
556
|
+
throw new Error("Strict keep_hosts/max_age_days retention cannot be combined with count-based keep_* retention");
|
|
557
|
+
}
|
|
558
|
+
const normalizedHosts = retention.keep_hosts.map((host) => host.trim());
|
|
559
|
+
if (normalizedHosts.some((host) => host.length === 0)) {
|
|
560
|
+
throw new Error("defaults.retention.keep_hosts cannot contain empty host names");
|
|
561
|
+
}
|
|
562
|
+
return { hosts: new Set(normalizedHosts), maxAgeDays: retention.max_age_days };
|
|
563
|
+
}
|
|
564
|
+
function buildSnapshotRetentionPlan(snapshots, retention, now = /* @__PURE__ */ new Date()) {
|
|
565
|
+
const { hosts, maxAgeDays } = validateStrictPolicy(retention);
|
|
566
|
+
const cutoff = new Date(now.getTime() - maxAgeDays * DAY_MS);
|
|
567
|
+
const keep = [];
|
|
568
|
+
const remove = [];
|
|
569
|
+
for (const snapshot of snapshots) {
|
|
570
|
+
const snapshotTime = new Date(snapshot.time);
|
|
571
|
+
if (Number.isNaN(snapshotTime.getTime())) {
|
|
572
|
+
throw new Error(`Snapshot ${snapshot.id} has an invalid timestamp: ${snapshot.time}`);
|
|
573
|
+
}
|
|
574
|
+
if (hosts.has(snapshot.hostname) && snapshotTime >= cutoff) {
|
|
575
|
+
keep.push(snapshot);
|
|
576
|
+
} else {
|
|
577
|
+
remove.push(snapshot);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return { cutoff, keep, remove };
|
|
581
|
+
}
|
|
582
|
+
function printSnapshots(label, snapshots) {
|
|
583
|
+
console.log(`${label} (${snapshots.length}):`);
|
|
584
|
+
for (const snapshot of snapshots) {
|
|
585
|
+
console.log(` ${snapshot.id.slice(0, 8)} ${snapshot.time} ${snapshot.hostname} ${(snapshot.tags || []).join(",") || "-"}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
function buildResticOptions(s3Options, retryLock) {
|
|
589
|
+
const options = [...s3Options];
|
|
590
|
+
if (retryLock) options.push(`--retry-lock=${retryLock}`);
|
|
591
|
+
return options;
|
|
592
|
+
}
|
|
593
|
+
async function executeGc(args, dryRun, env, resticOptions) {
|
|
594
|
+
if (!dryRun) {
|
|
595
|
+
console.log("Removing stale repository locks before cleanup...");
|
|
596
|
+
await execRestic(["unlock"], env, resticOptions);
|
|
597
|
+
}
|
|
598
|
+
await execRestic(args, env, resticOptions);
|
|
599
|
+
}
|
|
600
|
+
async function runStrictGc(retention, dryRun, env, resticOptions) {
|
|
601
|
+
const snapshotsJson = await execResticCapture(["snapshots", "--json", "--no-lock"], env, resticOptions);
|
|
602
|
+
const snapshots = JSON.parse(snapshotsJson);
|
|
603
|
+
if (!Array.isArray(snapshots)) throw new Error("restic snapshots --json did not return an array");
|
|
604
|
+
const plan = buildSnapshotRetentionPlan(snapshots, retention);
|
|
605
|
+
console.log(`Policy: keep hosts=${retention.keep_hosts.join(",")}, max age=${retention.max_age_days} days, cutoff=${plan.cutoff.toISOString()}`);
|
|
606
|
+
printSnapshots("Keep snapshots", plan.keep);
|
|
607
|
+
printSnapshots("Remove snapshots", plan.remove);
|
|
608
|
+
if (dryRun) {
|
|
609
|
+
console.log("Dry-run complete; no snapshots or repository data were modified.");
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
if (plan.remove.length === 0) {
|
|
613
|
+
console.log("No snapshots matched cleanup; prune was skipped.");
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
await executeGc(["forget", "--prune", ...plan.remove.map((snapshot) => snapshot.id)], false, env, resticOptions);
|
|
617
|
+
}
|
|
466
618
|
async function gc(options) {
|
|
467
619
|
const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
|
|
468
620
|
const config = loadConfig(wayDir, options.remote);
|
|
469
|
-
const retention = config.rules.defaults?.retention
|
|
470
|
-
const
|
|
471
|
-
const keepWeekly = retention.keep_weekly || 4;
|
|
472
|
-
const keepMonthly = retention.keep_monthly || 6;
|
|
473
|
-
const keepYearly = retention.keep_yearly;
|
|
621
|
+
const retention = options.retention ?? config.rules.defaults?.retention ?? {};
|
|
622
|
+
const dryRun = options.dryRun || false;
|
|
474
623
|
console.log("=== Cleaning snapshots ===");
|
|
475
|
-
console.log(`Policy: daily=${keepDaily}, weekly=${keepWeekly}, monthly=${keepMonthly}${keepYearly ? `, yearly=${keepYearly}` : ""}`);
|
|
476
624
|
const env = buildResticEnv(config.repository);
|
|
477
|
-
const
|
|
625
|
+
const resticOptions = buildResticOptions(
|
|
626
|
+
buildS3Options(config.repository),
|
|
627
|
+
config.rules.maintenance?.prune?.retry_lock
|
|
628
|
+
);
|
|
629
|
+
if (isStrictPolicy(retention)) {
|
|
630
|
+
await runStrictGc(retention, dryRun, env, resticOptions);
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
const keepDaily = retention.keep_daily ?? 7;
|
|
634
|
+
const keepWeekly = retention.keep_weekly ?? 4;
|
|
635
|
+
const keepMonthly = retention.keep_monthly ?? 6;
|
|
636
|
+
const keepYearly = retention.keep_yearly;
|
|
637
|
+
console.log(`Policy: daily=${keepDaily}, weekly=${keepWeekly}, monthly=${keepMonthly}${keepYearly !== void 0 ? `, yearly=${keepYearly}` : ""}`);
|
|
478
638
|
const args = [
|
|
479
639
|
"forget",
|
|
480
640
|
"--prune",
|
|
@@ -482,23 +642,23 @@ async function gc(options) {
|
|
|
482
642
|
`--keep-weekly=${keepWeekly}`,
|
|
483
643
|
`--keep-monthly=${keepMonthly}`
|
|
484
644
|
];
|
|
485
|
-
if (keepYearly) args.push(`--keep-yearly=${keepYearly}`);
|
|
486
|
-
if (
|
|
487
|
-
await
|
|
645
|
+
if (keepYearly !== void 0) args.push(`--keep-yearly=${keepYearly}`);
|
|
646
|
+
if (dryRun) args.push("--dry-run");
|
|
647
|
+
await executeGc(args, dryRun, env, resticOptions);
|
|
488
648
|
}
|
|
489
649
|
|
|
490
650
|
// src/commands/systemd.ts
|
|
491
651
|
import { execSync } from "child_process";
|
|
492
652
|
import fs5 from "fs";
|
|
493
|
-
import
|
|
653
|
+
import path6 from "path";
|
|
494
654
|
function resolveWayCommandPath(options = {}) {
|
|
495
655
|
const env = options.env ?? process.env;
|
|
496
656
|
const argv = options.argv ?? process.argv;
|
|
497
657
|
const execPath = options.execPath ?? process.execPath;
|
|
498
658
|
const whichWay = options.whichWay ?? (() => execSync("which way", { encoding: "utf-8" }).trim());
|
|
499
659
|
if (env.WAY_BIN) return env.WAY_BIN;
|
|
500
|
-
if (
|
|
501
|
-
if (argv[1] &&
|
|
660
|
+
if (path6.basename(execPath) === "way") return execPath;
|
|
661
|
+
if (argv[1] && path6.isAbsolute(argv[1])) return argv[1];
|
|
502
662
|
return whichWay();
|
|
503
663
|
}
|
|
504
664
|
async function systemd(options) {
|
|
@@ -528,7 +688,7 @@ WantedBy=multi-user.target
|
|
|
528
688
|
return;
|
|
529
689
|
}
|
|
530
690
|
const systemdDir = "/etc/systemd/system";
|
|
531
|
-
const servicePath =
|
|
691
|
+
const servicePath = path6.join(systemdDir, "way-backup.service");
|
|
532
692
|
if (options.action === "install") {
|
|
533
693
|
fs5.writeFileSync(servicePath, serviceContent);
|
|
534
694
|
execSync("systemctl daemon-reload");
|
|
@@ -555,10 +715,243 @@ WantedBy=multi-user.target
|
|
|
555
715
|
}
|
|
556
716
|
}
|
|
557
717
|
|
|
718
|
+
// src/commands/windows-service.ts
|
|
719
|
+
import { execFileSync } from "child_process";
|
|
720
|
+
import fs6 from "fs";
|
|
721
|
+
import os2 from "os";
|
|
722
|
+
import path7 from "path";
|
|
723
|
+
var WINDOWS_TASK_NAME = "Way Backup Daemon";
|
|
724
|
+
var WINDOWS_RUNNER_NAME = "way-daemon.cmd";
|
|
725
|
+
function assertWindows(platform) {
|
|
726
|
+
if (platform !== "win32") throw new Error("windows-service is only available on Windows");
|
|
727
|
+
}
|
|
728
|
+
function assertSafeCmdValue(value, name) {
|
|
729
|
+
if (/[\r\n\0]/.test(value)) throw new Error(`${name} cannot contain line breaks or NUL bytes`);
|
|
730
|
+
}
|
|
731
|
+
function xmlEscape(value) {
|
|
732
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
733
|
+
}
|
|
734
|
+
function resolveDaemonLaunch(options = {}) {
|
|
735
|
+
const env = options.env ?? process.env;
|
|
736
|
+
const argv = options.argv ?? process.argv;
|
|
737
|
+
const execPath = options.execPath ?? process.execPath;
|
|
738
|
+
if (env.WAY_BIN) return { command: env.WAY_BIN, args: ["daemon"] };
|
|
739
|
+
if (argv[1] && (path7.isAbsolute(argv[1]) || path7.win32.isAbsolute(argv[1]))) {
|
|
740
|
+
return { command: execPath, args: [argv[1], "daemon"] };
|
|
741
|
+
}
|
|
742
|
+
return { command: execPath, args: ["daemon"] };
|
|
743
|
+
}
|
|
744
|
+
function renderWindowsDaemonRunner(wayDir, launch, resticBin) {
|
|
745
|
+
for (const [name, value] of Object.entries({ wayDir, command: launch.command, resticBin })) {
|
|
746
|
+
if (value) assertSafeCmdValue(value, name);
|
|
747
|
+
}
|
|
748
|
+
for (const arg of launch.args) assertSafeCmdValue(arg, "daemon argument");
|
|
749
|
+
const setRestic = resticBin ? `set "WAY_RESTIC_BIN=${resticBin}"\r
|
|
750
|
+
` : "";
|
|
751
|
+
const args = launch.args.map((arg) => `"${arg.replace(/"/g, '""')}"`).join(" ");
|
|
752
|
+
const command = `"${launch.command.replace(/"/g, '""')}"`;
|
|
753
|
+
return `@echo off\r
|
|
754
|
+
setlocal\r
|
|
755
|
+
set "WAY_DIR=${wayDir}"\r
|
|
756
|
+
${setRestic}${command} ${args}\r
|
|
757
|
+
`;
|
|
758
|
+
}
|
|
759
|
+
function renderWindowsTaskXml(runnerPath) {
|
|
760
|
+
assertSafeCmdValue(runnerPath, "runner path");
|
|
761
|
+
const argumentsValue = `/d /s /c ""${runnerPath}""`;
|
|
762
|
+
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
763
|
+
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
764
|
+
<RegistrationInfo><Description>Way Backup Daemon</Description></RegistrationInfo>
|
|
765
|
+
<Triggers><BootTrigger><Enabled>true</Enabled><Delay>PT30S</Delay></BootTrigger></Triggers>
|
|
766
|
+
<Principals><Principal id="Author"><UserId>S-1-5-18</UserId><RunLevel>HighestAvailable</RunLevel></Principal></Principals>
|
|
767
|
+
<Settings>
|
|
768
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy><DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries><StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
769
|
+
<AllowHardTerminate>true</AllowHardTerminate><StartWhenAvailable>true</StartWhenAvailable><RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
|
|
770
|
+
<AllowStartOnDemand>true</AllowStartOnDemand><Enabled>true</Enabled><Hidden>true</Hidden><RunOnlyIfIdle>false</RunOnlyIfIdle><WakeToRun>false</WakeToRun>
|
|
771
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit><Priority>7</Priority><RestartOnFailure><Interval>PT1M</Interval><Count>3</Count></RestartOnFailure>
|
|
772
|
+
</Settings>
|
|
773
|
+
<Actions Context="Author"><Exec><Command>cmd.exe</Command><Arguments>${xmlEscape(argumentsValue)}</Arguments></Exec></Actions>
|
|
774
|
+
</Task>`;
|
|
775
|
+
}
|
|
776
|
+
function resolveResticPath(env, run) {
|
|
777
|
+
if (env.WAY_RESTIC_BIN) return env.WAY_RESTIC_BIN;
|
|
778
|
+
try {
|
|
779
|
+
return run("where.exe", ["restic.exe"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim().split(/\r?\n/)[0];
|
|
780
|
+
} catch {
|
|
781
|
+
return void 0;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
async function windowsService(options, dependencies = {}) {
|
|
785
|
+
const platform = dependencies.platform ?? process.platform;
|
|
786
|
+
assertWindows(platform);
|
|
787
|
+
const env = dependencies.env ?? process.env;
|
|
788
|
+
const run = dependencies.execFileSync ?? execFileSync;
|
|
789
|
+
const writeFile = dependencies.writeFileSync ?? fs6.writeFileSync;
|
|
790
|
+
const unlink = dependencies.unlinkSync ?? fs6.unlinkSync;
|
|
791
|
+
const exists = dependencies.existsSync ?? fs6.existsSync;
|
|
792
|
+
const makeTempDir = dependencies.mkdtempSync ?? fs6.mkdtempSync;
|
|
793
|
+
const wayDir = env.WAY_DIR || path7.join(os2.homedir(), ".way");
|
|
794
|
+
const runnerPath = path7.join(wayDir, WINDOWS_RUNNER_NAME);
|
|
795
|
+
const launch = resolveDaemonLaunch({ env, argv: dependencies.argv, execPath: dependencies.execPath });
|
|
796
|
+
const runner = renderWindowsDaemonRunner(wayDir, launch, resolveResticPath(env, run));
|
|
797
|
+
const taskXml = renderWindowsTaskXml(runnerPath);
|
|
798
|
+
if (options.action === "show") {
|
|
799
|
+
console.log(`=== ${WINDOWS_TASK_NAME} ===`);
|
|
800
|
+
console.log(taskXml);
|
|
801
|
+
console.log(`=== ${runnerPath} ===`);
|
|
802
|
+
console.log(runner);
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
if (options.action === "install") {
|
|
806
|
+
loadConfig(wayDir, options.remote);
|
|
807
|
+
fs6.mkdirSync(wayDir, { recursive: true });
|
|
808
|
+
writeFile(runnerPath, runner, { encoding: "utf8" });
|
|
809
|
+
const tempDir = makeTempDir(path7.join(os2.tmpdir(), "way-windows-service-"));
|
|
810
|
+
const xmlPath = path7.join(tempDir, "way-backup.xml");
|
|
811
|
+
try {
|
|
812
|
+
writeFile(xmlPath, `\uFEFF${taskXml}`, { encoding: "utf16le" });
|
|
813
|
+
run("schtasks.exe", ["/Create", "/TN", WINDOWS_TASK_NAME, "/XML", xmlPath, "/F"], { stdio: "inherit" });
|
|
814
|
+
run("schtasks.exe", ["/Run", "/TN", WINDOWS_TASK_NAME], { stdio: "inherit" });
|
|
815
|
+
console.log("Windows Way daemon task installed and started");
|
|
816
|
+
} finally {
|
|
817
|
+
if (exists(xmlPath)) unlink(xmlPath);
|
|
818
|
+
try {
|
|
819
|
+
fs6.rmdirSync(tempDir);
|
|
820
|
+
} catch {
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
if (options.action === "uninstall") {
|
|
826
|
+
try {
|
|
827
|
+
run("schtasks.exe", ["/End", "/TN", WINDOWS_TASK_NAME], { stdio: "ignore" });
|
|
828
|
+
} catch {
|
|
829
|
+
}
|
|
830
|
+
try {
|
|
831
|
+
run("schtasks.exe", ["/Delete", "/TN", WINDOWS_TASK_NAME, "/F"], { stdio: "inherit" });
|
|
832
|
+
} catch {
|
|
833
|
+
}
|
|
834
|
+
if (exists(runnerPath)) unlink(runnerPath);
|
|
835
|
+
console.log("Windows Way daemon task uninstalled");
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
run("schtasks.exe", ["/Query", "/TN", WINDOWS_TASK_NAME, "/FO", "LIST", "/V"], { stdio: "inherit" });
|
|
839
|
+
}
|
|
840
|
+
|
|
558
841
|
// src/commands/daemon.ts
|
|
559
842
|
import cron from "node-cron";
|
|
843
|
+
|
|
844
|
+
// src/commands/replicate.ts
|
|
845
|
+
import fs7 from "fs";
|
|
846
|
+
import os3 from "os";
|
|
847
|
+
import path8 from "path";
|
|
848
|
+
function selectLatestProjectSnapshots(snapshots, projectNames) {
|
|
849
|
+
const selected = /* @__PURE__ */ new Map();
|
|
850
|
+
for (const snapshot of snapshots) {
|
|
851
|
+
const snapshotTime = Date.parse(snapshot.time);
|
|
852
|
+
if (Number.isNaN(snapshotTime)) {
|
|
853
|
+
throw new Error(`Snapshot ${snapshot.id} has an invalid timestamp: ${snapshot.time}`);
|
|
854
|
+
}
|
|
855
|
+
for (const tag of snapshot.tags || []) {
|
|
856
|
+
if (!tag.startsWith("way:")) continue;
|
|
857
|
+
const projectName = tag.slice("way:".length);
|
|
858
|
+
if (!projectNames.includes(projectName)) continue;
|
|
859
|
+
const current = selected.get(projectName);
|
|
860
|
+
if (!current || snapshotTime > Date.parse(current.time)) {
|
|
861
|
+
selected.set(projectName, snapshot);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
return projectNames.flatMap((projectName) => {
|
|
866
|
+
const snapshot = selected.get(projectName);
|
|
867
|
+
return snapshot ? [snapshot.id] : [];
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
function validateReplication(name, replication, sourceType) {
|
|
871
|
+
if (replication.from === replication.to) {
|
|
872
|
+
throw new Error(`Replication ${name} must use different source and destination repositories`);
|
|
873
|
+
}
|
|
874
|
+
if (sourceType !== "local") {
|
|
875
|
+
throw new Error(`Replication ${name} source repository must be local`);
|
|
876
|
+
}
|
|
877
|
+
if (replication.snapshot_policy && replication.snapshot_policy !== "latest-per-project") {
|
|
878
|
+
throw new Error(`Replication ${name} has unsupported snapshot_policy: ${replication.snapshot_policy}`);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
async function withSourcePasswordFile(password, run) {
|
|
882
|
+
if (!password) throw new Error("Source repository password is required for replication");
|
|
883
|
+
const tempDir = fs7.mkdtempSync(path8.join(os3.tmpdir(), "way-replication-"));
|
|
884
|
+
const passwordFile = path8.join(tempDir, "source-password");
|
|
885
|
+
fs7.writeFileSync(passwordFile, `${password}
|
|
886
|
+
`, { encoding: "utf8", mode: 384 });
|
|
887
|
+
try {
|
|
888
|
+
return await run(passwordFile);
|
|
889
|
+
} finally {
|
|
890
|
+
fs7.rmSync(tempDir, { recursive: true, force: true });
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
async function replicateOne(wayDir, name, replication, initialize) {
|
|
894
|
+
const sourceConfig = loadConfig(wayDir, replication.from);
|
|
895
|
+
const destinationConfig = loadConfig(wayDir, replication.to);
|
|
896
|
+
validateReplication(name, replication, sourceConfig.repository.type);
|
|
897
|
+
const sourceEnv = buildResticEnv(sourceConfig.repository);
|
|
898
|
+
const destinationEnv = buildResticEnv(destinationConfig.repository);
|
|
899
|
+
const sourceOptions = buildS3Options(sourceConfig.repository);
|
|
900
|
+
const destinationOptions = buildS3Options(destinationConfig.repository);
|
|
901
|
+
const snapshots = JSON.parse(
|
|
902
|
+
await execResticCapture(["snapshots", "--json"], sourceEnv, sourceOptions)
|
|
903
|
+
);
|
|
904
|
+
if (!Array.isArray(snapshots)) throw new Error("restic snapshots --json did not return an array");
|
|
905
|
+
const snapshotIds = selectLatestProjectSnapshots(snapshots, Object.keys(sourceConfig.rules.projects));
|
|
906
|
+
await withSourcePasswordFile(sourceConfig.repository.credentials.password, async (passwordFile) => {
|
|
907
|
+
const sourceArguments = [
|
|
908
|
+
`--from-repo=${buildRepositoryLocation(sourceConfig.repository)}`,
|
|
909
|
+
`--from-password-file=${passwordFile}`
|
|
910
|
+
];
|
|
911
|
+
if (initialize) {
|
|
912
|
+
console.log(`Initializing replication destination ${replication.to} from ${replication.from}`);
|
|
913
|
+
await execRestic(["init", ...sourceArguments, "--copy-chunker-params"], destinationEnv, destinationOptions);
|
|
914
|
+
}
|
|
915
|
+
if (snapshotIds.length === 0) {
|
|
916
|
+
console.log(`No project snapshots available for replication ${name}`);
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
console.log(`Replicating ${snapshotIds.length} latest project snapshots: ${replication.from} -> ${replication.to}`);
|
|
920
|
+
await execRestic(["copy", ...sourceArguments, ...snapshotIds], destinationEnv, destinationOptions);
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
async function replicate(options = {}) {
|
|
924
|
+
const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
|
|
925
|
+
const defaultConfig = loadConfig(wayDir, "default");
|
|
926
|
+
const replications = defaultConfig.rules.replications || {};
|
|
927
|
+
const names = options.names?.length ? options.names : Object.keys(replications);
|
|
928
|
+
const succeeded = [];
|
|
929
|
+
const failed = [];
|
|
930
|
+
const startTime = Date.now();
|
|
931
|
+
for (const name of names) {
|
|
932
|
+
const replication = replications[name];
|
|
933
|
+
if (!replication) {
|
|
934
|
+
console.error(`Replication not found: ${name}`);
|
|
935
|
+
failed.push(name);
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
938
|
+
try {
|
|
939
|
+
await replicateOne(wayDir, name, replication, options.initialize || false);
|
|
940
|
+
succeeded.push(name);
|
|
941
|
+
} catch (error) {
|
|
942
|
+
console.error(`Failed replication ${name}:`, error);
|
|
943
|
+
failed.push(name);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
return { succeeded, failed, duration: Date.now() - startTime };
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// src/commands/daemon.ts
|
|
560
950
|
var isRunning = false;
|
|
561
951
|
var taskQueue = [];
|
|
952
|
+
var CRON_OPTIONS = {
|
|
953
|
+
missedExecutionTolerance: 5e3
|
|
954
|
+
};
|
|
562
955
|
function resolveProjectSchedule(project, defaults = {}) {
|
|
563
956
|
if (project.schedule !== void 0) return project.schedule;
|
|
564
957
|
return defaults?.schedule ?? false;
|
|
@@ -571,6 +964,24 @@ function assertValidSchedule(schedule, label) {
|
|
|
571
964
|
throw new Error(`${label} must be a cron expression or false. Use false to disable scheduling.`);
|
|
572
965
|
}
|
|
573
966
|
}
|
|
967
|
+
function resolvePrunePushUrl(rules) {
|
|
968
|
+
return rules.maintenance?.prune?.uptime_kuma?.push_url || rules.uptime_kuma?.push_url;
|
|
969
|
+
}
|
|
970
|
+
async function runPruneWithNotification(remote, pushUrl) {
|
|
971
|
+
const startTime = Date.now();
|
|
972
|
+
try {
|
|
973
|
+
await gc({ remote, dryRun: false });
|
|
974
|
+
} catch (error) {
|
|
975
|
+
if (pushUrl) {
|
|
976
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
977
|
+
await notifyUptimeKuma({ status: "down", msg: `Prune failed: ${reason}`, ping: Date.now() - startTime }, pushUrl);
|
|
978
|
+
}
|
|
979
|
+
throw error;
|
|
980
|
+
}
|
|
981
|
+
if (pushUrl) {
|
|
982
|
+
await notifyUptimeKuma({ status: "up", msg: "Prune succeeded", ping: Date.now() - startTime }, pushUrl);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
574
985
|
function addScheduledBackup(scheduledBackups, schedule, projectName) {
|
|
575
986
|
const projects = scheduledBackups.get(schedule) || [];
|
|
576
987
|
projects.push(projectName);
|
|
@@ -610,18 +1021,42 @@ async function daemon(options) {
|
|
|
610
1021
|
console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running backup: ${projects.join(", ")}`);
|
|
611
1022
|
await backup({ remote: options.remote, projects });
|
|
612
1023
|
});
|
|
613
|
-
});
|
|
1024
|
+
}, CRON_OPTIONS);
|
|
614
1025
|
console.log(`Scheduled backup for ${projects.join(", ")}: ${schedule}`);
|
|
615
1026
|
}
|
|
1027
|
+
for (const [name, replication] of Object.entries(config.rules.replications || {})) {
|
|
1028
|
+
assertValidSchedule(replication.schedule, `replications.${name}.schedule`);
|
|
1029
|
+
if (isEnabledSchedule(replication.schedule)) {
|
|
1030
|
+
cron.schedule(replication.schedule, () => {
|
|
1031
|
+
executeTask(async () => {
|
|
1032
|
+
console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running replication: ${name}`);
|
|
1033
|
+
const result = await replicate({ names: [name] });
|
|
1034
|
+
if (result.failed.length > 0) throw new Error(`Replication failed: ${result.failed.join(", ")}`);
|
|
1035
|
+
});
|
|
1036
|
+
}, CRON_OPTIONS);
|
|
1037
|
+
console.log(`Scheduled replication ${name}: ${replication.schedule}`);
|
|
1038
|
+
}
|
|
1039
|
+
assertValidSchedule(replication.prune_schedule, `replications.${name}.prune_schedule`);
|
|
1040
|
+
if (isEnabledSchedule(replication.prune_schedule)) {
|
|
1041
|
+
cron.schedule(replication.prune_schedule, () => {
|
|
1042
|
+
executeTask(async () => {
|
|
1043
|
+
console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running replication prune: ${name}`);
|
|
1044
|
+
await gc({ remote: replication.to, dryRun: false, retention: replication.retention });
|
|
1045
|
+
});
|
|
1046
|
+
}, CRON_OPTIONS);
|
|
1047
|
+
console.log(`Scheduled replication prune ${name}: ${replication.prune_schedule}`);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
616
1050
|
const pruneSchedule = config.rules.maintenance?.prune?.schedule;
|
|
617
1051
|
assertValidSchedule(pruneSchedule, "maintenance.prune.schedule");
|
|
618
1052
|
if (isEnabledSchedule(pruneSchedule)) {
|
|
1053
|
+
const prunePushUrl = resolvePrunePushUrl(config.rules);
|
|
619
1054
|
cron.schedule(pruneSchedule, () => {
|
|
620
1055
|
executeTask(async () => {
|
|
621
1056
|
console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running prune`);
|
|
622
|
-
await
|
|
1057
|
+
await runPruneWithNotification(options.remote, prunePushUrl);
|
|
623
1058
|
});
|
|
624
|
-
});
|
|
1059
|
+
}, CRON_OPTIONS);
|
|
625
1060
|
console.log(`Scheduled prune: ${pruneSchedule}`);
|
|
626
1061
|
}
|
|
627
1062
|
const checkSchedule = config.rules.maintenance?.check?.schedule;
|
|
@@ -631,7 +1066,7 @@ async function daemon(options) {
|
|
|
631
1066
|
executeTask(async () => {
|
|
632
1067
|
console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running check`);
|
|
633
1068
|
});
|
|
634
|
-
});
|
|
1069
|
+
}, CRON_OPTIONS);
|
|
635
1070
|
console.log(`Scheduled check: ${checkSchedule}`);
|
|
636
1071
|
}
|
|
637
1072
|
process.on("SIGTERM", () => {
|
|
@@ -646,7 +1081,7 @@ async function daemon(options) {
|
|
|
646
1081
|
|
|
647
1082
|
// src/cli.ts
|
|
648
1083
|
var program = new Command();
|
|
649
|
-
program.name("way").version("0.6.
|
|
1084
|
+
program.name("way").version("0.6.12").description("\u7B56\u7565\u5907\u4EFD\u5DE5\u5177 - \u57FA\u4E8E restic \u7684\u7B56\u7565\u5C01\u88C5").option("--remote <name>", "\u6307\u5B9A\u4ED3\u5E93", "default").addHelpText("after", `
|
|
650
1085
|
\u793A\u4F8B:
|
|
651
1086
|
$ way backup \u6267\u884C\u6240\u6709\u9879\u76EE\u5907\u4EFD
|
|
652
1087
|
$ way backup data \u53EA\u5907\u4EFD data \u9879\u76EE
|
|
@@ -655,6 +1090,7 @@ program.name("way").version("0.6.10").description("\u7B56\u7565\u5907\u4EFD\u5DE
|
|
|
655
1090
|
$ way --remote=oss backup \u4F7F\u7528 oss \u4ED3\u5E93\u6267\u884C\u5907\u4EFD\uFF08\u5168\u5C40\u9009\u9879\u9700\u653E\u5728\u5B50\u547D\u4EE4\u524D\uFF09
|
|
656
1091
|
$ way init \u521D\u59CB\u5316 way \u914D\u7F6E\u6587\u4EF6
|
|
657
1092
|
$ way gc \u6E05\u7406\u65E7\u5FEB\u7167
|
|
1093
|
+
$ way replicate \u590D\u5236\u5404\u9879\u76EE\u6700\u65B0\u5FEB\u7167\u5230\u8FDC\u7A0B\u4ED3\u5E93
|
|
658
1094
|
$ way systemd install \u5B89\u88C5\u5B9A\u65F6\u4EFB\u52A1
|
|
659
1095
|
$ way restic snapshots \u67E5\u770B\u5FEB\u7167\u5217\u8868
|
|
660
1096
|
$ way restic restore abc123 --target /tmp/restore
|
|
@@ -669,6 +1105,9 @@ program.name("way").version("0.6.10").description("\u7B56\u7565\u5907\u4EFD\u5DE
|
|
|
669
1105
|
function collectBackupArgs(command) {
|
|
670
1106
|
return command.args.filter((a) => a.startsWith("-") && !["--dry-run"].includes(a));
|
|
671
1107
|
}
|
|
1108
|
+
function resolveRemote(command) {
|
|
1109
|
+
return command.optsWithGlobals().remote;
|
|
1110
|
+
}
|
|
672
1111
|
var commonHelpText = `
|
|
673
1112
|
\u5168\u5C40\u7528\u6CD5:
|
|
674
1113
|
way --remote=oss <command> ... \u6307\u5B9A\u4ED3\u5E93\uFF08\u5168\u5C40\u9009\u9879\u9700\u653E\u5728\u5B50\u547D\u4EE4\u524D\uFF09
|
|
@@ -680,22 +1119,22 @@ var commonHelpText = `
|
|
|
680
1119
|
program.command("init").description("\u521D\u59CB\u5316 way \u914D\u7F6E\u6587\u4EF6").addHelpText("after", commonHelpText).action(() => {
|
|
681
1120
|
const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
|
|
682
1121
|
const files = ["repositories.yaml", "rules.yaml"];
|
|
683
|
-
|
|
1122
|
+
fs8.mkdirSync(wayDir, { recursive: true });
|
|
684
1123
|
for (const file of files) {
|
|
685
|
-
const target =
|
|
686
|
-
if (
|
|
1124
|
+
const target = path9.join(wayDir, file);
|
|
1125
|
+
if (fs8.existsSync(target)) {
|
|
687
1126
|
throw new Error(`${target} already exists, aborting to avoid overwriting existing config.`);
|
|
688
1127
|
}
|
|
689
1128
|
}
|
|
690
1129
|
for (const file of files) {
|
|
691
1130
|
const source = resolveExampleConfigPath(file);
|
|
692
|
-
const target =
|
|
693
|
-
|
|
1131
|
+
const target = path9.join(wayDir, file);
|
|
1132
|
+
fs8.copyFileSync(source, target);
|
|
694
1133
|
console.log(`Created ${target}`);
|
|
695
1134
|
}
|
|
696
1135
|
});
|
|
697
1136
|
program.command("backup [projects...]").description("\u6309 rules.yaml \u6267\u884C\u5907\u4EFD").option("--dry-run", "\u6A21\u62DF\u5907\u4EFD\uFF08\u4E0D\u5B9E\u9645\u5199\u5165\uFF09").addHelpText("after", commonHelpText).allowUnknownOption().allowExcessArguments().action(async function(projects) {
|
|
698
|
-
const remote = this
|
|
1137
|
+
const remote = resolveRemote(this);
|
|
699
1138
|
const dryRun = this.opts().dryRun;
|
|
700
1139
|
const extraArgs = collectBackupArgs(this);
|
|
701
1140
|
const result = await backup({
|
|
@@ -707,7 +1146,7 @@ program.command("backup [projects...]").description("\u6309 rules.yaml \u6267\u8
|
|
|
707
1146
|
if (result.failed.length > 0) process.exitCode = 1;
|
|
708
1147
|
});
|
|
709
1148
|
program.command("restore [projects...]").description("\u6309 rules.yaml \u6062\u590D\u9879\u76EE").requiredOption("--target <dir>", "\u6062\u590D\u76EE\u6807\u76EE\u5F55").option("--snapshot <snapshot>", "\u5FEB\u7167 ID \u6216 latest", "latest").option("--host <host>", "\u53EA\u6062\u590D\u6307\u5B9A host \u7684\u5FEB\u7167").option("--dry-run", "\u6A21\u62DF\u6062\u590D\uFF08\u4E0D\u5B9E\u9645\u5199\u5165\uFF09").option("--delete", "\u5220\u9664\u76EE\u6807\u4E2D\u5FEB\u7167\u4E0D\u5B58\u5728\u7684\u6587\u4EF6").option("-v, --verbose", "\u663E\u793A\u8BE6\u7EC6\u6062\u590D\u8BA1\u5212\uFF08\u4F20\u9012 --verbose=2 \u7ED9 restic\uFF09").addHelpText("after", commonHelpText).action(async function(projects, cmdOptions) {
|
|
710
|
-
const remote = this
|
|
1149
|
+
const remote = resolveRemote(this);
|
|
711
1150
|
await restore({
|
|
712
1151
|
remote,
|
|
713
1152
|
projects,
|
|
@@ -720,15 +1159,23 @@ program.command("restore [projects...]").description("\u6309 rules.yaml \u6062\u
|
|
|
720
1159
|
});
|
|
721
1160
|
});
|
|
722
1161
|
program.command("gc").description("\u6E05\u7406\u65E7\u5FEB\u7167").option("--dry-run", "\u6A21\u62DF\u6E05\u7406\uFF08\u4E0D\u5B9E\u9645\u5220\u9664\uFF09").addHelpText("after", commonHelpText).action(async function(cmdOptions) {
|
|
723
|
-
const remote = this
|
|
1162
|
+
const remote = resolveRemote(this);
|
|
724
1163
|
await gc({ remote, dryRun: cmdOptions.dryRun });
|
|
725
1164
|
});
|
|
1165
|
+
program.command("replicate [names...]").description("\u6309 rules.yaml \u590D\u5236\u5404\u9879\u76EE\u6700\u65B0\u5FEB\u7167\u5230\u76EE\u6807\u4ED3\u5E93").option("--init", "\u521D\u59CB\u5316\u76EE\u6807\u4ED3\u5E93\u5E76\u7EE7\u627F\u6E90\u4ED3\u5E93 chunker \u53C2\u6570").addHelpText("after", commonHelpText).action(async function(names, cmdOptions) {
|
|
1166
|
+
const result = await replicate({ names, initialize: cmdOptions.init });
|
|
1167
|
+
if (result.failed.length > 0) process.exitCode = 1;
|
|
1168
|
+
});
|
|
726
1169
|
program.command("systemd <action>").description("\u7BA1\u7406 systemd \u5B9A\u65F6\u4EFB\u52A1 (show|install|uninstall|status)").addHelpText("after", commonHelpText).action(async (action, options, command) => {
|
|
727
|
-
const remote = command
|
|
1170
|
+
const remote = resolveRemote(command);
|
|
728
1171
|
await systemd({ remote, action });
|
|
729
1172
|
});
|
|
1173
|
+
program.command("windows-service <action>").description("\u7BA1\u7406 Windows \u539F\u751F\u5F00\u673A\u5B88\u62A4\u4EFB\u52A1 (show|install|uninstall|status)").addHelpText("after", commonHelpText).action(async (action, options, command) => {
|
|
1174
|
+
const remote = resolveRemote(command);
|
|
1175
|
+
await windowsService({ remote, action });
|
|
1176
|
+
});
|
|
730
1177
|
program.command("daemon").description("\u542F\u52A8\u5E38\u9A7B\u8FDB\u7A0B\uFF0C\u6309\u914D\u7F6E\u5B9A\u65F6\u6267\u884C\u5907\u4EFD").addHelpText("after", commonHelpText).action(async (options, command) => {
|
|
731
|
-
const remote = command
|
|
1178
|
+
const remote = resolveRemote(command);
|
|
732
1179
|
await daemon({ remote });
|
|
733
1180
|
});
|
|
734
1181
|
program.command("env").description("\u663E\u793A\u73AF\u5883\u53D8\u91CF").addHelpText("after", commonHelpText).action(() => {
|
|
@@ -738,7 +1185,7 @@ program.command("env").description("\u663E\u793A\u73AF\u5883\u53D8\u91CF").addHe
|
|
|
738
1185
|
}
|
|
739
1186
|
});
|
|
740
1187
|
program.command("restic [args...]").description("\u663E\u5F0F\u900F\u4F20\u7ED9 restic").addHelpText("after", commonHelpText).allowUnknownOption().allowExcessArguments().action(async function(args) {
|
|
741
|
-
const remote = this
|
|
1188
|
+
const remote = resolveRemote(this);
|
|
742
1189
|
const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
|
|
743
1190
|
const config = loadConfig(wayDir, remote);
|
|
744
1191
|
const env = buildResticEnv(config.repository);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shellus/way",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.12",
|
|
4
4
|
"description": "将备份作为持续运营的项目,而非一次性任务。基于 restic 的策略封装。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -39,20 +39,18 @@
|
|
|
39
39
|
},
|
|
40
40
|
"homepage": "https://github.com/shellus/way#readme",
|
|
41
41
|
"engines": {
|
|
42
|
-
"node": ">=
|
|
42
|
+
"node": ">=22.12.0"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"commander": "^
|
|
46
|
-
"execa": "^
|
|
47
|
-
"js-yaml": "^
|
|
48
|
-
"node-cron": "^4.
|
|
45
|
+
"commander": "^15.0.0",
|
|
46
|
+
"execa": "^10.0.1",
|
|
47
|
+
"js-yaml": "^5.3.0",
|
|
48
|
+
"node-cron": "^4.6.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@types/
|
|
52
|
-
"@types/node": "^25.5.0",
|
|
53
|
-
"@types/node-cron": "^3.0.11",
|
|
51
|
+
"@types/node": "^22.20.1",
|
|
54
52
|
"tsup": "^8.5.1",
|
|
55
|
-
"typescript": "^
|
|
56
|
-
"vitest": "^4.1.
|
|
53
|
+
"typescript": "^7.0.2",
|
|
54
|
+
"vitest": "^4.1.11"
|
|
57
55
|
}
|
|
58
56
|
}
|
package/rules.yaml.example
CHANGED
|
@@ -15,6 +15,9 @@ defaults:
|
|
|
15
15
|
keep_daily: 7
|
|
16
16
|
keep_weekly: 4
|
|
17
17
|
keep_monthly: 6
|
|
18
|
+
# 如需按当前时间实行严格范围保留,改用以下两个字段,且不要与上述 keep_* 混用:
|
|
19
|
+
# keep_hosts: [backup-host]
|
|
20
|
+
# max_age_days: 7
|
|
18
21
|
|
|
19
22
|
# Uptime Kuma 全局通知(可选,未配置项目级地址时作为回退)
|
|
20
23
|
uptime_kuma:
|
|
@@ -95,9 +98,26 @@ global_excludes:
|
|
|
95
98
|
- "*.tmp"
|
|
96
99
|
- "*.swp"
|
|
97
100
|
|
|
101
|
+
# 仓库复制:每天只复制各项目的最新快照,远程仓库使用独立保留策略
|
|
102
|
+
replications:
|
|
103
|
+
local-to-s3:
|
|
104
|
+
from: local
|
|
105
|
+
to: s3
|
|
106
|
+
snapshot_policy: latest-per-project
|
|
107
|
+
schedule: "30 5 * * *"
|
|
108
|
+
prune_schedule: "30 6 * * 0"
|
|
109
|
+
retention:
|
|
110
|
+
keep_daily: 30
|
|
111
|
+
keep_weekly: 12
|
|
112
|
+
keep_monthly: 12
|
|
113
|
+
keep_yearly: 3
|
|
114
|
+
|
|
98
115
|
# 维护任务(可选)
|
|
99
116
|
maintenance:
|
|
100
117
|
prune:
|
|
101
118
|
schedule: "0 4 * * 0" # 每周日凌晨 4 点清理
|
|
119
|
+
# retry_lock: "30m" # 备份仍持有仓库锁时等待,避免立即失败
|
|
120
|
+
# uptime_kuma: # 定时 prune 成功推送 UP、失败推送 DOWN;未配置时回退到全局 uptime_kuma
|
|
121
|
+
# push_url: "https://uptime.example.com/api/push/prune-placeholder"
|
|
102
122
|
check:
|
|
103
123
|
schedule: false # 不执行
|