@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.
@@ -16,6 +16,33 @@
16
16
  "verbose": {
17
17
  "type": "boolean"
18
18
  },
19
+ "prunePolicy": {
20
+ "type": "object",
21
+ "properties": {
22
+ "keepMinutely": {
23
+ "type": "number"
24
+ },
25
+ "keepDaily": {
26
+ "type": "number"
27
+ },
28
+ "keepHourly": {
29
+ "type": "number"
30
+ },
31
+ "keepLast": {
32
+ "type": "number"
33
+ },
34
+ "keepMonthly": {
35
+ "type": "number"
36
+ },
37
+ "keepWeekly": {
38
+ "type": "number"
39
+ },
40
+ "keepYearly": {
41
+ "type": "number"
42
+ }
43
+ },
44
+ "additionalProperties": false
45
+ },
19
46
  "tasks": {
20
47
  "type": "array",
21
48
  "items": {
@@ -84,27 +111,7 @@
84
111
  "type": "number"
85
112
  },
86
113
  "connection": {
87
- "type": "object",
88
- "properties": {
89
- "hostname": {
90
- "type": "string"
91
- },
92
- "username": {
93
- "type": "string"
94
- },
95
- "password": {
96
- "type": "string"
97
- },
98
- "database": {
99
- "type": "string"
100
- }
101
- },
102
- "additionalProperties": false,
103
- "required": [
104
- "hostname",
105
- "password",
106
- "username"
107
- ]
114
+ "$ref": "#/definitions/Omit<MysqlCliOptions,\"verbose\">"
108
115
  }
109
116
  },
110
117
  "additionalProperties": false,
@@ -140,6 +147,33 @@
140
147
  "items": {
141
148
  "type": "string"
142
149
  }
150
+ },
151
+ "prunePolicy": {
152
+ "type": "object",
153
+ "properties": {
154
+ "keepMinutely": {
155
+ "type": "number"
156
+ },
157
+ "keepDaily": {
158
+ "type": "number"
159
+ },
160
+ "keepHourly": {
161
+ "type": "number"
162
+ },
163
+ "keepLast": {
164
+ "type": "number"
165
+ },
166
+ "keepMonthly": {
167
+ "type": "number"
168
+ },
169
+ "keepWeekly": {
170
+ "type": "number"
171
+ },
172
+ "keepYearly": {
173
+ "type": "number"
174
+ }
175
+ },
176
+ "additionalProperties": false
143
177
  }
144
178
  },
145
179
  "additionalProperties": false,
@@ -162,6 +196,33 @@
162
196
  },
163
197
  "uri": {
164
198
  "type": "string"
199
+ },
200
+ "prunePolicy": {
201
+ "type": "object",
202
+ "properties": {
203
+ "keepMinutely": {
204
+ "type": "number"
205
+ },
206
+ "keepDaily": {
207
+ "type": "number"
208
+ },
209
+ "keepHourly": {
210
+ "type": "number"
211
+ },
212
+ "keepLast": {
213
+ "type": "number"
214
+ },
215
+ "keepMonthly": {
216
+ "type": "number"
217
+ },
218
+ "keepWeekly": {
219
+ "type": "number"
220
+ },
221
+ "keepYearly": {
222
+ "type": "number"
223
+ }
224
+ },
225
+ "additionalProperties": false
165
226
  }
166
227
  },
167
228
  "additionalProperties": false,
@@ -178,5 +239,59 @@
178
239
  "packages",
179
240
  "repositories"
180
241
  ],
242
+ "definitions": {
243
+ "Omit<MysqlCliOptions,\"verbose\">": {
244
+ "type": "object",
245
+ "properties": {
246
+ "password": {
247
+ "anyOf": [
248
+ {
249
+ "type": "object",
250
+ "properties": {
251
+ "path": {
252
+ "type": "string"
253
+ }
254
+ },
255
+ "additionalProperties": false,
256
+ "required": [
257
+ "path"
258
+ ]
259
+ },
260
+ {
261
+ "type": "string"
262
+ }
263
+ ]
264
+ },
265
+ "hostname": {
266
+ "type": "string"
267
+ },
268
+ "port": {
269
+ "type": "number"
270
+ },
271
+ "username": {
272
+ "type": "string"
273
+ },
274
+ "database": {
275
+ "type": "string"
276
+ },
277
+ "ssl": {
278
+ "type": "boolean"
279
+ },
280
+ "vars": {
281
+ "$ref": "#/definitions/Record<string,string|number>"
282
+ }
283
+ },
284
+ "additionalProperties": false,
285
+ "required": [
286
+ "hostname",
287
+ "password",
288
+ "username"
289
+ ]
290
+ },
291
+ "Record<string,string|number>": {
292
+ "type": "object",
293
+ "additionalProperties": false
294
+ }
295
+ },
181
296
  "$schema": "http://json-schema.org/draft-07/schema#"
182
297
  }
@@ -1,40 +1,42 @@
1
- import { GlobalConfig, type Config } from "../config.js";
2
- import { MySQLDump } from "../utils/mysql.js";
3
- import { Ntfy } from "../utils/ntfy.js";
4
- import { CommonResticBackupTags, ResticBackup } from "../utils/restic-backup.js";
5
- export type BackupRunOptions = {
1
+ import { Action } from "./base.js";
2
+ export type BackupOptions = {
6
3
  packages?: string[];
7
4
  repositories?: string[];
5
+ prune?: boolean;
8
6
  };
9
- export declare class Backup {
10
- readonly config: Config;
11
- readonly global?: GlobalConfig | undefined;
12
- readonly ntfy: Ntfy;
13
- protected verbose: boolean | undefined;
14
- readonly tags: CommonResticBackupTags;
15
- constructor(config: Config, global?: GlobalConfig | undefined);
16
- protected createInstances(packageNames: string[], repositoryNames?: string[]): {
17
- repositories: ResticBackup[];
18
- sqlDumps: (readonly [MySQLDump, {
19
- type: "mysql-dump";
20
- packages: string[];
21
- name: string;
22
- config: {
23
- database: string;
24
- out: {
25
- package?: string;
26
- tables: string[];
27
- path: string | false;
28
- }[] | string;
29
- concurrency?: number;
30
- connection: {
31
- hostname: string;
32
- username: string;
33
- password: string;
34
- database?: string;
35
- };
36
- };
37
- }])[];
38
- };
39
- run(options?: BackupRunOptions): Promise<void>;
7
+ export type CommonResticBackupTags = {
8
+ id: string;
9
+ shortId: string;
10
+ hostname: string;
11
+ date: string;
12
+ vendor: string;
13
+ version: string;
14
+ };
15
+ export type ResticBackupTags = CommonResticBackupTags & {
16
+ package: string;
17
+ tags?: string[];
18
+ };
19
+ export declare class Backup extends Action {
20
+ protected runSingle(repoName: string, pkgName: string, tags: CommonResticBackupTags): Promise<{
21
+ error: Error | undefined;
22
+ files: number;
23
+ bytes: number;
24
+ diffSize: number | undefined;
25
+ }>;
26
+ protected filterTasks(packageNames: string[]): {
27
+ type: "mysql-dump";
28
+ packages: string[];
29
+ name: string;
30
+ config: {
31
+ database: string;
32
+ out: {
33
+ package?: string;
34
+ tables: string[];
35
+ path: string | false;
36
+ }[] | string;
37
+ concurrency?: number;
38
+ connection: import("../utils/mysql.js").MySQLDumpOptions["connection"];
39
+ };
40
+ }[] | undefined;
41
+ run(options?: BackupOptions): Promise<void>;
40
42
  }
@@ -1,78 +1,126 @@
1
+ import { createRunner, safeRun } from "../utils/async.js";
2
+ import { checkDiskSpace, fetchMultipleDiskStats } from "../utils/fs.js";
1
3
  import { MySQLDump } from "../utils/mysql.js";
2
- import { Ntfy } from "../utils/ntfy.js";
3
- import { ResticBackup, } from "../utils/restic-backup.js";
4
+ import { Action } from "./base.js";
5
+ import { Prune } from "./prune.js";
6
+ import { SnapshotTagEnum } from "@datatruck/cli/repositories/RepositoryAbstract.js";
7
+ import { ResticRepository } from "@datatruck/cli/repositories/ResticRepository.js";
4
8
  import { formatBytes } from "@datatruck/cli/utils/bytes.js";
5
- import { duration } from "@datatruck/cli/utils/date.js";
9
+ import { isLocalDir } from "@datatruck/cli/utils/fs.js";
10
+ import { progressPercent } from "@datatruck/cli/utils/math.js";
11
+ import { Restic } from "@datatruck/cli/utils/restic.js";
6
12
  import { match } from "@datatruck/cli/utils/string.js";
7
13
  import { randomUUID } from "crypto";
8
14
  import { hostname } from "os";
9
- export class Backup {
10
- config;
11
- global;
12
- ntfy;
13
- verbose;
14
- tags;
15
- constructor(config, global) {
16
- this.config = config;
17
- this.global = global;
18
- this.tags = {
19
- id: randomUUID().replaceAll("-", ""),
20
- get shortId() {
21
- return this.id.slice(0, 8);
15
+ export class Backup extends Action {
16
+ async runSingle(repoName, pkgName, tags) {
17
+ const repo = this.cm.findRepository(repoName);
18
+ const pkg = this.cm.findPackage(pkgName);
19
+ const restic = new Restic({
20
+ log: this.verbose,
21
+ env: {
22
+ RESTIC_PASSWORD: repo.password,
23
+ RESTIC_REPOSITORY: repo.uri,
22
24
  },
23
- hostname: this.config.hostname ?? hostname(),
24
- date: new Date().toISOString(),
25
- vendor: "dtt-restic",
26
- version: "1",
27
- };
28
- this.verbose = this.global?.verbose ?? this.config.verbose;
29
- this.ntfy = new Ntfy({
30
- token: this.config.ntfyToken,
31
- titlePrefix: `[${this.tags.hostname}] `,
32
25
  });
33
- }
34
- createInstances(packageNames, repositoryNames) {
35
- const repositories = this.config.repositories
36
- .filter((repo) => !repositoryNames || repositoryNames.includes(repo.name))
37
- .map((repo) => new ResticBackup({
38
- tags: this.tags,
39
- minFreeSpace: this.config.minFreeSpace,
40
- name: repo.name,
41
- connection: {
42
- password: repo.password,
43
- uri: repo.uri,
44
- },
45
- }, this.ntfy, this.verbose));
46
- const sqlDumps = this.config.tasks
47
- ?.filter((task) => task.type === "mysql-dump" &&
48
- task.packages.some((name) => match(name, packageNames)))
49
- .map((task) => [
50
- new MySQLDump({
26
+ let space;
27
+ let snapshotsAmount;
28
+ let bytes = 0;
29
+ let files = 0;
30
+ return await createRunner(async () => {
31
+ await restic.tryInit();
32
+ const targetPath = isLocalDir(repo.uri) ? repo.uri : undefined;
33
+ space = await checkDiskSpace({
51
34
  minFreeSpace: this.config.minFreeSpace,
52
- verbose: this.verbose,
53
- name: task.name,
54
- connection: task.config.connection,
55
- concurrency: task.config.concurrency,
56
- }, this.ntfy),
57
- task,
58
- ]) ?? [];
59
- return { repositories, sqlDumps };
35
+ minFreeSpacePath: targetPath ?? process.cwd(),
36
+ targetPath,
37
+ rutine: () => {
38
+ const pkgTags = {
39
+ ...tags,
40
+ package: pkg.name,
41
+ tags: [],
42
+ };
43
+ return restic.backup({
44
+ tags: ResticRepository.createSnapshotTags(pkgTags),
45
+ paths: [pkg.path],
46
+ exclude: pkg.exclude,
47
+ onStream(data) {
48
+ if (data.message_type === "summary") {
49
+ files = data.total_files_processed;
50
+ bytes = data.total_bytes_processed;
51
+ }
52
+ },
53
+ });
54
+ },
55
+ });
56
+ const snapshots = await restic.snapshots({
57
+ json: true,
58
+ tags: [
59
+ ResticRepository.createSnapshotTag(SnapshotTagEnum.PACKAGE, pkg.name),
60
+ ],
61
+ });
62
+ snapshotsAmount = snapshots.length;
63
+ }).start(async (data) => {
64
+ await this.ntfy.send("Backup", {
65
+ Repository: repo.name,
66
+ Package: pkg.name,
67
+ Size: formatBytes(bytes) +
68
+ (space !== undefined ? ` (${formatBytes(space.diff, true)})` : ""),
69
+ Files: files,
70
+ Snapshots: snapshotsAmount,
71
+ Duration: data.duration,
72
+ Error: data.error?.message,
73
+ }, data.error);
74
+ return {
75
+ error: data.error,
76
+ files,
77
+ bytes,
78
+ diffSize: space?.diff,
79
+ };
80
+ });
81
+ }
82
+ filterTasks(packageNames) {
83
+ return this.config.tasks?.filter((task) => task.type === "mysql-dump" &&
84
+ task.packages.some((pattern) => match(pattern, packageNames)));
60
85
  }
61
86
  async run(options = {}) {
62
- const now = Date.now();
63
- const packages = this.config.packages.filter((pkg) => options.packages ? match(pkg.name, options.packages) : true);
64
- const packageNames = packages.map((p) => p.name);
65
- const { sqlDumps, repositories } = this.createInstances(packageNames, options.repositories);
66
- let fatalError;
67
- try {
87
+ const sqlDumps = [];
88
+ const backups = [];
89
+ let localRepositoryPaths = [];
90
+ await createRunner(async () => {
91
+ const repositories = this.cm.filterRepositories(options.repositories);
92
+ const packages = this.cm.filterPackages(options.packages);
93
+ const packageNames = packages.map((p) => p.name);
94
+ const tasks = this.filterTasks(packageNames);
68
95
  await this.ntfy.send(`Backup start`, {
69
- "- Packages": packageNames.length,
96
+ Repositories: repositories.length,
97
+ Packages: packageNames.length,
98
+ Tasks: tasks?.length,
70
99
  });
71
- if (!packages.length)
72
- throw new Error("None package found");
73
- for (const [sqlDump, task] of sqlDumps) {
74
- await sqlDump.run([
75
- {
100
+ localRepositoryPaths = repositories
101
+ .filter((repo) => isLocalDir(repo.uri))
102
+ .map((repo) => repo.uri);
103
+ const tags = {
104
+ id: randomUUID().replaceAll("-", ""),
105
+ get shortId() {
106
+ return this.id.slice(0, 8);
107
+ },
108
+ hostname: this.config.hostname ?? hostname(),
109
+ date: new Date().toISOString(),
110
+ vendor: "dtt-restic",
111
+ version: "1",
112
+ };
113
+ for (const task of tasks ?? []) {
114
+ if (task.type === "mysql-dump") {
115
+ const mysqlDump = new MySQLDump({
116
+ minFreeSpace: this.config.minFreeSpace,
117
+ verbose: this.verbose,
118
+ name: task.name,
119
+ connection: task.config.connection,
120
+ concurrency: task.config.concurrency,
121
+ }, this.ntfy);
122
+ sqlDumps.push(mysqlDump);
123
+ await mysqlDump.run({
76
124
  database: task.config.database,
77
125
  name: task.name,
78
126
  out: typeof task.config.out === "string"
@@ -83,60 +131,79 @@ export class Backup {
83
131
  ? o.path
84
132
  : false,
85
133
  })),
86
- },
87
- ]);
134
+ });
135
+ }
88
136
  }
89
- for (const backup of repositories)
90
- await backup.run(packages);
91
- }
92
- catch (inError) {
93
- fatalError = inError;
94
- }
95
- finally {
96
- for (const [sqlDump] of sqlDumps)
97
- await sqlDump.cleanup();
98
- const backupSummary = {};
99
137
  for (const repo of repositories) {
100
- for (const process of repo.processes) {
101
- if (!backupSummary[process.name])
102
- backupSummary[process.name] = {
103
- name: process.name,
104
- total: 0,
105
- success: 0,
106
- errors: 0,
107
- bytes: 0,
108
- };
109
- backupSummary[process.name].total++;
110
- backupSummary[process.name].bytes += process.stats.bytes;
111
- if (process.error) {
112
- backupSummary[process.name].errors++;
113
- }
114
- else {
115
- backupSummary[process.name].success++;
116
- }
138
+ for (const pkg of packages) {
139
+ const result = await this.runSingle(repo.name, pkg.name, tags);
140
+ backups.push({
141
+ pkgName: pkg.name,
142
+ ...result,
143
+ });
117
144
  }
118
145
  }
119
- const backups = Object.values(backupSummary);
120
- const sqlDumpProccesses = sqlDumps.flatMap(([sql]) => sql.processes);
121
- const error = !!fatalError ||
122
- sqlDumpProccesses.some((p) => p.error) ||
123
- backups.some((p) => p.errors);
146
+ }).start(async (data) => {
147
+ for (const sqlDump of sqlDumps)
148
+ await sqlDump.cleanup();
149
+ const summary = backups.reduce((acc, p) => {
150
+ if (!acc[p.pkgName])
151
+ acc[p.pkgName] = {
152
+ name: p.pkgName,
153
+ total: 0,
154
+ success: 0,
155
+ errors: 0,
156
+ bytes: 0,
157
+ };
158
+ const group = acc[p.pkgName];
159
+ group.total++;
160
+ group.bytes += p.bytes;
161
+ group[p.error ? "errors" : "success"]++;
162
+ return acc;
163
+ }, {});
164
+ const backupsValues = Object.values(summary);
165
+ const sqlDumpProcesses = sqlDumps.flatMap((sql) => sql.processes);
166
+ const error = !!data.error ||
167
+ sqlDumpProcesses.some((p) => p.error) ||
168
+ backupsValues.some((p) => p.errors);
124
169
  const size = [
125
- ...sqlDumpProccesses.map((p) => p.stats.bytes),
126
- ...backups.map((b) => b.bytes),
170
+ ...sqlDumpProcesses.map((p) => p.stats.bytes),
171
+ ...backupsValues.map((b) => b.bytes),
127
172
  ].reduce((r, b) => r + b, 0);
128
- await this.ntfy.send(`Backup end`, [
129
- `- Duration: ${duration(Date.now() - now)}`,
130
- `- Size: ${formatBytes(size)}`,
131
- !!fatalError && `- Fatal error: ${fatalError.message}`,
132
- !!sqlDumpProccesses.length && "## SQL Dumps",
133
- ...sqlDumpProccesses.map((p) => `- ${p.error ? `❌ ` : ""}${p.name}: ${formatBytes(p.stats.bytes)}`),
134
- !!backups.length && "## Backups",
135
- ...backups.map((p) => `- ${p.errors ? `❌ ` : ""}${p.name}: ${p.success}/${p.total}`),
136
- ], {
137
- priority: error ? "high" : "default",
138
- tags: [error ? "red_circle" : "green_circle"],
139
- });
140
- }
173
+ const diskStats = await safeRun(() => fetchMultipleDiskStats(localRepositoryPaths));
174
+ if (diskStats.error)
175
+ console.error(diskStats.error);
176
+ await this.ntfy.send("Backup end", {
177
+ Duration: data.duration,
178
+ Size: formatBytes(size),
179
+ Error: data.error?.message,
180
+ "": [
181
+ !!diskStats.result?.length && { key: "Disk stats", value: "" },
182
+ ...(diskStats.result?.map((p) => ({
183
+ key: p.name,
184
+ value: `${formatBytes(p.free)}/${formatBytes(p.total)} (${progressPercent(p.total, p.free)}%)`,
185
+ level: 1,
186
+ })) || []),
187
+ !!sqlDumpProcesses.length && { key: "SQL Dumps", value: "" },
188
+ ...sqlDumpProcesses.map((p) => ({
189
+ key: p.name,
190
+ value: formatBytes(p.stats.bytes),
191
+ level: 1,
192
+ })),
193
+ !!backupsValues.length && { key: "Packages", value: "" },
194
+ ...backupsValues.map((p) => ({
195
+ key: p.name,
196
+ value: `${p.success}/${p.total}`,
197
+ level: 1,
198
+ })),
199
+ ],
200
+ }, error);
201
+ if (options.prune) {
202
+ await new Prune(this.config, this.global).run({
203
+ packages: options.packages,
204
+ repositories: options.repositories,
205
+ });
206
+ }
207
+ });
141
208
  }
142
209
  }
@@ -0,0 +1,10 @@
1
+ import { Config, ConfigManager, GlobalConfig } from "../config.js";
2
+ import { Ntfy } from "../utils/ntfy.js";
3
+ export declare class Action {
4
+ readonly config: Config;
5
+ readonly global?: GlobalConfig | undefined;
6
+ readonly ntfy: Ntfy;
7
+ protected verbose: boolean | undefined;
8
+ protected cm: ConfigManager;
9
+ constructor(config: Config, global?: GlobalConfig | undefined);
10
+ }
@@ -0,0 +1,19 @@
1
+ import { ConfigManager } from "../config.js";
2
+ import { Ntfy } from "../utils/ntfy.js";
3
+ export class Action {
4
+ config;
5
+ global;
6
+ ntfy;
7
+ verbose;
8
+ cm;
9
+ constructor(config, global) {
10
+ this.config = config;
11
+ this.global = global;
12
+ this.cm = new ConfigManager(this.config);
13
+ this.verbose = this.global?.verbose ?? this.config.verbose;
14
+ this.ntfy = new Ntfy({
15
+ token: this.config.ntfyToken,
16
+ titlePrefix: this.config.hostname,
17
+ });
18
+ }
19
+ }
@@ -1,16 +1,22 @@
1
- import { Config, GlobalConfig } from "../config.js";
2
- import { Ntfy } from "../utils/ntfy.js";
3
- export type CopyRunOptions = {
1
+ import { Action } from "./base.js";
2
+ export type CopyOptions = {
4
3
  packages?: string[];
5
4
  source: string;
6
5
  targets: string[];
6
+ prune?: boolean;
7
7
  };
8
- export declare class Copy {
9
- readonly config: Config;
10
- readonly global?: GlobalConfig | undefined;
11
- readonly ntfy: Ntfy;
12
- protected verbose: boolean | undefined;
13
- constructor(config: Config, global?: GlobalConfig | undefined);
14
- private findRepo;
15
- run(options: CopyRunOptions): Promise<void>;
8
+ export declare class Copy extends Action {
9
+ protected initializedRepos: Set<string>;
10
+ private findSnapshots;
11
+ private findPackageTag;
12
+ runSingle(options: {
13
+ source: string;
14
+ target: string;
15
+ snapshot: {
16
+ id: string;
17
+ short_id: string;
18
+ tags: string[];
19
+ };
20
+ }): Promise<number>;
21
+ run(options: CopyOptions): Promise<void>;
16
22
  }