/
rsyncbackup.js
executable file
·775 lines (726 loc) · 23.2 KB
/
rsyncbackup.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
#!/usr/bin/env /usr/local/bin/node
// jshint esversion: 8
// <xbar.title>RSync Backup Bitbar Plugin</xbar.title>
// <xbar.version>v1.0</xbar.version>
// <xbar.author>Gregory S. Read</xbar.author>
// <xbar.author.github>readgs</xbar.author.github>
// <xbar.desc>Schedule and monitor rsync backups via BitBar</xbar.desc>
// <xbar.dependencies>node npm/path npm/untildify npm/yargs npm/bitbar npm/mkdirp npm/jsonc npm/lockfile npm/execa npm/moment</xbar.dependencies>
// <xbar.abouturl></xbar.abouturl>
/**
* Quick Install
* -Make sure Node is installed (this also installs npm)
* -Copy script to your bitbar plugins folder.
* NOTE: Be sure to name it something like rsyncbackup.5s.js so that it will refresh
* backup status (and trigger backups) often enough to be useful.
* -Open up a terminal and navigate to plugin folder
* -Install required npm packages...
* NOTE: This will only install the required dependencies into the plugins folder.
* Specifically, into a subfolder called node_modules.
*
* From Terminal: npm install --no-package-lock path untildify yargs bitbar mkdirp jsonc lockfile execa moment
* -Refresh your plugins
* -Select "Configure" option to open config file up in your default editor
* -Make whatever changes necessary for your backup needs.
* NOTE: It is recommended that you don't set a schedule for your backup until
* you execute a "dry run" to make sure you like what it's backing up.
* -Select "Dry Run"
* -If you like the results, select Back Up Now
* -If you still like the results, update the schedule in the configuration.
* -Enjoy.
*/
const path = require('path');
const untildify = require('untildify');
const yargs = require('yargs');
const bitbar = require('bitbar');
const fs = require('fs');
const mkdirp = require('mkdirp');
const jsonc = require('jsonc');
const lockFile = require('lockfile');
const execa = require('execa');
const moment = require('moment');
/**
* Enumeration of valid backup statuses
*/
const BackupStatus = {
/**
* No status available on backup (i.e. it hasn't run ever)
*/
None: 'None',
/**
* Last backup was successful
*/
Succeeded: 'Succeeded',
/**
* Last backup failed
*/
Failed: 'Failed',
/**
* Backup currently in progress
*/
Running: 'Running'
}
/**
* Values that don't change :)
*/
const constants = {
/**
* Main folder where we store backup related stuff (like lock files, logs, etc)
*/
WORKINGFOLDER: '~/.backup',
/**
* Name of lockfile we'll be using
*/
LOCKFILE: 'backup.lock',
/**
* Name of file that will indicate the start of a backup
*/
STARTFILE: 'start.flag',
/**
* Name of file that will indicate an error status
*/
ERRORFILE: 'error.flag',
/**
* Name of file that will indicate a success status
*/
SUCCESSFILE: 'success.flag',
/**
* Config file for user-configurable settings (source, destination, etc.)
*/
CONFIGFILE: 'config.jsonc',
/**
* Name of excludes file for rsync
*/
EXCLUDESFILE: 'excludes.txt',
/**
* Name of file where errors are logged
*/
ERRORLOGFILE: 'errorlog.txt',
/**
* Name of file where rsync output is logged
*/
LOGFILE: 'log.txt',
/**
* Default content for the rsync excludes file
*/
DEFAULTEXCLUDES:
`.backup/
.Trash/
.DS_Store`,
/**
* Default configuration content to use if no config file exists.
*/
DEFAULTCONFIG:
`/**
* Configuration settings for backup
*/
{
/**
* Path to the rsync program. The version included with macOS is ancient, so you
* may want to install a newer version via homebrew. In this case, you would want
* to likely change this path to /usr/local/bin/rsync
*/
"rsyncPath": "/usr/bin/rsync",
/**
* How often a backup should be executed. Can expressed as a number, followed
* by the time unit (seconds, hours or days). For example...
* 10s Every 10 seconds
* 5m Every 5 minutes
* 1h Every 1 hour
* 2d Every 2 days
* manual Backup is only done on-demand
*/
"frequency": "1h",
/**
* Source to pass along to rsync for syncing data from
*/
// ***UNCOMMENT LINE BELOW TO SPECIFY A SOURCE***
//"source": "~",
/**
* Destination to pass along to rsync for syncing data to
*/
// ***UNCOMMENT LINE BELOW TO SPECIFY A DESTINATION***
//"destination": "/tmp/rsyncbackup/",
/**
* Additional arguments to pass to rsync. Default arguments are reasonable
* for a standard archival copy of the source to the destination (excluding
* permissions, ACLs, etc.). See documentation for 'rsync' for more info
* on arguments.
*
* NOTE: The rsyncbackup.js script already passed along the --exclude-from
* argument if the 'excludes' configuration is specified above.
*/
"rsyncAdditionalArguments": [
"--archive",
"--no-perms",
"--no-acls",
"--stats",
"--delete",
"--delete-excluded"
]
}`
}
/**
* Variables that we just want accessible from anywhere
*/
const globals = {
/**
* Path of main working folder for rsyncbackup script
*/
workingFolder: untildify(constants.WORKINGFOLDER),
/**
* Configuration settings for our script go here.
*/
configFile: getBackupPath(constants.CONFIGFILE),
/**
* Excluded files and folders for rsync will be in this file.
*/
excludesFile: getBackupPath(constants.EXCLUDESFILE),
/**
* Used for ensuring only one instance of the backup is running.
*/
lockFile: getBackupPath(constants.LOCKFILE),
/**
* Created when a backup starts (mostly to figure out when our backup started)
*/
startFile: getBackupPath(constants.STARTFILE),
/**
* If an error occured, this file will exist
*/
errorFile: getBackupPath(constants.ERRORFILE),
/**
* If backup succeeded, this file will exist
*/
successFile: getBackupPath(constants.SUCCESSFILE),
/**
* Logs from rsync go here
*/
logFile: getBackupPath(constants.LOGFILE),
/**
* Any errors that rsync output go here
*/
errorLogFile: getBackupPath(constants.ERRORLOGFILE),
/**
* Arguments retrieved from commandline
*/
args: {},
/**
* Configuration loaded from config.jsonc. If not loaded or validation error
* with configuration, then this value will be null.
*/
configuration: null,
/**
* If configuration is null, this value is set to a message indicating why
* the configuration was not loaded.
*/
configurationError: '',
/**
* Status of the last completed backup.
*/
backupStatus: BackupStatus.None,
/**
* Last date/time (formatted) of the last backup (regardless of outcome).
* If null, that means we never started a backup before.
*/
backupDate: null,
/**
* Number of milliseconds the backup has been running, or ran (depending on status)
*/
backupDuration: 0,
/**
* Date and time of the next scheduled backup
*/
nextScheduledBackup: null,
/**
* Arguments to pass to the rsync command
*/
rsyncArgs: []
}
/**
* Bitbar items that we show as the main item (shows on menubar) for a given bitBar view
*/
const bitbarHeaders = {
/**
* Show when backup is running
*/
backupRunning: {
text: ':running:'
},
/**
* Show when last backup ended in error
*/
backupError: {
text: ':rage:'
},
/**
* Show if last backup ended in success
*/
backupSuccess: {
text: ':smile:'
},
/**
* Show if there is no status for the backup (i.e. it hasn't run yet) */
backupNoStatus: {
text: ':expressionless:'
}
}
const bitbarItems = {
configurationError: {
text: 'Error in config file: '
}
}
const bitbarActions = {
/**
* Shows a "Configure..." option and brings up the config file in a text editor when selected.
*/
configure: {
text: 'Configure...',
bash: '/usr/bin/open',
param1: '-t',
param2: globals.configFile,
terminal: false
},
/**
* Shows a "View Logs..." option and brings up the last log in a text when selected.
*/
viewLog: {
text: 'View Log...',
bash: '/usr/bin/open',
param1: '-t',
param2: globals.logFile,
terminal: false
},
/**
* Shows a "View Logs..." option and brings up the last log in a text when selected.
*/
viewErrorLog: {
text: 'View Error Log...',
bash: '/usr/bin/open',
param1: '-t',
param2: globals.errorLogFile,
terminal: false
},
/**
* Shows a "Start backup" option and manually starts backup process when selected.
*/
startBackup: {
text: 'Back Up Now',
//bash: See init()
param1: '--start',
terminal: false
},
/**
* Shows a "Stop backup" option and manually stops backup process if running, when selected.
*/
stopBackup: {
text: 'Stop Backup',
//bash: See init(),
param1: '--stop',
terminal: false
},
/**
* Starts a backup, but not actually doing the copy. Will run in a terminal as well
* so user can observe what will be copied, etc.
*/
dryRun: {
text: 'Dry Run...',
//bash: See init()
param1: '--start',
param2: '--dry-run',
terminal: true
}
}
/**
* Do any initalization required for the script at startup
*/
async function init() {
// Pull in commandline arguments
globals.args = yargs
.boolean(['start', 'stop'])
.default('start', false)
.default('stop', false)
.describe('start', 'Starts the backup')
.describe('stop', 'Stops the backup')
.describe('dry-run', 'If starting backup, runs it in --dry-run rsync mode with a terminal')
.argv;
// Create missing items as needed
await createFolderIfNeeded(globals.workingFolder);
createFileIfNeeded(globals.configFile, constants.DEFAULTCONFIG);
createFileIfNeeded(globals.excludesFile, constants.DEFAULTEXCLUDES);
// Load in configuration
loadConfiguration();
// Initilize the rsync arguments based on configuration
if(globals.configuration) {
// Use the source, Luke...
let source = untildify(globals.configuration.source);
// If destination includes a colon, we'll treat it as a ssh server path.
// Otherwise, make sure the tilde gets translated.
let destination = globals.configuration.destination.includes(':') ?
globals.configuration.destination
: untildify(globals.configuration.destination);
// Merge additional arguments with our default arguments
globals.rsyncArgs = [
...globals.configuration.rsyncAdditionalArguments,
`--exclude-from=${globals.excludesFile}`,
source,
destination
];
// Enable dry-run mode if requested
if(globals.args.dryRun) {
globals.rsyncArgs.push('--dry-run');
}
}
// Setup any bitbar items with additional info after init
bitbarActions.startBackup.bash = __filename;
bitbarActions.stopBackup.bash = __filename;
bitbarActions.dryRun.bash = __filename;
bitbarItems.configurationError.text += globals.configurationError;
// Get and store our lastest backup status
getBackupStatus();
// Figure out when our next backup should occur, if applicable
getNextScheduledBackup();
}
/**
* Starts the backup process
*/
async function startBackup() {
// Ensure we can get a lock on the lockfile before we proceed
lockFile.lockSync(globals.lockFile, {});
// Indicate that we've started a backup
touch(globals.startFile);
// Remove the error and success flags since we are just starting
untouch(globals.errorFile);
untouch(globals.successFile);
// Run rsync
let exitCode = await executeRsync();
if(exitCode != 0) {
// Failed!
touch(globals.errorFile);
}
else {
// We succeeded!!
touch(globals.successFile);
}
// We're all done, unlock the lock file
lockFile.unlockSync(globals.lockFile);
}
/**
* Stops the backup process, if it's running
*/
function stopBackup() {
console.log('STOP BACKUP!');
}
/**
* Returns BitBar-friendly status of the backup. This is the default when no
* arguments are passed to the script.
*/
function defaultOutput() {
const formattedFileDate = !globals.backupDate ? 'never' : formatDate(globals.backupDate);
const formattedNextBackupDate = !globals.nextScheduledBackup ? 'never' : formatDate(globals.nextScheduledBackup);
const backupDurationInMinutes = (globals.backupDuration / 60000).toFixed(2);
// If lockfile exists, we're running
if(globals.backupStatus == BackupStatus.Running) {
bitbar([
bitbarHeaders.backupRunning,
bitbar.separator,
{ text: `Backup running...` },
{ text: `Started at ${formattedFileDate}` },
{ text: `Running for ${backupDurationInMinutes}` },
bitbar.separator,
bitbarActions.stopBackup
]);
}
// If error file exists, something is wrong
else if(globals.backupStatus == BackupStatus.Failed) {
bitbar([
bitbarHeaders.backupError,
bitbar.separator,
{ text: `Backup failed!` },
{ text: `Started at ${formattedFileDate}` },
{ text: `Ran for ${backupDurationInMinutes} minutes` },
{ text: `Next backup at ${formattedNextBackupDate}` },
bitbar.separator,
bitbarActions.configure,
bitbarActions.viewLog,
bitbarActions.viewErrorLog,
]);
}
// If success file exists, the last backup succeeded
else if (globals.backupStatus == BackupStatus.Succeeded) {
bitbar([
bitbarHeaders.backupSuccess,
bitbar.separator,
{ text: `Backup succeeded!` },
{ text: `Started at ${formattedFileDate}` },
{ text: `Ran for ${backupDurationInMinutes} minutes` },
{ text: `Next backup at ${formattedNextBackupDate}` },
bitbar.separator,
bitbarActions.configure,
bitbarActions.viewLog
]);
}
// Otherwise, we don't have any status (i.e. backup has never been run)
else {
bitbar([
bitbarHeaders.backupNoStatus,
bitbar.separator,
{ text: `Next backup at ${formattedNextBackupDate} minutes` },
bitbar.separator,
bitbarActions.configure
]);
}
// Always show run/configuration error if we're in any status other that running
if (globals.backupStatus != BackupStatus.Running) {
if(!globals.configuration) {
bitbar([bitbarItems.configurationError]);
}
else {
bitbar([bitbarActions.dryRun]);
bitbar([bitbarActions.startBackup]);
}
}
}
/**
* Run the actual rsync program with the configured arguments
*/
async function executeRsync() {
const subProcess = execa(globals.configuration.rsyncPath, globals.rsyncArgs, {});
// If not dry run, send output to log files. Otherwise output will just
// go to terminal.
if(!globals.args.dryRun) {
// Create our error and output logs
const errStream = fs.createWriteStream(globals.errorLogFile);
const outStream = fs.createWriteStream(globals.logFile);
// We don't want any output from rsync (spit out to our respective files)
subProcess.stderr.pipe(errStream);
subProcess.stdout.pipe(outStream);
} else {
// We're interactive dry run, send output to parent process
subProcess.stderr.pipe(process.stderr);
subProcess.stdout.pipe(process.stdout);
}
return (await subProcess).exitCode;
}
/**
* Create the folder if it doesn't exist
* @param {*} folderPath
*/
async function createFolderIfNeeded(folderPath) {
if(!fs.existsSync(folderPath)) {
await mkdirp(folderPath);
}
}
/**
* Creates the specified file with default content, if it doesn't exist.
* @param {string} filePath
* @param {string} defaultContent
*/
function createFileIfNeeded(filePath, defaultContent) {
if(!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, defaultContent);
}
}
/**
* Sets the date/time of when the next backup should take place
* based on the configuration and status.
*/
function getNextScheduledBackup() {
let now = new Date();
let frequencyInMinutes = getFrequencyInMinutes(globals.configuration.frequency);
// If we haven't done a backup before, schedule it for now!
if(!globals.backupDate) {
globals.nextScheduledBackup = now;
}
// If there's no frequency, assume it's manual
else if(!frequencyInMinutes) {
globals.nextScheduledBackup = null;
}
else {
// Get the date/time for the next scheduled backup
globals.nextScheduledBackup = moment(globals.backupDate).add(frequencyInMinutes, 'minutes').toDate();
// If next scheduled backup is in the past, set it to happen now
if(globals.nextScheduledBackup.getTime() < now.getTime()) {
globals.nextScheduledBackup = now;
}
}
}
/**
* Starts an instance of the backup if we are scheduled to do so
* @returns {boolean} True if backup started, otherwise false.
*/
function startBackupIfScheduled() {
let now = new Date();
// If the time has come!
if(globals.nextScheduledBackup && globals.nextScheduledBackup.getTime() <= now) {
// Execute ourselves with the "--start" parameter so we start the backup
// but don't wait for the process to finish.
const subProcess = execa(__filename, ['--start'], {
detached: true,
cleanup: false
});
subProcess.unref();
return true;
}
return false;
}
/**
* Retrieve and store information related to the last successful, failed or in-progress backup
*/
function getBackupStatus() {
let now = new Date();
// Only get modify date for startFile if the file exists
if(fs.existsSync(globals.startFile)) {
globals.backupDate = getFileDate(globals.startFile);
}
// If no start file exists, then should assume we never did a backup
else {
return;
}
// If a lockfile exists, then our backup is currently running
if(fs.existsSync(globals.lockFile)) {
// Duration is start of backup to now
globals.backupStatus = BackupStatus.Running;
globals.backupDuration = moment(now).diff(globals.backupDate);
}
// Else if error file exists, our last backup failed
else if(fs.existsSync(globals.errorFile)) {
// Duration is start of backup to date/time of error file
let errorFileDate = getFileDate(globals.errorFile);
globals.backupStatus = BackupStatus.Failed;
globals.backupDuration = moment(errorFileDate).diff(globals.backupDate);
}
// Else if success file exists, our last backup succeeded
else if(fs.existsSync(globals.successFile)) {
// Duration is start of backup to date/time of error file
let successFileDate = getFileDate(globals.successFile);
globals.backupStatus = BackupStatus.Succeeded;
globals.backupDuration = moment(successFileDate).diff(globals.backupDate);
}
// Else, we're in an unknown state (don't set any status)
}
/**
* Loads and verifies
*/
function loadConfiguration() {
let content = fs.readFileSync(globals.configFile).toString();
let configuration = {};
try {
configuration = jsonc.parse(content);
} catch(e) {
globals.configurationError = 'Error parsing configuration file';
return;
}
// By default, no error
let error = null;
// If rsync path is bad
if(!configuration.rsyncPath || !fs.existsSync(configuration.rsyncPath)) {
error = 'rsyncPath is invalid or file does not exist';
}
// If frequency isn't specified, or it's not a valid frequency
else if(!configuration.frequency || !(configuration.frequency === 'manual' || getFrequencyInMinutes(configuration.frequency))) {
error = 'frequency is invalid';
}
// Must have a valid source
else if(!configuration.source) {
error = 'source must be set';
}
// Must have a valid destination
else if(!configuration.destination) {
error = 'destination must be set';
}
// Check whether our rsync arguments is a legit string array
else if(!configuration.rsyncAdditionalArguments ||
!Array.isArray(configuration.rsyncAdditionalArguments) ||
!configuration.rsyncAdditionalArguments.reduce((prev, curr) => typeof prev === 'string' || typeof curr === 'string')) {
error = 'rsyncAdditionalArguments is invalid'
}
if(error) {
// Just save the error that we got
globals.configurationError = error;
}
else {
// Config is good, let's save it as our actual config
globals.configuration = configuration;
}
}
/**
* Parses a frequency string into number of minutes
* @param {string} frequency
*/
function getFrequencyInMinutes(frequency) {
let values = frequency.match(/^\s*(\d*)\s*([sSmMhHdD])\s*$/);
// If frequency was in a valid format
if(values) {
let unit = values[2].toLowerCase();
let value = values[1];
switch(unit) {
case 's':
return value / 60;
case 'm':
return value;
case 'h':
return value * 60;
case 'd':
return value * 1440;
}
}
return null;
}
/**
* Gets the last time the specified file was modified.
* @param {string} filePath
*/
function getFileDate(filePath) {
const { mtime } = fs.statSync(filePath);
return mtime;
}
/**
* Formats specified to display as "M/D/YY h:mm AM/PM"
* @param {Date} fileDate
*/
function formatDate(fileDate) {
return moment(fileDate).format('M/D/YY h:mm A');
}
/**
* Returns a full path and file using the configured backup path ()
* @param {string} fileName
*/
function getBackupPath(fileName) {
return untildify(path.join(constants.WORKINGFOLDER, fileName));
}
/**
* Creates an empty file and/or updates the modified date.
* @param {string} filePath
*/
function touch(filePath) {
fs.closeSync(fs.openSync(filePath, 'w'));
}
/**
* Removes the file if it exists.
* @param {string} filePath
*/
function untouch(filePath) {
if(fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
// START HERE
(async () => {
await init();
if(globals.args.start) {
await startBackup();
}
else if(globals.args.stop) {
stopBackup();
}
else {
let isBackupStarted = startBackupIfScheduled();
defaultOutput();
if(isBackupStarted) {
// If we started a backup, we want to kill our process. Otherwise
// it will wait for the other backup process that we started, which
// we don't want (so status gets reported back right away).
process.exit(0);
}
}
})();