govuk-connect 0.0.2 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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