UNPKG

52.3 kBJavaScriptView Raw
1/**
2 * Copyright 2016-2018 F5 Networks, Inc.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17'use strict';
18
19const EOL = require('os').EOL;
20const URL = require('url');
21const fs = require('fs');
22const path = require('path');
23const childProcess = require('child_process');
24const http = require('http');
25const https = require('https');
26const q = require('q');
27const Logger = require('./logger');
28const ipc = require('./ipc');
29const signals = require('./signals');
30const cloudProviderFactory = require('./cloudProviderFactory');
31
32const REBOOT_SCRIPTS_DIR = '/tmp/rebootScripts/';
33
34const ARGS_TO_KEEP = [
35 '--host',
36 '--user',
37 '--password',
38 '--password-uri',
39 '--password-url',
40 '--password-encrypted',
41 '--port',
42 '--signal',
43 '--log-level',
44 '--output',
45 '--error-file',
46 '--no-console'];
47
48let logger = Logger.getLogger({
49 logLevel: 'none',
50 module
51});
52
53/**
54 * Miscellaneous utility functions that don't have a better home
55 *
56 * @module
57 */
58module.exports = {
59
60 // 15 minutes
61 DEFAULT_RETRY: {
62 maxRetries: 90,
63 retryIntervalMs: 10000
64 },
65
66 // 15 minutes - explicit continue on all error codes
67 DEFAULT_RETRY_IGNORE_ERRORS: {
68 maxRetries: 90,
69 retryIntervalMs: 10000,
70 continueOnError: true
71 },
72
73 // 1 secondish
74 SHORT_RETRY: {
75 maxRetries: 3,
76 retryIntervalMs: 300
77 },
78
79 // 1 minute
80 MEDIUM_RETRY: {
81 maxRetries: 30,
82 retryIntervalMs: 2000
83 },
84
85 // 5 minutes, 5 second interval
86 LONG_RETRY: {
87 maxRetries: 60,
88 retryIntervalMs: 5000
89 },
90
91 // 5 minutes, 1 second interval
92 QUICK_BUT_LONG_RETRY: {
93 maxRetries: 300,
94 retryIntervalMs: 1000
95 },
96
97 NO_RETRY: {
98 maxRetries: 0,
99 retryIntervalMs: 0
100 },
101
102 getProduct() {
103 return this.getProductString()
104 .then((response) => {
105 if (!response) {
106 return this.runTmshCommand('save sys config')
107 .then(() => {
108 return this.getProductString();
109 })
110 .then((productString) => {
111 return q(productString);
112 })
113 .catch((errString) => {
114 logger.silly('Unable to get product string',
115 errString && errString.message ? errString.message : errString);
116 return q.reject(errString);
117 });
118 }
119 return q(response);
120 })
121 .catch((err) => {
122 return q.reject(err);
123 });
124 },
125
126 ipToNumber(ip) {
127 const d = ip.split('.');
128 let n = d[0] * Math.pow(256, 3); // eslint-disable-line no-restricted-properties
129 n += d[1] * Math.pow(256, 2); // eslint-disable-line no-restricted-properties
130 n += d[2] * 256;
131 n += d[3] * 1;
132 return n;
133 },
134
135 setLogger(aLogger) {
136 logger = aLogger;
137 },
138
139 setLoggerOptions(loggerOptions) {
140 const loggerOpts = {};
141 Object.keys(loggerOptions).forEach((option) => {
142 loggerOpts[option] = loggerOptions[option];
143 });
144 loggerOpts.module = module;
145 logger = Logger.getLogger(loggerOpts);
146 },
147
148 /**
149 * Tries a method until it succeeds or reaches a maximum number of retries.
150 *
151 * @param {Object} thisArg - The 'this' argument to pass to the
152 * called function
153 * @param {Object} retryOptions - Options for retrying the request.
154 * @param {Integer} retryOptions.maxRetries - Number of times to retry if first
155 * try fails. 0 to not retry.
156 * Default 60.
157 * @param {Integer} retryOptions.retryIntervalMs - Milliseconds between retries.
158 * Default 10000.
159 * @param {Boolean} [retryOptions.continueOnError] - Continue even if we get an
160 * HTTP BAD_REQUEST code. Default false.
161 * @param {String | RegExp} [retryOptions.continueOnErrorMessage] - Continue on error if the 400 error
162 * message matches this regex
163 * @param {Function} funcToTry - Function to try. Function should
164 * return a Promise which is later
165 * resolved or rejected.
166 * @param {Object[]} args - Array of arguments to pass to
167 * funcToTry
168 *
169 * @returns {Promise} A promise which is resolved with the return from funcToTry
170 * if funcToTry is resolved within maxRetries.
171 */
172 tryUntil(thisArg, retryOptions, funcToTry, args) {
173 let deferred;
174
175 const shouldReject = function (err) {
176 if (err && err.code && err.code === 400) {
177 if (retryOptions.continueOnError) {
178 return false;
179 }
180 if (err.message && retryOptions.continueOnErrorMessage) {
181 let regex = retryOptions.continueOnErrorMessage;
182 if (!(regex instanceof RegExp)) {
183 regex = new RegExp(retryOptions.continueOnErrorMessage);
184 }
185 return !regex.test(err.message);
186 }
187 return true;
188 }
189 return false;
190 };
191
192 const tryIt = function tryIt(maxRetries, interval, theFunc, deferredOrNull) {
193 let numRemaining = maxRetries;
194 let promise;
195
196 const retryOrReject = function (err) {
197 logger.silly(
198 'tryUntil: retryOrReject: numRemaining:', numRemaining,
199 ', code:', err && err.code ? err.code : err,
200 ', message:', err && err.message ? err.message : err
201 );
202 if (shouldReject(err)) {
203 logger.verbose('Unrecoverable error from HTTP request. Not retrying.');
204 deferred.reject(err);
205 } else if (numRemaining > 0) {
206 numRemaining -= 1;
207 setTimeout(tryIt, interval, numRemaining, interval, theFunc, deferred);
208 } else {
209 logger.verbose('Max tries reached.');
210 const originalMessage = err && err.message ? err.message : 'unknown';
211 const updatedError = {};
212 Object.assign(updatedError, err);
213 updatedError.message = `tryUntil: max tries reached: ${originalMessage}`;
214 updatedError.name = err && err.name ? err.name : '';
215 deferred.reject(updatedError);
216 }
217 };
218
219 if (!deferredOrNull) {
220 deferred = q.defer();
221 }
222
223 try {
224 promise = theFunc.apply(thisArg, args)
225 .then((response) => {
226 deferred.resolve(response);
227 })
228 .catch((err) => {
229 logger.silly('tryUntil: got error', err);
230 logger.silly('typeof err', typeof err);
231 let message = '';
232 if (err) {
233 message = err.message ? err.message : err;
234 }
235 logger.verbose('tryUntil error:', message, 'tries left:', maxRetries.toString());
236 retryOrReject(err);
237 });
238
239 // Allow this to work with native promises which do not have a done
240 if (promise.done) {
241 promise.done();
242 }
243 } catch (err) {
244 retryOrReject(err);
245 }
246
247 return deferred.promise;
248 };
249
250 return tryIt(retryOptions.maxRetries, retryOptions.retryIntervalMs, funcToTry);
251 },
252
253 /**
254 * Calls an array of promises in serial.
255 *
256 * @param {Object} thisArg - The 'this' argument to pass to the called function
257 * @param {Object[]} promises - An array of promise definitions. Each
258 * definition should be:
259 * {
260 * promise: A function that returns a promise
261 * arguments: Array of arguments to pass to the function,
262 * message: An optional message to display at the start of this promise
263 * }
264 * @param {Integer} [delay] - Delay in milliseconds to use between calls.
265 */
266 callInSerial(thisArg, promises, delay) {
267 const interval = delay || 0;
268
269 const deferred = q.defer();
270 const results = [];
271
272 const callThem = function (index) {
273 if (index === promises.length) {
274 deferred.resolve(results);
275 return;
276 }
277
278 if (promises[index].message) {
279 logger.info(promises[index].message);
280 }
281
282 promises[index].promise.apply(thisArg, promises[index].arguments)
283 .then((response) => {
284 results.push(response);
285
286 if (index < promises.length - 1) {
287 setTimeout(callThem, interval, index + 1);
288 } else {
289 deferred.resolve(results);
290 }
291 })
292 .catch((err) => {
293 deferred.reject(err);
294 });
295 };
296
297 callThem(0);
298
299 return deferred.promise;
300 },
301
302 deleteUser(user) {
303 return this.runTmshCommand(`delete auth user ${user}`);
304 },
305
306 /**
307 *
308 * Create a buffer from a data string.
309 * Buffer.from() is preferred, and should be used in versions of NodeJS that support it.
310 * Buffer.from() was introduced in Node 4.5.0 and in 5.10.0
311 *
312 * @param {String} data - data to create a buffer from
313 * @param {String} encoding - data encoding. Default is 'utf8'
314 */
315 createBufferFrom(data, encoding) {
316 if (typeof data !== 'string') {
317 throw new Error('data must be a string');
318 }
319 let useBufferFrom;
320 const nodeVersion = process.version.split('v').pop();
321 if (nodeVersion.split('.')[0] === '4' && (this.versionCompare(nodeVersion, '4.5.0') > -1)) {
322 useBufferFrom = true;
323 } else if (this.versionCompare(nodeVersion, '5.10.0') > -1) {
324 useBufferFrom = true;
325 } else {
326 useBufferFrom = false;
327 }
328 if (useBufferFrom) {
329 return Buffer.from(data, encoding || 'utf8');
330 }
331 // eslint-disable-next-line
332 return new Buffer(data, encoding || 'utf8');
333 },
334
335 /**
336 * Log a message and exit.
337 *
338 * Makes sure that message is logged before exiting.
339 *
340 * @param {String} message - Message to log
341 * @param {String} [level] - Level at which to log the message. Default info.
342 * @param {Number} [code] - Exit code. Default 0.
343 */
344 logAndExit(message, level, code) {
345 const logLevel = level || 'info';
346 let exitCode = code;
347
348 if (typeof exitCode === 'undefined') {
349 exitCode = 0;
350 }
351
352 logger.log(logLevel, message);
353 // exit on flush event if file transport exists
354 if (logger.transports.file) {
355 logger.transports.file.on('flush', () => {
356 process.exit(exitCode);
357 });
358 }
359 // if no file transport or flush event does not trigger
360 // simply exit after hard count
361 setTimeout(() => {
362 process.exit(exitCode);
363 }, 1000);
364 },
365
366 /**
367 * Log a message to the error log file.
368 *
369 * @param {String} message - Message to log
370 * @param {Object} options - Logger options
371 */
372 logError(message, options) {
373 const loggerOptions = {};
374 Object.assign(loggerOptions, options);
375
376 loggerOptions.json = true;
377 loggerOptions.verboseLabel = true;
378
379 if (loggerOptions.errorFile) {
380 loggerOptions.fileName = loggerOptions.errorFile;
381 } else if (loggerOptions.fileName) {
382 loggerOptions.fileName = `${path.dirname(loggerOptions.fileName)}/cloudLibsError.log`;
383 } else {
384 loggerOptions.fileName = '/tmp/cloudLibsError.log';
385 }
386
387 const errorLogger = Logger.getLogger(loggerOptions);
388 errorLogger.error(message);
389 },
390
391 /**
392 * Returns the count of running processes, given the provided grep command
393 * example:
394 * getProcessCount('grep autoscale.js')
395 *
396 * @param {String} grepCommand - grep command to execute.
397 *
398 * @returns {Promise} A Promise that is resolved with the output of the
399 * shell command or rejected if an error occurs.
400 */
401 getProcessCount(grepCommand) {
402 if (!grepCommand) {
403 const errorMessage = 'grep command is required';
404 logger.error(errorMessage);
405 return q.reject(new Error(errorMessage));
406 }
407 const shellCommand = `/bin/ps -eo pid,cmd | ${grepCommand} | wc -l`;
408
409 return this.runShellCommand(shellCommand)
410 .then((response) => {
411 return q(
412 (typeof response === 'string') ? response.trim() : response
413 );
414 })
415 .catch((err) => {
416 logger.warn('Unable to get process count', err && err.message ? err.message : err);
417 return q.reject(err);
418 });
419 },
420
421 /**
422 * Returns execution time and pid of process, given the provided grep command
423 * example:
424 * getProcessExecutionTimeWithPid('grep autoscale.js')
425 *
426 * @param {String} grepCommand - grep command to execute.
427 *
428 * @returns {Promise} A Promise that is resolved with the output of the
429 * shell command or rejected if an error occurs.
430 */
431 getProcessExecutionTimeWithPid(grepCommand) {
432 if (!grepCommand) {
433 const errorMessage = 'grep command is required';
434 logger.error(errorMessage);
435 return q.reject(new Error(errorMessage));
436 }
437 const cmd = `/bin/ps -eo pid,etime,cmd --sort=-time | ${grepCommand} | awk '{ print $1"-"$2 }'`;
438
439 logger.silly(`shellCommand: ${cmd}`);
440 return this.runShellCommand(cmd)
441 .then((response) => {
442 return q(
443 (typeof response === 'string') ? response.trim() : response
444 );
445 })
446 .catch((err) => {
447 logger.warn('Unable to get process execution time with pid',
448 err && err.message ? err.message : err);
449 return q.reject(err);
450 });
451 },
452
453 /**
454 * Terminates process using PID value
455 * example:
456 * terminateProcessById('1212')
457 *
458 * @param {String} pid - process pid value.
459 *
460 * @returns {Promise} A Promise that is resolved with the output of the
461 * shell command or rejected if an error occurs.
462 */
463 terminateProcessById(pid) {
464 if (!pid) {
465 const errorMessage = 'pid is required for process termination';
466 logger.error(errorMessage);
467 return q.reject(new Error(errorMessage));
468 }
469 logger.silly(`Autoscale Process ID to kill: ${pid}`);
470 const shellCommand = `/bin/kill -9 ${pid}`;
471 return this.runShellCommand(shellCommand)
472 .then((response) => {
473 return q(
474 (typeof response === 'string') ? response.trim() : response
475 );
476 })
477 .catch((err) => {
478 logger.warn('Unable to terminate the process',
479 err && err.message ? err.message : err);
480 return q.reject(err);
481 });
482 },
483
484 /**
485 * Reboots the device
486 *
487 * First save arguments from running scripts so that they are started
488 * again on startup
489 *
490 * @param {Object} bigIp - The BigIp instance to reboot.
491 * @param {Object} [options] - Optional parameters.
492 * @param {Boolean} [options.signalOnly] - Indicates that we should not actually reboot, just
493 * prepare args and signal that reboot is required
494 *
495 * @returns {Promise} A Promise that is resolved when the reboot command
496 * has been sent or rejected if an error occurs.
497 */
498 reboot(bigIp, options) {
499 return prepareArgsForReboot(logger)
500 .then(() => {
501 if (options && options.signalOnly) {
502 logger.info('Skipping reboot due to options. Signaling only.');
503 ipc.send(signals.REBOOT_REQUIRED);
504 return q();
505 }
506 logger.info('Rebooting.');
507 ipc.send(signals.REBOOT);
508 return bigIp.reboot();
509 });
510 },
511
512 /**
513 * Spawns a new process in the background and exits current process
514 *
515 * @param {Object} process - Node.js process
516 * @param {String} logFileName - Name to pass for output log file
517 */
518 runInBackgroundAndExit(process, logFileName) {
519 let args;
520 let myChild;
521
522 if (process.argv.length > 100) {
523 logger.warn("Too many arguments - maybe we're stuck in a restart loop?");
524 } else {
525 args = process.argv.slice(1);
526
527 // remove the background option(s)
528 for (let i = args.length - 1; i >= 0; i -= 1) {
529 if (args[i] === '--background') {
530 args.splice(i, 1);
531 }
532 }
533
534 // capture output in a log file
535 if (args.indexOf('--output') === -1) {
536 args.push('--output', logFileName);
537 }
538
539 logger.debug('Spawning child process.', args);
540 myChild = childProcess.spawn(
541 process.argv[0],
542 args,
543 {
544 stdio: 'ignore',
545 detached: true
546 }
547 );
548 myChild.unref();
549 }
550
551 logger.debug('Original process exiting.');
552 process.exit();
553 },
554
555 /**
556 * Filters options arguments provided based on a list of arguments to keep
557 *
558 * @param {Object} options - Options object to pull arguments from
559 * @param {Object} [argsToKeep] - Arguments to keep. Default ARGS_TO_KEEP.
560 */
561 getArgsToStripDuringForcedReboot(options, argsToKeep) {
562 const argsTK = argsToKeep || ARGS_TO_KEEP;
563 const argsToStrip = [];
564 options.options.forEach((opt) => {
565 // add to argsToStrip, unless it is one of the args we want to keep
566 if (argsTK) {
567 if (opt.long && argsTK.indexOf(opt.long) === -1) {
568 argsToStrip.push(opt.long);
569 // if short option exist for this arg, also strip
570 if (opt.short) {
571 argsToStrip.push(opt.short);
572 }
573 }
574 }
575 });
576 return argsToStrip;
577 },
578
579 /**
580 * Saves arguments that started a script so that they can be re-used
581 * in the event we get stuck and need to reboot.
582 *
583 * @param {String[]} args - Array of arguments that can be used to re-run
584 * the process (i.e. process.argv)
585 * @param {String} id - Some unique id for the process. This will be used
586 * as the file name in which
587 * to store the files.
588 * @param {Object} [argsToStrip] - Array of arguments to strip. Default none.
589 */
590 saveArgs(args, id, argsToStrip) {
591 const deferred = q.defer();
592 const fullPath = `${REBOOT_SCRIPTS_DIR}${id}.sh`;
593 const updatedArgs = [];
594
595 try {
596 fs.stat(REBOOT_SCRIPTS_DIR, (fsStatErr) => {
597 if (fsStatErr && fsStatErr.code === 'ENOENT') {
598 try {
599 fs.mkdirSync(REBOOT_SCRIPTS_DIR);
600 } catch (err) {
601 // Check for race condition on creating directory while others are doing the same
602 if (err.code !== 'EEXIST') {
603 logger.error(
604 'Error creating',
605 REBOOT_SCRIPTS_DIR,
606 'Not saving args for a second try.',
607 err
608 );
609 deferred.resolve();
610 }
611 }
612 } else if (fsStatErr) {
613 logger.warn('Unable to stat', REBOOT_SCRIPTS_DIR, 'Not saving args for a second try.');
614 // Just resolve - may as well try to run anyway
615 deferred.resolve();
616 }
617
618 try {
619 fs.open(fullPath, 'w', (fsOpenErr, fd) => {
620 if (fsOpenErr) {
621 logger.warn('Unable to open', fullPath, 'Not saving args for a second try.');
622 // Just resolve - may as well try to run anyway
623 deferred.resolve();
624 return;
625 }
626
627 // push the executable and script name
628 updatedArgs.push(args[0], args[1]);
629 for (let i = 2; i < args.length; i++) {
630 if (args[i][0] === '-') {
631 if (!argsToStrip || argsToStrip.indexOf(args[i]) === -1) {
632 updatedArgs.push(args[i]);
633
634 // If the first character of the next argument does not start with '-',
635 // assume it is a parameter for this argument
636 if (args.length > i + 1) {
637 if (args[i + 1][0] !== '-') {
638 updatedArgs.push(args[i + 1]);
639 }
640 }
641 }
642 }
643 }
644
645 try {
646 fs.writeSync(fd, `#!/bin/bash${EOL}`);
647 fs.writeSync(fd, updatedArgs.join(' ') + EOL);
648 fs.fchmodSync(fd, 0o755);
649 } catch (err) {
650 logger.warn('Unable to save args', err);
651 } finally {
652 fs.closeSync(fd);
653 deferred.resolve();
654 }
655 });
656 } catch (err) {
657 logger.warn('Unable to open args file', err);
658 deferred.resolve();
659 }
660 });
661 } catch (err) {
662 logger.warn('Unable to stat', REBOOT_SCRIPTS_DIR);
663 deferred.resolve();
664 }
665
666 return deferred.promise;
667 },
668
669 /**
670 * Deletes the arguments previously saved with saveArgs
671 *
672 * This should be called when a script successfully completes.
673 *
674 * @param {String} id - Some unique id for the process. This will be used as the file name in which
675 * to store the files.
676 */
677 deleteArgs(id) {
678 const file = `${REBOOT_SCRIPTS_DIR}${id}.sh`;
679 if (fs.existsSync(file)) {
680 fs.unlinkSync(file);
681 }
682 },
683
684 /**
685 * Adds value to an array
686 *
687 * Typically used by the option parser for collecting
688 * multiple values for a command line option
689 *
690 * @param {String} val - The comma separated value string
691 * @param {String[]} collecction - The array into which to put the value
692 *
693 * @returns {String[]} The updated collection
694 */
695 collect(val, collection) {
696 collection.push(val);
697 return collection;
698 },
699
700 /**
701 * Parses a comma-separated value
702 *
703 * Typically used by the option parser for collecting
704 * multiple values which are comma separated values.
705 *
706 * Leading and trailing spaces are removed from keys and values
707 *
708 * @param {String} val - The comma separated value string
709 * @param {String[][]} collecction - The array into which to put a new array conataining the values
710 *
711 * @returns {String[][]} The updated collection
712 */
713 csv(val, collection) {
714 const values = val.split(',');
715 const newEntry = [];
716 values.forEach((value) => {
717 newEntry.push(value.trim());
718 });
719 collection.push(newEntry);
720 return collection;
721 },
722
723 /**
724 * Parses a ':' deliminated key-value pair and stores them
725 * in a container.
726 *
727 * Leading and trailing spaces are removed from keys and values
728 *
729 * Typically used by the option parser for collecting
730 * multiple key-value pairs for a command line option
731 *
732 * @param {String} pair - String in the format of key:value
733 * @param {Object} container - Object into which to put the key:value
734 */
735 pair(pair, container) {
736 const nameVal = pair.split(/:(.+)/);
737 container[nameVal[0].trim()] = nameVal[1].trim(); // eslint-disable-line no-param-reassign
738 },
739
740 /**
741 * Parses a string of keys and values into a single map
742 *
743 * Keys are separated from values by a ':'
744 * Key value pairs are separated from each other by a ','
745 * Example:
746 * user:JoeBob,password:iamcool
747 *
748 * @param {String} mapString - String form of map. See example above.
749 * @param {Objcect} container - Container to hold the map
750 *
751 * @returns {Object} A map of all of key value pairs.
752 */
753 map(mapString, container) {
754 let params;
755 let key;
756 let value;
757
758 // prepend a ',' so we can use a regex to split
759 const mungedString = `,${mapString}`;
760
761 // split on ,<key>:<value>
762 params = mungedString.split(/,([^,]+?):/);
763
764 // strip off the first match, which is an empty string
765 params = params.splice(1);
766
767 for (let i = 0; i < params.length; i++) {
768 key = params[i].trim();
769 value = params[i + 1].trim();
770
771 if (value.toLocaleLowerCase() === 'true' || value.toLocaleLowerCase() === 'false') {
772 value = Boolean(value.toLocaleLowerCase() === 'true');
773 }
774
775 container[key] = value; // eslint-disable-line no-param-reassign
776 i += 1;
777 }
778 },
779
780 /**
781 * Parses a string of keys and values. Each call is one element
782 * in the array.
783 *
784 * Keys are separated from values by a ':'
785 * Key value pairs are separated from each other by a ','
786 * Example:
787 * user:JoeBob,password:iamcool
788 *
789 * @param {String} mapString - String form of map. See example above.
790 * @param {Objcect[]} container - Container into which to push the map object
791 *
792 * @returns {Object[]} An array containing one map per call with the same container
793 */
794 mapArray(mapString, container) {
795 const mapObject = {};
796 let params;
797 let i;
798
799 // prepend a ',' so we can use a regex to split
800 const mungedString = `,${mapString}`;
801
802 // split on ,<key>:<value>
803 params = mungedString.split(/,([^,]+?):/);
804
805 // strip off the first match, which is an empty string
806 params = params.splice(1);
807
808 for (i = 0; i < params.length; i++) {
809 mapObject[params[i].trim()] = params[i + 1].trim();
810 i += 1;
811 }
812
813 container.push(mapObject);
814 },
815
816 /**
817 * Lower cases all the keys in an object, including nested keys.
818 *
819 * Typically used when working with JSON input.
820 *
821 * If a parameter that is not of type object is provided, the original parameter will be returned.
822 *
823 * @param {Object} obj - Object in which to lowercase keys
824 *
825 * @returns {Object} - An object resembling the provided obj, but with lowercased keys.
826 */
827 lowerCaseKeys(obj) {
828 if (typeof obj === 'object') {
829 const newObj = {};
830 Object.keys(obj).forEach((key) => {
831 if (typeof obj[key] === 'object') {
832 const nestedObj = this.lowerCaseKeys(obj[key]);
833 newObj[key.toLocaleLowerCase()] = nestedObj;
834 } else {
835 newObj[key.toLocaleLowerCase()] = obj[key];
836 }
837 });
838 return newObj;
839 }
840 return obj;
841 },
842
843 /**
844 * Writes data to a file
845 *
846 * @param {String} data - The data to write
847 * @param {String} file - The file to write to
848 *
849 * @returns A promise which will be resolved when the file is written
850 * or rejected if an error occurs
851 */
852 writeDataToFile(data, file) {
853 const deferred = q.defer();
854
855 if (fs.existsSync(file)) {
856 fs.unlinkSync(file);
857 }
858
859 fs.writeFile(
860 file,
861 data,
862 { mode: 0o400 },
863 (err) => {
864 if (err) {
865 deferred.reject(err);
866 } else {
867 deferred.resolve();
868 }
869 }
870 );
871
872 return deferred.promise;
873 },
874
875 /**
876 * Reads data from a file
877 *
878 * @param {String} file - The file to read from
879 *
880 * @returns A promise which will be resolved with the contents of the file
881 * or rejected if an error occurs
882 */
883 readDataFromFile(file) {
884 const deferred = q.defer();
885
886 fs.readFile(file, (err, data) => {
887 if (err) {
888 deferred.reject(err);
889 } else {
890 deferred.resolve(data);
891 }
892 });
893
894 return deferred.promise;
895 },
896
897 /**
898 * Writes data to a URL.
899 *
900 * Only file URLs are supported for now.
901 *
902 * @param {String} data - The data to write
903 * @param {String} url - URL to which to write. Only file URLs are supported
904 */
905 writeDataToUrl(data, url) {
906 const deferred = q.defer();
907 let parsedUrl;
908
909 try {
910 parsedUrl = URL.parse(url);
911 if (parsedUrl.protocol === 'file:') {
912 this.writeDataToFile(data, parsedUrl.pathname)
913 .then(() => {
914 deferred.resolve();
915 })
916 .catch((err) => {
917 deferred.reject(err);
918 });
919 } else {
920 deferred.reject(new Error('Only file URLs are currently supported.'));
921 }
922 } catch (err) {
923 deferred.reject(err);
924 }
925
926 return deferred.promise;
927 },
928
929 /**
930 * Disambiguates data that is either raw data or in a URI.
931 *
932 * @param {String} dataOrUri - Data URI (file, http, https, AWS arn) to
933 * location containing data.
934 * @param {Boolean} dataIsUri - Indicates that password is a URI for the password
935 * @param {Object} [options] - Optional parameters.
936 * @param {Object} [options.clOptions] - Command line options if called from a script.
937 * Required for Azure Storage URIs
938 * @param {Object} [options.logger] - Logger to use. Or, pass loggerOptions to
939 * get your own logger.
940 * @param {Object} [options.loggerOptions] - Options for the logger.
941 * See {@link module:logger.getLogger} for details.
942 */
943 readData(dataOrUri, dataIsUri, options) {
944 const deferred = q.defer();
945 if (dataIsUri) {
946 const matchOptions = {
947 storageUri: dataOrUri
948 };
949 let provider;
950
951 // If no cloud provider match, proceed since URI may be plain URL or plain data
952 try {
953 provider = cloudProviderFactory.getCloudProvider(null, options, matchOptions);
954 } catch (err) {
955 if (err.message !== 'Unavailable cloud provider') {
956 throw err;
957 }
958 }
959
960 if (provider) {
961 const clOptions = (options && options.clOptions) ? options.clOptions : null;
962 provider.init(clOptions)
963 .then(() => {
964 return this.tryUntil(
965 provider,
966 this.MEDIUM_RETRY,
967 provider.getDataFromUri,
968 [dataOrUri]
969 );
970 })
971 .then((data) => {
972 deferred.resolve(data);
973 })
974 .catch((err) => {
975 logger.info('Could not find BIG-IQ credentials file in cloud provider storage');
976 deferred.reject(err);
977 });
978 } else {
979 // Plain old url
980 this.getDataFromUrl(dataOrUri)
981 .then((data) => {
982 deferred.resolve(data);
983 })
984 .catch((err) => {
985 deferred.reject(err);
986 });
987 }
988 } else {
989 // Plain old data
990 deferred.resolve(dataOrUri);
991 }
992
993 return deferred.promise;
994 },
995
996 /**
997 * Gets data from a URL.
998 *
999 * Only file, http, https URLs are supported for now.
1000 *
1001 * @param {String} url - URL from which to get the data. Only
1002 * file, http, https URLs are supported for now.
1003 * @param {Object} [options] - http/https request options
1004 *
1005 * @returns {String} A promise which will be resolved with the data
1006 * or rejected if an error occurs.
1007 */
1008 getDataFromUrl(url, options) {
1009 const parsedUrl = URL.parse(url);
1010 const deferred = q.defer();
1011 const requestOptions = Object.assign({}, options);
1012 let executor;
1013
1014 try {
1015 if (parsedUrl.protocol === 'file:') {
1016 fs.readFile(parsedUrl.pathname, { encoding: 'ascii' }, (err, data) => {
1017 if (err) {
1018 deferred.reject(err);
1019 } else {
1020 deferred.resolve(data.trim());
1021 }
1022 });
1023 } else if (parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:') {
1024 executor = parsedUrl.protocol === 'http:' ? http : https;
1025 requestOptions.protocol = parsedUrl.protocol;
1026 requestOptions.hostname = parsedUrl.hostname;
1027 requestOptions.port = parsedUrl.port;
1028 requestOptions.path = parsedUrl.pathname + (parsedUrl.search ? parsedUrl.search : '');
1029
1030 executor.get(requestOptions, (response) => {
1031 const statusCode = response.statusCode;
1032 const contentType = response.headers['content-type'];
1033 let rawData = '';
1034 let data;
1035
1036 if (statusCode >= 300) {
1037 const message = `${url.toString()} returned with status code ${statusCode}`;
1038 deferred.reject(new Error(message));
1039 response.resume();
1040 }
1041
1042 response.setEncoding('utf8');
1043 response.on('data', (chunk) => {
1044 rawData += chunk;
1045 });
1046 response.on('end', () => {
1047 if (contentType && contentType.indexOf('application/json') !== -1) {
1048 try {
1049 data = JSON.parse(rawData);
1050 } catch (err) {
1051 deferred.reject(err);
1052 }
1053 } else {
1054 data = rawData.trim();
1055 }
1056 deferred.resolve(data);
1057 });
1058 })
1059 .on('error', (err) => {
1060 deferred.reject(err);
1061 });
1062 } else {
1063 deferred.reject(new Error('Only file, http, and https URLs are currently supported.'));
1064 }
1065 } catch (err) {
1066 deferred.reject(err);
1067 }
1068
1069 return deferred.promise;
1070 },
1071
1072 /**
1073 * Downloads a file from a URL
1074 *
1075 * @param {String} url - URL to download from
1076 *
1077 * @returns {Promise} A promise which is resolved with the file name the file was downloaded to
1078 * or rejected if an error occurs.
1079 */
1080 download(url) {
1081 const deferred = q.defer();
1082 const parsedUrl = URL.parse(url);
1083 let executor;
1084
1085 if (parsedUrl.protocol === 'http:') {
1086 executor = http;
1087 } else if (parsedUrl.protocol === 'https:') {
1088 executor = https;
1089 } else {
1090 deferred.reject(new Error(`Unhandled protocol: ${parsedUrl.protocol}`));
1091 return deferred.promise;
1092 }
1093
1094 const fileName = `/tmp/f5-cloud-libs_'${Date.now()}`;
1095 const file = fs.createWriteStream(fileName);
1096
1097 executor.get(url, (response) => {
1098 response.pipe(file);
1099 file.on('finish', () => {
1100 file.close(() => {
1101 deferred.resolve(fileName);
1102 });
1103 });
1104 })
1105 .on('error', (err) => {
1106 if (fs.existsSync(fileName)) {
1107 fs.unlink(fileName);
1108 }
1109 deferred.reject(err);
1110 });
1111
1112 return deferred.promise;
1113 },
1114
1115 /**
1116 * Synchronously removes a directory and all files in the directory
1117 *
1118 * @param {String} dir - Directory to remove
1119 */
1120 removeDirectorySync(dir) {
1121 if (fs.existsSync(dir)) {
1122 fs.readdirSync(dir).forEach((file) => {
1123 const curPath = `${dir}/${file}`;
1124 if (fs.statSync(curPath).isDirectory()) {
1125 this.removeDirectorySync(curPath);
1126 } else {
1127 fs.unlinkSync(curPath);
1128 }
1129 });
1130 fs.rmdirSync(dir);
1131 }
1132 },
1133
1134 /**
1135 * Performs a local ready check
1136 *
1137 * @returns {Promise} A promise which is resolved upon completion of
1138 * the script or rejected if an error occurs.
1139 */
1140 localReady() {
1141 const deferred = q.defer();
1142
1143 logger.silly('Performing local ready check');
1144 childProcess.exec(`/bin/sh ${__dirname}/../scripts/waitForMcp.sh`, (error) => {
1145 if (error) {
1146 deferred.reject(error);
1147 } else {
1148 deferred.resolve();
1149 }
1150 });
1151
1152 return deferred.promise;
1153 },
1154
1155 /**
1156 * Runs a shell command and returns the output
1157 *
1158 * @param {String} commands - Command to run
1159 *
1160 * @returns {Promise} A promise which is resolved with the results of the
1161 * command or rejected if an error occurs.
1162 */
1163 runShellCommand(command) {
1164 const deferred = q.defer();
1165 childProcess.exec(command, (error, stdout, stderr) => {
1166 if (error) {
1167 deferred.reject(new Error(`${error}:${stderr}`));
1168 } else {
1169 deferred.resolve(stdout);
1170 }
1171 });
1172 return deferred.promise;
1173 },
1174
1175 /**
1176 * Runs a tmsh command and returns the output
1177 *
1178 * @param {String} commands - Command to run ('list ltm pool', for example)
1179 *
1180 * @returns {Promise} A promise which is resolved with the results of the
1181 * command or rejected if an error occurs.
1182 */
1183 runTmshCommand(command) {
1184 const deferred = q.defer();
1185
1186 this.localReady()
1187 .then(() => {
1188 const tmshCommand = `/usr/bin/tmsh -a ${command}`;
1189 return this.tryUntil(this, this.MEDIUM_RETRY, this.runShellCommand, [tmshCommand]);
1190 })
1191 .then((response) => {
1192 deferred.resolve(response);
1193 })
1194 .catch((err) => {
1195 logger.silly('tmsh command failed', err && err.message ? err.message : err);
1196 deferred.reject(err);
1197 });
1198 return deferred.promise;
1199 },
1200
1201 /**
1202 * Parse a tmsh response into an object
1203 *
1204 * @param {String} response - tmsh response data to parse
1205 *
1206 * @returns {Promise} A promise containing the parsed response data, as an object.
1207 */
1208 parseTmshResponse(response) {
1209 const keyVals = response.split(/\s+/);
1210 const result = {};
1211
1212 // find the parts inside the {}
1213 const openingBraceIndex = keyVals.indexOf('{');
1214 const closingBraceIndex = keyVals.lastIndexOf('}');
1215
1216 for (let i = openingBraceIndex + 1; i < closingBraceIndex - 1; i += 2) {
1217 result[keyVals[i]] = keyVals[i + 1];
1218 }
1219
1220 return result;
1221 },
1222
1223 /**
1224 * Returns the product type (BigIP/BigIQ) from the bigip_base.conf file
1225 */
1226 getProductString() {
1227 const deferred = q.defer();
1228 fs.stat('/usr/bin/tmsh', (fsStatErr) => {
1229 if (fsStatErr && fsStatErr.code === 'ENOENT') {
1230 deferred.resolve('CONTAINER');
1231 } else if (fsStatErr) {
1232 logger.silly('Unable to determine product', fsStatErr.message);
1233 deferred.reject(fsStatErr);
1234 } else {
1235 this.runShellCommand('grep -m 1 product /config/bigip_base.conf | awk \'{print $2}\'')
1236 .then((response) => {
1237 deferred.resolve(response.trim());
1238 })
1239 .catch((err) => {
1240 logger.silly('Unable to determine product', err && err.message ? err.message : err);
1241 deferred.reject(err);
1242 });
1243 }
1244 });
1245
1246 return deferred.promise;
1247 },
1248 /**
1249 * Compares two software version numbers (e.g. "1.7.1" or "1.2b").
1250 *
1251 * This function is based on https://gist.github.com/TheDistantSea/8021359
1252 *
1253 * @param {string} v1 The first version to be compared.
1254 * @param {string} v2 The second version to be compared.
1255 * @param {object} [options] Optional flags that affect comparison behavior:
1256 * <ul>
1257 * <li>
1258 * <tt>zeroExtend: true</tt> changes the result if one version
1259 * string has less parts than the other. In
1260 * this case the shorter string will be padded with "zero"
1261 * parts instead of being considered smaller.
1262 * </li>
1263 * </ul>
1264 * @returns {number}
1265 * <ul>
1266 * <li>0 if the versions are equal</li>
1267 * <li>a negative integer iff v1 < v2</li>
1268 * <li>a positive integer iff v1 > v2</li>
1269 * </ul>
1270 *
1271 * @copyright by Jon Papaioannou (["john", "papaioannou"].join(".") + "@gmail.com"),
1272 * Eugene Molotov (["eugene", "m92"].join(".") + "@gmail.com")
1273 * @license This function is in the public domain. Do what you want with it, no strings attached.
1274 */
1275 versionCompare(v1, v2, options) {
1276 function compareParts(v1parts, v2parts, zeroExtend) {
1277 if (zeroExtend) {
1278 while (v1parts.length < v2parts.length) v1parts.push('0');
1279 while (v2parts.length < v1parts.length) v2parts.push('0');
1280 }
1281
1282 for (let i = 0; i < v1parts.length; i++) {
1283 if (v2parts.length === i) {
1284 return 1;
1285 }
1286
1287 let v1part = parseInt(v1parts[i], 10);
1288 let v2part = parseInt(v2parts[i], 10);
1289 const v1partIsString = (Number.isNaN(v1part));
1290 const v2partIsString = (Number.isNaN(v2part));
1291 v1part = v1partIsString ? v1parts[i] : v1part;
1292 v2part = v2partIsString ? v2parts[i] : v2part;
1293
1294 if (v1partIsString === v2partIsString) {
1295 if (v1partIsString === false) {
1296 // integer compare
1297 if (v1part > v2part) {
1298 return 1;
1299 } else if (v1part < v2part) {
1300 return -1;
1301 }
1302 } else {
1303 // letters and numbers in string
1304 // split letters and numbers
1305 const v1subparts = v1part.match(/[a-zA-Z]+|[0-9]+/g);
1306 const v2subparts = v2part.match(/[a-zA-Z]+|[0-9]+/g);
1307 if ((v1subparts.length === 1) && (v2subparts.length === 1)) {
1308 // only letters in string
1309 v1part = v1subparts[0];
1310 v2part = v2subparts[0];
1311 if (v1part > v2part) {
1312 return 1;
1313 } else if (v1part < v2part) {
1314 return -1;
1315 }
1316 }
1317
1318 if (v1part !== v2part) {
1319 const result = compareParts(v1subparts, v2subparts);
1320 if (result !== 0) {
1321 return result;
1322 }
1323 }
1324 }
1325 } else {
1326 return v2partIsString ? 1 : -1;
1327 }
1328 }
1329
1330 if (v1parts.length !== v2parts.length) {
1331 return -1;
1332 }
1333
1334 return 0;
1335 }
1336
1337 const v1split = v1.split(/[.-]/);
1338 const v2split = v2.split(/[.-]/);
1339 const zeroExtend = options && options.zeroExtend;
1340 return compareParts(v1split, v2split, zeroExtend);
1341 },
1342 /**
1343 * Writes UCS file to disk
1344 *
1345 * @returns {Promise} A promise which is resolved upon completion of
1346 * the script or rejected if an error occurs.
1347 */
1348 writeUcsFile(ucsFilePath, ucsData) {
1349 const deferred = q.defer();
1350 let ucsFile;
1351 // If ucsData has a pipe method (is a stream), use it
1352 if (ucsData.pipe) {
1353 logger.silly('ucsData is a Stream');
1354 if (!fs.existsSync(ucsFilePath.substring(0, ucsFilePath.lastIndexOf('/')))) {
1355 fs.mkdirSync(ucsFilePath.substring(0, ucsFilePath.lastIndexOf('/')), { recursive: true });
1356 }
1357 ucsFile = fs.createWriteStream(ucsFilePath);
1358
1359 ucsData.pipe(ucsFile);
1360
1361 ucsFile.on('finish', () => {
1362 logger.silly('finished piping ucsData');
1363 ucsFile.close(() => {
1364 deferred.resolve(true);
1365 });
1366 });
1367 ucsFile.on('error', (err) => {
1368 logger.silly('Error piping ucsData', err);
1369 deferred.reject(err);
1370 });
1371 ucsData.on('error', (err) => {
1372 logger.info('Error reading ucs data', err);
1373 deferred.reject(err);
1374 });
1375 } else {
1376 // Otherwise, assume it's a Buffer
1377 logger.silly('ucsData is a Buffer');
1378 logger.silly(`ucsFilePath: ${ucsFilePath}`);
1379 try {
1380 fs.mkdirSync(ucsFilePath.substr(0, ucsFilePath.lastIndexOf('/')));
1381 } catch (err) {
1382 if (err.code !== 'EEXIST') {
1383 deferred.reject(err);
1384 }
1385 }
1386 fs.writeFile(ucsFilePath, ucsData, (err) => {
1387 logger.silly('finished writing ucsData');
1388 if (err) {
1389 logger.silly('Error writing ucsData', err);
1390 deferred.reject(err);
1391 return;
1392 }
1393 deferred.resolve(true);
1394 });
1395 }
1396
1397 return deferred.promise;
1398 },
1399
1400};
1401
1402/**
1403 * Copies all the saved arguments (from saveArgs) to the startup file
1404 * so that when the box reboots, the arguments are executed.
1405 */
1406function prepareArgsForReboot() {
1407 const deferred = q.defer();
1408 let startupScripts;
1409 let startupCommands;
1410 let startupCommandsChanged;
1411
1412 const STARTUP_DIR = '/config/';
1413 const STARTUP_FILE = `${STARTUP_DIR}startup`;
1414 const REBOOT_SIGNAL = ipc.signalBasePath + signals.REBOOT;
1415
1416 if (!fs.existsSync(STARTUP_DIR)) {
1417 logger.debug('No /config directory. Skipping.');
1418 deferred.resolve();
1419 return deferred.promise;
1420 }
1421
1422 try {
1423 startupCommands = fs.readFileSync(STARTUP_FILE, 'utf8');
1424 } catch (err) {
1425 logger.warn('Error reading starup file.');
1426 deferred.reject(err);
1427 return deferred.promise;
1428 }
1429
1430 try {
1431 startupScripts = fs.readdirSync(REBOOT_SCRIPTS_DIR);
1432 } catch (err) {
1433 logger.warn('Error reading directory with reboot args.');
1434 deferred.reject(err);
1435 return deferred.promise;
1436 }
1437
1438 // Make sure there's a new line at the end in case we add anything
1439 startupCommands += EOL;
1440
1441 // If we just rebooted, make sure the REBOOT signal file is deleted
1442 // so scripts don't think we need to reboot again
1443 if (startupCommands.indexOf(REBOOT_SIGNAL) === -1) {
1444 startupCommandsChanged = true;
1445 startupCommands += `rm -f ${REBOOT_SIGNAL}${EOL}`;
1446 }
1447
1448 startupScripts.forEach((script) => {
1449 const fullPath = REBOOT_SCRIPTS_DIR + script;
1450 if (startupCommands.indexOf(fullPath) === -1) {
1451 startupCommandsChanged = true;
1452 startupCommands += `if [ -f ${fullPath} ]; then${EOL}`;
1453 startupCommands += ` ${fullPath} &${EOL}`;
1454 startupCommands += `fi${EOL}`;
1455 startupCommands += EOL;
1456 }
1457 });
1458
1459 if (startupCommandsChanged) {
1460 try {
1461 fs.writeFileSync(STARTUP_FILE, startupCommands);
1462 } catch (err) {
1463 logger.warn('Failed writing startup file', STARTUP_FILE, err);
1464 }
1465 }
1466
1467 deferred.resolve();
1468
1469 return deferred.promise;
1470}