@shellus/way 0.6.11 → 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 +42 -0
- package/dist/cli.js +213 -42
- package/package.json +1 -1
- package/repositories.yaml.example +1 -0
- package/rules.yaml.example +16 -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
|
|
|
@@ -168,6 +171,8 @@ way restore data --host old-host --target /tmp/restore --dry-run # 从指定 ho
|
|
|
168
171
|
way restore data --target /tmp/restore --delete # 删除目标中快照不存在的文件
|
|
169
172
|
way gc # 按 retention 策略清理旧快照
|
|
170
173
|
way gc --dry-run # 模拟清理(不实际删除)
|
|
174
|
+
way replicate # 执行全部仓库复制任务
|
|
175
|
+
way replicate local-to-s3 --init # 首次初始化目标仓库并执行复制
|
|
171
176
|
|
|
172
177
|
# daemon 模式(推荐)
|
|
173
178
|
way daemon # 启动常驻进程,按配置定时执行
|
|
@@ -205,6 +210,8 @@ graph LR
|
|
|
205
210
|
C --> D[node-cron 定时触发]
|
|
206
211
|
D --> E[执行 restic backup]
|
|
207
212
|
E --> F[推送 Uptime Kuma]
|
|
213
|
+
D --> K[选择各项目最新快照]
|
|
214
|
+
K --> L[restic copy 到远程仓库]
|
|
208
215
|
|
|
209
216
|
G[systemd service] --> H[启动 daemon]
|
|
210
217
|
H --> I[进程崩溃自动重启]
|
|
@@ -237,7 +244,9 @@ WAY_DIR=/path/to/config way restic snapshots
|
|
|
237
244
|
- **projects.*.hooks**: 项目级备份钩子,支持 `before_backup` 和 `after_backup`
|
|
238
245
|
- **uptime_kuma.push_url**: 全局 Uptime Kuma Push 地址,作为项目未配置通知地址时的回退
|
|
239
246
|
- **projects.*.uptime_kuma.push_url**: 项目级 Uptime Kuma Push 地址,优先于全局地址
|
|
247
|
+
- **replications**: 仓库复制任务,包含源仓库、目标仓库、调度和目标仓库独立保留策略
|
|
240
248
|
- **maintenance**: 维护任务配置(prune、check)
|
|
249
|
+
- **maintenance.prune.uptime_kuma.push_url**: daemon 定时 prune 的 Uptime Kuma Push 地址,未配置时回退到全局地址;prune 成功推送 UP,失败推送带原因的 DOWN
|
|
241
250
|
- **global_excludes**: 全局排除规则
|
|
242
251
|
|
|
243
252
|
`defaults.retention` 支持两种互斥模式:
|
|
@@ -372,6 +381,27 @@ daemon 的调度心跳最多容忍 5 秒延迟;事件循环短暂阻塞后仍
|
|
|
372
381
|
| `"*/30 * * * *"` | 分钟间隔 | 每 30 分钟 |
|
|
373
382
|
| `false` | 禁用自动调度 | 只手动备份 |
|
|
374
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
|
+
|
|
375
405
|
#### 排除规则通配符语法
|
|
376
406
|
|
|
377
407
|
restic 使用 Go 的 filepath.Match 语法:
|
|
@@ -396,6 +426,18 @@ repositories:
|
|
|
396
426
|
path: /backup/repo
|
|
397
427
|
credentials:
|
|
398
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
|
|
399
441
|
```
|
|
400
442
|
|
|
401
443
|
建议设置文件权限:`chmod 600 ~/.way/repositories.yaml`
|
package/dist/cli.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
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";
|
|
@@ -93,19 +93,20 @@ function resolveResticBin(options = {}) {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
// src/core/restic.ts
|
|
96
|
-
function
|
|
97
|
-
const env = {};
|
|
96
|
+
function buildRepositoryLocation(repo) {
|
|
98
97
|
switch (repo.type) {
|
|
99
98
|
case "s3":
|
|
100
|
-
|
|
101
|
-
break;
|
|
99
|
+
return `s3:https://${repo.endpoint}/${repo.bucket}`;
|
|
102
100
|
case "local":
|
|
103
|
-
|
|
104
|
-
break;
|
|
101
|
+
return repo.path;
|
|
105
102
|
case "sftp":
|
|
106
|
-
|
|
107
|
-
break;
|
|
103
|
+
return `sftp:${repo.host}:${repo.path}`;
|
|
108
104
|
}
|
|
105
|
+
}
|
|
106
|
+
function buildResticEnv(repo) {
|
|
107
|
+
const env = {
|
|
108
|
+
RESTIC_REPOSITORY: buildRepositoryLocation(repo)
|
|
109
|
+
};
|
|
109
110
|
if (repo.credentials.password) env.RESTIC_PASSWORD = repo.credentials.password;
|
|
110
111
|
if (repo.credentials.access_key_id) env.AWS_ACCESS_KEY_ID = repo.credentials.access_key_id;
|
|
111
112
|
if (repo.credentials.secret_access_key) env.AWS_SECRET_ACCESS_KEY = repo.credentials.secret_access_key;
|
|
@@ -194,7 +195,7 @@ function buildRestoreArgs(name, project, options) {
|
|
|
194
195
|
];
|
|
195
196
|
if (options.host) args.push(`--host=${options.host}`);
|
|
196
197
|
args.push(`--target=${options.target}`);
|
|
197
|
-
for (const
|
|
198
|
+
for (const path10 of options.includePaths || project.paths) args.push(`--include=${normalizeResticPath(path10, options.platform)}`);
|
|
198
199
|
if (options.dryRun) args.push("--dry-run");
|
|
199
200
|
if (options.delete) args.push("--delete");
|
|
200
201
|
if (options.verbose) args.push("--verbose=2");
|
|
@@ -205,6 +206,9 @@ function buildS3Options(repo) {
|
|
|
205
206
|
if (repo.options?.bucket_lookup) {
|
|
206
207
|
options.push("-o", `s3.bucket-lookup=${repo.options.bucket_lookup}`);
|
|
207
208
|
}
|
|
209
|
+
if (repo.region) {
|
|
210
|
+
options.push("-o", `s3.region=${repo.region}`);
|
|
211
|
+
}
|
|
208
212
|
return options;
|
|
209
213
|
}
|
|
210
214
|
async function execRestic(args, env, s3Options = []) {
|
|
@@ -230,6 +234,23 @@ async function runRestic(args, env, s3Options, capture) {
|
|
|
230
234
|
}
|
|
231
235
|
}
|
|
232
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
|
+
|
|
233
254
|
// src/commands/backup.ts
|
|
234
255
|
import fs4 from "fs";
|
|
235
256
|
import os from "os";
|
|
@@ -318,7 +339,11 @@ async function backup(options) {
|
|
|
318
339
|
if (failed.length > 0) console.log("Failed:", failed.join(", "));
|
|
319
340
|
if (!dryRun) {
|
|
320
341
|
for (const [pushUrl, result] of groupUptimeKumaResults(projectResults)) {
|
|
321
|
-
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);
|
|
322
347
|
}
|
|
323
348
|
}
|
|
324
349
|
return { succeeded, failed, duration };
|
|
@@ -422,21 +447,6 @@ async function runProjectHooks(hooks, context) {
|
|
|
422
447
|
}
|
|
423
448
|
}
|
|
424
449
|
}
|
|
425
|
-
async function notifyUptimeKuma(result, pushUrl) {
|
|
426
|
-
const status = result.failed.length > 0 ? "down" : "up";
|
|
427
|
-
const msg = `Succeeded: ${result.succeeded.length}, Failed: ${result.failed.length}`;
|
|
428
|
-
const url = `${pushUrl}?status=${status}&msg=${encodeURIComponent(msg)}&ping=${result.duration}`;
|
|
429
|
-
try {
|
|
430
|
-
const response = await fetch(url);
|
|
431
|
-
if (response.ok) {
|
|
432
|
-
console.log(`Uptime Kuma notified: status=${status}, ping=${result.duration}ms`);
|
|
433
|
-
} else {
|
|
434
|
-
console.error("Uptime Kuma notification failed");
|
|
435
|
-
}
|
|
436
|
-
} catch (error) {
|
|
437
|
-
console.error("Uptime Kuma notification failed:", error);
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
450
|
|
|
441
451
|
// src/commands/restore.ts
|
|
442
452
|
import path5 from "path";
|
|
@@ -580,6 +590,13 @@ function buildResticOptions(s3Options, retryLock) {
|
|
|
580
590
|
if (retryLock) options.push(`--retry-lock=${retryLock}`);
|
|
581
591
|
return options;
|
|
582
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
|
+
}
|
|
583
600
|
async function runStrictGc(retention, dryRun, env, resticOptions) {
|
|
584
601
|
const snapshotsJson = await execResticCapture(["snapshots", "--json", "--no-lock"], env, resticOptions);
|
|
585
602
|
const snapshots = JSON.parse(snapshotsJson);
|
|
@@ -596,12 +613,12 @@ async function runStrictGc(retention, dryRun, env, resticOptions) {
|
|
|
596
613
|
console.log("No snapshots matched cleanup; prune was skipped.");
|
|
597
614
|
return;
|
|
598
615
|
}
|
|
599
|
-
await
|
|
616
|
+
await executeGc(["forget", "--prune", ...plan.remove.map((snapshot) => snapshot.id)], false, env, resticOptions);
|
|
600
617
|
}
|
|
601
618
|
async function gc(options) {
|
|
602
619
|
const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
|
|
603
620
|
const config = loadConfig(wayDir, options.remote);
|
|
604
|
-
const retention = config.rules.defaults?.retention
|
|
621
|
+
const retention = options.retention ?? config.rules.defaults?.retention ?? {};
|
|
605
622
|
const dryRun = options.dryRun || false;
|
|
606
623
|
console.log("=== Cleaning snapshots ===");
|
|
607
624
|
const env = buildResticEnv(config.repository);
|
|
@@ -613,11 +630,11 @@ async function gc(options) {
|
|
|
613
630
|
await runStrictGc(retention, dryRun, env, resticOptions);
|
|
614
631
|
return;
|
|
615
632
|
}
|
|
616
|
-
const keepDaily = retention.keep_daily
|
|
617
|
-
const keepWeekly = retention.keep_weekly
|
|
618
|
-
const keepMonthly = retention.keep_monthly
|
|
633
|
+
const keepDaily = retention.keep_daily ?? 7;
|
|
634
|
+
const keepWeekly = retention.keep_weekly ?? 4;
|
|
635
|
+
const keepMonthly = retention.keep_monthly ?? 6;
|
|
619
636
|
const keepYearly = retention.keep_yearly;
|
|
620
|
-
console.log(`Policy: daily=${keepDaily}, weekly=${keepWeekly}, monthly=${keepMonthly}${keepYearly ? `, yearly=${keepYearly}` : ""}`);
|
|
637
|
+
console.log(`Policy: daily=${keepDaily}, weekly=${keepWeekly}, monthly=${keepMonthly}${keepYearly !== void 0 ? `, yearly=${keepYearly}` : ""}`);
|
|
621
638
|
const args = [
|
|
622
639
|
"forget",
|
|
623
640
|
"--prune",
|
|
@@ -625,9 +642,9 @@ async function gc(options) {
|
|
|
625
642
|
`--keep-weekly=${keepWeekly}`,
|
|
626
643
|
`--keep-monthly=${keepMonthly}`
|
|
627
644
|
];
|
|
628
|
-
if (keepYearly) args.push(`--keep-yearly=${keepYearly}`);
|
|
645
|
+
if (keepYearly !== void 0) args.push(`--keep-yearly=${keepYearly}`);
|
|
629
646
|
if (dryRun) args.push("--dry-run");
|
|
630
|
-
await
|
|
647
|
+
await executeGc(args, dryRun, env, resticOptions);
|
|
631
648
|
}
|
|
632
649
|
|
|
633
650
|
// src/commands/systemd.ts
|
|
@@ -823,6 +840,113 @@ async function windowsService(options, dependencies = {}) {
|
|
|
823
840
|
|
|
824
841
|
// src/commands/daemon.ts
|
|
825
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
|
|
826
950
|
var isRunning = false;
|
|
827
951
|
var taskQueue = [];
|
|
828
952
|
var CRON_OPTIONS = {
|
|
@@ -840,6 +964,24 @@ function assertValidSchedule(schedule, label) {
|
|
|
840
964
|
throw new Error(`${label} must be a cron expression or false. Use false to disable scheduling.`);
|
|
841
965
|
}
|
|
842
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
|
+
}
|
|
843
985
|
function addScheduledBackup(scheduledBackups, schedule, projectName) {
|
|
844
986
|
const projects = scheduledBackups.get(schedule) || [];
|
|
845
987
|
projects.push(projectName);
|
|
@@ -882,13 +1024,37 @@ async function daemon(options) {
|
|
|
882
1024
|
}, CRON_OPTIONS);
|
|
883
1025
|
console.log(`Scheduled backup for ${projects.join(", ")}: ${schedule}`);
|
|
884
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
|
+
}
|
|
885
1050
|
const pruneSchedule = config.rules.maintenance?.prune?.schedule;
|
|
886
1051
|
assertValidSchedule(pruneSchedule, "maintenance.prune.schedule");
|
|
887
1052
|
if (isEnabledSchedule(pruneSchedule)) {
|
|
1053
|
+
const prunePushUrl = resolvePrunePushUrl(config.rules);
|
|
888
1054
|
cron.schedule(pruneSchedule, () => {
|
|
889
1055
|
executeTask(async () => {
|
|
890
1056
|
console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running prune`);
|
|
891
|
-
await
|
|
1057
|
+
await runPruneWithNotification(options.remote, prunePushUrl);
|
|
892
1058
|
});
|
|
893
1059
|
}, CRON_OPTIONS);
|
|
894
1060
|
console.log(`Scheduled prune: ${pruneSchedule}`);
|
|
@@ -915,7 +1081,7 @@ async function daemon(options) {
|
|
|
915
1081
|
|
|
916
1082
|
// src/cli.ts
|
|
917
1083
|
var program = new Command();
|
|
918
|
-
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", `
|
|
919
1085
|
\u793A\u4F8B:
|
|
920
1086
|
$ way backup \u6267\u884C\u6240\u6709\u9879\u76EE\u5907\u4EFD
|
|
921
1087
|
$ way backup data \u53EA\u5907\u4EFD data \u9879\u76EE
|
|
@@ -924,6 +1090,7 @@ program.name("way").version("0.6.11").description("\u7B56\u7565\u5907\u4EFD\u5DE
|
|
|
924
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
|
|
925
1091
|
$ way init \u521D\u59CB\u5316 way \u914D\u7F6E\u6587\u4EF6
|
|
926
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
|
|
927
1094
|
$ way systemd install \u5B89\u88C5\u5B9A\u65F6\u4EFB\u52A1
|
|
928
1095
|
$ way restic snapshots \u67E5\u770B\u5FEB\u7167\u5217\u8868
|
|
929
1096
|
$ way restic restore abc123 --target /tmp/restore
|
|
@@ -952,17 +1119,17 @@ var commonHelpText = `
|
|
|
952
1119
|
program.command("init").description("\u521D\u59CB\u5316 way \u914D\u7F6E\u6587\u4EF6").addHelpText("after", commonHelpText).action(() => {
|
|
953
1120
|
const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
|
|
954
1121
|
const files = ["repositories.yaml", "rules.yaml"];
|
|
955
|
-
|
|
1122
|
+
fs8.mkdirSync(wayDir, { recursive: true });
|
|
956
1123
|
for (const file of files) {
|
|
957
|
-
const target =
|
|
958
|
-
if (
|
|
1124
|
+
const target = path9.join(wayDir, file);
|
|
1125
|
+
if (fs8.existsSync(target)) {
|
|
959
1126
|
throw new Error(`${target} already exists, aborting to avoid overwriting existing config.`);
|
|
960
1127
|
}
|
|
961
1128
|
}
|
|
962
1129
|
for (const file of files) {
|
|
963
1130
|
const source = resolveExampleConfigPath(file);
|
|
964
|
-
const target =
|
|
965
|
-
|
|
1131
|
+
const target = path9.join(wayDir, file);
|
|
1132
|
+
fs8.copyFileSync(source, target);
|
|
966
1133
|
console.log(`Created ${target}`);
|
|
967
1134
|
}
|
|
968
1135
|
});
|
|
@@ -995,6 +1162,10 @@ program.command("gc").description("\u6E05\u7406\u65E7\u5FEB\u7167").option("--dr
|
|
|
995
1162
|
const remote = resolveRemote(this);
|
|
996
1163
|
await gc({ remote, dryRun: cmdOptions.dryRun });
|
|
997
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
|
+
});
|
|
998
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) => {
|
|
999
1170
|
const remote = resolveRemote(command);
|
|
1000
1171
|
await systemd({ remote, action });
|
package/package.json
CHANGED
package/rules.yaml.example
CHANGED
|
@@ -98,10 +98,26 @@ global_excludes:
|
|
|
98
98
|
- "*.tmp"
|
|
99
99
|
- "*.swp"
|
|
100
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
|
+
|
|
101
115
|
# 维护任务(可选)
|
|
102
116
|
maintenance:
|
|
103
117
|
prune:
|
|
104
118
|
schedule: "0 4 * * 0" # 每周日凌晨 4 点清理
|
|
105
119
|
# retry_lock: "30m" # 备份仍持有仓库锁时等待,避免立即失败
|
|
120
|
+
# uptime_kuma: # 定时 prune 成功推送 UP、失败推送 DOWN;未配置时回退到全局 uptime_kuma
|
|
121
|
+
# push_url: "https://uptime.example.com/api/push/prune-placeholder"
|
|
106
122
|
check:
|
|
107
123
|
schedule: false # 不执行
|