UNPKG

16.6 kBJavaScriptView Raw
1// Copyright © 2017, 2021 IBM Corp. All rights reserved.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14'use strict';
15
16/**
17 * CouchBackup module.
18 * @module couchbackup
19 * @see module:couchbackup
20 */
21
22const backupFull = require('./includes/backup.js');
23const defaults = require('./includes/config.js').apiDefaults;
24const error = require('./includes/error.js');
25const request = require('./includes/request.js');
26const restoreInternal = require('./includes/restore.js');
27const backupShallow = require('./includes/shallowbackup.js');
28const debug = require('debug')('couchbackup:app');
29const events = require('events');
30const fs = require('fs');
31const URL = require('url').URL;
32
33/**
34 * Test for a positive, safe integer.
35 *
36 * @param {object} x - Object under test.
37 */
38function isSafePositiveInteger(x) {
39 // https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER
40 const MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER || 9007199254740991;
41 // Is it a number?
42 return Object.prototype.toString.call(x) === '[object Number]' &&
43 // Is it an integer?
44 x % 1 === 0 &&
45 // Is it positive?
46 x > 0 &&
47 // Is it less than the maximum safe integer?
48 x <= MAX_SAFE_INTEGER;
49}
50
51/**
52 * Validate arguments.
53 *
54 * @param {object} url - URL of database.
55 * @param {object} opts - Options.
56 * @param {function} cb - Callback to be called on error.
57 */
58function validateArgs(url, opts, cb) {
59 if (typeof url !== 'string') {
60 cb(new error.BackupError('InvalidOption', 'Invalid URL, must be type string'), null);
61 return;
62 }
63 if (opts && typeof opts.bufferSize !== 'undefined' && !isSafePositiveInteger(opts.bufferSize)) {
64 cb(new error.BackupError('InvalidOption', 'Invalid buffer size option, must be a positive integer in the range (0, MAX_SAFE_INTEGER]'), null);
65 return;
66 }
67 if (opts && typeof opts.iamApiKey !== 'undefined' && typeof opts.iamApiKey !== 'string') {
68 cb(new error.BackupError('InvalidOption', 'Invalid iamApiKey option, must be type string'), null);
69 return;
70 }
71 if (opts && typeof opts.log !== 'undefined' && typeof opts.log !== 'string') {
72 cb(new error.BackupError('InvalidOption', 'Invalid log option, must be type string'), null);
73 return;
74 }
75 if (opts && typeof opts.mode !== 'undefined' && ['full', 'shallow'].indexOf(opts.mode) === -1) {
76 cb(new error.BackupError('InvalidOption', 'Invalid mode option, must be either "full" or "shallow"'), null);
77 return;
78 }
79 if (opts && typeof opts.output !== 'undefined' && typeof opts.output !== 'string') {
80 cb(new error.BackupError('InvalidOption', 'Invalid output option, must be type string'), null);
81 return;
82 }
83 if (opts && typeof opts.parallelism !== 'undefined' && !isSafePositiveInteger(opts.parallelism)) {
84 cb(new error.BackupError('InvalidOption', 'Invalid parallelism option, must be a positive integer in the range (0, MAX_SAFE_INTEGER]'), null);
85 return;
86 }
87 if (opts && typeof opts.requestTimeout !== 'undefined' && !isSafePositiveInteger(opts.requestTimeout)) {
88 cb(new error.BackupError('InvalidOption', 'Invalid request timeout option, must be a positive integer in the range (0, MAX_SAFE_INTEGER]'), null);
89 return;
90 }
91 if (opts && typeof opts.resume !== 'undefined' && typeof opts.resume !== 'boolean') {
92 cb(new error.BackupError('InvalidOption', 'Invalid resume option, must be type boolean'), null);
93 return;
94 }
95
96 // Validate URL and ensure no auth if using key
97 try {
98 const urlObject = new URL(url);
99 // We require a protocol, host and path (for db), fail if any is missing.
100 if (urlObject.protocol !== 'https:' && urlObject.protocol !== 'http:') {
101 cb(new error.BackupError('InvalidOption', 'Invalid URL protocol.'));
102 return;
103 }
104 if (!urlObject.host) {
105 cb(new error.BackupError('InvalidOption', 'Invalid URL host.'));
106 return;
107 }
108 if (!urlObject.pathname || urlObject.pathname === '/') {
109 cb(new error.BackupError('InvalidOption', 'Invalid URL, missing path element (no database).'));
110 return;
111 }
112 if (opts && opts.iamApiKey && (urlObject.username || urlObject.password)) {
113 cb(new error.BackupError('InvalidOption', 'URL user information must not be supplied when using IAM API key.'));
114 return;
115 }
116 } catch (err) {
117 cb(err);
118 return;
119 }
120
121 // Perform validation of invalid options for shallow mode and WARN
122 // We don't error for backwards compatibility with scripts that may have been
123 // written passing complete sets of options through
124 if (opts && opts.mode === 'shallow') {
125 if (opts.log || opts.resume) {
126 console.warn('WARNING: the options "log" and "resume" are invalid when using shallow mode.');
127 }
128 if (opts.parallelism) {
129 console.warn('WARNING: the option "parallelism" has no effect when using shallow mode.');
130 }
131 }
132
133 if (opts && opts.resume) {
134 if (!opts.log) {
135 // This is the second place we check for the presence of the log option in conjunction with resume
136 // It has to be here for the API case
137 cb(new error.BackupError('NoLogFileName', 'To resume a backup, a log file must be specified'), null);
138 return;
139 } else if (!fs.existsSync(opts.log)) {
140 cb(new error.BackupError('LogDoesNotExist', 'To resume a backup, the log file must exist'), null);
141 return;
142 }
143 }
144 return true;
145}
146
147function addEventListener(indicator, emitter, event, f) {
148 emitter.on(event, function(...args) {
149 if (!indicator.errored) {
150 if (event === 'error') indicator.errored = true;
151 f(...args);
152 }
153 });
154}
155
156/*
157 Check the backup database exists and that the credentials used have
158 visibility. Callback with a fatal error if there is a problem with the DB.
159 @param {string} db - database object
160 @param {function(err)} callback - error is undefined if DB exists
161*/
162function proceedIfBackupDbValid(db, callback) {
163 db.service.headDatabase({ db: db.db }).then(() => callback()).catch(err => {
164 err = error.convertResponseError(err, err => parseIfDbValidResponseError(db, err));
165 callback(err);
166 });
167}
168
169/*
170 Check that the restore database exists, is new and is empty. Also verify that the credentials used have
171 visibility. Callback with a fatal error if there is a problem with the DB.
172 @param {string} db - database object
173 @param {function(err)} callback - error is undefined if DB exists, new and empty
174*/
175function proceedIfRestoreDbValid(db, callback) {
176 db.service.getDatabaseInformation({ db: db.db }).then(response => {
177 const { doc_count: docCount, doc_del_count: deletedDocCount } = response.result;
178 // The system databases can have a validation ddoc(s) injected in them on creation.
179 // This sets the doc count off, so we just complitely exclude the system databases from this check.
180 // The assumption here is that users restoring system databases know what they are doing.
181 if (!db.db.startsWith('_') && (docCount !== 0 || deletedDocCount !== 0)) {
182 const notEmptyDBErr = new Error(`Target database ${db.url}${db.db} is not empty.`);
183 notEmptyDBErr.name = 'DatabaseNotEmpty';
184 callback(notEmptyDBErr);
185 } else {
186 callback();
187 }
188 }).catch(err => {
189 err = error.convertResponseError(err, err => parseIfDbValidResponseError(db, err));
190 callback(err);
191 });
192}
193
194/*
195 Convert the database validation response error to a special DatabaseNotFound error
196 in case the database is missing. Otherwise delegate to the default error factory.
197 @param {object} db - database object
198 @param {object} err - HTTP response error
199*/
200function parseIfDbValidResponseError(db, err) {
201 if (err && err.status === 404) {
202 // Override the error type and message for the DB not found case
203 const msg = `Database ${db.url}` +
204 `${db.db} does not exist. ` +
205 'Check the URL and database name have been specified correctly.';
206 const noDBErr = new Error(msg);
207 noDBErr.name = 'DatabaseNotFound';
208 return noDBErr;
209 }
210 // Delegate to the default error factory if it wasn't a 404
211 return error.convertResponseError(err);
212}
213
214module.exports = {
215
216 /**
217 * Backup a Cloudant database to a stream.
218 *
219 * @param {string} srcUrl - URL of database to backup.
220 * @param {stream.Writable} targetStream - Stream to write content to.
221 * @param {object} opts - Backup options.
222 * @param {number} [opts.parallelism=5] - Number of parallel HTTP requests to use.
223 * @param {number} [opts.bufferSize=500] - Number of documents per batch request.
224 * @param {number} [opts.requestTimeout=120000] - Milliseconds to wait before retrying a HTTP request.
225 * @param {string} [opts.iamApiKey] - IAM API key to use to access Cloudant database.
226 * @param {string} [opts.log] - Log file name. Default uses a temporary file.
227 * @param {boolean} [opts.resume] - Whether to resume from existing log.
228 * @param {string} [opts.mode=full] - Use `full` or `shallow` mode.
229 * @param {backupRestoreCallback} callback - Called on completion.
230 */
231 backup: function(srcUrl, targetStream, opts, callback) {
232 const listenerErrorIndicator = { errored: false };
233 if (typeof callback === 'undefined' && typeof opts === 'function') {
234 callback = opts;
235 opts = {};
236 }
237 if (!validateArgs(srcUrl, opts, callback)) {
238 // bad args, bail
239 return;
240 }
241
242 // if there is an error writing to the stream, call the completion
243 // callback with the error set
244 addEventListener(listenerErrorIndicator, targetStream, 'error', function(err) {
245 debug('Error ' + JSON.stringify(err));
246 if (callback) callback(err);
247 });
248
249 opts = Object.assign({}, defaults(), opts);
250
251 const ee = new events.EventEmitter();
252
253 // Set up the DB client
254 const backupDB = request.client(srcUrl, opts);
255
256 // Validate the DB exists, before proceeding to backup
257 proceedIfBackupDbValid(backupDB, function(err) {
258 if (err) {
259 if (err.name === 'DatabaseNotFound') {
260 err.message = `${err.message} Ensure the backup source database exists.`;
261 }
262 // Didn't exist, or another fatal error, exit
263 callback(err);
264 return;
265 }
266 let backup = null;
267 if (opts.mode === 'shallow') {
268 backup = backupShallow;
269 } else { // full mode
270 backup = backupFull;
271 }
272
273 // If resuming write a newline as it's possible one would be missing from
274 // an interruption of the previous backup. If the backup was clean this
275 // will cause an empty line that will be gracefully handled by the restore.
276 if (opts.resume) {
277 targetStream.write('\n');
278 }
279
280 // Get the event emitter from the backup process so we can handle events
281 // before passing them on to the app's event emitter if needed.
282 const internalEE = backup(backupDB, opts);
283 addEventListener(listenerErrorIndicator, internalEE, 'changes', function(batch) {
284 ee.emit('changes', batch);
285 });
286 addEventListener(listenerErrorIndicator, internalEE, 'received', function(obj, q, logCompletedBatch) {
287 // this may be too verbose to have as well as the "backed up" message
288 // debug(' received batch', obj.batch, ' docs: ', obj.total, 'Time', obj.time);
289 // Callback to emit the written event when the content is flushed
290 function writeFlushed() {
291 ee.emit('written', { total: obj.total, time: obj.time, batch: obj.batch });
292 if (logCompletedBatch) {
293 logCompletedBatch(obj.batch);
294 }
295 debug(' backed up batch', obj.batch, ' docs: ', obj.total, 'Time', obj.time);
296 }
297 // Write the received content to the targetStream
298 const continueWriting = targetStream.write(JSON.stringify(obj.data) + '\n',
299 'utf8',
300 writeFlushed);
301 if (!continueWriting) {
302 // The buffer was full, pause the queue to stop the writes until we
303 // get a drain event
304 if (q && !q.paused) {
305 q.pause();
306 targetStream.once('drain', function() {
307 q.resume();
308 });
309 }
310 }
311 });
312 // For errors we expect, may or may not be fatal
313 addEventListener(listenerErrorIndicator, internalEE, 'error', function(err) {
314 debug('Error ' + JSON.stringify(err));
315 callback(err);
316 });
317 addEventListener(listenerErrorIndicator, internalEE, 'finished', function(obj) {
318 function emitFinished() {
319 debug('Backup complete - written ' + JSON.stringify(obj));
320 const summary = { total: obj.total };
321 ee.emit('finished', summary);
322 if (callback) callback(null, summary);
323 }
324 if (targetStream === process.stdout) {
325 // stdout cannot emit a finish event so use a final write + callback
326 targetStream.write('', 'utf8', emitFinished);
327 } else {
328 // If we're writing to a file, end the writes and register the
329 // emitFinished function for a callback when the file stream's finish
330 // event is emitted.
331 targetStream.end('', 'utf8', emitFinished);
332 }
333 });
334 });
335 return ee;
336 },
337
338 /**
339 * Restore a backup from a stream.
340 *
341 * @param {stream.Readable} srcStream - Stream containing backed up data.
342 * @param {string} targetUrl - Target database.
343 * @param {object} opts - Restore options.
344 * @param {number} opts.parallelism - Number of parallel HTTP requests to use. Default 5.
345 * @param {number} opts.bufferSize - Number of documents per batch request. Default 500.
346 * @param {number} opts.requestTimeout - Milliseconds to wait before retrying a HTTP request. Default 120000.
347 * @param {string} opts.iamApiKey - IAM API key to use to access Cloudant database.
348 * @param {backupRestoreCallback} callback - Called on completion.
349 */
350 restore: function(srcStream, targetUrl, opts, callback) {
351 const listenerErrorIndicator = { errored: false };
352 if (typeof callback === 'undefined' && typeof opts === 'function') {
353 callback = opts;
354 opts = {};
355 }
356 validateArgs(targetUrl, opts, callback);
357 opts = Object.assign({}, defaults(), opts);
358
359 const ee = new events.EventEmitter();
360
361 // Set up the DB client
362 const restoreDB = request.client(targetUrl, opts);
363
364 // Validate the DB exists, before proceeding to restore
365 proceedIfRestoreDbValid(restoreDB, function(err) {
366 if (err) {
367 if (err.name === 'DatabaseNotFound') {
368 err.message = `${err.message} Create the target database before restoring.`;
369 } else if (err.name === 'DatabaseNotEmpty') {
370 err.message = `${err.message} A target database must be a new and empty database.`;
371 }
372 // Didn't exist, or another fatal error, exit
373 callback(err);
374 return;
375 }
376
377 restoreInternal(
378 restoreDB,
379 opts,
380 srcStream,
381 ee,
382 function(err, writer) {
383 if (err) {
384 callback(err, null);
385 return;
386 }
387 if (writer != null) {
388 addEventListener(listenerErrorIndicator, writer, 'restored', function(obj) {
389 debug(' restored ', obj.total);
390 ee.emit('restored', { documents: obj.documents, total: obj.total });
391 });
392 addEventListener(listenerErrorIndicator, writer, 'error', function(err) {
393 debug('Error ' + JSON.stringify(err));
394 // Only call destroy if it is available on the stream
395 if (srcStream.destroy && srcStream.destroy instanceof Function) {
396 srcStream.destroy();
397 }
398 callback(err);
399 });
400 addEventListener(listenerErrorIndicator, writer, 'finished', function(obj) {
401 debug('restore complete');
402 ee.emit('finished', { total: obj.total });
403 callback(null, obj);
404 });
405 }
406 }
407 );
408 });
409 return ee;
410 }
411};
412
413/**
414 * Backup/restore callback
415 * @callback backupRestoreCallback
416 * @param {Error} err - Error object if operation failed.
417 * @param {object} data - summary data for backup/restore
418 */