@datatruck/restic 0.0.1
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/config.schema.json +182 -0
- package/lib/actions/backup.d.ts +40 -0
- package/lib/actions/backup.js +142 -0
- package/lib/actions/copy.d.ts +16 -0
- package/lib/actions/copy.js +129 -0
- package/lib/actions/init.d.ts +13 -0
- package/lib/actions/init.js +56 -0
- package/lib/bin.d.ts +1 -0
- package/lib/bin.js +57 -0
- package/lib/config.d.ts +45 -0
- package/lib/config.js +22 -0
- package/lib/index.d.ts +4 -0
- package/lib/index.js +4 -0
- package/lib/utils/fs.d.ts +7 -0
- package/lib/utils/fs.js +23 -0
- package/lib/utils/mysql.d.ts +44 -0
- package/lib/utils/mysql.js +172 -0
- package/lib/utils/ntfy.d.ts +18 -0
- package/lib/utils/ntfy.js +52 -0
- package/lib/utils/restic-backup.d.ts +49 -0
- package/lib/utils/restic-backup.js +91 -0
- package/package.json +48 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "object",
|
|
3
|
+
"properties": {
|
|
4
|
+
"$schema": {
|
|
5
|
+
"type": "string"
|
|
6
|
+
},
|
|
7
|
+
"hostname": {
|
|
8
|
+
"type": "string"
|
|
9
|
+
},
|
|
10
|
+
"ntfyToken": {
|
|
11
|
+
"type": "string"
|
|
12
|
+
},
|
|
13
|
+
"minFreeSpace": {
|
|
14
|
+
"type": "string"
|
|
15
|
+
},
|
|
16
|
+
"verbose": {
|
|
17
|
+
"type": "boolean"
|
|
18
|
+
},
|
|
19
|
+
"tasks": {
|
|
20
|
+
"type": "array",
|
|
21
|
+
"items": {
|
|
22
|
+
"type": "object",
|
|
23
|
+
"properties": {
|
|
24
|
+
"type": {
|
|
25
|
+
"type": "string",
|
|
26
|
+
"const": "mysql-dump"
|
|
27
|
+
},
|
|
28
|
+
"packages": {
|
|
29
|
+
"type": "array",
|
|
30
|
+
"items": {
|
|
31
|
+
"type": "string"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"name": {
|
|
35
|
+
"type": "string"
|
|
36
|
+
},
|
|
37
|
+
"config": {
|
|
38
|
+
"type": "object",
|
|
39
|
+
"properties": {
|
|
40
|
+
"database": {
|
|
41
|
+
"type": "string"
|
|
42
|
+
},
|
|
43
|
+
"out": {
|
|
44
|
+
"anyOf": [
|
|
45
|
+
{
|
|
46
|
+
"type": "array",
|
|
47
|
+
"items": {
|
|
48
|
+
"type": "object",
|
|
49
|
+
"properties": {
|
|
50
|
+
"package": {
|
|
51
|
+
"type": "string"
|
|
52
|
+
},
|
|
53
|
+
"tables": {
|
|
54
|
+
"type": "array",
|
|
55
|
+
"items": {
|
|
56
|
+
"type": "string"
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"path": {
|
|
60
|
+
"anyOf": [
|
|
61
|
+
{
|
|
62
|
+
"const": false,
|
|
63
|
+
"type": "boolean"
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"type": "string"
|
|
67
|
+
}
|
|
68
|
+
]
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"additionalProperties": false,
|
|
72
|
+
"required": [
|
|
73
|
+
"path",
|
|
74
|
+
"tables"
|
|
75
|
+
]
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"type": "string"
|
|
80
|
+
}
|
|
81
|
+
]
|
|
82
|
+
},
|
|
83
|
+
"concurrency": {
|
|
84
|
+
"type": "number"
|
|
85
|
+
},
|
|
86
|
+
"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
|
+
]
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
"additionalProperties": false,
|
|
111
|
+
"required": [
|
|
112
|
+
"connection",
|
|
113
|
+
"database",
|
|
114
|
+
"out"
|
|
115
|
+
]
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
"additionalProperties": false,
|
|
119
|
+
"required": [
|
|
120
|
+
"config",
|
|
121
|
+
"name",
|
|
122
|
+
"packages",
|
|
123
|
+
"type"
|
|
124
|
+
]
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
"packages": {
|
|
128
|
+
"type": "array",
|
|
129
|
+
"items": {
|
|
130
|
+
"type": "object",
|
|
131
|
+
"properties": {
|
|
132
|
+
"name": {
|
|
133
|
+
"type": "string"
|
|
134
|
+
},
|
|
135
|
+
"path": {
|
|
136
|
+
"type": "string"
|
|
137
|
+
},
|
|
138
|
+
"exclude": {
|
|
139
|
+
"type": "array",
|
|
140
|
+
"items": {
|
|
141
|
+
"type": "string"
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
"additionalProperties": false,
|
|
146
|
+
"required": [
|
|
147
|
+
"name",
|
|
148
|
+
"path"
|
|
149
|
+
]
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
"repositories": {
|
|
153
|
+
"type": "array",
|
|
154
|
+
"items": {
|
|
155
|
+
"type": "object",
|
|
156
|
+
"properties": {
|
|
157
|
+
"name": {
|
|
158
|
+
"type": "string"
|
|
159
|
+
},
|
|
160
|
+
"password": {
|
|
161
|
+
"type": "string"
|
|
162
|
+
},
|
|
163
|
+
"uri": {
|
|
164
|
+
"type": "string"
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
"additionalProperties": false,
|
|
168
|
+
"required": [
|
|
169
|
+
"name",
|
|
170
|
+
"password",
|
|
171
|
+
"uri"
|
|
172
|
+
]
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
"additionalProperties": false,
|
|
177
|
+
"required": [
|
|
178
|
+
"packages",
|
|
179
|
+
"repositories"
|
|
180
|
+
],
|
|
181
|
+
"$schema": "http://json-schema.org/draft-07/schema#"
|
|
182
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
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 = {
|
|
6
|
+
packages?: string[];
|
|
7
|
+
repositories?: string[];
|
|
8
|
+
};
|
|
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>;
|
|
40
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { MySQLDump } from "../utils/mysql.js";
|
|
2
|
+
import { Ntfy } from "../utils/ntfy.js";
|
|
3
|
+
import { ResticBackup, } from "../utils/restic-backup.js";
|
|
4
|
+
import { formatBytes } from "@datatruck/cli/utils/bytes.js";
|
|
5
|
+
import { duration } from "@datatruck/cli/utils/date.js";
|
|
6
|
+
import { match } from "@datatruck/cli/utils/string.js";
|
|
7
|
+
import { randomUUID } from "crypto";
|
|
8
|
+
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);
|
|
22
|
+
},
|
|
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
|
+
});
|
|
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({
|
|
51
|
+
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 };
|
|
60
|
+
}
|
|
61
|
+
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 {
|
|
68
|
+
await this.ntfy.send(`Backup start`, {
|
|
69
|
+
"- Packages": packageNames.length,
|
|
70
|
+
});
|
|
71
|
+
if (!packages.length)
|
|
72
|
+
throw new Error("None package found");
|
|
73
|
+
for (const [sqlDump, task] of sqlDumps) {
|
|
74
|
+
await sqlDump.run([
|
|
75
|
+
{
|
|
76
|
+
database: task.config.database,
|
|
77
|
+
name: task.name,
|
|
78
|
+
out: typeof task.config.out === "string"
|
|
79
|
+
? task.config.out
|
|
80
|
+
: task.config.out.map((o) => ({
|
|
81
|
+
tables: o.tables,
|
|
82
|
+
path: !o.package || match(o.package, packageNames)
|
|
83
|
+
? o.path
|
|
84
|
+
: false,
|
|
85
|
+
})),
|
|
86
|
+
},
|
|
87
|
+
]);
|
|
88
|
+
}
|
|
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
|
+
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
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
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);
|
|
124
|
+
const size = [
|
|
125
|
+
...sqlDumpProccesses.map((p) => p.stats.bytes),
|
|
126
|
+
...backups.map((b) => b.bytes),
|
|
127
|
+
].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
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Config, GlobalConfig } from "../config.js";
|
|
2
|
+
import { Ntfy } from "../utils/ntfy.js";
|
|
3
|
+
export type CopyRunOptions = {
|
|
4
|
+
packages?: string[];
|
|
5
|
+
source: string;
|
|
6
|
+
targets: string[];
|
|
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>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { checkDiskSpace } from "../utils/fs.js";
|
|
2
|
+
import { Ntfy } from "../utils/ntfy.js";
|
|
3
|
+
import { SnapshotTagEnum } from "@datatruck/cli/repositories/RepositoryAbstract.js";
|
|
4
|
+
import { ResticRepository } from "@datatruck/cli/repositories/ResticRepository.js";
|
|
5
|
+
import { formatBytes } from "@datatruck/cli/utils/bytes.js";
|
|
6
|
+
import { duration } from "@datatruck/cli/utils/date.js";
|
|
7
|
+
import { isLocalDir } from "@datatruck/cli/utils/fs.js";
|
|
8
|
+
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}] `,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
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;
|
|
28
|
+
}
|
|
29
|
+
async run(options) {
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
let globalDiffSize;
|
|
32
|
+
let error;
|
|
33
|
+
try {
|
|
34
|
+
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
|
+
},
|
|
45
|
+
});
|
|
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"]);
|
|
69
|
+
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"],
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
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"],
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Config, GlobalConfig } from "../config.js";
|
|
2
|
+
import { Ntfy } from "../utils/ntfy.js";
|
|
3
|
+
export type InitOptions = {
|
|
4
|
+
repositories?: string[];
|
|
5
|
+
};
|
|
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);
|
|
12
|
+
run(options: InitOptions): Promise<void>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
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";
|
|
4
|
+
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}] `,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
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"],
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
await this.ntfy.send(`Init end`, {});
|
|
55
|
+
}
|
|
56
|
+
}
|
package/lib/bin.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/lib/bin.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
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();
|
package/lib/config.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export type GlobalConfig = {
|
|
2
|
+
config?: string;
|
|
3
|
+
verbose?: boolean;
|
|
4
|
+
};
|
|
5
|
+
export type Config = {
|
|
6
|
+
$schema?: string;
|
|
7
|
+
hostname?: string;
|
|
8
|
+
ntfyToken?: string;
|
|
9
|
+
minFreeSpace?: string;
|
|
10
|
+
verbose?: boolean;
|
|
11
|
+
tasks?: {
|
|
12
|
+
type: "mysql-dump";
|
|
13
|
+
packages: string[];
|
|
14
|
+
name: string;
|
|
15
|
+
config: {
|
|
16
|
+
database: string;
|
|
17
|
+
out: {
|
|
18
|
+
package?: string;
|
|
19
|
+
tables: string[];
|
|
20
|
+
path: string | false;
|
|
21
|
+
}[] | string;
|
|
22
|
+
concurrency?: number;
|
|
23
|
+
connection: {
|
|
24
|
+
hostname: string;
|
|
25
|
+
username: string;
|
|
26
|
+
password: string;
|
|
27
|
+
database?: string;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
}[];
|
|
31
|
+
packages: {
|
|
32
|
+
name: string;
|
|
33
|
+
path: string;
|
|
34
|
+
exclude?: string[];
|
|
35
|
+
}[];
|
|
36
|
+
repositories: {
|
|
37
|
+
name: string;
|
|
38
|
+
password: string;
|
|
39
|
+
uri: string;
|
|
40
|
+
}[];
|
|
41
|
+
};
|
|
42
|
+
export declare function defineConfig(config: Config): Config;
|
|
43
|
+
export declare function validateConfig(config: unknown): Promise<void>;
|
|
44
|
+
export declare function readConfigSchemaFile(): Promise<unknown>;
|
|
45
|
+
export declare function parseConfigFile(path?: string): Promise<Config>;
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { parseJSONFile } from "./utils/fs.js";
|
|
2
|
+
import { Ajv } from "ajv";
|
|
3
|
+
export function defineConfig(config) {
|
|
4
|
+
return config;
|
|
5
|
+
}
|
|
6
|
+
let validate;
|
|
7
|
+
export async function validateConfig(config) {
|
|
8
|
+
if (!validate) {
|
|
9
|
+
const schema = await readConfigSchemaFile();
|
|
10
|
+
validate = new Ajv({ allowUnionTypes: true }).compile(schema);
|
|
11
|
+
}
|
|
12
|
+
if (!validate(config))
|
|
13
|
+
throw new Error("Json schema error: " + JSON.stringify(validate.errors, null, 2));
|
|
14
|
+
}
|
|
15
|
+
export async function readConfigSchemaFile() {
|
|
16
|
+
return parseJSONFile(`${import.meta.dirname}/../config.schema.json`);
|
|
17
|
+
}
|
|
18
|
+
export async function parseConfigFile(path = "datatruck.restic.json") {
|
|
19
|
+
const config = await parseJSONFile(path);
|
|
20
|
+
await validateConfig(config);
|
|
21
|
+
return config;
|
|
22
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { Backup, type BackupRunOptions } from "./actions/backup.js";
|
|
2
|
+
export { Copy, type CopyRunOptions } from "./actions/copy.js";
|
|
3
|
+
export { Init, type InitOptions } from "./actions/init.js";
|
|
4
|
+
export { type Config, type GlobalConfig, parseConfigFile, defineConfig, validateConfig, } from "./config.js";
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare function parseJSONFile<T>(path: string): Promise<T>;
|
|
2
|
+
export declare function checkDiskSpace(options: {
|
|
3
|
+
minFreeSpace?: string | undefined;
|
|
4
|
+
minFreeSpacePath?: string | undefined;
|
|
5
|
+
targetPath?: string | undefined;
|
|
6
|
+
rutine: () => any;
|
|
7
|
+
}): Promise<number | undefined>;
|
package/lib/utils/fs.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ensureFreeDiskSpace, fastFolderSizeAsync, fetchDiskStats, } from "@datatruck/cli/utils/fs.js";
|
|
2
|
+
import { readFile } from "fs/promises";
|
|
3
|
+
export async function parseJSONFile(path) {
|
|
4
|
+
const buffer = await readFile(path);
|
|
5
|
+
return JSON.parse(buffer.toString());
|
|
6
|
+
}
|
|
7
|
+
export async function checkDiskSpace(options) {
|
|
8
|
+
if (options.minFreeSpace) {
|
|
9
|
+
const minFreeSpacePath = options.minFreeSpacePath ?? options.targetPath;
|
|
10
|
+
if (minFreeSpacePath) {
|
|
11
|
+
const diskStats = await fetchDiskStats(minFreeSpacePath);
|
|
12
|
+
await ensureFreeDiskSpace(diskStats, options.minFreeSpace);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
if (!options.targetPath) {
|
|
16
|
+
await options.rutine();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const prev = await fastFolderSizeAsync(options.targetPath);
|
|
20
|
+
await options.rutine();
|
|
21
|
+
const next = await fastFolderSizeAsync(options.targetPath);
|
|
22
|
+
return next - prev;
|
|
23
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Ntfy } from "./ntfy.js";
|
|
2
|
+
import { createMysqlCli } from "@datatruck/cli/utils/mysql.js";
|
|
3
|
+
export type MySQLDumpItem = {
|
|
4
|
+
name: string;
|
|
5
|
+
database: string;
|
|
6
|
+
out: {
|
|
7
|
+
tables: string[];
|
|
8
|
+
path: string | false;
|
|
9
|
+
}[] | string;
|
|
10
|
+
};
|
|
11
|
+
export type MySQLDumpOptions = {
|
|
12
|
+
name: string;
|
|
13
|
+
verbose?: boolean;
|
|
14
|
+
minFreeSpace?: string;
|
|
15
|
+
concurrency?: number;
|
|
16
|
+
connection: MySQLDumpConnection;
|
|
17
|
+
};
|
|
18
|
+
export type MySQLDumpConnection = {
|
|
19
|
+
hostname: string;
|
|
20
|
+
password: string;
|
|
21
|
+
username: string;
|
|
22
|
+
port?: number;
|
|
23
|
+
};
|
|
24
|
+
export type MySQLDumpStats = {
|
|
25
|
+
bytes: number;
|
|
26
|
+
files: number;
|
|
27
|
+
};
|
|
28
|
+
export declare class MySQLDump {
|
|
29
|
+
readonly options: MySQLDumpOptions;
|
|
30
|
+
protected ntfy: Ntfy;
|
|
31
|
+
constructor(options: MySQLDumpOptions, ntfy: Ntfy);
|
|
32
|
+
readonly processes: {
|
|
33
|
+
name: string;
|
|
34
|
+
stats: MySQLDumpStats;
|
|
35
|
+
error?: Error;
|
|
36
|
+
}[];
|
|
37
|
+
protected tables: Record<string, string[]>;
|
|
38
|
+
protected outPaths: Set<string>;
|
|
39
|
+
protected startTime: number;
|
|
40
|
+
protected fetchTables(database: string, fetcher: (database: string) => Promise<string[]>): Promise<string[]>;
|
|
41
|
+
run(input: MySQLDumpItem[] | MySQLDumpItem): Promise<void>;
|
|
42
|
+
cleanup(init?: boolean): Promise<void>;
|
|
43
|
+
protected runSingle(sql: Awaited<ReturnType<typeof createMysqlCli>>, item: MySQLDumpItem): Promise<void>;
|
|
44
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
|
|
2
|
+
if (value !== null && value !== void 0) {
|
|
3
|
+
if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
|
|
4
|
+
var dispose, inner;
|
|
5
|
+
if (async) {
|
|
6
|
+
if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
|
|
7
|
+
dispose = value[Symbol.asyncDispose];
|
|
8
|
+
}
|
|
9
|
+
if (dispose === void 0) {
|
|
10
|
+
if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
|
|
11
|
+
dispose = value[Symbol.dispose];
|
|
12
|
+
if (async) inner = dispose;
|
|
13
|
+
}
|
|
14
|
+
if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
|
|
15
|
+
if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
|
|
16
|
+
env.stack.push({ value: value, dispose: dispose, async: async });
|
|
17
|
+
}
|
|
18
|
+
else if (async) {
|
|
19
|
+
env.stack.push({ async: true });
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
};
|
|
23
|
+
var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
|
|
24
|
+
return function (env) {
|
|
25
|
+
function fail(e) {
|
|
26
|
+
env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
|
|
27
|
+
env.hasError = true;
|
|
28
|
+
}
|
|
29
|
+
var r, s = 0;
|
|
30
|
+
function next() {
|
|
31
|
+
while (r = env.stack.pop()) {
|
|
32
|
+
try {
|
|
33
|
+
if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
|
|
34
|
+
if (r.dispose) {
|
|
35
|
+
var result = r.dispose.call(r.value);
|
|
36
|
+
if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
|
|
37
|
+
}
|
|
38
|
+
else s |= 1;
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
fail(e);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
|
|
45
|
+
if (env.hasError) throw env.error;
|
|
46
|
+
}
|
|
47
|
+
return next();
|
|
48
|
+
};
|
|
49
|
+
})(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
|
|
50
|
+
var e = new Error(message);
|
|
51
|
+
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
52
|
+
});
|
|
53
|
+
import { runParallel } from "@datatruck/cli/utils/async.js";
|
|
54
|
+
import { formatBytes } from "@datatruck/cli/utils/bytes.js";
|
|
55
|
+
import { duration } from "@datatruck/cli/utils/date.js";
|
|
56
|
+
import { ensureFreeDiskSpace, fetchDiskStats, } from "@datatruck/cli/utils/fs.js";
|
|
57
|
+
import { createMysqlCli } from "@datatruck/cli/utils/mysql.js";
|
|
58
|
+
import { match } from "@datatruck/cli/utils/string.js";
|
|
59
|
+
import { existsSync } from "fs";
|
|
60
|
+
import { mkdir, rm, stat } from "fs/promises";
|
|
61
|
+
import { basename, dirname } from "path";
|
|
62
|
+
export class MySQLDump {
|
|
63
|
+
options;
|
|
64
|
+
ntfy;
|
|
65
|
+
constructor(options, ntfy) {
|
|
66
|
+
this.options = options;
|
|
67
|
+
this.ntfy = ntfy;
|
|
68
|
+
}
|
|
69
|
+
processes = [];
|
|
70
|
+
tables = {};
|
|
71
|
+
outPaths = new Set();
|
|
72
|
+
startTime = Date.now();
|
|
73
|
+
async fetchTables(database, fetcher) {
|
|
74
|
+
const tables = this.tables[database] ?? (await fetcher(database));
|
|
75
|
+
this.tables[database] = tables;
|
|
76
|
+
return tables;
|
|
77
|
+
}
|
|
78
|
+
async run(input) {
|
|
79
|
+
const env_1 = { stack: [], error: void 0, hasError: false };
|
|
80
|
+
try {
|
|
81
|
+
const sql = __addDisposableResource(env_1, await createMysqlCli({
|
|
82
|
+
...this.options.connection,
|
|
83
|
+
verbose: this.options.verbose,
|
|
84
|
+
}), true);
|
|
85
|
+
const items = Array.isArray(input) ? input : [input];
|
|
86
|
+
for (const item of items) {
|
|
87
|
+
await this.runSingle(sql, item);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch (e_1) {
|
|
91
|
+
env_1.error = e_1;
|
|
92
|
+
env_1.hasError = true;
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
const result_1 = __disposeResources(env_1);
|
|
96
|
+
if (result_1)
|
|
97
|
+
await result_1;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async cleanup(init = false) {
|
|
101
|
+
for (const outPath of this.outPaths) {
|
|
102
|
+
const parentFolder = basename(dirname(outPath));
|
|
103
|
+
if (!parentFolder.startsWith("sql-dump"))
|
|
104
|
+
throw new Error(`sql-dump out dir must begins with 'sql-dump': ${outPath}`);
|
|
105
|
+
if (existsSync(outPath))
|
|
106
|
+
await rm(outPath, { recursive: true });
|
|
107
|
+
if (!init)
|
|
108
|
+
this.outPaths.delete(outPath);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async runSingle(sql, item) {
|
|
112
|
+
let error;
|
|
113
|
+
const tables = await this.fetchTables(item.database, (db) => sql.fetchTableNames(db));
|
|
114
|
+
const now = Date.now();
|
|
115
|
+
const stats = { files: 0, bytes: 0 };
|
|
116
|
+
const outs = typeof item.out === "string"
|
|
117
|
+
? [{ path: item.out, tables: ["*"] }]
|
|
118
|
+
: item.out;
|
|
119
|
+
const items = tables
|
|
120
|
+
.map((table) => {
|
|
121
|
+
const out = outs.find((o) => match(table, o.tables));
|
|
122
|
+
return (!!out &&
|
|
123
|
+
!!out.path && {
|
|
124
|
+
table,
|
|
125
|
+
out: `${out.path}/${table}.sql`,
|
|
126
|
+
});
|
|
127
|
+
})
|
|
128
|
+
.filter((o) => !!o);
|
|
129
|
+
for (const item of items)
|
|
130
|
+
this.outPaths.add(dirname(item.out));
|
|
131
|
+
await this.cleanup(true);
|
|
132
|
+
try {
|
|
133
|
+
await runParallel({
|
|
134
|
+
items,
|
|
135
|
+
concurrency: this.options.concurrency ?? 1,
|
|
136
|
+
onItem: async (data) => {
|
|
137
|
+
const output = data.item.out;
|
|
138
|
+
const outDir = dirname(output);
|
|
139
|
+
await mkdir(outDir, { recursive: true });
|
|
140
|
+
if (this.options.minFreeSpace)
|
|
141
|
+
await ensureFreeDiskSpace(await fetchDiskStats(outDir), this.options.minFreeSpace);
|
|
142
|
+
await sql.dump({
|
|
143
|
+
database: item.database,
|
|
144
|
+
items: [data.item.table],
|
|
145
|
+
output,
|
|
146
|
+
});
|
|
147
|
+
const infoFile = await stat(output);
|
|
148
|
+
stats.files++;
|
|
149
|
+
stats.bytes += infoFile.size;
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
catch (inError) {
|
|
154
|
+
error = inError;
|
|
155
|
+
}
|
|
156
|
+
this.processes.push({
|
|
157
|
+
name: item.name,
|
|
158
|
+
error,
|
|
159
|
+
stats,
|
|
160
|
+
});
|
|
161
|
+
await this.ntfy.send(`SQL dump`, {
|
|
162
|
+
"- Package": item.name,
|
|
163
|
+
"- Size": formatBytes(stats.bytes),
|
|
164
|
+
"- Files": stats.files,
|
|
165
|
+
"- Duration": duration(Date.now() - now),
|
|
166
|
+
"- Error": error?.message,
|
|
167
|
+
}, {
|
|
168
|
+
priority: error ? "high" : "default",
|
|
169
|
+
tags: [error ? "red_circle" : "green_circle"],
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Agent } from "undici";
|
|
2
|
+
export declare class Ntfy {
|
|
3
|
+
readonly options: {
|
|
4
|
+
token?: string;
|
|
5
|
+
titlePrefix?: string;
|
|
6
|
+
delay?: number;
|
|
7
|
+
};
|
|
8
|
+
protected agent: Agent | undefined;
|
|
9
|
+
constructor(options: {
|
|
10
|
+
token?: string;
|
|
11
|
+
titlePrefix?: string;
|
|
12
|
+
delay?: number;
|
|
13
|
+
});
|
|
14
|
+
send(inTitle: string, message: any[] | string | Record<string, any>, options?: {
|
|
15
|
+
priority?: "default" | "high";
|
|
16
|
+
tags?: string[];
|
|
17
|
+
}): Promise<void>;
|
|
18
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { setTimeout } from "timers/promises";
|
|
2
|
+
import { Agent, fetch } from "undici";
|
|
3
|
+
export class Ntfy {
|
|
4
|
+
options;
|
|
5
|
+
agent;
|
|
6
|
+
constructor(options) {
|
|
7
|
+
this.options = options;
|
|
8
|
+
this.agent = new Agent({
|
|
9
|
+
keepAliveTimeout: 60_000,
|
|
10
|
+
keepAliveMaxTimeout: 60_000,
|
|
11
|
+
connections: 1,
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
async send(inTitle, message, options = {}) {
|
|
15
|
+
const title = this.options.titlePrefix
|
|
16
|
+
? `${this.options.titlePrefix}${inTitle}`
|
|
17
|
+
: inTitle;
|
|
18
|
+
const body = Array.isArray(message)
|
|
19
|
+
? message
|
|
20
|
+
.filter((v) => typeof v === "string" || typeof v === "number")
|
|
21
|
+
.join("\n")
|
|
22
|
+
: typeof message === "object" && !!message
|
|
23
|
+
? Object.entries(message)
|
|
24
|
+
.filter(([, value]) => value !== undefined)
|
|
25
|
+
.map(([name, value]) => `${name}: ${value}`)
|
|
26
|
+
.join("\n")
|
|
27
|
+
: message;
|
|
28
|
+
const lines = [title, body].filter((v) => v.length);
|
|
29
|
+
if (lines.length)
|
|
30
|
+
console.info([...lines, ""].join("\n"));
|
|
31
|
+
try {
|
|
32
|
+
if (this.options.token)
|
|
33
|
+
await fetch(`https://ntfy.sh/${this.options.token}`, {
|
|
34
|
+
dispatcher: this.agent,
|
|
35
|
+
method: "POST",
|
|
36
|
+
body,
|
|
37
|
+
headers: {
|
|
38
|
+
Markdown: "yes",
|
|
39
|
+
Title: title,
|
|
40
|
+
Priority: options.priority ?? "default",
|
|
41
|
+
...(options.tags && {
|
|
42
|
+
Tags: options.tags?.join(","),
|
|
43
|
+
}),
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
await setTimeout(this.options.delay ?? 800);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
console.error("Ntfy error", error);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Ntfy } from "./ntfy.js";
|
|
2
|
+
import { Restic } from "@datatruck/cli/utils/restic.js";
|
|
3
|
+
export type CommonResticBackupTags = {
|
|
4
|
+
id: string;
|
|
5
|
+
shortId: string;
|
|
6
|
+
hostname: string;
|
|
7
|
+
date: string;
|
|
8
|
+
vendor: string;
|
|
9
|
+
version: string;
|
|
10
|
+
};
|
|
11
|
+
export type ResticBackupTags = CommonResticBackupTags & {
|
|
12
|
+
package: string;
|
|
13
|
+
tags?: string[];
|
|
14
|
+
};
|
|
15
|
+
export type ResticBackupPackage = {
|
|
16
|
+
name: string;
|
|
17
|
+
tags?: string[];
|
|
18
|
+
path: string;
|
|
19
|
+
exclude?: string[];
|
|
20
|
+
};
|
|
21
|
+
export type ResticBackupStats = {
|
|
22
|
+
files: number;
|
|
23
|
+
bytes: number;
|
|
24
|
+
diffBytes: number | undefined;
|
|
25
|
+
};
|
|
26
|
+
export type ResticOptions = {
|
|
27
|
+
name: string;
|
|
28
|
+
tags: CommonResticBackupTags;
|
|
29
|
+
minFreeSpace?: string;
|
|
30
|
+
connection: {
|
|
31
|
+
password: string;
|
|
32
|
+
uri: string;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
export declare class ResticBackup {
|
|
36
|
+
readonly options: ResticOptions;
|
|
37
|
+
protected ntfy: Ntfy;
|
|
38
|
+
protected log: boolean | undefined;
|
|
39
|
+
readonly processes: {
|
|
40
|
+
name: string;
|
|
41
|
+
error?: Error;
|
|
42
|
+
stats: ResticBackupStats;
|
|
43
|
+
}[];
|
|
44
|
+
protected startTime: number;
|
|
45
|
+
readonly restic: Restic;
|
|
46
|
+
constructor(options: ResticOptions, ntfy: Ntfy, log: boolean | undefined);
|
|
47
|
+
run(input: ResticBackupPackage | ResticBackupPackage[]): Promise<void>;
|
|
48
|
+
protected runSingle(item: ResticBackupPackage): Promise<void>;
|
|
49
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { checkDiskSpace } from "./fs.js";
|
|
2
|
+
import { ResticRepository } from "@datatruck/cli/repositories/ResticRepository.js";
|
|
3
|
+
import { formatBytes } from "@datatruck/cli/utils/bytes.js";
|
|
4
|
+
import { duration } from "@datatruck/cli/utils/date.js";
|
|
5
|
+
import { isLocalDir } from "@datatruck/cli/utils/fs.js";
|
|
6
|
+
import { Restic } from "@datatruck/cli/utils/restic.js";
|
|
7
|
+
export class ResticBackup {
|
|
8
|
+
options;
|
|
9
|
+
ntfy;
|
|
10
|
+
log;
|
|
11
|
+
processes = [];
|
|
12
|
+
startTime;
|
|
13
|
+
restic;
|
|
14
|
+
constructor(options, ntfy, log) {
|
|
15
|
+
this.options = options;
|
|
16
|
+
this.ntfy = ntfy;
|
|
17
|
+
this.log = log;
|
|
18
|
+
this.startTime = Date.now();
|
|
19
|
+
this.restic = new Restic({
|
|
20
|
+
env: {
|
|
21
|
+
RESTIC_PASSWORD: options.connection.password,
|
|
22
|
+
RESTIC_REPOSITORY: options.connection.uri,
|
|
23
|
+
},
|
|
24
|
+
log,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
async run(input) {
|
|
28
|
+
const items = Array.isArray(input) ? input : [input];
|
|
29
|
+
for (const item of items) {
|
|
30
|
+
await this.runSingle(item);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async runSingle(item) {
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
let error;
|
|
36
|
+
const stats = {
|
|
37
|
+
bytes: 0,
|
|
38
|
+
files: 0,
|
|
39
|
+
diffBytes: undefined,
|
|
40
|
+
};
|
|
41
|
+
try {
|
|
42
|
+
if (isLocalDir(this.options.connection.uri) &&
|
|
43
|
+
!(await this.restic.checkRepository()))
|
|
44
|
+
await this.restic.exec(["init"]);
|
|
45
|
+
const targetPath = isLocalDir(this.options.connection.uri)
|
|
46
|
+
? this.options.connection.uri
|
|
47
|
+
: undefined;
|
|
48
|
+
stats.diffBytes = await checkDiskSpace({
|
|
49
|
+
minFreeSpace: this.options.minFreeSpace,
|
|
50
|
+
minFreeSpacePath: targetPath ?? process.cwd(),
|
|
51
|
+
targetPath,
|
|
52
|
+
rutine: () => {
|
|
53
|
+
const tags = {
|
|
54
|
+
...this.options.tags,
|
|
55
|
+
package: item.name,
|
|
56
|
+
tags: item.tags,
|
|
57
|
+
};
|
|
58
|
+
return this.restic.backup({
|
|
59
|
+
tags: ResticRepository.createSnapshotTags(tags),
|
|
60
|
+
paths: [item.path],
|
|
61
|
+
exclude: item.exclude,
|
|
62
|
+
onStream(data) {
|
|
63
|
+
if (data.message_type === "summary") {
|
|
64
|
+
stats.files = data.total_files_processed;
|
|
65
|
+
stats.bytes = data.total_bytes_processed;
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
catch (inError) {
|
|
73
|
+
error = inError;
|
|
74
|
+
}
|
|
75
|
+
this.processes.push({ name: item.name, error, stats });
|
|
76
|
+
await this.ntfy.send(`Backup`, {
|
|
77
|
+
"- Repository": this.options.name,
|
|
78
|
+
"- Package": item.name,
|
|
79
|
+
"- Size": formatBytes(stats.bytes),
|
|
80
|
+
...(stats.diffBytes !== undefined && {
|
|
81
|
+
"- Size change": (stats.diffBytes > 0 ? "+" : "") + formatBytes(stats.diffBytes),
|
|
82
|
+
}),
|
|
83
|
+
"- Files": stats.files,
|
|
84
|
+
"- Duration": duration(Date.now() - now),
|
|
85
|
+
"- Error": error?.message,
|
|
86
|
+
}, {
|
|
87
|
+
priority: error ? "high" : "default",
|
|
88
|
+
tags: [error ? "red_circle" : "green_circle"],
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@datatruck/restic",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Tool for creating and managing backups",
|
|
5
|
+
"homepage": "https://github.com/swordev/datatruck#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/swordev/datatruck/issues"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/swordev/datatruck"
|
|
12
|
+
},
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": {
|
|
15
|
+
"name": "Juanra GM",
|
|
16
|
+
"email": "juanrgm724@gmail.com",
|
|
17
|
+
"url": "https://github.com/juanrgm"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./lib/index.js",
|
|
22
|
+
"./*": "./lib/*"
|
|
23
|
+
},
|
|
24
|
+
"main": "lib/index.js",
|
|
25
|
+
"bin": {
|
|
26
|
+
"datatruck-restic": "lib/bin.js",
|
|
27
|
+
"dttr": "lib/bin.js"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"lib",
|
|
31
|
+
"config.schema.json"
|
|
32
|
+
],
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"ajv": "^8.17.1",
|
|
35
|
+
"commander": "^14.0.2",
|
|
36
|
+
"undici": "^7.18.2",
|
|
37
|
+
"@datatruck/cli": "0.41.5"
|
|
38
|
+
},
|
|
39
|
+
"engine": {
|
|
40
|
+
"node": ">=20.0.0"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsc -b && pnpm build:schema",
|
|
44
|
+
"build:schema": "node ./packages/cli/scripts/gen-schema.mjs",
|
|
45
|
+
"clean": "tsc -b --clean",
|
|
46
|
+
"watch": "tsc -b -w"
|
|
47
|
+
}
|
|
48
|
+
}
|