govuk-connect 0.0.2 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,991 @@
1
+ require "uri"
2
+ require "yaml"
3
+ require "open3"
4
+ require "socket"
5
+ require "timeout"
6
+ require "optparse"
7
+ require "govuk_connect/version"
8
+
9
+ class GovukConnect::CLI
10
+ def self.bold(string)
11
+ "\e[1m#{string}\e[0m"
12
+ end
13
+
14
+ def bold(string)
15
+ self.class.bold(string)
16
+ end
17
+
18
+ USAGE_BANNER = "Usage: govuk-connect TYPE TARGET [options]".freeze
19
+
20
+ EXAMPLES = <<-EXAMPLES.freeze
21
+ govuk-connect ssh --environment integration backend
22
+
23
+ govuk-connect app-console --environment staging publishing-api
24
+
25
+ govuk-connect app-dbconsole -e integration whitehall_backend/whitehall
26
+
27
+ govuk-connect rabbitmq -e staging aws/rabbitmq
28
+
29
+ govuk-connect sidekiq-monitoring -e integration
30
+ EXAMPLES
31
+
32
+ MACHINE_TARGET_DESCRIPTION = <<-DOCS.freeze
33
+ The ssh, rabbitmq and sidekiq-monitoring connection types target
34
+ machines.
35
+
36
+ The machine can be specified by name, for example:
37
+
38
+ govuk-connect ssh -e integration #{bold('backend')}
39
+
40
+ If the hosting provider is ambiguous, you'll need to specify it prior
41
+ to the name, for example:
42
+
43
+ govuk-connect ssh -e staging #{bold('aws/')}backend
44
+
45
+ If you want to connect to a specific machine, you can specify a number
46
+ after the name, for example:
47
+
48
+ govuk-connect ssh -e integration backend#{bold(':2')}
49
+ DOCS
50
+
51
+ APP_TARGET_DESCRIPTION = <<-DOCS.freeze
52
+ The app-console and app-dbconsole connection types target
53
+ applications.
54
+
55
+ The application is specified by name, for example:
56
+
57
+ govuk-connect app-console -e integration #{bold('publishing-api')}
58
+
59
+ If the node class is ambiguous, you'll need to specify it prior to
60
+ the name, for example:
61
+
62
+ govuk-connect app-console -e integration #{bold('whitehall_backend/')}whitehall
63
+
64
+ If you want to connect to a specific machine, you can specify a
65
+ number after the name, for example:
66
+
67
+ govuk-connect app-console -e integration publishing-api#{bold(':2')}
68
+ DOCS
69
+
70
+ CONNECTION_TYPE_DESCRIPTIONS = {
71
+ "ssh" => "Connect to a machine through SSH.",
72
+ "app-console" => "Launch a console for an application. For example, a rails console when connecting to a Rails application.",
73
+ "app-dbconsole" => "Launch a console for the database for an application.",
74
+ "rabbitmq" => "Setup port forwarding to the RabbitMQ admin interface.",
75
+ "sidekiq-monitoring" => "Setup port forwarding to the Sidekiq Monitoring application.",
76
+ }.freeze
77
+
78
+ RABBITMQ_PORT = 15672
79
+ SIDEKIQ_MONITORING_PORT = 3211
80
+
81
+ JUMPBOXES = {
82
+ ci: {
83
+ carrenza: "ci-jumpbox.integration.publishing.service.gov.uk",
84
+ },
85
+ integration: {
86
+ aws: "jumpbox.integration.publishing.service.gov.uk",
87
+ },
88
+ staging: {
89
+ carrenza: "jumpbox.staging.publishing.service.gov.uk",
90
+ aws: "jumpbox.staging.govuk.digital",
91
+ },
92
+ production: {
93
+ carrenza: "jumpbox.publishing.service.gov.uk",
94
+ aws: "jumpbox.production.govuk.digital",
95
+ },
96
+ }.freeze
97
+
98
+ def log(message)
99
+ warn message if $verbose
100
+ end
101
+
102
+ def print_empty_line
103
+ warn ""
104
+ end
105
+
106
+ def info(message)
107
+ warn message
108
+ end
109
+
110
+ def error(message)
111
+ warn "\e[1m\e[31m#{message}\e[0m"
112
+ end
113
+
114
+ def print_ssh_username_configuration_help
115
+ info "The SSH username used was: #{bold(ssh_username)}"
116
+ info "Check this is correct, and if it isn't, set the `USER` environment variable to the correct username."
117
+ end
118
+
119
+ # From Rosetta Code: https://rosettacode.org/wiki/Levenshtein_distance#Ruby
120
+ def levenshtein_distance(string1, string2)
121
+ string1 = string1.downcase
122
+ string2 = string2.downcase
123
+ costs = Array(0..string2.length) # i == 0
124
+ (1..string1.length).each do |i|
125
+ costs[0] = i
126
+ nw = i - 1 # j == 0; nw is lev(i-1, j)
127
+ (1..string2.length).each do |j|
128
+ costs[j], nw = [
129
+ costs[j] + 1, costs[j - 1] + 1,
130
+ string1[i - 1] == string2[j - 1] ? nw : nw + 1
131
+ ].min, costs[j]
132
+ end
133
+ end
134
+ costs[string2.length]
135
+ end
136
+
137
+ def strings_similar_to(target, strings)
138
+ strings.select do |s|
139
+ levenshtein_distance(s, target) <= 3 # No specific reasoning for this value
140
+ end
141
+ end
142
+
143
+ def check_ruby_version_greater_than(required_major:, required_minor:)
144
+ major, minor = RUBY_VERSION.split "."
145
+
146
+ insufficient_version = (
147
+ major.to_i < required_major || (
148
+ major.to_i == required_major &&
149
+ minor.to_i < required_minor
150
+ )
151
+ )
152
+
153
+ if insufficient_version
154
+ error "insufficient Ruby version: #{RUBY_VERSION}"
155
+ error "must be at least #{required_major}.#{required_minor}"
156
+
157
+ exit 1
158
+ end
159
+ end
160
+
161
+ def port_free?(port)
162
+ # No idea how well this works, but it's hopefully better than nothing
163
+
164
+ log "debug: checking if port #{port} is free"
165
+ Socket.tcp("127.0.0.1", port, connect_timeout: 0.1) {}
166
+ false
167
+ rescue Errno::ETIMEDOUT
168
+ log "debug: port #{port} doesn't seem to be free"
169
+ false
170
+ rescue Errno::ECONNREFUSED
171
+ log "debug: port #{port} is free"
172
+ true
173
+ end
174
+
175
+ def random_free_port
176
+ tries = 0
177
+
178
+ while tries <= 10
179
+ port = rand(32768...61000)
180
+
181
+ return port if port_free? port
182
+
183
+ tries += 1
184
+ end
185
+
186
+ raise "couldn't find open port"
187
+ end
188
+
189
+ def hosting_providers
190
+ JUMPBOXES
191
+ .map { |_env, jumpboxes| jumpboxes.keys }
192
+ .flatten
193
+ .uniq
194
+ end
195
+
196
+ def jumpbox_for_environment_and_hosting(environment, hosting)
197
+ raise "missing environment" unless environment
198
+ raise "missing hosting" unless hosting
199
+
200
+ jumpbox = JUMPBOXES[environment][hosting]
201
+
202
+ unless jumpbox
203
+ error "error: couldn't determine jumpbox for #{hosting}/#{environment}"
204
+ exit 1
205
+ end
206
+
207
+ jumpbox
208
+ end
209
+
210
+ def single_hosting_provider_for_environment(environment)
211
+ jumpboxes = JUMPBOXES[environment]
212
+
213
+ if jumpboxes.size == 1
214
+ jumpboxes.keys[0]
215
+ else
216
+ false
217
+ end
218
+ end
219
+
220
+ def config_file
221
+ @config_file ||= begin
222
+ directory = ENV.fetch("XDG_CONFIG_HOME", "#{Dir.home}/.config")
223
+
224
+ File.join(directory, "config.yaml")
225
+ end
226
+ end
227
+
228
+ def ssh_username
229
+ @ssh_username ||= begin
230
+ if File.exist? config_file
231
+ config_ssh_username = YAML::load_file(config_file)["ssh_username"]
232
+ end
233
+
234
+ config_ssh_username || ENV["USER"]
235
+ end
236
+ end
237
+
238
+ def ssh_identity_file
239
+ @ssh_identity_file ||= begin
240
+ YAML::load_file(config_file)["ssh_identity_file"] if File.exist? config_file
241
+ end
242
+ end
243
+
244
+ def ssh_identity_arguments
245
+ if ssh_identity_file
246
+ ["-i", ssh_identity_file]
247
+ else
248
+ []
249
+ end
250
+ end
251
+
252
+ def user_at_host(user, host)
253
+ "#{user}@#{host}"
254
+ end
255
+
256
+ def govuk_node_list_classes(environment, hosting)
257
+ log "debug: looking up classes in #{hosting}/#{environment}"
258
+ command = [
259
+ "ssh",
260
+ "-o", "ConnectTimeout=2", # Show a failure quickly
261
+ *ssh_identity_arguments,
262
+ user_at_host(
263
+ ssh_username,
264
+ jumpbox_for_environment_and_hosting(environment, hosting),
265
+ ),
266
+ "govuk_node_list --classes"
267
+ ].join(" ")
268
+
269
+ log "debug: running command: #{command}"
270
+ output, status = Open3.capture2(command)
271
+
272
+ unless status.success?
273
+ error "\nerror: command failed: #{command}"
274
+ print_empty_line
275
+ print_ssh_username_configuration_help
276
+ exit 1
277
+ end
278
+
279
+ classes = output.split("\n").sort
280
+
281
+ log "debug: classes:"
282
+ classes.each { |c| log " - #{c}" }
283
+
284
+ classes
285
+ end
286
+
287
+ def get_domains_for_node_class(target, environment, hosting, ssh_username)
288
+ command = [
289
+ "ssh",
290
+ "-o", "ConnectTimeout=2", # Show a failure quickly
291
+ *ssh_identity_arguments,
292
+ user_at_host(
293
+ ssh_username,
294
+ jumpbox_for_environment_and_hosting(environment, hosting),
295
+ ),
296
+ "govuk_node_list -c #{target}"
297
+ ].join(" ")
298
+
299
+ output, status = Open3.capture2(command)
300
+
301
+ unless status.success?
302
+ error "error: command failed: #{command}"
303
+ print_empty_line
304
+ print_ssh_username_configuration_help
305
+ exit 1
306
+ end
307
+
308
+ output.split("\n").sort
309
+ end
310
+
311
+ def govuk_directory
312
+ File.join(ENV["HOME"], "govuk")
313
+ end
314
+
315
+ def govuk_puppet_node_class_data(environment, hosting)
316
+ log "debug: fetching govuk-puppet node class data for #{hosting} #{environment}"
317
+
318
+ local_hieradata_root = File.join(
319
+ govuk_directory,
320
+ "govuk-puppet",
321
+ {
322
+ carrenza: "hieradata",
323
+ aws: "hieradata_aws",
324
+ }[hosting],
325
+ )
326
+
327
+ hieradata_file = File.join(local_hieradata_root, "#{environment}.yaml")
328
+ log "debug: reading #{hieradata_file}"
329
+
330
+ environment_specific_hieradata = YAML::load_file(hieradata_file)
331
+
332
+ if environment_specific_hieradata["node_class"]
333
+ environment_specific_hieradata["node_class"]
334
+ else
335
+ common_hieradata = YAML::load_file(
336
+ File.join(local_hieradata_root, "common.yaml"),
337
+ )
338
+
339
+ common_hieradata["node_class"]
340
+ end
341
+ end
342
+
343
+ def node_classes_for_environment_and_hosting(environment, hosting)
344
+ govuk_puppet_node_class_data(
345
+ environment,
346
+ hosting,
347
+ ).map do |node_class, _data|
348
+ node_class
349
+ end
350
+ end
351
+
352
+ def application_names_from_node_class_data(environment, hosting)
353
+ node_class_data = govuk_puppet_node_class_data(
354
+ environment,
355
+ hosting,
356
+ )
357
+
358
+ all_names = node_class_data.flat_map do |_node_class, data|
359
+ data["apps"]
360
+ end
361
+
362
+ all_names.sort.uniq
363
+ end
364
+
365
+ def node_class_for_app(app_name, environment, hosting)
366
+ log "debug: finding node class for #{app_name} in #{hosting} #{environment}"
367
+
368
+ node_class_data = govuk_puppet_node_class_data(
369
+ environment,
370
+ hosting,
371
+ )
372
+
373
+ app_lookup_hash = {}
374
+ node_class_data.each do |node_class, data|
375
+ data["apps"].each do |app|
376
+ if app_lookup_hash.key? app
377
+ app_lookup_hash[app] += [node_class]
378
+ else
379
+ app_lookup_hash[app] = [node_class]
380
+ end
381
+ end
382
+ end
383
+
384
+ node_classes = app_lookup_hash[app_name]
385
+
386
+ return if node_classes.nil?
387
+
388
+ if node_classes.length > 1
389
+ error "error: ambiguous node class for #{app_name} in #{environment}"
390
+ print_empty_line
391
+ info "specify the node class and application mame, for example: "
392
+ node_classes.each do |node_class|
393
+ info "\n govuk-connect app-console -e #{environment} #{node_class}/#{app_name}"
394
+ end
395
+ print_empty_line
396
+
397
+ exit 1
398
+ else
399
+ node_class = node_classes.first
400
+ end
401
+
402
+ log "debug: node class: #{node_class}"
403
+
404
+ node_class
405
+ end
406
+
407
+ def hosting_for_target_and_environment(target, environment)
408
+ hosting = single_hosting_provider_for_environment(
409
+ environment,
410
+ )
411
+
412
+ unless hosting
413
+ hosting, name, _number = parse_hosting_name_and_number(target)
414
+
415
+ hosting ||= hosting_for_node_type(name, environment)
416
+ end
417
+
418
+ hosting
419
+ end
420
+
421
+ def hosting_for_node_type(node_type, environment)
422
+ log "debug: Looking up hosting for node_type: #{node_type}"
423
+ hosting = single_hosting_provider_for_environment(environment)
424
+
425
+ return hosting if hosting
426
+
427
+ aws_node_types = govuk_node_list_classes(environment, :aws)
428
+ carrenza_node_types = govuk_node_list_classes(environment, :carrenza)
429
+
430
+ if aws_node_types.include?(node_type) &&
431
+ carrenza_node_types.include?(node_type)
432
+
433
+ error "error: ambiguous hosting for #{node_type} in #{environment}"
434
+ print_empty_line
435
+ info "specify the hosting provider and node type, for example: "
436
+ hosting_providers.each do |hosting_provider|
437
+ info "\n govuk-connect ssh #{bold(hosting_provider)}/#{node_type}"
438
+ end
439
+ info "\n"
440
+
441
+ exit 1
442
+ elsif aws_node_types.include?(node_type)
443
+ :aws
444
+ elsif carrenza_node_types.include?(node_type)
445
+ :carrenza
446
+ else
447
+ error "error: couldn't find #{node_type} in #{environment}"
448
+
449
+ all_node_types = (aws_node_types + carrenza_node_types).uniq.sort
450
+ similar_node_types = strings_similar_to(node_type, all_node_types)
451
+
452
+ if similar_node_types.any?
453
+ info "\ndid you mean:"
454
+ similar_node_types.each { |s| info " - #{s}" }
455
+ else
456
+ info "\nall node types:"
457
+ all_node_types.each { |s| info " - #{s}" }
458
+ end
459
+
460
+ exit 1
461
+ end
462
+ end
463
+
464
+ def hosting_for_app(app_name, environment)
465
+ log "debug: finding hosting for #{app_name} in #{environment}"
466
+
467
+ hosting = single_hosting_provider_for_environment(environment)
468
+
469
+ if hosting
470
+ log "debug: this environment has a single hosting provider: #{hosting}"
471
+ return hosting
472
+ end
473
+
474
+ aws_app_names = application_names_from_node_class_data(
475
+ environment,
476
+ :aws,
477
+ )
478
+
479
+ if aws_app_names.include? app_name
480
+ log "debug: #{app_name} is hosted in AWS"
481
+
482
+ return :aws
483
+ end
484
+
485
+ carrenza_app_names = application_names_from_node_class_data(
486
+ environment,
487
+ :carrenza,
488
+ )
489
+
490
+ if carrenza_app_names.include? app_name
491
+ log "debug: #{app_name} is hosted in Carrenza"
492
+
493
+ return :carrenza
494
+ end
495
+
496
+ error "error: unknown hosting value '#{hosting}' for #{app_name}"
497
+ exit 1
498
+ end
499
+
500
+ def govuk_app_command(target, environment, command)
501
+ node_class, app_name, number = parse_node_class_app_name_and_number(target)
502
+
503
+ info "Connecting to the app #{command} for #{bold(app_name)},\
504
+ in the #{bold(environment)} environment"
505
+
506
+ hosting = hosting_for_app(app_name, environment)
507
+
508
+ info "The relevant hosting provider is #{bold(hosting)}"
509
+
510
+ node_class ||= node_class_for_app(
511
+ app_name,
512
+ environment,
513
+ hosting,
514
+ )
515
+
516
+ unless node_class
517
+ error "error: application '#{app_name}' not found."
518
+ print_empty_line
519
+
520
+ application_names = application_names_from_node_class_data(
521
+ environment,
522
+ hosting,
523
+ )
524
+
525
+ similar_application_names = strings_similar_to(app_name, application_names)
526
+ if similar_application_names.any?
527
+ info "did you mean:"
528
+ similar_application_names.each { |s| info " - #{s}" }
529
+ else
530
+ info "all applications:"
531
+ print_empty_line
532
+ info " #{application_names.join(', ')}"
533
+ print_empty_line
534
+ end
535
+
536
+ exit 1
537
+ end
538
+
539
+ info "The relevant node class is #{bold(node_class)}"
540
+
541
+ ssh(
542
+ {
543
+ hosting: hosting,
544
+ name: node_class,
545
+ number: number,
546
+ },
547
+ environment,
548
+ command: "govuk_app_#{command} #{app_name}",
549
+ )
550
+ end
551
+
552
+ def ssh(
553
+ target,
554
+ environment,
555
+ command: false,
556
+ port_forward: false,
557
+ additional_arguments: []
558
+ )
559
+ log "debug: ssh to #{target} in #{environment}"
560
+
561
+ # Split something like aws/backend:2 in to :aws, 'backend', 2
562
+ hosting, name, number = parse_hosting_name_and_number(target)
563
+
564
+ if name.end_with? ".internal"
565
+ ssh_target = name
566
+ hosting = :aws
567
+ elsif name.end_with? ".gov.uk"
568
+ ssh_target = name
569
+ hosting = :carrenza
570
+ else
571
+ # The hosting might not have been provided, so check if necessary
572
+ hosting ||= hosting_for_target_and_environment(target, environment)
573
+
574
+ domains = get_domains_for_node_class(
575
+ name,
576
+ environment,
577
+ hosting,
578
+ ssh_username,
579
+ )
580
+
581
+ if domains.length.zero?
582
+ error "error: couldn't find #{name} in #{hosting}/#{environment}"
583
+
584
+ node_types = govuk_node_list_classes(environment, hosting)
585
+
586
+ similar_node_types = strings_similar_to(name, node_types)
587
+
588
+ if similar_node_types.any?
589
+ info "\ndid you mean:"
590
+ similar_node_types.each { |s| info " - #{s}" }
591
+ else
592
+ info "\nall node types:"
593
+ node_types.each { |s| info " - #{s}" }
594
+ end
595
+
596
+ exit 1
597
+ elsif domains.length == 1
598
+ ssh_target = domains.first
599
+
600
+ info "There is #{bold('one machine')} to connect to"
601
+ else
602
+ n_machines = bold("#{domains.length} machines")
603
+ info "There are #{n_machines} of this class"
604
+
605
+ if number
606
+ unless number.positive?
607
+ print_empty_line
608
+ error "error: invalid machine number '#{number}', it must be > 0"
609
+ exit 1
610
+ end
611
+
612
+ unless number <= domains.length
613
+ print_empty_line
614
+ error "error: cannot connect to machine number: #{number}"
615
+ exit 1
616
+ end
617
+
618
+ ssh_target = domains[number - 1]
619
+ info "Connecting to number #{number}"
620
+ else
621
+ ssh_target = domains.sample
622
+ info "Connecting to a random machine (number #{domains.find_index(ssh_target) + 1})"
623
+ end
624
+ end
625
+ end
626
+
627
+ ssh_command = [
628
+ "ssh",
629
+ *ssh_identity_arguments,
630
+ "-J", user_at_host(
631
+ ssh_username,
632
+ jumpbox_for_environment_and_hosting(environment, hosting),
633
+ ),
634
+ user_at_host(
635
+ ssh_username,
636
+ ssh_target,
637
+ )
638
+ ]
639
+
640
+ if command
641
+ ssh_command += [
642
+ "-t", # Force tty allocation so that interactive commands work
643
+ command,
644
+ ]
645
+ elsif port_forward
646
+ localhost_port = random_free_port
647
+
648
+ ssh_command += [
649
+ "-N",
650
+ "-L", "#{localhost_port}:127.0.0.1:#{port_forward}"
651
+ ]
652
+
653
+ info "Port forwarding setup, access:\n\n http://127.0.0.1:#{localhost_port}/\n\n"
654
+ end
655
+
656
+ ssh_command += additional_arguments
657
+
658
+ info "\n#{bold('Running command:')} #{ssh_command.join(' ')}\n\n"
659
+
660
+ exec(*ssh_command)
661
+ end
662
+
663
+ def rabbitmq_root_password_command(hosting, environment)
664
+ hieradata_directory = {
665
+ aws: "puppet_aws",
666
+ carrenza: "puppet",
667
+ }[hosting]
668
+
669
+ directory = File.join(
670
+ govuk_directory,
671
+ "govuk-secrets",
672
+ hieradata_directory,
673
+ )
674
+
675
+ "cd #{directory} && rake eyaml:decrypt_value[#{environment},govuk_rabbitmq::root_password]"
676
+ end
677
+
678
+ def hosting_and_environment_from_url(url)
679
+ uri = URI(url)
680
+
681
+ host_to_hosting_and_environment = {
682
+ "ci-alert.integration.publishing.service.gov.uk" => %i[carrenza ci],
683
+ "alert.integration.publishing.service.gov.uk" => %i[aws integration],
684
+ "alert.staging.govuk.digital" => %i[aws staging],
685
+ "alert.blue.staging.govuk.digital" => %i[aws staging],
686
+ "alert.staging.publishing.service.gov.uk" => %i[carrenza staging],
687
+ "alert.production.govuk.digital" => %i[aws production],
688
+ "alert.blue.production.govuk.digital" => %i[aws production],
689
+ "alert.publishing.service.gov.uk" => %i[carrenza production],
690
+ }
691
+
692
+ unless host_to_hosting_and_environment.key? uri.host
693
+ error "error: unknown hosting and environment for: #{uri.host}"
694
+ exit 1
695
+ end
696
+
697
+ host_to_hosting_and_environment[uri.host]
698
+ end
699
+
700
+ def parse_options(argv)
701
+ options = {}
702
+
703
+ # rubocop:disable Metrics/BlockLength
704
+ @option_parser = OptionParser.new do |opts|
705
+ opts.banner = USAGE_BANNER
706
+
707
+ opts.on(
708
+ "-e",
709
+ "--environment ENVIRONMENT",
710
+ "Select which environment to connect to",
711
+ ) do |o|
712
+ options[:environment] = o.to_sym
713
+ end
714
+ opts.on(
715
+ "--hosting-and-environment-from-alert-url URL",
716
+ "Select which environment to connect to based on the URL provided.",
717
+ ) do |o|
718
+ hosting, environment = hosting_and_environment_from_url(o)
719
+ options[:hosting] = hosting
720
+ options[:environment] = environment
721
+ end
722
+ opts.on("-p", "--port-forward SERVICE", "Connect to a remote port") do |o|
723
+ options[:port_forward] = o
724
+ end
725
+ opts.on("-v", "--verbose", "Enable more detailed logging") do
726
+ $verbose = true
727
+ end
728
+
729
+ opts.on("-h", "--help", "Prints usage information and examples") do
730
+ info opts
731
+ print_empty_line
732
+ info bold("CONNECTION TYPES")
733
+ types.keys.each do |x|
734
+ info " #{x}"
735
+ description = CONNECTION_TYPE_DESCRIPTIONS[x]
736
+ info " #{description}" if description
737
+ end
738
+ print_empty_line
739
+ info bold("MACHINE TARGET")
740
+ info MACHINE_TARGET_DESCRIPTION
741
+ print_empty_line
742
+ info bold("APPLICATION TARGET")
743
+ info APP_TARGET_DESCRIPTION
744
+ print_empty_line
745
+ info bold("EXAMPLES")
746
+ info EXAMPLES
747
+ exit
748
+ end
749
+ opts.on("-V", "--version", "Prints version information") do
750
+ info GovukConnect::VERSION.to_s
751
+ exit
752
+ end
753
+ end
754
+ # rubocop:enable Metrics/BlockLength
755
+
756
+ @option_parser.parse!(argv)
757
+
758
+ options
759
+ end
760
+
761
+ def parse_hosting_name_and_number(target)
762
+ log "debug: parsing target: #{target}"
763
+ if target.is_a? Hash
764
+ return %i[hosting name number].map do |key|
765
+ target[key]
766
+ end
767
+ end
768
+
769
+ if target.include? "/"
770
+ hosting, name_and_number = target.split "/"
771
+
772
+ hosting = hosting.to_sym
773
+
774
+ unless %i[carrenza aws].include? hosting
775
+ error "error: unknown hosting provider: #{hosting}"
776
+ print_empty_line
777
+ info "available hosting providers are:"
778
+ hosting_providers.each { |x| info " - #{x}" }
779
+
780
+ exit 1
781
+ end
782
+ else
783
+ name_and_number = target
784
+ end
785
+
786
+ if name_and_number.include? ":"
787
+ name, number = name_and_number.split ":"
788
+
789
+ number = number.to_i
790
+ else
791
+ name = name_and_number
792
+ end
793
+
794
+ log "debug: hosting: #{hosting.inspect}, name: #{name.inspect}, number: #{number.inspect}"
795
+
796
+ [hosting, name, number]
797
+ end
798
+
799
+ def parse_node_class_app_name_and_number(target)
800
+ log "debug: parsing target: #{target}"
801
+ if target.is_a? Hash
802
+ return %i[node_class app_name number].map do |key|
803
+ target[key]
804
+ end
805
+ end
806
+
807
+ if target.include? "/"
808
+ node_class, app_name_and_number = target.split "/"
809
+ else
810
+ app_name_and_number = target
811
+ end
812
+
813
+ if app_name_and_number.include? ":"
814
+ app_name, number = name_and_number.split ":"
815
+
816
+ number = number.to_i
817
+ else
818
+ app_name = app_name_and_number
819
+ end
820
+
821
+ log "debug: node_class: #{node_class.inspect}, app_name: #{app_name.inspect}, number: #{number.inspect}"
822
+
823
+ [node_class, app_name, number]
824
+ end
825
+
826
+ def check_for_target(target)
827
+ unless target
828
+ error "error: you must specify the target\n"
829
+ warn USAGE_BANNER
830
+ print_empty_line
831
+ warn EXAMPLES
832
+ exit 1
833
+ end
834
+ end
835
+
836
+ def check_for_additional_arguments(command, args)
837
+ unless args.empty?
838
+ error "error: #{command} doesn't support arguments: #{args}"
839
+ exit 1
840
+ end
841
+ end
842
+
843
+ def types
844
+ @types ||= {
845
+ "app-console" => Proc.new do |target, environment, args, _options|
846
+ check_for_target(target)
847
+ check_for_additional_arguments("app-console", args)
848
+ govuk_app_command(target, environment, "console")
849
+ end,
850
+
851
+ "app-dbconsole" => Proc.new do |target, environment, args, _options|
852
+ check_for_target(target)
853
+ check_for_additional_arguments("app-dbconsole", args)
854
+ govuk_app_command(target, environment, "dbconsole")
855
+ end,
856
+
857
+ "rabbitmq" => Proc.new do |target, environment, args, _options|
858
+ check_for_additional_arguments("rabbitmq", args)
859
+
860
+ target ||= "rabbitmq"
861
+
862
+ root_password_command = rabbitmq_root_password_command(
863
+ hosting_for_target_and_environment(target, environment),
864
+ environment,
865
+ )
866
+
867
+ info "You'll need to login as the RabbitMQ #{bold('root')} user."
868
+ info "Get the password from govuk-secrets, or example:\n\n"
869
+ info " #{bold(root_password_command)}"
870
+ print_empty_line
871
+
872
+ ssh(
873
+ target,
874
+ environment,
875
+ port_forward: RABBITMQ_PORT,
876
+ )
877
+ end,
878
+
879
+ "sidekiq-monitoring" => Proc.new do |target, environment, args, _options|
880
+ check_for_additional_arguments("sidekiq-monitoring", args)
881
+ ssh(
882
+ target || "backend",
883
+ environment,
884
+ port_forward: SIDEKIQ_MONITORING_PORT,
885
+ )
886
+ end,
887
+
888
+ "ssh" => Proc.new do |target, environment, args, options|
889
+ check_for_target(target)
890
+
891
+ if options.key? :hosting
892
+ hosting, name, number = parse_hosting_name_and_number(target)
893
+ if hosting
894
+ error "error: hosting specified twice"
895
+ exit 1
896
+ end
897
+
898
+ target = {
899
+ hosting: options[:hosting],
900
+ name: name,
901
+ number: number,
902
+ }
903
+ end
904
+
905
+ ssh(
906
+ target,
907
+ environment,
908
+ port_forward: options[:port_forward],
909
+ additional_arguments: args,
910
+ )
911
+ end,
912
+ }
913
+ end
914
+
915
+ def main(argv)
916
+ check_ruby_version_greater_than(required_major: 2, required_minor: 0)
917
+
918
+ double_dash_index = argv.index "--"
919
+ if double_dash_index
920
+ # This is used in the case of passing extra options to ssh, the --
921
+ # acts as a separator, so to avoid optparse interpreting those
922
+ # options, split argv around -- before parsing the options
923
+ rest = argv[double_dash_index + 1, argv.length]
924
+ argv = argv[0, double_dash_index]
925
+
926
+ argv.clear
927
+ argv.concat argv
928
+
929
+ options = parse_options(argv)
930
+
931
+ type, target = argv
932
+ else
933
+ options = parse_options(argv)
934
+
935
+ type, target, *rest = argv
936
+ end
937
+
938
+ unless type
939
+ error "error: you must specify the connection type\n"
940
+
941
+ warn @option_parser.help
942
+
943
+ warn "\nValid connection types are:\n"
944
+ types.keys.each do |x|
945
+ warn " - #{x}"
946
+ end
947
+ print_empty_line
948
+ warn "Example commands:"
949
+ warn EXAMPLES
950
+
951
+ exit 1
952
+ end
953
+
954
+ handler = types[type]
955
+
956
+ unless handler
957
+ error "error: unknown connection type: #{type}\n"
958
+
959
+ warn "Valid connection types are:\n"
960
+ types.keys.each do |x|
961
+ warn " - #{x}"
962
+ end
963
+ print_empty_line
964
+ warn "Example commands:"
965
+ warn EXAMPLES
966
+
967
+ exit 1
968
+ end
969
+
970
+ environment = options[:environment]&.to_sym
971
+
972
+ unless environment
973
+ error "error: you must specify the environment\n"
974
+ warn @option_parser.help
975
+ exit 1
976
+ end
977
+
978
+ unless JUMPBOXES.key? environment
979
+ error "error: unknown environment '#{environment}'"
980
+ print_empty_line
981
+ info "Valid environments are:"
982
+ JUMPBOXES.keys.each { |e| info " - #{e}" }
983
+ exit 1
984
+ end
985
+
986
+ handler.call(target, environment, rest, options)
987
+ rescue Interrupt
988
+ # Handle SIGTERM without printing a stacktrace
989
+ exit 1
990
+ end
991
+ end