nukebase 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/nukebase.js +9 -1
- package/lib/guide.js +2 -0
- package/lib/project-commands.js +186 -5
- package/lib/storage.js +67 -29
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ Push and pull carry it, so a fresh clone is already linked.
|
|
|
41
41
|
```
|
|
42
42
|
nukebase create <name> [size] [email] sizes: 128mb, 256mb, 512mb, 1gb
|
|
43
43
|
nukebase link <name> [email] connect a folder to an existing project
|
|
44
|
-
nukebase push | pull | logs | rollback | backup | env push | domain | ...
|
|
44
|
+
nukebase push | pull | logs | rollback | backup | restore | env push | domain | ...
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
Run `nukebase help` for every command.
|
package/bin/nukebase.js
CHANGED
|
@@ -24,6 +24,7 @@ if (!(args.has('version') && !args.command)) {
|
|
|
24
24
|
const ACCOUNT_COMMANDS = ['login', 'logout', 'accounts', 'use', 'whoami', 'create', 'link'];
|
|
25
25
|
const PROJECT_COMMANDS = [
|
|
26
26
|
'push', 'pull', 'update', 'rollback', 'restorepoints', 'restore-points', 'backup', 'env',
|
|
27
|
+
'restore', 'backups',
|
|
27
28
|
'pause', 'restart', 'resume', 'delete', 'domain', 'logs', 'download',
|
|
28
29
|
'regiontransfer', 'region-transfer'
|
|
29
30
|
];
|
|
@@ -69,7 +70,7 @@ In a project folder (runs as the account that owns the project)
|
|
|
69
70
|
update refresh the NukeBase engine files
|
|
70
71
|
backup [--out <dir>] download the backup set from the server
|
|
71
72
|
backup --storage also the app's files in file storage (new or
|
|
72
|
-
changed ones
|
|
73
|
+
changed ones)
|
|
73
74
|
env push make the hosted environment (process.env) equal
|
|
74
75
|
to ./.env, then restart the project
|
|
75
76
|
env list [--show] variable names set on the server
|
|
@@ -77,6 +78,13 @@ In a project folder (runs as the account that owns the project)
|
|
|
77
78
|
rollback backup [id] undo a push AND restore the backup set as it was
|
|
78
79
|
then; discards writes since
|
|
79
80
|
rollback list show restore points (also: restorepoints)
|
|
81
|
+
restore list backups in file storage: database and code,
|
|
82
|
+
every 4 hours and after each push, 30 days
|
|
83
|
+
(also: backups)
|
|
84
|
+
restore <id> put one back (--code-only / --data-only);
|
|
85
|
+
what it replaces becomes a restore point
|
|
86
|
+
backup --from <id> download one: its database into ./server/data,
|
|
87
|
+
or everything with --out <dir>
|
|
80
88
|
pause stop the project (base fee + disk still accrue)
|
|
81
89
|
restart | resume restart it; also resumes a paused project
|
|
82
90
|
delete delete it on the server; asks for the name
|
package/lib/guide.js
CHANGED
|
@@ -103,6 +103,8 @@ Then, inside the project folder:
|
|
|
103
103
|
npx nukebase domain email send the app's emails from that domain
|
|
104
104
|
npx nukebase rollback undo the last push (their data is kept)
|
|
105
105
|
npx nukebase backup copy the app's data to this computer
|
|
106
|
+
npx nukebase restore list the app's backups (database + code, 30 days)
|
|
107
|
+
(add --storage for the files it stores)
|
|
106
108
|
npx nukebase env push send secrets from .env to the server
|
|
107
109
|
npx nukebase pause / restart stop the app, or start it again
|
|
108
110
|
npx nukebase help every command
|
package/lib/project-commands.js
CHANGED
|
@@ -1521,7 +1521,8 @@ async function push() {
|
|
|
1521
1521
|
const { remove, diskWarning } = synced;
|
|
1522
1522
|
if (diskWarning) console.log('⚠️ ' + diskWarning);
|
|
1523
1523
|
|
|
1524
|
-
// --include backup on a project with file storage: its files (
|
|
1524
|
+
// --include backup on a project with file storage: its files (storage/ or
|
|
1525
|
+
// server/files)
|
|
1525
1526
|
// go straight to the storage, not to the server, and only the ones it lacks
|
|
1526
1527
|
// or holds at another size. Nothing there is deleted.
|
|
1527
1528
|
let store = null;
|
|
@@ -1539,7 +1540,8 @@ async function push() {
|
|
|
1539
1540
|
storageUp = storage.changedLocal(storageLocal, store.files);
|
|
1540
1541
|
}
|
|
1541
1542
|
}
|
|
1542
|
-
const
|
|
1543
|
+
const filesRel = storage.filesDir(root);
|
|
1544
|
+
const inFilesDir = (f) => f === filesRel || f.startsWith(filesRel + '/');
|
|
1543
1545
|
const upload = store && store.storage ? synced.upload.filter((f) => !inFilesDir(f)) : synced.upload;
|
|
1544
1546
|
const storageBytes = storageUp.reduce((n, k) => n + storageLocal[k][0], 0);
|
|
1545
1547
|
|
|
@@ -1597,7 +1599,7 @@ async function push() {
|
|
|
1597
1599
|
console.log(' Run `nukebase backup` first if you meant to restore a copy.');
|
|
1598
1600
|
}
|
|
1599
1601
|
if (store && store.storage) {
|
|
1600
|
-
console.log(`\n File storage: ${storageUp.length} file(s), ${(storageBytes / 1024 / 1024).toFixed(2)} MB from ${storage.
|
|
1602
|
+
console.log(`\n File storage: ${storageUp.length} file(s), ${(storageBytes / 1024 / 1024).toFixed(2)} MB from ${storage.filesDir(root)}`);
|
|
1601
1603
|
console.log(' go straight to the storage (missing or changed ones only; nothing there is deleted).');
|
|
1602
1604
|
storageUp.slice(0, 10).forEach(f => console.log(' ⇈ ' + f));
|
|
1603
1605
|
if (storageUp.length > 10) console.log(` ... and ${storageUp.length - 10} more`);
|
|
@@ -2254,6 +2256,7 @@ async function rollback() {
|
|
|
2254
2256
|
// the working tree.
|
|
2255
2257
|
async function backup() {
|
|
2256
2258
|
await connectAndLogin();
|
|
2259
|
+
if (args.get('from')) return backupFrom(args.get('from'));
|
|
2257
2260
|
|
|
2258
2261
|
const dest = args.get('out') ? resolve(args.get('out')) : root;
|
|
2259
2262
|
|
|
@@ -2337,7 +2340,7 @@ async function backup() {
|
|
|
2337
2340
|
}
|
|
2338
2341
|
|
|
2339
2342
|
// File storage: a project whose files live in object storage. Listed always;
|
|
2340
|
-
// downloaded with --storage,
|
|
2343
|
+
// downloaded with --storage, new or changed only.
|
|
2341
2344
|
let store = null;
|
|
2342
2345
|
let storageChanged = [];
|
|
2343
2346
|
let storageBytes = 0;
|
|
@@ -2355,7 +2358,7 @@ async function backup() {
|
|
|
2355
2358
|
storageBytes = storageChanged.reduce((n, k) => n + store.files[k][0], 0);
|
|
2356
2359
|
if (!storageChanged.length) console.log(' ✓ Already up to date.');
|
|
2357
2360
|
else {
|
|
2358
|
-
console.log(` ${storageChanged.length} changed, ${(storageBytes / 1024 / 1024).toFixed(2)} MB to download into ${storage.
|
|
2361
|
+
console.log(` ${storageChanged.length} changed, ${(storageBytes / 1024 / 1024).toFixed(2)} MB to download into ${storage.filesDir(dest)}`);
|
|
2359
2362
|
storageChanged.slice(0, 10).forEach(f => console.log(' ↓ ' + f));
|
|
2360
2363
|
if (storageChanged.length > 10) console.log(` ... and ${storageChanged.length - 10} more`);
|
|
2361
2364
|
}
|
|
@@ -2555,6 +2558,182 @@ async function envList() {
|
|
|
2555
2558
|
process.exit(0);
|
|
2556
2559
|
}
|
|
2557
2560
|
|
|
2561
|
+
// ---------------------------------------------------------------------------
|
|
2562
|
+
// Backups in file storage: restore, restore list, backup --from
|
|
2563
|
+
// ---------------------------------------------------------------------------
|
|
2564
|
+
// A project with file storage backs itself up into its own storage: its
|
|
2565
|
+
// database and code, every few hours and after each push, kept 30 days (the
|
|
2566
|
+
// engine does it, 1.12+). `restore list` shows them; `restore <id>` puts one
|
|
2567
|
+
// back on the server: the code at once, the database as the app starts
|
|
2568
|
+
// again. What it replaces is saved as a restore point first, so `nukebase
|
|
2569
|
+
// rollback backup <that id>` undoes it. `backup --from <id>` downloads one
|
|
2570
|
+
// instead.
|
|
2571
|
+
|
|
2572
|
+
function ageOf(at) {
|
|
2573
|
+
const h = (Date.now() - at) / 3600000;
|
|
2574
|
+
return h < 1 ? `${Math.max(1, Math.round(h * 60))} minutes ago` : h < 48 ? `${h.toFixed(1)} hours ago` : `${(h / 24).toFixed(1)} days ago`;
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
async function listBackups() {
|
|
2578
|
+
const res = await fetch(`${API}/backups`, {
|
|
2579
|
+
method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: '{}'
|
|
2580
|
+
});
|
|
2581
|
+
if (res.status === 401) authExpired();
|
|
2582
|
+
if (res.status === 404) {
|
|
2583
|
+
console.error('❌ This region does not keep backups in file storage yet.');
|
|
2584
|
+
process.exit(1);
|
|
2585
|
+
}
|
|
2586
|
+
if (!res.ok) {
|
|
2587
|
+
console.error('❌ Could not list backups:', res.status, await res.text());
|
|
2588
|
+
process.exit(1);
|
|
2589
|
+
}
|
|
2590
|
+
return res.json();
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
async function restoreCommand() {
|
|
2594
|
+
await connectAndLogin();
|
|
2595
|
+
const first = args.positional[0];
|
|
2596
|
+
const { storage: has, backups, late } = await listBackups();
|
|
2597
|
+
if (!has) {
|
|
2598
|
+
console.log(`\n${project} keeps its files on the server's disk, so it has no backups in file storage.`);
|
|
2599
|
+
console.log(' Its daily backups are on the server (server/backup); restore points: nukebase rollback list\n');
|
|
2600
|
+
process.exit(0);
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
if (!first || first === 'list' || command === 'backups') {
|
|
2604
|
+
if (!backups.length) {
|
|
2605
|
+
console.log(`\nNo backups of ${project} yet. They are made a few minutes after each start`);
|
|
2606
|
+
console.log('and every 4 hours (engine 1.12+: nukebase update, then push).\n');
|
|
2607
|
+
process.exit(0);
|
|
2608
|
+
}
|
|
2609
|
+
console.log(`\nBackups of ${project} (kept 30 days):\n`);
|
|
2610
|
+
for (const b of backups) {
|
|
2611
|
+
console.log(` ${b.id} ${new Date(b.at).toISOString().replace('T', ' ').slice(0, 19)} ${ageOf(b.at)}`);
|
|
2612
|
+
}
|
|
2613
|
+
if (late) console.log('\n ⚠️ No backup for over 9 hours: check the logs (nukebase logs).');
|
|
2614
|
+
console.log('\n nukebase restore <id> the database and the code as they were then');
|
|
2615
|
+
console.log(' nukebase restore <id> --code-only only the code (keeps live data)');
|
|
2616
|
+
console.log(' nukebase restore <id> --data-only only the database');
|
|
2617
|
+
console.log(' nukebase backup --from <id> download it instead (--out <dir> for everything)\n');
|
|
2618
|
+
process.exit(0);
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
const target = backups.find((b) => b.id === first);
|
|
2622
|
+
if (!target) {
|
|
2623
|
+
console.error(`❌ No such backup: ${first}`);
|
|
2624
|
+
console.error(" Run 'nukebase restore list' to see them.");
|
|
2625
|
+
process.exit(1);
|
|
2626
|
+
}
|
|
2627
|
+
const code = !args.has('data-only');
|
|
2628
|
+
const data = !args.has('code-only');
|
|
2629
|
+
if (!code && !data) {
|
|
2630
|
+
console.error('❌ --code-only and --data-only together leave nothing to restore.');
|
|
2631
|
+
process.exit(1);
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
console.log(`\n⏪ Restoring ${project} to backup ${target.id}`);
|
|
2635
|
+
console.log(` taken ${new Date(target.at).toISOString().replace('T', ' ').slice(0, 19)} (${ageOf(target.at)})`);
|
|
2636
|
+
console.log(` ${[data && 'the database', code && 'the code'].filter(Boolean).join(' and ')}; the project restarts`);
|
|
2637
|
+
console.log(' What is replaced is saved as a restore point first, so it can be undone.');
|
|
2638
|
+
if (data) {
|
|
2639
|
+
console.log('\n' + '─'.repeat(66));
|
|
2640
|
+
console.log(' ⚠️ THIS DISCARDS DATA. Read before continuing.');
|
|
2641
|
+
console.log('─'.repeat(66));
|
|
2642
|
+
console.log(`\n Every write of the last ${ageOf(target.at).replace(' ago', '')} is lost from the live app`);
|
|
2643
|
+
console.log(" (kept only in that restore point). The app's stored files are not");
|
|
2644
|
+
console.log(' changed. If you only need the code back, press Ctrl+C and run:');
|
|
2645
|
+
console.log(` nukebase restore ${target.id} --code-only\n`);
|
|
2646
|
+
const typed = await confirmTyped(project, ` Type the project name (${project}) to confirm: `);
|
|
2647
|
+
if (!typed) { console.log('Aborted.'); process.exit(0); }
|
|
2648
|
+
} else {
|
|
2649
|
+
const ok = await confirm('\nContinue? (y/n) ');
|
|
2650
|
+
if (!ok) { console.log('Aborted.'); process.exit(0); }
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
const res = await fetch(`${API}/backup-restore`, {
|
|
2654
|
+
method: 'POST',
|
|
2655
|
+
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
|
2656
|
+
body: JSON.stringify({ id: target.id, data, code, confirm: project })
|
|
2657
|
+
});
|
|
2658
|
+
if (!res.ok) {
|
|
2659
|
+
console.error('\n❌ Restore failed:', res.status, await res.text());
|
|
2660
|
+
process.exit(1);
|
|
2661
|
+
}
|
|
2662
|
+
const result = await res.json();
|
|
2663
|
+
console.log(`\n✅ Restored backup ${target.id}`);
|
|
2664
|
+
if (code) console.log(` ${result.codeFiles} code file(s) put back`);
|
|
2665
|
+
if (data) console.log(result.database ? ' the database is restored as the app starts (see the logs below)' : ' ⚠️ the database could not be marked for restore');
|
|
2666
|
+
if (result.failures && result.failures.length) {
|
|
2667
|
+
console.log(` ⚠️ ${result.failures.length} file(s) could not be restored:`);
|
|
2668
|
+
result.failures.slice(0, 10).forEach((f) => console.log(' ! ' + f));
|
|
2669
|
+
}
|
|
2670
|
+
if (result.undoId) {
|
|
2671
|
+
console.log(`\n What it replaced is restore point ${result.undoId}`);
|
|
2672
|
+
console.log(` Undo with: nukebase rollback backup ${result.undoId}`);
|
|
2673
|
+
}
|
|
2674
|
+
if (AUTO_YES) { await tailLogsUntilSettled(); process.exit(0); }
|
|
2675
|
+
watchLogs();
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
// backup --from <id>: the backup's database into ./server/data, as a plain
|
|
2679
|
+
// backup does; with --out <dir>, everything it holds (code too) into that
|
|
2680
|
+
// folder, on links the server signs (served by the server).
|
|
2681
|
+
async function backupFrom(id) {
|
|
2682
|
+
const res = await fetch(`${API}/backup-links`, {
|
|
2683
|
+
method: 'POST',
|
|
2684
|
+
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
|
2685
|
+
body: JSON.stringify({ id })
|
|
2686
|
+
});
|
|
2687
|
+
if (res.status === 401) authExpired();
|
|
2688
|
+
if (!res.ok) {
|
|
2689
|
+
console.error(`❌ Could not read backup ${id}:`, res.status, await res.text());
|
|
2690
|
+
if (res.status === 404) console.error(" Run 'nukebase restore list' to see the backups.");
|
|
2691
|
+
process.exit(1);
|
|
2692
|
+
}
|
|
2693
|
+
const snap = await res.json();
|
|
2694
|
+
const out = args.get('out') ? resolve(args.get('out')) : null;
|
|
2695
|
+
const entries = Object.entries(snap.files).filter(([rel]) => out || rel.startsWith('server/data/'));
|
|
2696
|
+
const total = entries.reduce((n, [, f]) => n + (f.size || 0), 0);
|
|
2697
|
+
console.log(`\n💾 Backup ${snap.id} of ${project} (${ageOf(snap.at)})`);
|
|
2698
|
+
console.log(` ${entries.length} file(s), ${(total / 1024 / 1024).toFixed(2)} MB` + (out ? `, into ${out}` : ', its database, into ./server/data'));
|
|
2699
|
+
if (!out && existsSync(join(root, 'server', 'data'))) {
|
|
2700
|
+
console.log(' Your local ./server/data is moved to ./server/backup/before-' + snap.id);
|
|
2701
|
+
}
|
|
2702
|
+
if (!entries.length) { console.log('\n Nothing to download.\n'); process.exit(0); }
|
|
2703
|
+
const ok = await confirm('\nContinue? (y/n) ');
|
|
2704
|
+
if (!ok) { console.log('Aborted.'); process.exit(0); }
|
|
2705
|
+
|
|
2706
|
+
// The database goes to a folder in server/backup (excluded from push) and
|
|
2707
|
+
// replaces ./server/data only once all of it is here.
|
|
2708
|
+
const fsm = require('fs');
|
|
2709
|
+
const staging = out || join(root, 'server', 'backup', 'from-' + snap.id);
|
|
2710
|
+
const target = (rel) => out ? join(out, ...rel.split('/')) : join(staging, ...rel.slice('server/data/'.length).split('/'));
|
|
2711
|
+
if (!out) fsm.rmSync(staging, { recursive: true, force: true });
|
|
2712
|
+
const meter = progressMeter(total);
|
|
2713
|
+
const failed = [];
|
|
2714
|
+
await storage.pool(entries, 4, async ([rel, f]) => {
|
|
2715
|
+
const file = target(rel);
|
|
2716
|
+
if (!resolve(file).startsWith(resolve(staging) + sep)) { failed.push(`${rel}: outside the folder`); return; }
|
|
2717
|
+
try { await storage.fetchTo(rel, f.url, file, () => meter.counter()); } catch (e) { failed.push(`${rel}: ${e.message}`); }
|
|
2718
|
+
});
|
|
2719
|
+
meter.done();
|
|
2720
|
+
if (failed.length) {
|
|
2721
|
+
console.error(`\n❌ ${failed.length} file(s) failed; nothing was replaced:`);
|
|
2722
|
+
failed.slice(0, 10).forEach((f) => console.error(' ' + f));
|
|
2723
|
+
process.exit(1);
|
|
2724
|
+
}
|
|
2725
|
+
if (!out) {
|
|
2726
|
+
const live = join(root, 'server', 'data');
|
|
2727
|
+
const aside = join(root, 'server', 'backup', 'before-' + snap.id);
|
|
2728
|
+
fsm.rmSync(aside, { recursive: true, force: true });
|
|
2729
|
+
if (existsSync(live)) fsm.renameSync(live, aside);
|
|
2730
|
+
fsm.renameSync(staging, live);
|
|
2731
|
+
}
|
|
2732
|
+
console.log(`\n✅ Backup ${snap.id} downloaded` + (out ? ` into ${out}` : ' into ./server/data'));
|
|
2733
|
+
if (!out) console.log(' To send it back up: nukebase push --include backup\n');
|
|
2734
|
+
process.exit(0);
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2558
2737
|
// bin/nukebase.js has already checked the command name.
|
|
2559
2738
|
const run = (fn) => fn().catch(err => { console.error('❌', err.message); process.exit(1); });
|
|
2560
2739
|
if (command === 'push' && args.positional[0] === 'env') {
|
|
@@ -2582,6 +2761,8 @@ if (command === 'push' && args.positional[0] === 'env') {
|
|
|
2582
2761
|
run(rollback);
|
|
2583
2762
|
} else if (command === 'backup') {
|
|
2584
2763
|
run(backup);
|
|
2764
|
+
} else if (command === 'restore' || command === 'backups') {
|
|
2765
|
+
run(restoreCommand);
|
|
2585
2766
|
} else if (command === 'pause') {
|
|
2586
2767
|
run(pauseProject);
|
|
2587
2768
|
} else if (command === 'restart' || command === 'resume') {
|
package/lib/storage.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
// Object storage for `nukebase backup --storage` and `nukebase push --include
|
|
2
2
|
// backup`. A project with object storage keeps its files (what the app stores
|
|
3
|
-
// with setFile) in a bucket instead of server/files.
|
|
4
|
-
// this machine
|
|
5
|
-
// one hour
|
|
6
|
-
//
|
|
3
|
+
// with setFile) in a bucket instead of server/files. Uploads go straight from
|
|
4
|
+
// this machine to the bucket through links the server signs for one file and
|
|
5
|
+
// one hour. Downloads come through the server, on links it signs the same way:
|
|
6
|
+
// the storage lets only the project's own key read what the app wrote, and
|
|
7
|
+
// that key works only from the server. Locally they
|
|
8
|
+
// live where a project without storage keeps them: storage/<path> in projects
|
|
9
|
+
// made from the engine 1.12 template (marked by storage/$nukebase),
|
|
10
|
+
// server/files/<path> before.
|
|
7
11
|
//
|
|
8
12
|
// Every transfer is IPv4: the storage provider stalls parallel uploads over
|
|
9
13
|
// IPv6 (measured from the server: 13 of 18 stalled, against none over IPv4).
|
|
@@ -14,6 +18,12 @@ const { join, dirname, relative, sep } = require('path');
|
|
|
14
18
|
const { pipeline } = require('stream/promises');
|
|
15
19
|
|
|
16
20
|
const FILES_DIR = 'server/files';
|
|
21
|
+
// The project's file folder, relative to root: the engine and the server pick
|
|
22
|
+
// the same way.
|
|
23
|
+
function filesDir(root) {
|
|
24
|
+
try { if (fs.statSync(join(root, 'storage', '$nukebase')).isFile()) return 'storage'; } catch {}
|
|
25
|
+
return FILES_DIR;
|
|
26
|
+
}
|
|
17
27
|
const IDLE_MS = 30000;
|
|
18
28
|
const TRIES = 4;
|
|
19
29
|
const PARALLEL = 6;
|
|
@@ -90,43 +100,55 @@ async function links(API, authHeaders, keys) {
|
|
|
90
100
|
return out;
|
|
91
101
|
}
|
|
92
102
|
|
|
93
|
-
// Which bucket files differ from the local copy
|
|
94
|
-
//
|
|
95
|
-
// mtime to the bucket's
|
|
103
|
+
// Which bucket files differ from the local copy in the file folder: by size,
|
|
104
|
+
// or by being newer in the bucket than here by more than a second. A download
|
|
105
|
+
// sets the local mtime to the bucket's, and an upload sets it to when the
|
|
106
|
+
// bucket got it, so neither is fetched again.
|
|
96
107
|
function changedRemote(dest, files) {
|
|
108
|
+
const dir = filesDir(dest);
|
|
97
109
|
return Object.keys(files).filter((key) => {
|
|
98
|
-
const local = join(dest,
|
|
110
|
+
const local = join(dest, dir, ...key.split('/'));
|
|
99
111
|
let st;
|
|
100
112
|
try { st = fs.statSync(local); } catch { return true; }
|
|
101
113
|
const [size, mtime] = files[key];
|
|
102
|
-
return st.size !== size || Math.
|
|
114
|
+
return st.size !== size || mtime > Math.floor(st.mtimeMs / 1000) + 1;
|
|
103
115
|
});
|
|
104
116
|
}
|
|
105
117
|
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
118
|
+
// One URL into `target`, through a .part renamed when complete, so an
|
|
119
|
+
// interrupted run leaves no torn file.
|
|
120
|
+
async function fetchTo(what, url, target, counter) {
|
|
121
|
+
fs.mkdirSync(dirname(target), { recursive: true });
|
|
122
|
+
const part = target + '.part';
|
|
123
|
+
try {
|
|
124
|
+
await retry(what, async () => {
|
|
125
|
+
const r = await request('GET', url);
|
|
126
|
+
if (r.status !== 200) { r.stream.resume(); throw new Error(`HTTP ${r.status}`); }
|
|
127
|
+
await pipeline(r.stream, counter(), fs.createWriteStream(part));
|
|
128
|
+
});
|
|
129
|
+
fs.renameSync(part, target);
|
|
130
|
+
} catch (e) {
|
|
131
|
+
try { fs.unlinkSync(part); } catch {}
|
|
132
|
+
throw e;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Download `keys` into the file folder under dest. counter() is a
|
|
137
|
+
// pass-through that counts bytes for the progress line.
|
|
109
138
|
async function download(API, authHeaders, dest, files, keys, counter) {
|
|
110
139
|
const failed = [];
|
|
140
|
+
const dir = filesDir(dest);
|
|
111
141
|
for (let i = 0; i < keys.length; i += 500) {
|
|
112
142
|
const batch = keys.slice(i, i + 500);
|
|
113
143
|
const urls = await links(API, authHeaders, batch); // an hour each: fetched per batch
|
|
114
144
|
await pool(batch, PARALLEL, async (key) => {
|
|
115
|
-
const target = join(dest,
|
|
116
|
-
if (!target.startsWith(join(dest,
|
|
117
|
-
fs.mkdirSync(dirname(target), { recursive: true });
|
|
118
|
-
const part = target + '.part';
|
|
145
|
+
const target = join(dest, dir, ...key.split('/'));
|
|
146
|
+
if (!target.startsWith(join(dest, dir) + sep)) return;
|
|
119
147
|
try {
|
|
120
|
-
await
|
|
121
|
-
const r = await request('GET', urls[key]);
|
|
122
|
-
if (r.status !== 200) { r.stream.resume(); throw new Error(`HTTP ${r.status}`); }
|
|
123
|
-
await pipeline(r.stream, counter(), fs.createWriteStream(part));
|
|
124
|
-
});
|
|
125
|
-
fs.renameSync(part, target);
|
|
148
|
+
await fetchTo(key, urls[key], target, counter);
|
|
126
149
|
const mtime = files[key][1];
|
|
127
150
|
if (mtime) fs.utimesSync(target, mtime, mtime);
|
|
128
151
|
} catch (e) {
|
|
129
|
-
try { fs.unlinkSync(part); } catch {}
|
|
130
152
|
failed.push(e.message);
|
|
131
153
|
}
|
|
132
154
|
});
|
|
@@ -134,11 +156,11 @@ async function download(API, authHeaders, dest, files, keys, counter) {
|
|
|
134
156
|
return failed;
|
|
135
157
|
}
|
|
136
158
|
|
|
137
|
-
// The local files a restore would send: everything
|
|
138
|
-
//
|
|
159
|
+
// The local files a restore would send: everything in the file folder except
|
|
160
|
+
// names the engine reserves ("$...", its index and spool) and partial
|
|
139
161
|
// downloads, as { key: [size, mtime] }.
|
|
140
162
|
function localFiles(root) {
|
|
141
|
-
const base = join(root,
|
|
163
|
+
const base = join(root, filesDir(root));
|
|
142
164
|
const out = {};
|
|
143
165
|
const walk = (dir) => {
|
|
144
166
|
let ents;
|
|
@@ -177,11 +199,16 @@ async function postJson(API, authHeaders, path, body) {
|
|
|
177
199
|
return res.json();
|
|
178
200
|
}
|
|
179
201
|
|
|
180
|
-
// Send each key from
|
|
202
|
+
// Send each key from the file folder to the bucket. Returns the failures.
|
|
203
|
+
// Each file sent then takes the bucket's date for it as its mtime, the way a
|
|
204
|
+
// download does, so neither `backup --storage` nor the next push moves it
|
|
205
|
+
// again.
|
|
181
206
|
async function upload(API, authHeaders, root, local, keys, onBytes) {
|
|
182
207
|
const failed = [];
|
|
208
|
+
const dir = filesDir(root);
|
|
209
|
+
const sent = [];
|
|
183
210
|
await pool(keys, 4, async (key) => {
|
|
184
|
-
const file = join(root,
|
|
211
|
+
const file = join(root, dir, ...key.split('/'));
|
|
185
212
|
const size = local[key][0];
|
|
186
213
|
try {
|
|
187
214
|
const plan = await postJson(API, authHeaders, '/storage-upload', { key, size });
|
|
@@ -193,6 +220,7 @@ async function upload(API, authHeaders, root, local, keys, onBytes) {
|
|
|
193
220
|
if (r.status !== 200) throw new Error(`HTTP ${r.status}`);
|
|
194
221
|
});
|
|
195
222
|
onBytes(size);
|
|
223
|
+
sent.push(key);
|
|
196
224
|
return;
|
|
197
225
|
}
|
|
198
226
|
const fd = fs.openSync(file, 'r');
|
|
@@ -211,6 +239,7 @@ async function upload(API, authHeaders, root, local, keys, onBytes) {
|
|
|
211
239
|
onBytes(len);
|
|
212
240
|
});
|
|
213
241
|
await postJson(API, authHeaders, '/storage-upload-complete', { key, uploadId: plan.uploadId, etags });
|
|
242
|
+
sent.push(key);
|
|
214
243
|
} catch (e) {
|
|
215
244
|
await postJson(API, authHeaders, '/storage-upload-complete', { key, uploadId: plan.uploadId, abort: true }).catch(() => {});
|
|
216
245
|
throw e;
|
|
@@ -221,7 +250,16 @@ async function upload(API, authHeaders, root, local, keys, onBytes) {
|
|
|
221
250
|
failed.push(`${key}: ${e.message}`);
|
|
222
251
|
}
|
|
223
252
|
});
|
|
253
|
+
if (sent.length) {
|
|
254
|
+
try {
|
|
255
|
+
const after = await manifest(API, authHeaders);
|
|
256
|
+
for (const key of sent) {
|
|
257
|
+
const r = after && after.files[key];
|
|
258
|
+
if (r && r[1]) fs.utimesSync(join(root, dir, ...key.split('/')), r[1], r[1]);
|
|
259
|
+
}
|
|
260
|
+
} catch {} // only the next run's comparison suffers
|
|
261
|
+
}
|
|
224
262
|
return failed;
|
|
225
263
|
}
|
|
226
264
|
|
|
227
|
-
module.exports = { FILES_DIR, manifest, changedRemote, download, localFiles, changedLocal, upload };
|
|
265
|
+
module.exports = { FILES_DIR, filesDir, manifest, changedRemote, download, localFiles, changedLocal, upload, fetchTo, pool };
|