@saltcorn/admin-models 1.7.0-alpha.0 → 1.7.0-alpha.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/cjs/models/backup-retention.cjs +18 -0
- package/cjs/models/file-globs.cjs +18 -0
- package/cjs/models/zip-extract.cjs +18 -0
- package/cjs/models/zip-format.cjs +18 -0
- package/cjs/models/zip-worker.cjs +18 -0
- package/cjs/models/zip-writer.cjs +18 -0
- package/cjs/models/zip.cjs +18 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/models/backup-retention.d.ts +57 -0
- package/dist/models/backup-retention.d.ts.map +1 -0
- package/dist/models/backup-retention.js +108 -0
- package/dist/models/backup-retention.js.map +1 -0
- package/dist/models/backup.d.ts +4 -1
- package/dist/models/backup.d.ts.map +1 -1
- package/dist/models/backup.js +364 -302
- package/dist/models/backup.js.map +1 -1
- package/dist/models/file-globs.d.ts +34 -0
- package/dist/models/file-globs.d.ts.map +1 -0
- package/dist/models/file-globs.js +47 -0
- package/dist/models/file-globs.js.map +1 -0
- package/dist/models/pack.d.ts +1 -1
- package/dist/models/pack.d.ts.map +1 -1
- package/dist/models/pack.js +26 -1
- package/dist/models/pack.js.map +1 -1
- package/dist/models/tenant.d.ts.map +1 -1
- package/dist/models/tenant.js +2 -0
- package/dist/models/tenant.js.map +1 -1
- package/dist/models/zip-extract.d.ts +14 -0
- package/dist/models/zip-extract.d.ts.map +1 -0
- package/dist/models/zip-extract.js +110 -0
- package/dist/models/zip-extract.js.map +1 -0
- package/dist/models/zip-format.d.ts +57 -0
- package/dist/models/zip-format.d.ts.map +1 -0
- package/dist/models/zip-format.js +106 -0
- package/dist/models/zip-format.js.map +1 -0
- package/dist/models/zip-worker.d.ts +2 -0
- package/dist/models/zip-worker.d.ts.map +1 -0
- package/dist/models/zip-worker.js +177 -0
- package/dist/models/zip-worker.js.map +1 -0
- package/dist/models/zip-writer.d.ts +75 -0
- package/dist/models/zip-writer.d.ts.map +1 -0
- package/dist/models/zip-writer.js +321 -0
- package/dist/models/zip-writer.js.map +1 -0
- package/dist/models/zip.d.ts +101 -0
- package/dist/models/zip.d.ts.map +1 -0
- package/dist/models/zip.js +211 -0
- package/dist/models/zip.js.map +1 -0
- package/dist/tests/backup-retention.test.d.ts +2 -0
- package/dist/tests/backup-retention.test.d.ts.map +1 -0
- package/dist/tests/backup-retention.test.js +134 -0
- package/dist/tests/backup-retention.test.js.map +1 -0
- package/dist/tests/backup.test.js +186 -2
- package/dist/tests/backup.test.js.map +1 -1
- package/dist/tests/file-globs.test.d.ts +2 -0
- package/dist/tests/file-globs.test.d.ts.map +1 -0
- package/dist/tests/file-globs.test.js +66 -0
- package/dist/tests/file-globs.test.js.map +1 -0
- package/dist/tests/pack.test.js +14 -3
- package/dist/tests/pack.test.js.map +1 -1
- package/dist/tests/random.test.js +3 -3
- package/dist/tests/random.test.js.map +1 -1
- package/dist/tests/tenant.test.js +7 -7
- package/dist/tests/tenant.test.js.map +1 -1
- package/dist/tests/zip.test.d.ts +2 -0
- package/dist/tests/zip.test.d.ts.map +1 -0
- package/dist/tests/zip.test.js +319 -0
- package/dist/tests/zip.test.js.map +1 -0
- package/package.json +15 -9
package/dist/models/backup.js
CHANGED
|
@@ -13,10 +13,10 @@ import Page from "@saltcorn/data/models/page";
|
|
|
13
13
|
import PageGroup from "@saltcorn/data/models/page_group";
|
|
14
14
|
import Plugin from "@saltcorn/data/models/plugin";
|
|
15
15
|
import { getMigrationsInDB } from "@saltcorn/data/migrate";
|
|
16
|
-
import
|
|
17
|
-
import {
|
|
18
|
-
import { writeFile, mkdir,
|
|
19
|
-
import { existsSync, readdirSync
|
|
16
|
+
import { ZipBuilder } from "./zip.js";
|
|
17
|
+
import { extractZip } from "./zip-extract.js";
|
|
18
|
+
import { writeFile, mkdir, mkdtemp, copyFile, readFile, unlink, readdir, rm, stat, } from "fs/promises";
|
|
19
|
+
import { createReadStream, existsSync, readdirSync } from "fs";
|
|
20
20
|
import { join, basename } from "path";
|
|
21
21
|
import dateFormatLib from "dateformat";
|
|
22
22
|
const dateFormat = dateFormatLib; // NodeNext default-import interop for dateformat
|
|
@@ -34,7 +34,6 @@ import Model from "@saltcorn/data/models/model";
|
|
|
34
34
|
import ModelInstance from "@saltcorn/data/models/model_instance";
|
|
35
35
|
import EventLog from "@saltcorn/data/models/eventlog";
|
|
36
36
|
import path from "path";
|
|
37
|
-
import { execSync, spawn } from "child_process";
|
|
38
37
|
import SftpClient from "ssh2-sftp-client";
|
|
39
38
|
import os from "os";
|
|
40
39
|
import semver from "semver";
|
|
@@ -44,6 +43,8 @@ let _awsS3;
|
|
|
44
43
|
const awsS3 = () => _awsS3 || (_awsS3 = require("@aws-sdk/client-s3"));
|
|
45
44
|
let _awsLibStorage;
|
|
46
45
|
const awsLibStorage = () => _awsLibStorage || (_awsLibStorage = require("@aws-sdk/lib-storage"));
|
|
46
|
+
import { selectBackupsToDeleteGrouped, } from "./backup-retention.js";
|
|
47
|
+
import { parseGlobList, matchesAnyGlob } from "./file-globs.js";
|
|
47
48
|
import MetaData from "@saltcorn/data/models/metadata";
|
|
48
49
|
import lodash from "lodash";
|
|
49
50
|
const { orderBy } = lodash;
|
|
@@ -123,82 +124,91 @@ const create_pack_json = async (withEventLog = false, forSnapshot = false) => {
|
|
|
123
124
|
continue;
|
|
124
125
|
if (state.isFixedConfig(cfg.key))
|
|
125
126
|
continue;
|
|
126
|
-
config[cfg.key] = (db.
|
|
127
|
+
config[cfg.key] = (db.json_read_returns_string ? JSON.parse(cfg.value) : cfg.value)?.v;
|
|
127
128
|
}
|
|
128
129
|
pack.config = config;
|
|
129
130
|
}
|
|
130
131
|
return pack;
|
|
131
132
|
};
|
|
132
133
|
/**
|
|
133
|
-
* @param
|
|
134
|
+
* @param zip
|
|
134
135
|
*/
|
|
135
|
-
const
|
|
136
|
+
const add_pack = async (zip) => {
|
|
136
137
|
const pack = await create_pack_json(getState().getConfig("backup_with_event_log", false));
|
|
137
|
-
await
|
|
138
|
+
await zip.addBuffer("pack.json", Buffer.from(JSON.stringify(pack)));
|
|
138
139
|
};
|
|
139
140
|
/**
|
|
140
|
-
*
|
|
141
141
|
* @param rows
|
|
142
|
-
* @
|
|
142
|
+
* @returns the rows as CSV, or null if there are no rows
|
|
143
143
|
*/
|
|
144
|
-
const
|
|
144
|
+
const csv_from_rows = (rows) => {
|
|
145
145
|
if (rows.length === 0)
|
|
146
|
-
return;
|
|
147
|
-
|
|
146
|
+
return null;
|
|
147
|
+
return stringify(rows, {
|
|
148
148
|
header: true,
|
|
149
149
|
cast: {
|
|
150
150
|
date: (value) => value.toISOString(),
|
|
151
151
|
},
|
|
152
152
|
});
|
|
153
|
-
await writeFile(fnm, s);
|
|
154
153
|
};
|
|
155
|
-
const sanitiseTableName = (nm) => nm.replaceAll("/", "");
|
|
156
154
|
/**
|
|
157
|
-
*
|
|
158
|
-
* @param
|
|
159
|
-
* @param
|
|
160
|
-
* @returns {Promise<void>}
|
|
155
|
+
*
|
|
156
|
+
* @param rows
|
|
157
|
+
* @param fnm
|
|
161
158
|
*/
|
|
162
|
-
const
|
|
163
|
-
|
|
159
|
+
const create_csv_from_rows = async (rows, fnm) => {
|
|
160
|
+
const s = csv_from_rows(rows);
|
|
161
|
+
if (s === null)
|
|
162
|
+
return;
|
|
163
|
+
await writeFile(fnm, s);
|
|
164
164
|
};
|
|
165
|
+
const sanitiseTableName = (nm) => nm.replaceAll("/", "");
|
|
165
166
|
/**
|
|
167
|
+
* Add the JSON dump of every non-external table (and its history, if the
|
|
168
|
+
* table is versioned) straight into the archive.
|
|
166
169
|
* @function
|
|
167
|
-
* @param {
|
|
170
|
+
* @param {ZipBuilder} zip
|
|
168
171
|
* @return {Promise<void>}
|
|
169
172
|
*/
|
|
170
|
-
const
|
|
171
|
-
const dirpath = join(root_dirpath, "tables");
|
|
172
|
-
await mkdir(dirpath, { recursive: true });
|
|
173
|
+
const add_table_jsons = async (zip) => {
|
|
173
174
|
const tables = await Table.find({});
|
|
174
175
|
const backup_history = getState().getConfig("backup_history", true);
|
|
175
176
|
for (const t of tables) {
|
|
176
177
|
if (!t.external && !t.provider_name) {
|
|
177
|
-
|
|
178
|
+
// streamed out of the database and into the archive: a table larger
|
|
179
|
+
// than memory is never assembled anywhere
|
|
180
|
+
await zip.addStream(`tables/${sanitiseTableName(t.name)}.json`, t.dump_to_json_stream());
|
|
178
181
|
if (t.versioned && backup_history) {
|
|
179
|
-
await
|
|
182
|
+
await zip.addStream(`tables/${sanitiseTableName(t.name)}__history.json`, t.dump_history_to_json_stream());
|
|
180
183
|
}
|
|
181
184
|
}
|
|
182
185
|
}
|
|
183
186
|
};
|
|
184
187
|
/**
|
|
188
|
+
* Add the uploaded files to the archive. The files are read straight from
|
|
189
|
+
* the file store by the zip worker - they are never copied anywhere first.
|
|
185
190
|
* @function
|
|
186
|
-
* @param {
|
|
191
|
+
* @param {ZipBuilder} zip
|
|
187
192
|
* @returns {Promise<void>}
|
|
188
193
|
*/
|
|
189
|
-
const
|
|
194
|
+
const add_files = async (zip) => {
|
|
190
195
|
const backup_file_prefix = getState().getConfig("backup_file_prefix");
|
|
191
|
-
const
|
|
192
|
-
await mkdir(dirpath);
|
|
196
|
+
const excludeGlobs = parseGlobList(getState().getConfig("backup_exclude_file_globs", ""));
|
|
193
197
|
const allFiles = [];
|
|
194
198
|
const iterFolder = async (folder) => {
|
|
195
199
|
const files = await File.find(folder ? { folder } : {});
|
|
196
200
|
for (const f of files) {
|
|
197
201
|
const base = basename(f.location);
|
|
202
|
+
const relPath = folder ? `${folder}/${base}` : base;
|
|
203
|
+
//exclude files and folders matching the configured globs
|
|
204
|
+
if (matchesAnyGlob(excludeGlobs, relPath, !!f.isDirectory)) {
|
|
205
|
+
getState().log(6, `Excluding ${relPath} from backup`);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
198
208
|
if (f.isDirectory && (await f.is_symlink()))
|
|
199
209
|
continue;
|
|
200
210
|
else if (f.isDirectory && !(await f.is_symlink())) {
|
|
201
|
-
await
|
|
211
|
+
await zip.addDirectory(`files/${f.path_to_serve}`);
|
|
202
212
|
await iterFolder(f.path_to_serve);
|
|
203
213
|
}
|
|
204
214
|
else {
|
|
@@ -207,49 +217,48 @@ const backup_files = async (root_dirpath) => {
|
|
|
207
217
|
f.mime_sub === "zip" &&
|
|
208
218
|
!f.user_id)
|
|
209
219
|
continue;
|
|
210
|
-
await
|
|
220
|
+
await zip.addLocalFile(`files/${folder ? `${folder}/` : ""}${base}`, f.location);
|
|
211
221
|
}
|
|
212
222
|
f.location = path.join(folder || "", base);
|
|
213
223
|
allFiles.push(f);
|
|
214
224
|
}
|
|
215
225
|
};
|
|
216
226
|
await iterFolder();
|
|
217
|
-
|
|
227
|
+
const csv = csv_from_rows(allFiles);
|
|
228
|
+
if (csv !== null)
|
|
229
|
+
await zip.addBuffer("files.csv", Buffer.from(csv));
|
|
218
230
|
};
|
|
219
|
-
const
|
|
231
|
+
const add_migrations = async (zip) => {
|
|
220
232
|
const migrations = await getMigrationsInDB();
|
|
221
|
-
await
|
|
233
|
+
await zip.addBuffer("migrations.json", Buffer.from(JSON.stringify(migrations)));
|
|
222
234
|
};
|
|
223
|
-
const
|
|
235
|
+
const add_metadata = async (zip) => {
|
|
224
236
|
const metadata = await MetaData.find({});
|
|
225
|
-
await
|
|
237
|
+
await zip.addBuffer("metadata.json", Buffer.from(JSON.stringify(metadata)));
|
|
226
238
|
};
|
|
227
239
|
/**
|
|
228
240
|
* @function
|
|
229
|
-
* @param {
|
|
241
|
+
* @param {ZipBuilder} zip
|
|
230
242
|
* @returns {Promise<void>}
|
|
231
243
|
*/
|
|
232
|
-
const
|
|
233
|
-
const dirpath = join(root_dirpath, "config");
|
|
234
|
-
await mkdir(dirpath);
|
|
244
|
+
const add_config = async (zip) => {
|
|
235
245
|
const cfgs = await db.select("_sc_config");
|
|
236
246
|
const state = getState();
|
|
237
247
|
for (const cfg of cfgs) {
|
|
238
248
|
if (!state.isFixedConfig(cfg.key))
|
|
239
|
-
await
|
|
249
|
+
await zip.addBuffer(`config/${cfg.key}`, Buffer.from(JSON.stringify(db.json_read_returns_string ? JSON.parse(cfg.value) : cfg.value)));
|
|
240
250
|
}
|
|
241
251
|
};
|
|
242
|
-
const
|
|
243
|
-
const state = getState();
|
|
252
|
+
const add_info_file = async (zip) => {
|
|
244
253
|
const migrations_run = await getMigrationsInDB();
|
|
245
254
|
const dbversion = await db.getVersion(true);
|
|
246
255
|
const saltcorn_version = db.connectObj.sc_version;
|
|
247
|
-
await
|
|
256
|
+
await zip.addBuffer("backup-info.json", Buffer.from(JSON.stringify({
|
|
248
257
|
saltcorn_version,
|
|
249
258
|
migrations_run,
|
|
250
259
|
backup_date: new Date().toISOString(),
|
|
251
260
|
node_version: process.version,
|
|
252
|
-
database_type: db.
|
|
261
|
+
database_type: db.sql_backend_display_name,
|
|
253
262
|
database_version: dbversion,
|
|
254
263
|
os: {
|
|
255
264
|
platform: os.platform(),
|
|
@@ -259,131 +268,118 @@ const backup_info_file = async (root_dirpath) => {
|
|
|
259
268
|
type: os.type(),
|
|
260
269
|
release: os.release(),
|
|
261
270
|
},
|
|
262
|
-
}, null, 2));
|
|
263
|
-
};
|
|
264
|
-
const zipFolder = async (folder, zipFileName) => {
|
|
265
|
-
const backup_with_system_zip = executableIsAvailable("zip");
|
|
266
|
-
const backup_password = getState().getConfig("backup_password", "");
|
|
267
|
-
if (backup_with_system_zip) {
|
|
268
|
-
return await new Promise((resolve, reject) => {
|
|
269
|
-
const absZipPath = path.join(process.cwd(), zipFileName);
|
|
270
|
-
const args = [
|
|
271
|
-
"-5",
|
|
272
|
-
"-rq",
|
|
273
|
-
...(backup_password ? [`-P`, backup_password] : []),
|
|
274
|
-
absZipPath,
|
|
275
|
-
".",
|
|
276
|
-
];
|
|
277
|
-
const subprocess = spawn("zip", args, { cwd: folder });
|
|
278
|
-
subprocess.stdout.on("data", (data) => {
|
|
279
|
-
getState().log(6, data.toString());
|
|
280
|
-
});
|
|
281
|
-
subprocess.stderr.on("data", (data) => {
|
|
282
|
-
getState().log(1, data.toString());
|
|
283
|
-
});
|
|
284
|
-
subprocess.on("close", (exitCode) => {
|
|
285
|
-
if (exitCode !== 0)
|
|
286
|
-
reject(new Error("zip failed"));
|
|
287
|
-
else
|
|
288
|
-
resolve(undefined);
|
|
289
|
-
});
|
|
290
|
-
});
|
|
291
|
-
}
|
|
292
|
-
else {
|
|
293
|
-
const zip = new Zip();
|
|
294
|
-
zip.addLocalFolder(folder);
|
|
295
|
-
zip.writeZip(zipFileName);
|
|
296
|
-
}
|
|
271
|
+
}, null, 2)));
|
|
297
272
|
};
|
|
298
273
|
/**
|
|
299
|
-
*
|
|
300
|
-
* @param fnm
|
|
274
|
+
* The name a backup taken now gets, e.g. sc-backup-Saltcorn-2026-08-14-09-15.zip
|
|
301
275
|
*/
|
|
302
|
-
const
|
|
303
|
-
const tmpDir = await dir({ unsafeCleanup: true });
|
|
304
|
-
await create_pack(tmpDir.path);
|
|
305
|
-
await create_table_jsons(tmpDir.path);
|
|
306
|
-
await backup_files(tmpDir.path);
|
|
307
|
-
await backup_config(tmpDir.path);
|
|
308
|
-
await backup_migrations(tmpDir.path);
|
|
309
|
-
await backup_info_file(tmpDir.path);
|
|
310
|
-
await backup_metadata(tmpDir.path);
|
|
276
|
+
const backup_file_name = () => {
|
|
311
277
|
const day = dateFormat(new Date(), "yyyy-mm-dd-HH-MM");
|
|
312
278
|
const ten = db.getTenantSchema();
|
|
313
279
|
const tens = ten === db.connectObj.default_schema
|
|
314
280
|
? getState().getConfig("site_name", "Saltcorn")
|
|
315
281
|
: ten;
|
|
316
282
|
const backup_file_prefix = getState().getConfig("backup_file_prefix");
|
|
317
|
-
|
|
318
|
-
await zipFolder(tmpDir.path, zipFileName);
|
|
319
|
-
await tmpDir.cleanup();
|
|
320
|
-
return zipFileName;
|
|
283
|
+
return `${backup_file_prefix}${tens}-${day}.zip`;
|
|
321
284
|
};
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
285
|
+
/**
|
|
286
|
+
* Put everything a backup consists of into the archive. Each entry goes
|
|
287
|
+
* straight in as it is produced - nothing is staged in a temporary directory,
|
|
288
|
+
* and the archive itself is streamed out as it is built, so the memory and
|
|
289
|
+
* the disk a backup needs do not grow with the size of the site.
|
|
290
|
+
*/
|
|
291
|
+
const fill_backup = async (zip) => {
|
|
292
|
+
await add_pack(zip);
|
|
293
|
+
await add_table_jsons(zip);
|
|
294
|
+
await add_files(zip);
|
|
295
|
+
await add_config(zip);
|
|
296
|
+
await add_migrations(zip);
|
|
297
|
+
await add_info_file(zip);
|
|
298
|
+
await add_metadata(zip);
|
|
299
|
+
};
|
|
300
|
+
const backup_password = () => getState().getConfig("backup_password", "");
|
|
301
|
+
/**
|
|
302
|
+
* Write a backup to its final location. The archive is written where it
|
|
303
|
+
* belongs, not written somewhere else and moved - moving it would fail
|
|
304
|
+
* across filesystems (Docker volumes, external drives).
|
|
305
|
+
*/
|
|
306
|
+
const write_backup_to_file = async (filePath) => {
|
|
307
|
+
const zip = await ZipBuilder.toFile(filePath, backup_password());
|
|
325
308
|
try {
|
|
326
|
-
|
|
327
|
-
return true;
|
|
309
|
+
await fill_backup(zip);
|
|
328
310
|
}
|
|
329
|
-
catch (
|
|
330
|
-
|
|
311
|
+
catch (e) {
|
|
312
|
+
await zip.discard(e);
|
|
313
|
+
throw e;
|
|
331
314
|
}
|
|
332
|
-
|
|
315
|
+
await zip.finish();
|
|
316
|
+
};
|
|
317
|
+
/**
|
|
318
|
+
* Build a backup and hand it to `consume` as a stream, for destinations
|
|
319
|
+
* written over the network (S3, SFTP) or straight to a browser, where no
|
|
320
|
+
* local copy is wanted. The archive is produced as the consumer reads it,
|
|
321
|
+
* so only a few buffers of it exist at a time.
|
|
322
|
+
*/
|
|
323
|
+
const backup_to_stream = async (consume) => {
|
|
324
|
+
const zip = await ZipBuilder.toStream(backup_password());
|
|
325
|
+
// start the consumer first: the archive is only produced as fast as it is
|
|
326
|
+
// read, so filling it before anything reads would deadlock
|
|
327
|
+
const consumed = consume(zip.stream);
|
|
328
|
+
let consumeError;
|
|
329
|
+
// if the consumer gives up - a failed upload, a browser that went away -
|
|
330
|
+
// stop building rather than waiting forever for it to read more
|
|
331
|
+
consumed.catch((e) => {
|
|
332
|
+
consumeError = e;
|
|
333
|
+
zip.discard(e);
|
|
334
|
+
});
|
|
335
|
+
try {
|
|
336
|
+
await fill_backup(zip);
|
|
337
|
+
await zip.finish();
|
|
338
|
+
}
|
|
339
|
+
catch (e) {
|
|
340
|
+
await zip.discard(e);
|
|
341
|
+
// let the consumer settle before reporting: when it was the consumer
|
|
342
|
+
// that failed, its error is the one worth showing
|
|
343
|
+
await consumed.catch(() => { });
|
|
344
|
+
throw consumeError || e;
|
|
345
|
+
}
|
|
346
|
+
return await consumed;
|
|
347
|
+
};
|
|
348
|
+
/**
|
|
349
|
+
* Create backup
|
|
350
|
+
* @param fnm optional file name, otherwise a name based on the site name
|
|
351
|
+
* and the current time
|
|
352
|
+
*/
|
|
353
|
+
const create_backup = async (fnm) => {
|
|
354
|
+
const zipFileName = fnm || backup_file_name();
|
|
355
|
+
await write_backup_to_file(zipFileName);
|
|
356
|
+
return zipFileName;
|
|
357
|
+
};
|
|
333
358
|
/**
|
|
359
|
+
* Extract a zip file into a directory
|
|
334
360
|
* @function
|
|
335
361
|
* @param {string} fnm
|
|
336
362
|
* @param {string} dir
|
|
337
363
|
* @returns {Promise<void>}
|
|
338
364
|
*/
|
|
339
365
|
const extract = async (fnm, dir, password) => {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
if (output.includes("password") || output.includes("encrypted")) {
|
|
358
|
-
reject({ requiresPassword: true });
|
|
359
|
-
}
|
|
360
|
-
});
|
|
361
|
-
subprocess.on("close", (exitCode) => {
|
|
362
|
-
if (exitCode !== 0)
|
|
363
|
-
reject(new Error("unzip failed"));
|
|
364
|
-
else
|
|
365
|
-
resolve();
|
|
366
|
-
});
|
|
367
|
-
});
|
|
368
|
-
}
|
|
369
|
-
else {
|
|
370
|
-
const zip = new Zip(fnm);
|
|
371
|
-
try {
|
|
372
|
-
if (password) {
|
|
373
|
-
zip.extractAllTo(dir, true, false, password);
|
|
374
|
-
}
|
|
375
|
-
else {
|
|
376
|
-
zip.extractAllTo(dir, true, false);
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
catch (error) {
|
|
380
|
-
if (error.message.includes("password") ||
|
|
381
|
-
error.message.includes("encrypted")) {
|
|
382
|
-
error.requiresPassword = true;
|
|
383
|
-
}
|
|
384
|
-
throw error;
|
|
385
|
-
}
|
|
386
|
-
}
|
|
366
|
+
await extractZip(File.normalise(fnm), dir, password);
|
|
367
|
+
};
|
|
368
|
+
/**
|
|
369
|
+
* A scratch directory for a restore, on the same volume as the file store.
|
|
370
|
+
* Deliberately not the OS temporary directory: that is small, or in RAM, on
|
|
371
|
+
* many distributions, while the file store is sized for the app's data.
|
|
372
|
+
*/
|
|
373
|
+
const staging_dir = async (prefix) => {
|
|
374
|
+
const base = join(db.connectObj.file_store || process.cwd(), ".saltcorn-tmp");
|
|
375
|
+
await mkdir(base, { recursive: true });
|
|
376
|
+
const dirPath = await mkdtemp(join(base, prefix));
|
|
377
|
+
return {
|
|
378
|
+
path: dirPath,
|
|
379
|
+
cleanup: async () => {
|
|
380
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
381
|
+
},
|
|
382
|
+
};
|
|
387
383
|
};
|
|
388
384
|
/**
|
|
389
385
|
* @function
|
|
@@ -467,13 +463,14 @@ const restore_file_users = async (file_users) => {
|
|
|
467
463
|
* @param {boolean} [restore_first_user]
|
|
468
464
|
* @returns {Promise<string|undefined>}
|
|
469
465
|
*/
|
|
470
|
-
const restore_tables = async (dirpath, restore_first_user) => {
|
|
466
|
+
const restore_tables = async (dirpath, restore_first_user, onLog) => {
|
|
471
467
|
let err;
|
|
472
468
|
const tables = await Table.find();
|
|
473
469
|
const restore_history = getState().getConfig("restore_history", true);
|
|
474
470
|
for (const table of tables) {
|
|
475
471
|
if (!isTest())
|
|
476
472
|
getState().log(2, `restoring table ${table.name}`);
|
|
473
|
+
onLog?.(`Restoring table ${table.name}`);
|
|
477
474
|
const fnm_csv = table.name === "users"
|
|
478
475
|
? join(dirpath, "users.csv")
|
|
479
476
|
: join(dirpath, "tables", sanitiseTableName(table.name) + ".csv");
|
|
@@ -543,148 +540,174 @@ const restore_metadata = async (dirpath) => {
|
|
|
543
540
|
* @param fnm
|
|
544
541
|
* @param loadAndSaveNewPlugin
|
|
545
542
|
* @param restore_first_user
|
|
543
|
+
* @param password
|
|
544
|
+
* @param onLog optional callback, called with a short progress message for
|
|
545
|
+
* each restore step and entity type/table - in addition to, not instead
|
|
546
|
+
* of, the usual state.log calls
|
|
546
547
|
*/
|
|
547
|
-
const restore = async (fnm, loadAndSaveNewPlugin, restore_first_user, password) => {
|
|
548
|
+
const restore = async (fnm, loadAndSaveNewPlugin, restore_first_user, password, onLog) => {
|
|
548
549
|
const state = getState();
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
550
|
+
const log = (msg) => {
|
|
551
|
+
if (!isTest())
|
|
552
|
+
state.log(2, msg);
|
|
553
|
+
onLog?.(msg);
|
|
554
|
+
};
|
|
555
|
+
log(`Starting restore to tenant ${db.getTenantSchema()}`);
|
|
556
|
+
const tmpDir = await staging_dir("sc-restore-");
|
|
557
|
+
try {
|
|
558
|
+
await extract(fnm, tmpDir.path, password);
|
|
559
|
+
log(`Unzip done`);
|
|
560
|
+
let basePath = tmpDir.path;
|
|
561
|
+
// safari re-compressed. Safari unpacks zip files on download. If the user
|
|
562
|
+
// chooses compress in finder, the backup dir is nested inside the zip file
|
|
563
|
+
if (!existsSync(join(basePath, "pack.json"))) {
|
|
564
|
+
const files = await readdir(basePath);
|
|
565
|
+
let found = false;
|
|
566
|
+
for (const file of files) {
|
|
567
|
+
if (existsSync(join(basePath, file, "pack.json"))) {
|
|
568
|
+
basePath = join(basePath, file);
|
|
569
|
+
found = true;
|
|
570
|
+
break;
|
|
571
|
+
}
|
|
564
572
|
}
|
|
573
|
+
if (!found)
|
|
574
|
+
return "Not a valid backup file";
|
|
565
575
|
}
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
semver.gt(info.saltcorn_version, saltcorn_version)) {
|
|
575
|
-
err = `Warning: backup is from a more recent version (${info.saltcorn_version}) than the installed version (${saltcorn_version}). `;
|
|
576
|
+
let err;
|
|
577
|
+
if (existsSync(join(basePath, "backup-info.json"))) {
|
|
578
|
+
const info = JSON.parse((await readFile(join(basePath, "backup-info.json"))).toString());
|
|
579
|
+
const saltcorn_version = db.connectObj.sc_version;
|
|
580
|
+
if (info.saltcorn_version &&
|
|
581
|
+
semver.gt(info.saltcorn_version, saltcorn_version)) {
|
|
582
|
+
err = `Warning: backup is from a more recent version (${info.saltcorn_version}) than the installed version (${saltcorn_version}). `;
|
|
583
|
+
}
|
|
576
584
|
}
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
if (typeof can_restore !== "boolean" && can_restore.error) {
|
|
584
|
-
return `Cannot restore backup, clashing entities:
|
|
585
|
+
//install pack
|
|
586
|
+
log(`Reading pack`);
|
|
587
|
+
const pack = JSON.parse((await readFile(join(basePath, "pack.json"))).toString());
|
|
588
|
+
const can_restore = await can_install_pack(pack);
|
|
589
|
+
if (typeof can_restore !== "boolean" && can_restore.error) {
|
|
590
|
+
return `Cannot restore backup, clashing entities:
|
|
585
591
|
${can_restore.error || ""}
|
|
586
592
|
Delete these entities or restore to a pristine instance.
|
|
587
593
|
`;
|
|
594
|
+
}
|
|
595
|
+
//config
|
|
596
|
+
log(`Restoring config`);
|
|
597
|
+
await restore_config(basePath);
|
|
598
|
+
log(`Restoring pack`);
|
|
599
|
+
await install_pack(pack, undefined, loadAndSaveNewPlugin, true, log);
|
|
600
|
+
// files
|
|
601
|
+
log(`Restoring files`);
|
|
602
|
+
const { file_users, newLocations } = await restore_files(basePath);
|
|
603
|
+
//table csvs
|
|
604
|
+
log(`Restoring tables`);
|
|
605
|
+
const tabres = await restore_tables(basePath, restore_first_user, log);
|
|
606
|
+
if (tabres)
|
|
607
|
+
err = (err || "") + tabres;
|
|
608
|
+
log(`Restoring metadata`);
|
|
609
|
+
await restore_metadata(basePath);
|
|
610
|
+
if (Object.keys(newLocations).length > 0)
|
|
611
|
+
await correct_fileid_references_to_location(newLocations);
|
|
612
|
+
await restore_file_users(file_users);
|
|
613
|
+
state.log(2, `Completed restore to tenant ${db.getTenantSchema()}${err ? ` with errors ${err}` : " successfully"}`);
|
|
614
|
+
return err;
|
|
615
|
+
}
|
|
616
|
+
finally {
|
|
617
|
+
await tmpDir.cleanup();
|
|
588
618
|
}
|
|
589
|
-
//config
|
|
590
|
-
if (!isTest())
|
|
591
|
-
state.log(2, `Restoring config`);
|
|
592
|
-
await restore_config(basePath);
|
|
593
|
-
if (!isTest())
|
|
594
|
-
state.log(2, `Restoring pack`);
|
|
595
|
-
await install_pack(pack, undefined, loadAndSaveNewPlugin, true);
|
|
596
|
-
// files
|
|
597
|
-
if (!isTest())
|
|
598
|
-
state.log(2, `Restoring files`);
|
|
599
|
-
const { file_users, newLocations } = await restore_files(basePath);
|
|
600
|
-
//table csvs
|
|
601
|
-
if (!isTest())
|
|
602
|
-
state.log(2, `Restoring tables`);
|
|
603
|
-
const tabres = await restore_tables(basePath, restore_first_user);
|
|
604
|
-
if (tabres)
|
|
605
|
-
err = (err || "") + tabres;
|
|
606
|
-
if (!isTest())
|
|
607
|
-
state.log(2, `Restoring metadata`);
|
|
608
|
-
await restore_metadata(basePath);
|
|
609
|
-
if (Object.keys(newLocations).length > 0)
|
|
610
|
-
await correct_fileid_references_to_location(newLocations);
|
|
611
|
-
await restore_file_users(file_users);
|
|
612
|
-
await tmpDir.cleanup();
|
|
613
|
-
state.log(2, `Completed restore to tenant ${db.getTenantSchema()}${err ? ` with errors ${err}` : " successfully"}`);
|
|
614
|
-
return err;
|
|
615
619
|
};
|
|
616
620
|
/**
|
|
617
|
-
*
|
|
621
|
+
* Configuration object shared by the S3 upload and the S3 retention client.
|
|
622
|
+
*/
|
|
623
|
+
const s3ClientConfig = (state) => {
|
|
624
|
+
const s3EndpointCfg = state.getConfig("backup_s3_endpoint");
|
|
625
|
+
const s3Secure = state.getConfig("backup_s3_secure", true);
|
|
626
|
+
const endpoint = s3EndpointCfg
|
|
627
|
+
? /:\/\//.test(s3EndpointCfg)
|
|
628
|
+
? s3EndpointCfg
|
|
629
|
+
: `${s3Secure ? "https" : "http"}://${s3EndpointCfg}`
|
|
630
|
+
: undefined;
|
|
631
|
+
return {
|
|
632
|
+
credentials: {
|
|
633
|
+
accessKeyId: state.getConfig("backup_s3_access_key"),
|
|
634
|
+
secretAccessKey: state.getConfig("backup_s3_access_secret"),
|
|
635
|
+
},
|
|
636
|
+
region: state.getConfig("backup_s3_region"),
|
|
637
|
+
...(endpoint ? { endpoint, forcePathStyle: true } : {}),
|
|
638
|
+
};
|
|
639
|
+
};
|
|
640
|
+
const buildS3Client = (state) => new (awsS3().S3Client)(s3ClientConfig(state));
|
|
641
|
+
/**
|
|
642
|
+
* The tiered (GFS) retention policy, or null if retention is by expiration age
|
|
643
|
+
*/
|
|
644
|
+
const getRetentionPolicy = () => {
|
|
645
|
+
const mode = getState().getConfig("auto_backup_retention_mode", "Expiration");
|
|
646
|
+
if (mode !== "Tiered (GFS)")
|
|
647
|
+
return null;
|
|
648
|
+
return {
|
|
649
|
+
keep_daily: +getState().getConfig("auto_backup_keep_daily", 7),
|
|
650
|
+
keep_weekly: +getState().getConfig("auto_backup_keep_weekly", 4),
|
|
651
|
+
keep_monthly: +getState().getConfig("auto_backup_keep_monthly", 12),
|
|
652
|
+
keep_yearly: +getState().getConfig("auto_backup_keep_yearly", 3),
|
|
653
|
+
keep_min: +getState().getConfig("auto_backup_keep_min", 3),
|
|
654
|
+
};
|
|
655
|
+
};
|
|
656
|
+
/**
|
|
657
|
+
* Delete old backups: collect the existing backups at the destination,
|
|
658
|
+
* classify them according to the configured retention policy (expiration
|
|
659
|
+
* age or tiered GFS), then delete those not retained.
|
|
618
660
|
*/
|
|
619
661
|
const delete_old_backups = async () => {
|
|
620
662
|
const directory = getState().getConfig("auto_backup_directory");
|
|
621
663
|
const expire_days = getState().getConfig("auto_backup_expire_days");
|
|
622
664
|
const backup_file_prefix = getState().getConfig("backup_file_prefix");
|
|
623
665
|
const destination = getState().getConfig("auto_backup_destination");
|
|
624
|
-
|
|
666
|
+
const retention = getRetentionPolicy();
|
|
667
|
+
// nothing to do: neither expiration nor tiered retention configured
|
|
668
|
+
if (!retention && (!expire_days || expire_days < 0))
|
|
625
669
|
return;
|
|
670
|
+
// ---- 1. collect --------------------------------------------------------
|
|
671
|
+
const entries = [];
|
|
672
|
+
let s3;
|
|
673
|
+
let bucket = "";
|
|
626
674
|
if (destination === "Local directory") {
|
|
627
675
|
const files = await readdir(directory);
|
|
628
676
|
for (const file of files) {
|
|
629
677
|
if (!file.startsWith(backup_file_prefix))
|
|
630
678
|
continue;
|
|
679
|
+
if (!file.endsWith(".zip"))
|
|
680
|
+
continue;
|
|
631
681
|
const stats = await stat(path.join(directory, file));
|
|
632
682
|
const fileTime = stats.birthtime?.getTime
|
|
633
683
|
? stats.birthtime.getTime()
|
|
634
684
|
: stats.mtime.getTime();
|
|
635
|
-
|
|
636
|
-
const ageDays = ageMs / (1000 * 3600 * 24);
|
|
637
|
-
if (ageDays > expire_days)
|
|
638
|
-
await unlink(path.join(directory, file));
|
|
685
|
+
entries.push({ key: file, date: new Date(fileTime) });
|
|
639
686
|
}
|
|
640
687
|
}
|
|
641
688
|
else if (destination === "S3") {
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
const endpoint = s3EndpointCfg
|
|
645
|
-
? /:\/\//.test(s3EndpointCfg)
|
|
646
|
-
? s3EndpointCfg
|
|
647
|
-
: `${s3Secure ? "https" : "http"}://${s3EndpointCfg}`
|
|
648
|
-
: undefined;
|
|
649
|
-
const s3 = new (awsS3().S3Client)({
|
|
650
|
-
credentials: {
|
|
651
|
-
accessKeyId: getState().getConfig("backup_s3_access_key"),
|
|
652
|
-
secretAccessKey: getState().getConfig("backup_s3_access_secret"),
|
|
653
|
-
},
|
|
654
|
-
region: getState().getConfig("backup_s3_region"),
|
|
655
|
-
...(endpoint ? { endpoint } : {}),
|
|
656
|
-
});
|
|
657
|
-
const bucket = getState().getConfig("backup_s3_bucket");
|
|
689
|
+
s3 = buildS3Client(getState());
|
|
690
|
+
bucket = getState().getConfig("backup_s3_bucket");
|
|
658
691
|
const keyPrefix = (getState().getConfig("backup_s3_path_prefix", "") || "")
|
|
659
692
|
.toString()
|
|
660
693
|
.replace(/^\/+|\/+$/g, "");
|
|
661
694
|
const listPrefix = [keyPrefix, backup_file_prefix]
|
|
662
695
|
.filter((s) => s && s.length)
|
|
663
696
|
.join("/");
|
|
664
|
-
const listParams = {
|
|
665
|
-
Bucket: bucket,
|
|
666
|
-
Prefix: listPrefix,
|
|
667
|
-
};
|
|
668
697
|
try {
|
|
669
698
|
let continuationToken = undefined;
|
|
670
699
|
do {
|
|
671
700
|
const listedObjects = await s3.send(new (awsS3().ListObjectsV2Command)({
|
|
672
|
-
|
|
701
|
+
Bucket: bucket,
|
|
702
|
+
Prefix: listPrefix,
|
|
673
703
|
ContinuationToken: continuationToken,
|
|
674
704
|
}));
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
if (ageDays > expire_days) {
|
|
682
|
-
await s3.send(new (awsS3().DeleteObjectCommand)({
|
|
683
|
-
Bucket: bucket,
|
|
684
|
-
Key: obj.Key,
|
|
685
|
-
}));
|
|
686
|
-
}
|
|
687
|
-
}
|
|
705
|
+
for (const obj of listedObjects.Contents || []) {
|
|
706
|
+
if (!obj.Key || !obj.LastModified)
|
|
707
|
+
continue;
|
|
708
|
+
if (!obj.Key.endsWith(".zip"))
|
|
709
|
+
continue;
|
|
710
|
+
entries.push({ key: obj.Key, date: new Date(obj.LastModified) });
|
|
688
711
|
}
|
|
689
712
|
continuationToken = listedObjects.IsTruncated
|
|
690
713
|
? listedObjects.NextContinuationToken
|
|
@@ -692,7 +715,40 @@ const delete_old_backups = async () => {
|
|
|
692
715
|
} while (continuationToken);
|
|
693
716
|
}
|
|
694
717
|
catch (err) {
|
|
695
|
-
console.error(`Error
|
|
718
|
+
console.error(`Error listing old backups in S3: ${err.message}`);
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
else
|
|
723
|
+
return; // Saltcorn files / SFTP: no retention
|
|
724
|
+
// guard against acting on a misconfigured prefix
|
|
725
|
+
if (entries.length === 0)
|
|
726
|
+
return;
|
|
727
|
+
// ---- 2. classify -------------------------------------------------------
|
|
728
|
+
let toDelete;
|
|
729
|
+
if (retention) {
|
|
730
|
+
toDelete = selectBackupsToDeleteGrouped(entries, retention);
|
|
731
|
+
getState().log(5, `Backup retention (GFS): ${entries.length} found, ` +
|
|
732
|
+
`${entries.length - toDelete.length} kept, ${toDelete.length} to delete`);
|
|
733
|
+
}
|
|
734
|
+
else {
|
|
735
|
+
const now = new Date().getTime();
|
|
736
|
+
toDelete = entries.filter((e) => (now - e.date.getTime()) / (1000 * 3600 * 24) > expire_days);
|
|
737
|
+
}
|
|
738
|
+
// ---- 3. delete ---------------------------------------------------------
|
|
739
|
+
for (const entry of toDelete) {
|
|
740
|
+
try {
|
|
741
|
+
if (destination === "Local directory")
|
|
742
|
+
await unlink(path.join(directory, entry.key));
|
|
743
|
+
else
|
|
744
|
+
await s3.send(new (awsS3().DeleteObjectCommand)({
|
|
745
|
+
Bucket: bucket,
|
|
746
|
+
Key: entry.key,
|
|
747
|
+
}));
|
|
748
|
+
getState().log(6, `Deleted expired backup ${entry.key}`);
|
|
749
|
+
}
|
|
750
|
+
catch (err) {
|
|
751
|
+
console.error(`Error deleting old backup ${entry.key}: ${err.message}`);
|
|
696
752
|
}
|
|
697
753
|
}
|
|
698
754
|
};
|
|
@@ -700,22 +756,22 @@ const delete_old_backups = async () => {
|
|
|
700
756
|
* Do autobackup now
|
|
701
757
|
*/
|
|
702
758
|
const auto_backup_now_tenant = async (state) => {
|
|
703
|
-
state.log(6, `Creating backup file`);
|
|
704
|
-
const fileName = await create_backup();
|
|
705
|
-
state.log(6, `Created backup file with name ${fileName}`);
|
|
706
759
|
const destination = state.getConfig("auto_backup_destination", "Saltcorn files");
|
|
707
760
|
const directory = state.getConfig("auto_backup_directory", "");
|
|
708
761
|
if (directory === null)
|
|
709
762
|
throw new Error("Directory is unspecified");
|
|
710
|
-
|
|
763
|
+
// the backup is built straight into its destination, so decide where that
|
|
764
|
+
// is before building it
|
|
765
|
+
const fileName = backup_file_name();
|
|
766
|
+
state.log(6, `Creating backup ${fileName} for destination ${destination}`);
|
|
711
767
|
switch (destination) {
|
|
712
|
-
case "Saltcorn files":
|
|
768
|
+
case "Saltcorn files": {
|
|
713
769
|
if (directory.length > 0) {
|
|
714
770
|
await File.new_folder(directory);
|
|
715
771
|
}
|
|
716
772
|
const newPath = File.get_new_path(join(directory, fileName));
|
|
717
|
-
|
|
718
|
-
await
|
|
773
|
+
await write_backup_to_file(newPath);
|
|
774
|
+
const stats = await stat(newPath);
|
|
719
775
|
await File.create({
|
|
720
776
|
filename: fileName,
|
|
721
777
|
location: newPath,
|
|
@@ -725,71 +781,73 @@ const auto_backup_now_tenant = async (state) => {
|
|
|
725
781
|
mime_sub: "zip",
|
|
726
782
|
min_role_read: 1,
|
|
727
783
|
});
|
|
728
|
-
await unlink(fileName);
|
|
729
784
|
break;
|
|
730
|
-
|
|
731
|
-
|
|
785
|
+
}
|
|
786
|
+
case "Local directory": {
|
|
732
787
|
if (directory.length > 0) {
|
|
733
788
|
await mkdir(directory, { recursive: true });
|
|
734
789
|
}
|
|
735
|
-
|
|
790
|
+
// written in place rather than written elsewhere and renamed: a rename
|
|
791
|
+
// into a Docker volume or an external drive fails with EXDEV
|
|
792
|
+
await write_backup_to_file(join(directory, fileName));
|
|
736
793
|
await delete_old_backups();
|
|
737
794
|
break;
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
host: state.getConfig("auto_backup_server"),
|
|
742
|
-
port: state.getConfig("auto_backup_port"),
|
|
743
|
-
username: state.getConfig("auto_backup_username"),
|
|
744
|
-
password: state.getConfig("auto_backup_password"),
|
|
745
|
-
});
|
|
746
|
-
let data = createReadStream(fileName);
|
|
747
|
-
let remote = join(state.getConfig("auto_backup_directory", ""), basename(fileName));
|
|
748
|
-
const putres = await sftp.put(data, remote);
|
|
749
|
-
state.log(6, `SFTP Put response: ${putres}`);
|
|
750
|
-
await sftp.end();
|
|
795
|
+
}
|
|
796
|
+
case "SFTP server": {
|
|
797
|
+
const remote = join(state.getConfig("auto_backup_directory", ""), fileName);
|
|
751
798
|
const retain_dir = state.getConfig("auto_backup_retain_local_directory");
|
|
799
|
+
const upload = async (body) => {
|
|
800
|
+
const sftp = new SftpClient();
|
|
801
|
+
await sftp.connect({
|
|
802
|
+
host: state.getConfig("auto_backup_server"),
|
|
803
|
+
port: state.getConfig("auto_backup_port"),
|
|
804
|
+
username: state.getConfig("auto_backup_username"),
|
|
805
|
+
password: state.getConfig("auto_backup_password"),
|
|
806
|
+
});
|
|
807
|
+
try {
|
|
808
|
+
const putres = await sftp.put(body, remote);
|
|
809
|
+
state.log(6, `SFTP Put response: ${putres}`);
|
|
810
|
+
}
|
|
811
|
+
finally {
|
|
812
|
+
await sftp.end();
|
|
813
|
+
}
|
|
814
|
+
};
|
|
752
815
|
if (retain_dir) {
|
|
816
|
+
// a local copy is wanted, so write that first and send it from
|
|
817
|
+
// there rather than holding the archive in memory to do both
|
|
753
818
|
await mkdir(retain_dir, { recursive: true });
|
|
754
|
-
|
|
819
|
+
const retained = join(retain_dir, fileName);
|
|
820
|
+
await write_backup_to_file(retained);
|
|
821
|
+
await upload(createReadStream(retained));
|
|
822
|
+
}
|
|
823
|
+
else {
|
|
824
|
+
// straight to the server: the archive is never on the local disk
|
|
825
|
+
await backup_to_stream(upload);
|
|
755
826
|
}
|
|
756
|
-
await unlink(fileName);
|
|
757
827
|
break;
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
const
|
|
761
|
-
const bEndpoint = bEndpointCfg
|
|
762
|
-
? /:\/\//.test(bEndpointCfg)
|
|
763
|
-
? bEndpointCfg
|
|
764
|
-
: `${bSecure ? "https" : "http"}://${bEndpointCfg}`
|
|
765
|
-
: undefined;
|
|
766
|
-
const s3 = new (awsS3().S3)({
|
|
767
|
-
credentials: {
|
|
768
|
-
accessKeyId: state.getConfig("backup_s3_access_key"),
|
|
769
|
-
secretAccessKey: state.getConfig("backup_s3_access_secret"),
|
|
770
|
-
},
|
|
771
|
-
region: state.getConfig("backup_s3_region"),
|
|
772
|
-
...(bEndpoint ? { endpoint: bEndpoint } : {}),
|
|
773
|
-
});
|
|
828
|
+
}
|
|
829
|
+
case "S3": {
|
|
830
|
+
const s3 = new (awsS3().S3)(s3ClientConfig(state));
|
|
774
831
|
const bucket = state.getConfig("backup_s3_bucket");
|
|
775
832
|
const pathPrefix = (state.getConfig("backup_s3_path_prefix", "") || "")
|
|
776
833
|
.toString()
|
|
777
834
|
.replace(/^\/+|\/+$/g, "");
|
|
778
|
-
const s3Key = [pathPrefix,
|
|
835
|
+
const s3Key = [pathPrefix, fileName]
|
|
779
836
|
.filter((s) => s && s.length)
|
|
780
837
|
.join("/");
|
|
781
|
-
const fileStream = () => createReadStream(fileName);
|
|
782
838
|
try {
|
|
783
|
-
|
|
839
|
+
// streamed to the bucket as it is built, in multipart chunks: the
|
|
840
|
+
// archive is neither held in memory nor written to the local disk
|
|
841
|
+
const uploadResult = await backup_to_stream(async (body) => await new (awsLibStorage().Upload)({
|
|
784
842
|
client: s3,
|
|
785
843
|
params: {
|
|
786
844
|
Bucket: bucket,
|
|
787
845
|
Key: s3Key,
|
|
788
|
-
Body:
|
|
846
|
+
Body: body,
|
|
789
847
|
ACL: "private",
|
|
790
848
|
ContentType: "application/zip",
|
|
791
849
|
},
|
|
792
|
-
}).done();
|
|
850
|
+
}).done());
|
|
793
851
|
if (uploadResult.$metadata.httpStatusCode !== 200) {
|
|
794
852
|
throw new Error(`S3 Upload failed with status code ${uploadResult.$metadata.httpStatusCode}`);
|
|
795
853
|
}
|
|
@@ -800,9 +858,11 @@ const auto_backup_now_tenant = async (state) => {
|
|
|
800
858
|
}
|
|
801
859
|
await delete_old_backups();
|
|
802
860
|
break;
|
|
861
|
+
}
|
|
803
862
|
default:
|
|
804
863
|
throw new Error("Unknown destination: " + destination);
|
|
805
864
|
}
|
|
865
|
+
state.log(6, `Backup ${fileName} written to destination ${destination}`);
|
|
806
866
|
};
|
|
807
867
|
const auto_backup_now = async () => {
|
|
808
868
|
const isRoot = db.getTenantSchema() === db.connectObj.default_schema;
|
|
@@ -849,6 +909,8 @@ const auto_backup_now = async () => {
|
|
|
849
909
|
};
|
|
850
910
|
export default {
|
|
851
911
|
create_backup,
|
|
912
|
+
backup_to_stream,
|
|
913
|
+
backup_file_name,
|
|
852
914
|
restore,
|
|
853
915
|
create_csv_from_rows,
|
|
854
916
|
auto_backup_now,
|