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 | ;
|
18 |
|
19 | const q = require('q');
|
20 | const BigIp = require('../lib/bigIp');
|
21 | const Logger = require('../lib/logger');
|
22 | const ActiveError = require('../lib/activeError');
|
23 | const cloudProviderFactory = require('../lib/cloudProviderFactory');
|
24 | const ipc = require('../lib/ipc');
|
25 | const signals = require('../lib/signals');
|
26 | const util = require('../lib/util');
|
27 | const commonOptions = require('./commonOptions');
|
28 | const localCryptoUtil = require('../lib/localCryptoUtil');
|
29 |
|
30 | (function run() {
|
31 | const runner = {
|
32 | /**
|
33 | * Runs the clustering script
|
34 | *
|
35 | * @param {String[]} argv - The process arguments
|
36 | * @param {Object} testOpts - Options used during testing
|
37 | * @param {Object} testOpts.bigIp - BigIp object to use for testing
|
38 | * @param {Function} cb - Optional cb to call when done
|
39 | */
|
40 | run(argv, testOpts, cb) {
|
41 | const DEFAULT_LOG_FILE = '/tmp/cluster.log';
|
42 | const ARGS_FILE_ID = `cluster_${Date.now()}`;
|
43 | const KEYS_TO_MASK = ['-p', '--password', '--remote-password'];
|
44 | const REQUIRED_OPTIONS = ['host', 'user'];
|
45 |
|
46 | const OPTIONS_TO_UNDEFINE = [
|
47 | 'remotePassword',
|
48 | 'remotePasswordUrl',
|
49 | 'password',
|
50 | 'passwordUrl'
|
51 | ];
|
52 |
|
53 | const providerOptions = {};
|
54 | const loggerOptions = {};
|
55 | const optionsForTest = {};
|
56 |
|
57 | let provider;
|
58 | let loggableArgs;
|
59 | let logger;
|
60 | let logFileName;
|
61 | let bigIp;
|
62 | let rebooting;
|
63 | let exiting;
|
64 |
|
65 | let bigIqPasswordData = {};
|
66 |
|
67 | Object.assign(optionsForTest, testOpts);
|
68 |
|
69 | try {
|
70 | /* eslint-disable max-len */
|
71 | const options = commonOptions.getCommonOptions(DEFAULT_LOG_FILE)
|
72 | .option(
|
73 | '--config-sync-ip <config_sync_ip>',
|
74 | 'IP address for config sync.'
|
75 | )
|
76 | .option(
|
77 | '--big-iq-failover-peer-ip <peer_ip>',
|
78 | 'If configuring a BIG-IQ failover primary, this is the management IP address for the secondary'
|
79 | )
|
80 | .option(
|
81 | '--cloud <provider>',
|
82 | 'Cloud provider (aws | azure | etc.). Optionally use this if passwords are stored in cloud storage. This replaces the need for --remote-user/--remote-password(-url). An implemetation of cloudProvider must exist at the correct location.'
|
83 | )
|
84 | .option(
|
85 | ' --big-iq-password-data-uri <key_uri>',
|
86 | ' URI (arn, url, etc.) to a JSON file containing the BIG-IQ passwords (required keys: admin, root)'
|
87 | )
|
88 | .option(
|
89 | ' --big-iq-password-data-encrypted',
|
90 | ' Indicates that the BIG-IQ password data is encrypted (either with encryptDataToFile or generatePassword)'
|
91 | )
|
92 | .option(
|
93 | ' --primary',
|
94 | 'If using a cloud provider, indicates that this is the primary. If running on a BIG-IP credentials should be stored. If running on a BIG-IQ, --create-group and --join-group options are not needed.'
|
95 | )
|
96 | .option(
|
97 | ' --provider-options <cloud_options>',
|
98 | 'Any options (JSON stringified) that are required for the specific cloud provider.',
|
99 | util.map,
|
100 | providerOptions
|
101 | )
|
102 | .option(
|
103 | '--create-group',
|
104 | 'Create a device group with the options:'
|
105 | )
|
106 | .option(
|
107 | ' --device-group <device_group>',
|
108 | ' Name of the device group.'
|
109 | )
|
110 | .option(
|
111 | ' --sync-type <sync_type>',
|
112 | ' Type of sync this cluster is for ("sync-only" | "sync-failover").'
|
113 | )
|
114 | .option(
|
115 | ' --device <device_name>',
|
116 | ' A device name to add to the group. For multiple devices, use multiple --device entries.',
|
117 | util.collect,
|
118 | []
|
119 | )
|
120 | .option(
|
121 | ' --auto-sync',
|
122 | ' Enable auto sync.'
|
123 | )
|
124 | .option(
|
125 | ' --save-on-auto-sync',
|
126 | ' Enable save on sync if auto sync is enabled.'
|
127 | )
|
128 | .option(
|
129 | ' --full-load-on-sync',
|
130 | ' Enable full load on sync.'
|
131 | )
|
132 | .option(
|
133 | ' --asm-sync',
|
134 | ' Enable ASM sync.'
|
135 | )
|
136 | .option(
|
137 | ' --network-failover',
|
138 | ' Enable network failover.'
|
139 | )
|
140 | .option(
|
141 | '--join-group',
|
142 | 'Join a remote device group with the options:'
|
143 | )
|
144 | .option(
|
145 | ' --remote-host <remote_ip_address>',
|
146 | ' Managemnt IP for the BIG-IP on which the group exists.'
|
147 | )
|
148 | .option(
|
149 | ' --remote-user <remote_user>',
|
150 | ' Remote BIG-IP admin user name.'
|
151 | )
|
152 | .option(
|
153 | ' --remote-password [remote_password]',
|
154 | ' Remote BIG-IP admin user password. Use this or --remote-password-url'
|
155 | )
|
156 | .option(
|
157 | ' --remote-password-url [remote_password_url]',
|
158 | ' URL (file, http(s)) that contains. Use this or --remote-password'
|
159 | )
|
160 | .option(
|
161 | ' --remote-port <remote_port>',
|
162 | ' Remote BIG-IP port to connect to. Default is port of this BIG-IP.',
|
163 | parseInt
|
164 | )
|
165 | .option(
|
166 | ' --device-group <remote_device_group_name>',
|
167 | ' Name of existing device group on remote BIG-IP to join.'
|
168 | )
|
169 | .option(
|
170 | ' --sync',
|
171 | ' Tell the remote to sync to us after joining the group.'
|
172 | )
|
173 | .option(
|
174 | '--remove-from-cluster',
|
175 | 'Remove a device from the cluster'
|
176 | )
|
177 | .option(
|
178 | ' --device-group <device_group>',
|
179 | ' Name of the device group.'
|
180 | )
|
181 | .option(
|
182 | ' --device <device_name>',
|
183 | ' Device name to remove.'
|
184 | )
|
185 | .parse(argv);
|
186 | /* eslint-enable max-len */
|
187 |
|
188 | options.port = options.port || 443;
|
189 |
|
190 | loggerOptions.console = options.console;
|
191 | loggerOptions.logLevel = options.logLevel;
|
192 | loggerOptions.module = module;
|
193 |
|
194 | if (options.output) {
|
195 | loggerOptions.fileName = options.output;
|
196 | }
|
197 |
|
198 | if (options.errorFile) {
|
199 | loggerOptions.errorFile = options.errorFile;
|
200 | }
|
201 |
|
202 | logger = Logger.getLogger(loggerOptions);
|
203 | ipc.setLoggerOptions(loggerOptions);
|
204 | util.setLoggerOptions(loggerOptions);
|
205 |
|
206 | // Remove specific options with no provided value
|
207 | OPTIONS_TO_UNDEFINE.forEach((opt) => {
|
208 | if (typeof options[opt] === 'boolean') {
|
209 | logger.debug(`No value set for option ${opt}. Removing option.`);
|
210 | options[opt] = undefined;
|
211 | }
|
212 | });
|
213 |
|
214 | // Expose options for test code
|
215 | this.options = options;
|
216 |
|
217 | // Log the input, but don't log passwords
|
218 | loggableArgs = argv.slice();
|
219 | for (let i = 0; i < loggableArgs.length; i++) {
|
220 | if (KEYS_TO_MASK.indexOf(loggableArgs[i]) !== -1) {
|
221 | loggableArgs[i + 1] = '*******';
|
222 | }
|
223 | }
|
224 | logger.info(`${loggableArgs[1]} called with`, loggableArgs.join(' '));
|
225 |
|
226 |
|
227 | for (let i = 0; i < REQUIRED_OPTIONS.length; i++) {
|
228 | if (!options[REQUIRED_OPTIONS[i]]) {
|
229 | const error = `${REQUIRED_OPTIONS[i]} is a required command line option.`;
|
230 |
|
231 | ipc.send(signals.CLOUD_LIBS_ERROR);
|
232 |
|
233 | util.logError(error, loggerOptions);
|
234 | util.logAndExit(error, 'error', 1);
|
235 | }
|
236 | }
|
237 |
|
238 | if (!options.password && !options.passwordUrl && !options.bigIqPasswordDataUri) {
|
239 | const error =
|
240 | 'One of --password, --password-url or --big-iq-password-data-uri is required.';
|
241 |
|
242 | ipc.send(signals.CLOUD_LIBS_ERROR);
|
243 |
|
244 | util.logError(error, loggerOptions);
|
245 | util.logAndExit(error, 'error', 1);
|
246 | }
|
247 |
|
248 | if (options.bigIqFailoverPeerIp && !options.bigIqPasswordDataUri) {
|
249 | const error = '--big-iq-password-data-uri is required for BIG-IQ failover';
|
250 |
|
251 | ipc.send(signals.CLOUD_LIBS_ERROR);
|
252 |
|
253 | util.logError(error, loggerOptions);
|
254 | util.logAndExit(error, 'error', 1);
|
255 | }
|
256 |
|
257 | // When running in cloud init, we need to exit so that cloud init can complete and
|
258 | // allow the BIG-IP services to start
|
259 | if (options.background) {
|
260 | logFileName = options.output || DEFAULT_LOG_FILE;
|
261 | logger.info('Spawning child process to do the work. Output will be in', logFileName);
|
262 | util.runInBackgroundAndExit(process, logFileName);
|
263 | }
|
264 |
|
265 | if (options.cloud) {
|
266 | // Create provider client, allowing provider to be overwritten in test code
|
267 | provider = optionsForTest.cloudProvider;
|
268 | if (!provider) {
|
269 | provider = cloudProviderFactory.getCloudProvider(
|
270 | options.cloud,
|
271 | {
|
272 | loggerOptions,
|
273 | clOptions: options
|
274 | }
|
275 | );
|
276 | }
|
277 | }
|
278 |
|
279 | // Save args in restart script in case we need to reboot to recover from an error
|
280 | util.saveArgs(argv, ARGS_FILE_ID)
|
281 | .then(() => {
|
282 | if (options.waitFor) {
|
283 | logger.info('Waiting for', options.waitFor);
|
284 | return ipc.once(options.waitFor);
|
285 | }
|
286 | return q();
|
287 | })
|
288 | .then(() => {
|
289 | // Whatever we're waiting for is done, so don't wait for
|
290 | // that again in case of a reboot
|
291 | return util.saveArgs(argv, ARGS_FILE_ID, ['--wait-for']);
|
292 | })
|
293 | .then(() => {
|
294 | logger.info('Cluster starting.');
|
295 | ipc.send(signals.CLUSTER_RUNNING);
|
296 |
|
297 | // Retrieve, and save, stored password data
|
298 | if (options.bigIqPasswordDataUri) {
|
299 | return util.readData(options.bigIqPasswordDataUri,
|
300 | true,
|
301 | {
|
302 | clOptions: providerOptions,
|
303 | logger,
|
304 | loggerOptions
|
305 | })
|
306 | .then((uriData) => {
|
307 | if (options.bigIqPasswordDataEncrypted) {
|
308 | return localCryptoUtil.decryptPassword(uriData);
|
309 | }
|
310 | return q(uriData);
|
311 | })
|
312 | .then((uriData) => {
|
313 | bigIqPasswordData = util.lowerCaseKeys(
|
314 | JSON.parse(uriData.trim())
|
315 | );
|
316 | })
|
317 | .then(() => {
|
318 | if (!bigIqPasswordData.admin || !bigIqPasswordData.root) {
|
319 | const msg =
|
320 | 'Required passwords missing from --biq-iq-password-data-uri';
|
321 | logger.info(msg);
|
322 | return q.reject(msg);
|
323 | }
|
324 | return q();
|
325 | })
|
326 | .catch((err) => {
|
327 | logger.info('Unable to retrieve JSON from --big-iq-password-data-uri');
|
328 | return q.reject(err);
|
329 | });
|
330 | }
|
331 | return q();
|
332 | })
|
333 | .then(() => {
|
334 | // Create the bigIp client object
|
335 | bigIp = optionsForTest.bigIp || new BigIp({ loggerOptions });
|
336 |
|
337 | logger.info('Initializing BIG-IP.');
|
338 | return bigIp.init(
|
339 | options.host,
|
340 | options.user || 'admin',
|
341 | options.password || options.passwordUrl || bigIqPasswordData.admin,
|
342 | {
|
343 | port: options.port,
|
344 | passwordIsUrl: typeof options.passwordUrl !== 'undefined',
|
345 | passwordEncrypted: options.passwordEncrypted,
|
346 | clOptions: providerOptions
|
347 | }
|
348 | );
|
349 | })
|
350 | .then(() => {
|
351 | logger.info('Waiting for BIG-IP to be ready.');
|
352 | return bigIp.ready();
|
353 | })
|
354 | .then(() => {
|
355 | logger.info('BIG-IP is ready.');
|
356 |
|
357 | if (options.cloud) {
|
358 | logger.info('Initializing cloud provider.');
|
359 | return provider.init(providerOptions);
|
360 | }
|
361 | return q();
|
362 | })
|
363 | .then(() => {
|
364 | if (options.cloud) {
|
365 | return provider.bigIpReady();
|
366 | }
|
367 | return q();
|
368 | })
|
369 | .then(() => {
|
370 | // Primary BIG-IQ initiates peering with secondary BIG-IQ
|
371 | if (options.primary
|
372 | && options.bigIqFailoverPeerIp
|
373 | && bigIp.isBigIq()
|
374 | ) {
|
375 | logger.info(`Adding ${options.bigIqFailoverPeerIp} as high availability peer.`);
|
376 | return bigIp.cluster.addSecondary(
|
377 | options.bigIqFailoverPeerIp,
|
378 | options.user || 'admin',
|
379 | bigIp.password,
|
380 | bigIqPasswordData.root
|
381 | );
|
382 | }
|
383 | return q();
|
384 | })
|
385 | .then(() => {
|
386 | if (options.configSyncIp) {
|
387 | logger.info('Setting config sync ip.');
|
388 | return bigIp.cluster.configSyncIp(options.configSyncIp);
|
389 | }
|
390 | return q();
|
391 | })
|
392 | .then(() => {
|
393 | if (options.createGroup && bigIp.isBigIp()) {
|
394 | if (!options.deviceGroup || !options.syncType) {
|
395 | throw new Error('Create device group: device-group and sync-type required.');
|
396 | }
|
397 |
|
398 | logger.info('Creating group', options.deviceGroup);
|
399 | const deviceGroupOptions = {
|
400 | autoSync: options.autoSync,
|
401 | saveOnAutoSync: options.saveOnAutoSync,
|
402 | fullLoadOnSync: options.fullLoadOnSync,
|
403 | asmSync: options.asmSync,
|
404 | networkFailover: options.networkFailover
|
405 | };
|
406 |
|
407 | return bigIp.cluster.createDeviceGroup(
|
408 | options.deviceGroup,
|
409 | options.syncType,
|
410 | options.device,
|
411 | deviceGroupOptions
|
412 | );
|
413 | }
|
414 | return q();
|
415 | })
|
416 | .then((response) => {
|
417 | logger.debug(response);
|
418 |
|
419 | // If we are using cloud storage and are the primary, store our credentials
|
420 | if (options.cloud && options.primary && bigIp.isBigIp()) {
|
421 | logger.info('Storing credentials.');
|
422 | return util.tryUntil(
|
423 | provider,
|
424 | util.DEFAULT_RETRY,
|
425 | provider.putPrimaryCredentials
|
426 | );
|
427 | }
|
428 | return q();
|
429 | })
|
430 | .then((response) => {
|
431 | logger.debug(response);
|
432 |
|
433 | // options.cloud set indicates that the provider must use some storage
|
434 | // for its primary credentials
|
435 | if (options.cloud && options.joinGroup && bigIp.isBigIp()) {
|
436 | logger.info('Getting primary credentials.');
|
437 | return util.tryUntil(
|
438 | provider,
|
439 | util.DEFAULT_RETRY,
|
440 | provider.getPrimaryCredentials,
|
441 | [options.remoteHost, options.remotePort]
|
442 | );
|
443 | }
|
444 | return q();
|
445 | })
|
446 | .then((response) => {
|
447 | // Don't log the response here - it has the credentials in it
|
448 | if (options.cloud && options.joinGroup && bigIp.isBigIp()) {
|
449 | logger.info('Got primary credentials.');
|
450 |
|
451 | options.remoteUser = response.username;
|
452 | options.remotePassword = response.password;
|
453 | options.passwordEncrypted = false;
|
454 | }
|
455 |
|
456 | if (options.joinGroup && bigIp.isBigIp()) {
|
457 | logger.info('Joining group.');
|
458 |
|
459 | return bigIp.cluster.joinCluster(
|
460 | options.deviceGroup,
|
461 | options.remoteHost,
|
462 | options.remoteUser,
|
463 | options.remotePassword || options.remotePasswordUrl,
|
464 | false,
|
465 | {
|
466 | remotePort: options.remotePort,
|
467 | sync: options.sync,
|
468 | passwordIsUrl: typeof options.remotePasswordUrl !== 'undefined',
|
469 | passwordEncrypted: options.passwordEncrypted
|
470 | }
|
471 | );
|
472 | }
|
473 | return q();
|
474 | })
|
475 | .then((response) => {
|
476 | logger.debug(response);
|
477 |
|
478 | if (options.removeFromCluster) {
|
479 | logger.info('Removing', options.device, 'from', options.deviceGroup);
|
480 | return bigIp.cluster.removeFromCluster(options.device);
|
481 | }
|
482 | return q();
|
483 | })
|
484 | .then((response) => {
|
485 | logger.debug(response);
|
486 | logger.info('Waiting for BIG-IP to be active.');
|
487 | return bigIp.active();
|
488 | })
|
489 | .catch((err) => {
|
490 | let message;
|
491 |
|
492 | if (!err) {
|
493 | message = 'unknown reason';
|
494 | } else {
|
495 | message = err.message;
|
496 | }
|
497 |
|
498 | if (err) {
|
499 | if (err instanceof ActiveError || err.name === 'ActiveError') {
|
500 | logger.warn('BIG-IP active check failed.');
|
501 | rebooting = true;
|
502 | return util.reboot(bigIp, { signalOnly: !(options.reboot) });
|
503 | }
|
504 | }
|
505 |
|
506 | ipc.send(signals.CLOUD_LIBS_ERROR);
|
507 |
|
508 | const error = `Cluster failed: ${message}`;
|
509 | util.logError(error, loggerOptions);
|
510 | util.logAndExit(error, 'error', 1);
|
511 |
|
512 | exiting = true;
|
513 | return q();
|
514 | })
|
515 | .done((response) => {
|
516 | logger.debug(response);
|
517 |
|
518 | if ((!rebooting || !options.reboot) && !exiting) {
|
519 | ipc.send(options.signal || signals.CLUSTER_DONE);
|
520 | }
|
521 |
|
522 | // Perform callback before final logAndExit
|
523 | if (cb) {
|
524 | cb();
|
525 | }
|
526 |
|
527 | if (!rebooting) {
|
528 | util.deleteArgs(ARGS_FILE_ID);
|
529 |
|
530 | if (!exiting) {
|
531 | util.logAndExit('Cluster finished.');
|
532 | }
|
533 | } else if (!options.reboot) {
|
534 | // If we are rebooting, but we were called with --no-reboot, send signal
|
535 | if (!exiting) {
|
536 | util.logAndExit('Cluster finished. Reboot required but not rebooting.');
|
537 | }
|
538 | } else {
|
539 | util.logAndExit('Cluster finished. Reboot required.');
|
540 | }
|
541 | });
|
542 |
|
543 | // If another script has signaled an error, exit, marking ourselves as DONE
|
544 | ipc.once(signals.CLOUD_LIBS_ERROR)
|
545 | .then(() => {
|
546 | ipc.send(options.signal || signals.CLUSTER_DONE);
|
547 | util.logAndExit('ERROR signaled from other script. Exiting');
|
548 | });
|
549 |
|
550 | // If we reboot due to some other script, exit - otherwise cloud providers
|
551 | // won't know we're done. If we forced the reboot ourselves, we will exit
|
552 | // when that call completes.
|
553 | ipc.once('REBOOT')
|
554 | .then(() => {
|
555 | if (!rebooting) {
|
556 | util.logAndExit('REBOOT signaled. Exiting.');
|
557 | }
|
558 | });
|
559 | } catch (err) {
|
560 | if (logger) {
|
561 | logger.error('Clustering error:', err);
|
562 | }
|
563 | }
|
564 | }
|
565 | };
|
566 |
|
567 | module.exports = runner;
|
568 |
|
569 | // If we're called from the command line, run
|
570 | // This allows for test code to call us as a module
|
571 | if (!module.parent) {
|
572 | runner.run(process.argv);
|
573 | }
|
574 | }());
|