UNPKG

15 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 referenced 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 proceedIfDbValid(db, callback) {
163 db.service.headDatabase({ db: db.db }).then(() => callback()).catch(err => {
164 err = error.convertResponseError(err, function(err) {
165 if (err && err.status === 404) {
166 // Override the error type and mesasge for the DB not found case
167 var msg = `Database ${db.url}` +
168 `${db.db} does not exist. ` +
169 'Check the URL and database name have been specified correctly.';
170 var noDBErr = new Error(msg);
171 noDBErr.name = 'DatabaseNotFound';
172 return noDBErr;
173 } else {
174 // Delegate to the default error factory if it wasn't a 404
175 return error.convertResponseError(err);
176 }
177 });
178 callback(err);
179 });
180}
181
182module.exports = {
183
184 /**
185 * Backup a Cloudant database to a stream.
186 *
187 * @param {string} srcUrl - URL of database to backup.
188 * @param {stream.Writable} targetStream - Stream to write content to.
189 * @param {object} opts - Backup options.
190 * @param {number} [opts.parallelism=5] - Number of parallel HTTP requests to use.
191 * @param {number} [opts.bufferSize=500] - Number of documents per batch request.
192 * @param {number} [opts.requestTimeout=120000] - Milliseconds to wait before retrying a HTTP request.
193 * @param {string} [opts.iamApiKey] - IAM API key to use to access Cloudant database.
194 * @param {string} [opts.log] - Log file name. Default uses a temporary file.
195 * @param {boolean} [opts.resume] - Whether to resume from existing log.
196 * @param {string} [opts.mode=full] - Use `full` or `shallow` mode.
197 * @param {backupRestoreCallback} callback - Called on completion.
198 */
199 backup: function(srcUrl, targetStream, opts, callback) {
200 var listenerErrorIndicator = { errored: false };
201 if (typeof callback === 'undefined' && typeof opts === 'function') {
202 callback = opts;
203 opts = {};
204 }
205 if (!validateArgs(srcUrl, opts, callback)) {
206 // bad args, bail
207 return;
208 }
209
210 // if there is an error writing to the stream, call the completion
211 // callback with the error set
212 addEventListener(listenerErrorIndicator, targetStream, 'error', function(err) {
213 debug('Error ' + JSON.stringify(err));
214 if (callback) callback(err);
215 });
216
217 opts = Object.assign({}, defaults(), opts);
218
219 const ee = new events.EventEmitter();
220
221 // Set up the DB client
222 const backupDB = request.client(srcUrl, opts);
223
224 // Validate the DB exists, before proceeding to backup
225 proceedIfDbValid(backupDB, function(err) {
226 if (err) {
227 if (err.name === 'DatabaseNotFound') {
228 err.message = `${err.message} Ensure the backup source database exists.`;
229 }
230 // Didn't exist, or another fatal error, exit
231 callback(err);
232 return;
233 }
234 var backup = null;
235 if (opts.mode === 'shallow') {
236 backup = backupShallow;
237 } else { // full mode
238 backup = backupFull;
239 }
240
241 // If resuming write a newline as it's possible one would be missing from
242 // an interruption of the previous backup. If the backup was clean this
243 // will cause an empty line that will be gracefully handled by the restore.
244 if (opts.resume) {
245 targetStream.write('\n');
246 }
247
248 // Get the event emitter from the backup process so we can handle events
249 // before passing them on to the app's event emitter if needed.
250 const internalEE = backup(backupDB, opts);
251 addEventListener(listenerErrorIndicator, internalEE, 'changes', function(batch) {
252 ee.emit('changes', batch);
253 });
254 addEventListener(listenerErrorIndicator, internalEE, 'received', function(obj, q, logCompletedBatch) {
255 // this may be too verbose to have as well as the "backed up" message
256 // debug(' received batch', obj.batch, ' docs: ', obj.total, 'Time', obj.time);
257 // Callback to emit the written event when the content is flushed
258 function writeFlushed() {
259 ee.emit('written', { total: obj.total, time: obj.time, batch: obj.batch });
260 if (logCompletedBatch) {
261 logCompletedBatch(obj.batch);
262 }
263 debug(' backed up batch', obj.batch, ' docs: ', obj.total, 'Time', obj.time);
264 }
265 // Write the received content to the targetStream
266 const continueWriting = targetStream.write(JSON.stringify(obj.data) + '\n',
267 'utf8',
268 writeFlushed);
269 if (!continueWriting) {
270 // The buffer was full, pause the queue to stop the writes until we
271 // get a drain event
272 if (q && !q.paused) {
273 q.pause();
274 targetStream.once('drain', function() {
275 q.resume();
276 });
277 }
278 }
279 });
280 // For errors we expect, may or may not be fatal
281 addEventListener(listenerErrorIndicator, internalEE, 'error', function(err) {
282 debug('Error ' + JSON.stringify(err));
283 callback(err);
284 });
285 addEventListener(listenerErrorIndicator, internalEE, 'finished', function(obj) {
286 function emitFinished() {
287 debug('Backup complete - written ' + JSON.stringify(obj));
288 const summary = { total: obj.total };
289 ee.emit('finished', summary);
290 if (callback) callback(null, summary);
291 }
292 if (targetStream === process.stdout) {
293 // stdout cannot emit a finish event so use a final write + callback
294 targetStream.write('', 'utf8', emitFinished);
295 } else {
296 // If we're writing to a file, end the writes and register the
297 // emitFinished function for a callback when the file stream's finish
298 // event is emitted.
299 targetStream.end('', 'utf8', emitFinished);
300 }
301 });
302 });
303 return ee;
304 },
305
306 /**
307 * Restore a backup from a stream.
308 *
309 * @param {stream.Readable} srcStream - Stream containing backed up data.
310 * @param {string} targetUrl - Target database.
311 * @param {object} opts - Restore options.
312 * @param {number} opts.parallelism - Number of parallel HTTP requests to use. Default 5.
313 * @param {number} opts.bufferSize - Number of documents per batch request. Default 500.
314 * @param {number} opts.requestTimeout - Milliseconds to wait before retrying a HTTP request. Default 120000.
315 * @param {string} opts.iamApiKey - IAM API key to use to access Cloudant database.
316 * @param {backupRestoreCallback} callback - Called on completion.
317 */
318 restore: function(srcStream, targetUrl, opts, callback) {
319 var listenerErrorIndicator = { errored: false };
320 if (typeof callback === 'undefined' && typeof opts === 'function') {
321 callback = opts;
322 opts = {};
323 }
324 validateArgs(targetUrl, opts, callback);
325 opts = Object.assign({}, defaults(), opts);
326
327 const ee = new events.EventEmitter();
328
329 // Set up the DB client
330 const restoreDB = request.client(targetUrl, opts);
331
332 // Validate the DB exists, before proceeding to restore
333 proceedIfDbValid(restoreDB, function(err) {
334 if (err) {
335 if (err.name === 'DatabaseNotFound') {
336 err.message = `${err.message} Create the target database before restoring.`;
337 }
338 // Didn't exist, or another fatal error, exit
339 callback(err);
340 return;
341 }
342
343 restoreInternal(
344 restoreDB,
345 opts,
346 srcStream,
347 ee,
348 function(err, writer) {
349 if (err) {
350 callback(err, null);
351 return;
352 }
353 if (writer != null) {
354 addEventListener(listenerErrorIndicator, writer, 'restored', function(obj) {
355 debug(' restored ', obj.total);
356 ee.emit('restored', { documents: obj.documents, total: obj.total });
357 });
358 addEventListener(listenerErrorIndicator, writer, 'error', function(err) {
359 debug('Error ' + JSON.stringify(err));
360 // Only call destroy if it is available on the stream
361 if (srcStream.destroy && srcStream.destroy instanceof Function) {
362 srcStream.destroy();
363 }
364 callback(err);
365 });
366 addEventListener(listenerErrorIndicator, writer, 'finished', function(obj) {
367 debug('restore complete');
368 ee.emit('finished', { total: obj.total });
369 callback(null, obj);
370 });
371 }
372 }
373 );
374 });
375 return ee;
376 }
377};
378
379/**
380 * Backup/restore callback
381 * @callback backupRestoreCallback
382 * @param {Error} err - Error object if operation failed.
383 * @param {object} data - summary data for backup/restore
384 */