govuk-connect 0.0.2 → 0.3.2

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