@datatruck/restic 0.0.1 → 0.0.3

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.
@@ -1,129 +1,119 @@
1
+ import { createRunner } from "../utils/async.js";
1
2
  import { checkDiskSpace } from "../utils/fs.js";
2
- import { Ntfy } from "../utils/ntfy.js";
3
+ import { Action } from "./base.js";
4
+ import { Prune } from "./prune.js";
3
5
  import { SnapshotTagEnum } from "@datatruck/cli/repositories/RepositoryAbstract.js";
4
6
  import { ResticRepository } from "@datatruck/cli/repositories/ResticRepository.js";
5
7
  import { formatBytes } from "@datatruck/cli/utils/bytes.js";
6
- import { duration } from "@datatruck/cli/utils/date.js";
7
8
  import { isLocalDir } from "@datatruck/cli/utils/fs.js";
8
9
  import { Restic } from "@datatruck/cli/utils/restic.js";
9
- export class Copy {
10
- config;
11
- global;
12
- ntfy;
13
- verbose;
14
- constructor(config, global) {
15
- this.config = config;
16
- this.global = global;
17
- this.verbose = this.global?.verbose ?? this.config.verbose;
18
- this.ntfy = new Ntfy({
19
- token: this.config.ntfyToken,
20
- titlePrefix: `[${this.config.hostname}] `,
10
+ export class Copy extends Action {
11
+ initializedRepos = new Set();
12
+ async findSnapshots(name, packages) {
13
+ const repo = this.cm.findRepository(name);
14
+ const restic = new Restic({
15
+ log: this.verbose,
16
+ env: {
17
+ RESTIC_REPOSITORY: repo.uri,
18
+ RESTIC_PASSWORD: repo.password,
19
+ },
21
20
  });
21
+ const snapshots = (await restic.snapshots({
22
+ latest: 1,
23
+ group: ["path"],
24
+ tags: packages
25
+ ? packages.map((name) => ResticRepository.createSnapshotTag(SnapshotTagEnum.PACKAGE, name))
26
+ : undefined,
27
+ }));
28
+ return snapshots.flatMap((s) => s.snapshots);
22
29
  }
23
- findRepo(name) {
24
- const repo = this.config.repositories.find((repo) => repo.name === name);
25
- if (!repo)
26
- throw new Error(`Repository '${name}' not found`);
27
- return repo;
30
+ findPackageTag(inTags) {
31
+ const tags = inTags
32
+ .map((t) => ResticRepository.parseSnapshotTag(t))
33
+ .filter((t) => !!t);
34
+ return tags.find((t) => t.name === SnapshotTagEnum.PACKAGE);
35
+ }
36
+ async runSingle(options) {
37
+ const { snapshot } = options;
38
+ const targetRepo = this.cm.findRepository(options.target);
39
+ const sourceRepo = this.cm.findRepository(options.source);
40
+ const pkgTag = this.findPackageTag(snapshot.tags);
41
+ const packageName = pkgTag?.value;
42
+ const targetPath = isLocalDir(targetRepo.uri) ? targetRepo.uri : undefined;
43
+ const target = new Restic({
44
+ log: this.verbose,
45
+ env: {
46
+ RESTIC_REPOSITORY: targetRepo.uri,
47
+ RESTIC_PASSWORD: targetRepo.password,
48
+ ["GODEBUG"]: "http2client=0",
49
+ },
50
+ });
51
+ let space;
52
+ await createRunner(async () => {
53
+ if (!this.initializedRepos.has(targetRepo.name)) {
54
+ await target.tryInit();
55
+ this.initializedRepos.add(targetRepo.name);
56
+ }
57
+ space = await checkDiskSpace({
58
+ minFreeSpace: this.config.minFreeSpace,
59
+ targetPath,
60
+ rutine: () => target.copy({
61
+ ids: [snapshot.id],
62
+ fromRepo: sourceRepo.uri,
63
+ fromRepoPassword: sourceRepo.password,
64
+ }),
65
+ });
66
+ }).start(async (data) => {
67
+ await this.ntfy.send(`Copy`, {
68
+ Id: snapshot.short_id,
69
+ Source: sourceRepo.name,
70
+ Target: targetRepo.name,
71
+ Package: packageName,
72
+ ...(space !== undefined && {
73
+ Size: `${formatBytes(space.size)} (${formatBytes(space.diff, true)})`,
74
+ }),
75
+ Duration: data.duration,
76
+ Error: data.error?.message,
77
+ }, data.error);
78
+ });
79
+ return space?.diff ?? 0;
28
80
  }
29
81
  async run(options) {
30
- const now = Date.now();
31
82
  let globalDiffSize;
32
- let error;
33
- try {
83
+ await createRunner(async () => {
84
+ const [source] = this.cm.filterRepositories([options.source]);
85
+ const targets = this.cm.filterRepositories(options.targets);
34
86
  await this.ntfy.send(`Copy start`, {
35
- "- Source": options.source,
36
- "- Targets": options.targets.join(", "),
37
- });
38
- const sourceRepo = this.findRepo(options.source);
39
- const source = new Restic({
40
- log: this.verbose,
41
- env: {
42
- RESTIC_REPOSITORY: sourceRepo.uri,
43
- RESTIC_PASSWORD: sourceRepo.password,
44
- },
87
+ Source: source.name,
88
+ Targets: targets.map((t) => t.name).join(", "),
45
89
  });
46
- const targetRepos = options.targets.map((target) => this.findRepo(target));
47
- if (!targetRepos.length)
48
- throw new Error(`No target repositories specified`);
49
- const inSnapshots = (await source.snapshots({
50
- latest: 1,
51
- group: ["path"],
52
- tags: options.packages
53
- ? options.packages.map((name) => ResticRepository.createSnapshotTag(SnapshotTagEnum.PACKAGE, name))
54
- : undefined,
55
- }));
56
- const snapshots = inSnapshots.flatMap((s) => s.snapshots);
57
- for (const targetRepo of targetRepos) {
58
- const target = new Restic({
59
- log: this.verbose,
60
- env: {
61
- RESTIC_REPOSITORY: targetRepo.uri,
62
- RESTIC_PASSWORD: targetRepo.password,
63
- ["GODEBUG"]: "http2client=0",
64
- },
65
- });
66
- const exists = await target.checkRepository();
67
- if (!exists && isLocalDir(targetRepo.uri))
68
- await target.exec(["init"]);
90
+ const snapshots = await this.findSnapshots(source.name, options.packages);
91
+ for (const target of targets) {
69
92
  for (const snapshot of snapshots) {
70
- const tags = snapshot.tags
71
- .map((t) => ResticRepository.parseSnapshotTag(t))
72
- .filter((t) => !!t);
73
- const pkgTag = tags.find((t) => t.name === SnapshotTagEnum.PACKAGE);
74
- const packageName = pkgTag?.value;
75
- const now = Date.now();
76
- let copyError;
77
- let diffSize;
78
- const targetPath = isLocalDir(targetRepo.uri)
79
- ? targetRepo.uri
80
- : undefined;
81
- try {
82
- diffSize = await checkDiskSpace({
83
- minFreeSpace: this.config.minFreeSpace,
84
- targetPath,
85
- rutine: () => target.copy({
86
- ids: [snapshot.id],
87
- fromRepo: sourceRepo.uri,
88
- fromRepoPassword: sourceRepo.password,
89
- }),
90
- });
91
- if (diffSize !== undefined) {
92
- globalDiffSize = (globalDiffSize ?? 0) + (diffSize ?? 0);
93
- }
94
- }
95
- catch (inError) {
96
- copyError = inError;
97
- }
98
- await this.ntfy.send(`Copy`, {
99
- "- Id": snapshot.short_id,
100
- "- Source": sourceRepo.name,
101
- "- Target": targetRepo.name,
102
- "- Package": packageName,
103
- ...(diffSize !== undefined && {
104
- "- Diff size": (diffSize > 0 ? "+" : "") + formatBytes(diffSize),
105
- }),
106
- "- Duration": duration(Date.now() - now),
107
- "- Error": copyError?.message,
108
- }, {
109
- priority: copyError ? "high" : "default",
110
- tags: [copyError ? "red_circle" : "green_circle"],
93
+ const diffSize = await this.runSingle({
94
+ snapshot,
95
+ source: source.name,
96
+ target: target.name,
111
97
  });
98
+ globalDiffSize = (globalDiffSize ?? 0) + diffSize;
112
99
  }
113
100
  }
114
- }
115
- catch (inError) {
116
- error = inError;
117
- }
118
- await this.ntfy.send(`Copy end`, {
119
- ...(globalDiffSize !== undefined && {
120
- "- Diff size": (globalDiffSize > 0 ? "+" : "") + formatBytes(globalDiffSize),
121
- }),
122
- "- Duration": duration(Date.now() - now),
123
- "- Error": error?.message,
124
- }, {
125
- priority: error ? "high" : "default",
126
- tags: [error ? "red_circle" : "green_circle"],
101
+ if (!options.targets.length)
102
+ throw new Error(`No target repositories specified`);
103
+ }).start(async (data) => {
104
+ await this.ntfy.send(`Copy end`, {
105
+ ...(globalDiffSize !== undefined && {
106
+ "Diff size": formatBytes(globalDiffSize, true),
107
+ }),
108
+ Duration: data.duration,
109
+ Error: data.error?.message,
110
+ }, data.error);
111
+ if (options.prune) {
112
+ await new Prune(this.config, this.global).run({
113
+ packages: options.packages,
114
+ repositories: options.targets,
115
+ });
116
+ }
127
117
  });
128
118
  }
129
119
  }
@@ -0,0 +1,10 @@
1
+ export type CreateOptions = {
2
+ cwd: string;
3
+ config: string;
4
+ force?: boolean;
5
+ };
6
+ export declare class Create {
7
+ constructor();
8
+ protected random(): string;
9
+ run(options: CreateOptions): Promise<void>;
10
+ }
@@ -0,0 +1,49 @@
1
+ import { formatBytes } from "@datatruck/cli/utils/bytes.js";
2
+ import { fetchDiskStats } from "@datatruck/cli/utils/fs.js";
3
+ import { existsSync } from "fs";
4
+ import { writeFile } from "fs/promises";
5
+ import { hostname } from "os";
6
+ import { join, relative } from "path";
7
+ export class Create {
8
+ constructor() { }
9
+ random() {
10
+ return crypto.randomUUID().replaceAll("-", "");
11
+ }
12
+ async run(options) {
13
+ const stats = await fetchDiskStats(options.cwd);
14
+ const minFreeSpaceBytes = (stats.total * 25) / 100;
15
+ const minFreeSpace = formatBytes(minFreeSpaceBytes);
16
+ const cwd = process.cwd();
17
+ const config = {
18
+ $schema: "https://unpkg.com/@datatruck/restic/config.schema.json",
19
+ hostname: hostname(),
20
+ minFreeSpace,
21
+ ntfyToken: this.random(),
22
+ packages: [
23
+ {
24
+ name: "default",
25
+ path: join(cwd, "data").replaceAll("\\", "/"),
26
+ },
27
+ ],
28
+ prunePolicy: {
29
+ keepDaily: 7,
30
+ keepMonthly: 12,
31
+ keepYearly: 5,
32
+ },
33
+ repositories: [
34
+ {
35
+ name: "default",
36
+ uri: join(cwd, "repo").replaceAll("\\", "/"),
37
+ password: this.random(),
38
+ },
39
+ ],
40
+ };
41
+ const configPath = join(cwd, options.config);
42
+ if (existsSync(configPath) && !options.force)
43
+ throw new Error(`Config file already exists at path: ${configPath}`);
44
+ await writeFile(configPath, JSON.stringify(config, null, 2) + "\n");
45
+ const relativeConfigPath = relative(cwd, configPath);
46
+ console.log(`- Created config file at path: ${relativeConfigPath}`);
47
+ console.info(`- Show log remotly via ntfy: https://ntfy.sh/${config.ntfyToken}`);
48
+ }
49
+ }
@@ -1,13 +1,8 @@
1
- import { Config, GlobalConfig } from "../config.js";
2
- import { Ntfy } from "../utils/ntfy.js";
1
+ import { Action } from "./base.js";
3
2
  export type InitOptions = {
4
3
  repositories?: string[];
5
4
  };
6
- export declare class Init {
7
- readonly config: Config;
8
- readonly global?: GlobalConfig | undefined;
9
- readonly ntfy: Ntfy;
10
- protected verbose: boolean | undefined;
11
- constructor(config: Config, global?: GlobalConfig | undefined);
5
+ export declare class Init extends Action {
6
+ protected runSingle(name: string): Promise<void>;
12
7
  run(options: InitOptions): Promise<void>;
13
8
  }
@@ -1,56 +1,41 @@
1
- import { Ntfy } from "../utils/ntfy.js";
2
- import { duration } from "@datatruck/cli/utils/date.js";
3
- import { isLocalDir } from "@datatruck/cli/utils/fs.js";
1
+ import { createRunner } from "../utils/async.js";
2
+ import { Action } from "./base.js";
4
3
  import { Restic } from "@datatruck/cli/utils/restic.js";
5
- export class Init {
6
- config;
7
- global;
8
- ntfy;
9
- verbose;
10
- constructor(config, global) {
11
- this.config = config;
12
- this.global = global;
13
- this.verbose = this.global?.verbose ?? this.config.verbose;
14
- this.ntfy = new Ntfy({
15
- token: this.config.ntfyToken,
16
- titlePrefix: `[${this.config.hostname}] `,
4
+ export class Init extends Action {
5
+ async runSingle(name) {
6
+ let exists;
7
+ await createRunner(async () => {
8
+ const repo = this.cm.findRepository(name);
9
+ const restic = new Restic({
10
+ log: this.verbose,
11
+ env: {
12
+ RESTIC_REPOSITORY: repo.uri,
13
+ RESTIC_PASSWORD: repo.password,
14
+ },
15
+ });
16
+ exists = await restic.tryInit();
17
+ }).start(async (data) => {
18
+ await this.ntfy.send(`Init`, {
19
+ Repository: name,
20
+ Exists: exists ? "yes" : "no",
21
+ Duration: data.duration,
22
+ Error: data.error?.message,
23
+ }, data.error);
17
24
  });
18
25
  }
19
26
  async run(options) {
20
- const now = Date.now();
21
- await this.ntfy.send(`Init start`, {});
22
- const repositories = options.repositories ?? this.config.repositories.map((r) => r.name);
23
- for (const name of repositories) {
24
- let error;
25
- let exists;
26
- try {
27
- const repo = this.config.repositories.find((repo) => repo.name === name);
28
- if (!repo)
29
- throw new Error(`Repository not found`);
30
- const source = new Restic({
31
- log: this.verbose,
32
- env: {
33
- RESTIC_REPOSITORY: repo.uri,
34
- RESTIC_PASSWORD: repo.password,
35
- },
36
- });
37
- exists = await source.checkRepository();
38
- if (!exists && isLocalDir(repo.uri))
39
- await source.exec(["init"]);
40
- }
41
- catch (inError) {
42
- error = inError;
43
- }
44
- await this.ntfy.send(`Init`, {
45
- "- Repository": name,
46
- "- Exists": exists ? "yes" : "no",
47
- "- Duration": duration(Date.now() - now),
48
- "- Error": error?.message,
49
- }, {
50
- priority: error ? "high" : "default",
51
- tags: [error ? "red_circle" : "green_circle"],
27
+ await createRunner(async () => {
28
+ const repositories = this.cm.filterRepositories(options.repositories);
29
+ await this.ntfy.send(`Init start`, {
30
+ Repositories: repositories.length,
52
31
  });
53
- }
54
- await this.ntfy.send(`Init end`, {});
32
+ for (const repo of repositories)
33
+ await this.runSingle(repo.name);
34
+ }).start(async (data) => {
35
+ await this.ntfy.send(`Init end`, {
36
+ Duration: data.duration,
37
+ Error: data.error?.message,
38
+ });
39
+ });
55
40
  }
56
41
  }
@@ -0,0 +1,14 @@
1
+ import { PrunePolicy } from "../config.js";
2
+ import { Action } from "./base.js";
3
+ export type PruneOptions = {
4
+ packages?: string[];
5
+ repositories?: string[];
6
+ };
7
+ export declare class Prune extends Action {
8
+ protected runSingle(repoName: string, pkgName: string, policy: PrunePolicy): Promise<{
9
+ diffSize: number;
10
+ removed: number | undefined;
11
+ }>;
12
+ protected fetchPackages(repoName: string): Promise<string[]>;
13
+ run(options: PruneOptions): Promise<void>;
14
+ }
@@ -0,0 +1,123 @@
1
+ import { createRunner, safeRun } from "../utils/async.js";
2
+ import { checkDiskSpace, fetchMultipleDiskStats } from "../utils/fs.js";
3
+ import { Action } from "./base.js";
4
+ import { SnapshotTagEnum } from "@datatruck/cli/repositories/RepositoryAbstract.js";
5
+ import { ResticRepository } from "@datatruck/cli/repositories/ResticRepository.js";
6
+ import { formatBytes } from "@datatruck/cli/utils/bytes.js";
7
+ import { isLocalDir } from "@datatruck/cli/utils/fs.js";
8
+ import { progressPercent } from "@datatruck/cli/utils/math.js";
9
+ import { match } from "@datatruck/cli/utils/string.js";
10
+ export class Prune extends Action {
11
+ async runSingle(repoName, pkgName, policy) {
12
+ let stats;
13
+ let space;
14
+ let removed;
15
+ await createRunner(async () => {
16
+ const [restic, repo] = this.cm.createRestic(repoName, this.verbose);
17
+ const targetPath = isLocalDir(repo.uri) ? repo.uri : undefined;
18
+ space = await checkDiskSpace({
19
+ minFreeSpace: this.config.minFreeSpace,
20
+ targetPath,
21
+ rutine: async () => {
22
+ const results = await restic.forget({
23
+ ...policy,
24
+ json: true,
25
+ prune: true,
26
+ args: ["--group-by", ""],
27
+ tag: [
28
+ ResticRepository.createSnapshotTag(SnapshotTagEnum.PACKAGE, pkgName),
29
+ ],
30
+ });
31
+ stats = {
32
+ keep: results.at(0)?.keep?.length ?? 0,
33
+ remove: results.at(0)?.remove?.length ?? 0,
34
+ };
35
+ },
36
+ });
37
+ }).start(async (data) => {
38
+ if (stats)
39
+ removed = stats.remove;
40
+ await this.ntfy.send(`Prune`, {
41
+ Repository: repoName,
42
+ Package: pkgName,
43
+ ...(stats && {
44
+ Keeped: `${stats.keep}`,
45
+ Removed: `${stats.remove}`,
46
+ }),
47
+ ...(space !== undefined && {
48
+ Size: `${formatBytes(space.size)} (${formatBytes(space.diff, true)})`,
49
+ }),
50
+ Duration: data.duration,
51
+ Error: data.error?.message,
52
+ }, data.error);
53
+ });
54
+ return {
55
+ diffSize: space?.diff ?? 0,
56
+ removed,
57
+ };
58
+ }
59
+ async fetchPackages(repoName) {
60
+ const [restic] = this.cm.createRestic(repoName, this.verbose);
61
+ if (!(await restic.checkRepository()))
62
+ return [];
63
+ const snapshots = await restic.snapshots({ json: true });
64
+ const packages = new Set(snapshots.map((s) => {
65
+ const tags = ResticRepository.parseSnapshotTags(s.tags ?? []);
66
+ return tags[SnapshotTagEnum.PACKAGE];
67
+ }));
68
+ return [...packages].filter((v) => typeof v === "string");
69
+ }
70
+ async run(options) {
71
+ let globalDiffSize;
72
+ let globalRemoved = 0;
73
+ let localRepositoryPaths = [];
74
+ await createRunner(async () => {
75
+ const repositories = this.cm.filterRepositories(options.repositories);
76
+ await this.ntfy.send(`Prune start`, {
77
+ Repositories: repositories.length,
78
+ });
79
+ localRepositoryPaths = repositories
80
+ .filter((repo) => isLocalDir(repo.uri))
81
+ .map((repo) => repo.uri);
82
+ let some = false;
83
+ for (const repo of repositories) {
84
+ const inPackageNames = await this.fetchPackages(repo.name);
85
+ const packageNames = inPackageNames.filter((pkgName) => options.packages ? match(pkgName, options.packages) : true);
86
+ for (const pkgName of packageNames) {
87
+ const pkg = this.config.packages.find((pkg) => pkg.name === pkgName);
88
+ const policy = pkg?.prunePolicy ?? repo.prunePolicy ?? this.config.prunePolicy;
89
+ if (policy) {
90
+ const { diffSize, removed } = await this.runSingle(repo.name, pkgName, policy);
91
+ some = true;
92
+ if (diffSize !== undefined)
93
+ globalDiffSize = (globalDiffSize ?? 0) + diffSize;
94
+ if (removed !== undefined)
95
+ globalRemoved += removed;
96
+ }
97
+ }
98
+ }
99
+ if (options.packages && !some)
100
+ throw new Error(`No packages found for filter: ${options.packages.join(", ")}`);
101
+ }).start(async (data) => {
102
+ const diskStats = await safeRun(() => fetchMultipleDiskStats(localRepositoryPaths));
103
+ if (diskStats.error)
104
+ console.error(diskStats.error);
105
+ await this.ntfy.send(`Prune end`, {
106
+ Duration: data.duration,
107
+ Removed: globalRemoved,
108
+ ...(globalDiffSize !== undefined && {
109
+ "Diff size": (globalDiffSize > 0 ? "+" : "") + formatBytes(globalDiffSize),
110
+ }),
111
+ Error: data.error?.message,
112
+ "": [
113
+ !!diskStats.result?.length && { key: "Disk stats", value: "" },
114
+ ...(diskStats.result?.map((p) => ({
115
+ key: p.name,
116
+ value: `${formatBytes(p.free)}/${formatBytes(p.total)} (${progressPercent(p.total, p.free)}%)`,
117
+ level: 1,
118
+ })) || []),
119
+ ],
120
+ });
121
+ });
122
+ }
123
+ }
package/lib/bin.js CHANGED
@@ -1,57 +1,2 @@
1
- import { Backup } from "./actions/backup.js";
2
- import { Copy } from "./actions/copy.js";
3
- import { Init } from "./actions/init.js";
4
- import { parseConfigFile } from "./config.js";
5
- import { parseStringList } from "@datatruck/cli/utils/string.js";
6
- import { program } from "commander";
7
- import { resolve } from "path";
8
- function getGlobalOptions() {
9
- const options = program.opts();
10
- return {
11
- ...options,
12
- config: resolve(options.config),
13
- verbose: process.env.DEBUG ? true : options.verbose,
14
- };
15
- }
16
- async function load() {
17
- const globalOptions = getGlobalOptions();
18
- const config = await parseConfigFile(globalOptions.config);
19
- return { globalOptions, config };
20
- }
21
- program
22
- .option("-v, --verbose")
23
- .option("-c, --config <path>", "Path to config file", "datatruck.restic.json");
24
- program
25
- .command("init")
26
- .alias("i")
27
- .description("Run init action")
28
- .option("-r, --repositories <names>", "Repository names", (v) => parseStringList(v))
29
- .action(async (options) => {
30
- const { config, globalOptions } = await load();
31
- const init = new Init(config, globalOptions);
32
- await init.run(options);
33
- });
34
- program
35
- .command("backup")
36
- .alias("b")
37
- .description("Run backup action")
38
- .option("-r, --repositories <names>", "Repository names", (v) => parseStringList(v))
39
- .option("-p, --packages <packages>", "Package names", (v) => parseStringList(v))
40
- .action(async (options) => {
41
- const { config, globalOptions } = await load();
42
- const backup = new Backup(config, globalOptions);
43
- await backup.run(options);
44
- });
45
- program
46
- .command("copy")
47
- .alias("c")
48
- .description("Run copy action")
49
- .option("-p, --packages <packages>", "Package names", (v) => parseStringList(v))
50
- .requiredOption("-s, --source <name>", "Source repository name")
51
- .requiredOption("-t, --targets <names>", "Target repository names", (v) => parseStringList(v))
52
- .action(async (options) => {
53
- const { config, globalOptions } = await load();
54
- const copy = new Copy(config, globalOptions);
55
- await copy.run(options);
56
- });
57
- program.parse();
1
+ import { createBin } from "./create-bin.js";
2
+ createBin().parse();