govuk-connect 0.0.1 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,1090 @@
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
+ output.split("\n").sort
320
+ end
321
+
322
+ def govuk_directory
323
+ File.join(ENV["HOME"], "govuk")
324
+ end
325
+
326
+ def govuk_puppet_node_class_data(environment, hosting)
327
+ log "debug: fetching govuk-puppet node class data for #{hosting} #{environment}"
328
+
329
+ local_hieradata_root = File.join(
330
+ govuk_directory,
331
+ "govuk-puppet",
332
+ {
333
+ carrenza: "hieradata",
334
+ aws: "hieradata_aws",
335
+ }[hosting],
336
+ )
337
+
338
+ hieradata_file = File.join(local_hieradata_root, "#{environment}.yaml")
339
+ log "debug: reading #{hieradata_file}"
340
+
341
+ environment_specific_hieradata = YAML.load_file(hieradata_file)
342
+
343
+ if environment_specific_hieradata["node_class"]
344
+ environment_specific_hieradata["node_class"]
345
+ else
346
+ common_hieradata = YAML.load_file(
347
+ File.join(local_hieradata_root, "common.yaml"),
348
+ )
349
+
350
+ common_hieradata["node_class"]
351
+ end
352
+ end
353
+
354
+ def node_classes_for_environment_and_hosting(environment, hosting)
355
+ govuk_puppet_node_class_data(
356
+ environment,
357
+ hosting,
358
+ ).map do |node_class, _data|
359
+ node_class
360
+ end
361
+ end
362
+
363
+ def application_names_from_node_class_data(environment, hosting)
364
+ node_class_data = govuk_puppet_node_class_data(
365
+ environment,
366
+ hosting,
367
+ )
368
+
369
+ all_names = node_class_data.flat_map do |_node_class, data|
370
+ data["apps"]
371
+ end
372
+
373
+ all_names.sort.uniq
374
+ end
375
+
376
+ def node_class_for_app(app_name, environment, hosting)
377
+ log "debug: finding node class for #{app_name} in #{hosting} #{environment}"
378
+
379
+ node_class_data = govuk_puppet_node_class_data(
380
+ environment,
381
+ hosting,
382
+ )
383
+
384
+ app_lookup_hash = {}
385
+ node_class_data.each do |node_class, data|
386
+ data["apps"].each do |app|
387
+ if app_lookup_hash.key? app
388
+ app_lookup_hash[app] += [node_class]
389
+ else
390
+ app_lookup_hash[app] = [node_class]
391
+ end
392
+ end
393
+ end
394
+
395
+ node_classes = app_lookup_hash[app_name]
396
+
397
+ return if node_classes.nil?
398
+
399
+ if node_classes.length > 1
400
+ error "error: ambiguous node class for #{app_name} in #{environment}"
401
+ print_empty_line
402
+ info "specify the node class and application mame, for example: "
403
+ node_classes.each do |node_class|
404
+ info "\n govuk-connect app-console -e #{environment} #{node_class}/#{app_name}"
405
+ end
406
+ print_empty_line
407
+
408
+ exit 1
409
+ else
410
+ node_class = node_classes.first
411
+ end
412
+
413
+ log "debug: node class: #{node_class}"
414
+
415
+ node_class
416
+ end
417
+
418
+ def hosting_for_target_and_environment(target, environment)
419
+ hosting = single_hosting_provider_for_environment(
420
+ environment,
421
+ )
422
+
423
+ unless hosting
424
+ hosting, name, _number = parse_hosting_name_and_number(target)
425
+
426
+ hosting ||= hosting_for_node_type(name, environment)
427
+ end
428
+
429
+ hosting
430
+ end
431
+
432
+ def hosting_for_node_type(node_type, environment)
433
+ log "debug: Looking up hosting for node_type: #{node_type}"
434
+ hosting = single_hosting_provider_for_environment(environment)
435
+
436
+ return hosting if hosting
437
+
438
+ aws_node_types = govuk_node_list_classes(environment, :aws)
439
+ carrenza_node_types = govuk_node_list_classes(environment, :carrenza)
440
+
441
+ if aws_node_types.include?(node_type) &&
442
+ carrenza_node_types.include?(node_type)
443
+
444
+ error "error: ambiguous hosting for #{node_type} in #{environment}"
445
+ print_empty_line
446
+ info "specify the hosting provider and node type, for example: "
447
+ hosting_providers.each do |hosting_provider|
448
+ info "\n govuk-connect ssh #{bold(hosting_provider)}/#{node_type}"
449
+ end
450
+ info "\n"
451
+
452
+ exit 1
453
+ elsif aws_node_types.include?(node_type)
454
+ :aws
455
+ elsif carrenza_node_types.include?(node_type)
456
+ :carrenza
457
+ else
458
+ error "error: couldn't find #{node_type} in #{environment}"
459
+
460
+ all_node_types = (aws_node_types + carrenza_node_types).uniq.sort
461
+ similar_node_types = strings_similar_to(node_type, all_node_types)
462
+
463
+ if similar_node_types.any?
464
+ info "\ndid you mean:"
465
+ similar_node_types.each { |s| info " - #{s}" }
466
+ else
467
+ info "\nall node types:"
468
+ all_node_types.each { |s| info " - #{s}" }
469
+ end
470
+
471
+ exit 1
472
+ end
473
+ end
474
+
475
+ def hosting_for_app(app_name, environment)
476
+ log "debug: finding hosting for #{app_name} in #{environment}"
477
+
478
+ hosting = single_hosting_provider_for_environment(environment)
479
+
480
+ if hosting
481
+ log "debug: this environment has a single hosting provider: #{hosting}"
482
+ return hosting
483
+ end
484
+
485
+ aws_app_names = application_names_from_node_class_data(
486
+ environment,
487
+ :aws,
488
+ )
489
+
490
+ if aws_app_names.include? app_name
491
+ log "debug: #{app_name} is hosted in AWS"
492
+
493
+ return :aws
494
+ end
495
+
496
+ carrenza_app_names = application_names_from_node_class_data(
497
+ environment,
498
+ :carrenza,
499
+ )
500
+
501
+ if carrenza_app_names.include? app_name
502
+ log "debug: #{app_name} is hosted in Carrenza"
503
+
504
+ return :carrenza
505
+ end
506
+
507
+ error "error: unknown hosting value '#{hosting}' for #{app_name}"
508
+ exit 1
509
+ end
510
+
511
+ def govuk_app_command(target, environment, command)
512
+ node_class, app_name, number = parse_node_class_app_name_and_number(target)
513
+
514
+ info "Connecting to the app #{command} for #{bold(app_name)},\
515
+ in the #{bold(environment)} environment"
516
+
517
+ hosting = hosting_for_app(app_name, environment)
518
+
519
+ info "The relevant hosting provider is #{bold(hosting)}"
520
+
521
+ node_class ||= node_class_for_app(
522
+ app_name,
523
+ environment,
524
+ hosting,
525
+ )
526
+
527
+ unless node_class
528
+ error "error: application '#{app_name}' not found."
529
+ print_empty_line
530
+
531
+ application_names = application_names_from_node_class_data(
532
+ environment,
533
+ hosting,
534
+ )
535
+
536
+ similar_application_names = strings_similar_to(app_name, application_names)
537
+ if similar_application_names.any?
538
+ info "did you mean:"
539
+ similar_application_names.each { |s| info " - #{s}" }
540
+ else
541
+ info "all applications:"
542
+ print_empty_line
543
+ info " #{application_names.join(', ')}"
544
+ print_empty_line
545
+ end
546
+
547
+ exit 1
548
+ end
549
+
550
+ info "The relevant node class is #{bold(node_class)}"
551
+
552
+ ssh(
553
+ {
554
+ hosting: hosting,
555
+ name: node_class,
556
+ number: number,
557
+ },
558
+ environment,
559
+ command: "govuk_app_#{command} #{app_name}",
560
+ )
561
+ end
562
+
563
+ def ssh(
564
+ target,
565
+ environment,
566
+ command: false,
567
+ port_forward: false,
568
+ additional_arguments: []
569
+ )
570
+ log "debug: ssh to #{target} in #{environment}"
571
+
572
+ target, hosting = ssh_target(target, environment)
573
+
574
+ ssh_command = [
575
+ "ssh",
576
+ *ssh_identity_arguments,
577
+ "-J",
578
+ user_at_host(
579
+ ssh_username,
580
+ jumpbox_for_environment_and_hosting(environment, hosting),
581
+ ),
582
+ user_at_host(
583
+ ssh_username,
584
+ target,
585
+ ),
586
+ ]
587
+
588
+ if command
589
+ ssh_command += [
590
+ "-t", # Force tty allocation so that interactive commands work
591
+ command,
592
+ ]
593
+ elsif port_forward
594
+ localhost_port = random_free_port
595
+
596
+ ssh_command += [
597
+ "-N",
598
+ "-L",
599
+ "#{localhost_port}:127.0.0.1:#{port_forward}",
600
+ ]
601
+
602
+ info "Port forwarding setup, access:\n\n http://127.0.0.1:#{localhost_port}/\n\n"
603
+ end
604
+
605
+ ssh_command += additional_arguments
606
+
607
+ info "\n#{bold('Running command:')} #{ssh_command.join(' ')}\n\n"
608
+
609
+ exec(*ssh_command)
610
+ end
611
+
612
+ def scp(
613
+ target,
614
+ environment,
615
+ files,
616
+ push: false,
617
+ additional_arguments: []
618
+ )
619
+ log "debug: scp #{push ? 'push' : 'pull'} to #{target} in #{environment}"
620
+
621
+ target, hosting = ssh_target(target, environment)
622
+
623
+ sources = files[0, files.length - 1]
624
+ destination = files[-1]
625
+
626
+ if push
627
+ destination = "#{target}:#{destination}"
628
+ else
629
+ sources = sources.map { |source| "#{target}:#{source}" }
630
+ end
631
+
632
+ scp_command = [
633
+ "scp",
634
+ *ssh_identity_arguments,
635
+ "-o",
636
+ "ProxyJump=#{user_at_host(ssh_username, jumpbox_for_environment_and_hosting(environment, hosting))}",
637
+ "-o",
638
+ "User=#{ssh_username}",
639
+ *additional_arguments,
640
+ "--",
641
+ *sources,
642
+ destination,
643
+ ]
644
+
645
+ info "\n#{bold('Running command:')} #{scp_command.join(' ')}\n\n"
646
+
647
+ exec(*scp_command)
648
+ end
649
+
650
+ def rabbitmq_root_password_command(hosting, environment)
651
+ hieradata_directory = {
652
+ aws: "puppet_aws",
653
+ carrenza: "puppet",
654
+ }[hosting]
655
+
656
+ directory = File.join(
657
+ govuk_directory,
658
+ "govuk-secrets",
659
+ hieradata_directory,
660
+ )
661
+
662
+ "cd #{directory} && rake eyaml:decrypt_value[#{environment},govuk_rabbitmq::root_password]"
663
+ end
664
+
665
+ def hosting_and_environment_from_url(url)
666
+ uri = URI(url)
667
+
668
+ host_to_hosting_and_environment = {
669
+ "ci-alert.integration.publishing.service.gov.uk" => %i[carrenza ci],
670
+ "alert.integration.publishing.service.gov.uk" => %i[aws integration],
671
+ "alert.staging.govuk.digital" => %i[aws staging],
672
+ "alert.blue.staging.govuk.digital" => %i[aws staging],
673
+ "alert.staging.publishing.service.gov.uk" => %i[carrenza staging],
674
+ "alert.production.govuk.digital" => %i[aws production],
675
+ "alert.blue.production.govuk.digital" => %i[aws production],
676
+ "alert.publishing.service.gov.uk" => %i[carrenza production],
677
+ }
678
+
679
+ unless host_to_hosting_and_environment.key? uri.host
680
+ error "error: unknown hosting and environment for: #{uri.host}"
681
+ exit 1
682
+ end
683
+
684
+ host_to_hosting_and_environment[uri.host]
685
+ end
686
+
687
+ def parse_options(argv)
688
+ options = {}
689
+ @option_parser = OptionParser.new do |opts|
690
+ opts.banner = USAGE_BANNER
691
+
692
+ opts.on(
693
+ "-e",
694
+ "--environment ENVIRONMENT",
695
+ "Select which environment to connect to",
696
+ ) do |o|
697
+ options[:environment] = o.to_sym
698
+ end
699
+ opts.on(
700
+ "--hosting-and-environment-from-alert-url URL",
701
+ "Select which environment to connect to based on the URL provided.",
702
+ ) do |o|
703
+ hosting, environment = hosting_and_environment_from_url(o)
704
+ options[:hosting] = hosting
705
+ options[:environment] = environment
706
+ end
707
+ opts.on("-p", "--port-forward SERVICE", "Connect to a remote port") do |o|
708
+ options[:port_forward] = o
709
+ end
710
+ opts.on("-v", "--verbose", "Enable more detailed logging") do
711
+ @verbose = true
712
+ end
713
+
714
+ opts.on("-h", "--help", "Prints usage information and examples") do
715
+ info opts
716
+ print_empty_line
717
+ info bold("CONNECTION TYPES")
718
+ types.keys.each do |x|
719
+ info " #{x}"
720
+ description = CONNECTION_TYPE_DESCRIPTIONS[x]
721
+ info " #{description}" if description
722
+ end
723
+ print_empty_line
724
+ info bold("MACHINE TARGET")
725
+ info MACHINE_TARGET_DESCRIPTION
726
+ print_empty_line
727
+ info bold("APPLICATION TARGET")
728
+ info APP_TARGET_DESCRIPTION
729
+ print_empty_line
730
+ info bold("EXAMPLES")
731
+ info EXAMPLES
732
+ exit
733
+ end
734
+ opts.on("-V", "--version", "Prints version information") do
735
+ info GovukConnect::VERSION.to_s
736
+ exit
737
+ end
738
+ end
739
+
740
+ @option_parser.parse!(argv)
741
+
742
+ options
743
+ end
744
+
745
+ def parse_hosting_name_and_number(target)
746
+ log "debug: parsing target: #{target}"
747
+ if target.is_a? Hash
748
+ return %i[hosting name number].map do |key|
749
+ target[key]
750
+ end
751
+ end
752
+
753
+ if target.include? "/"
754
+ hosting, name_and_number = target.split "/"
755
+
756
+ hosting = hosting.to_sym
757
+
758
+ unless %i[carrenza aws].include? hosting
759
+ error "error: unknown hosting provider: #{hosting}"
760
+ print_empty_line
761
+ info "available hosting providers are:"
762
+ hosting_providers.each { |x| info " - #{x}" }
763
+
764
+ exit 1
765
+ end
766
+ else
767
+ name_and_number = target
768
+ end
769
+
770
+ if name_and_number.include? ":"
771
+ name, number = name_and_number.split ":"
772
+
773
+ number = number.to_i
774
+ else
775
+ name = name_and_number
776
+ end
777
+
778
+ log "debug: hosting: #{hosting.inspect}, name: #{name.inspect}, number: #{number.inspect}"
779
+
780
+ [hosting, name, number]
781
+ end
782
+
783
+ def parse_node_class_app_name_and_number(target)
784
+ log "debug: parsing target: #{target}"
785
+ if target.is_a? Hash
786
+ return %i[node_class app_name number].map do |key|
787
+ target[key]
788
+ end
789
+ end
790
+
791
+ if target.include? "/"
792
+ node_class, app_name_and_number = target.split "/"
793
+ else
794
+ app_name_and_number = target
795
+ end
796
+
797
+ if app_name_and_number.include? ":"
798
+ app_name, number = name_and_number.split ":"
799
+
800
+ number = number.to_i
801
+ else
802
+ app_name = app_name_and_number
803
+ end
804
+
805
+ log "debug: node_class: #{node_class.inspect}, app_name: #{app_name.inspect}, number: #{number.inspect}"
806
+
807
+ [node_class, app_name, number]
808
+ end
809
+
810
+ def target_from_options(target, options)
811
+ if options.key? :hosting
812
+ hosting, name, number = parse_hosting_name_and_number(target)
813
+ if hosting
814
+ error "error: hosting specified twice"
815
+ exit 1
816
+ end
817
+
818
+ {
819
+ hosting: options[:hosting],
820
+ name: name,
821
+ number: number,
822
+ }
823
+ else
824
+ target
825
+ end
826
+ end
827
+
828
+ def ssh_target(target, environment)
829
+ # Split something like aws/backend:2 in to :aws, 'backend', 2
830
+ hosting, name, number = parse_hosting_name_and_number(target)
831
+
832
+ if name.end_with? ".internal"
833
+ target = name
834
+ hosting = :aws
835
+ elsif name.end_with? ".gov.uk"
836
+ target = name
837
+ hosting = :carrenza
838
+ else
839
+ # The hosting might not have been provided, so check if necessary
840
+ hosting ||= hosting_for_target_and_environment(target, environment)
841
+
842
+ domains = get_domains_for_node_class(
843
+ name,
844
+ environment,
845
+ hosting,
846
+ ssh_username,
847
+ )
848
+
849
+ if domains.length.zero?
850
+ error "error: couldn't find #{name} in #{hosting}/#{environment}"
851
+
852
+ node_types = govuk_node_list_classes(environment, hosting)
853
+
854
+ similar_node_types = strings_similar_to(name, node_types)
855
+
856
+ if similar_node_types.any?
857
+ info "\ndid you mean:"
858
+ similar_node_types.each { |s| info " - #{s}" }
859
+ else
860
+ info "\nall node types:"
861
+ node_types.each { |s| info " - #{s}" }
862
+ end
863
+
864
+ exit 1
865
+ elsif domains.length == 1
866
+ target = domains.first
867
+
868
+ info "There is #{bold('one machine')} to connect to"
869
+ else
870
+ n_machines = bold("#{domains.length} machines")
871
+ info "There are #{n_machines} of this class"
872
+
873
+ if number
874
+ unless number.positive?
875
+ print_empty_line
876
+ error "error: invalid machine number '#{number}', it must be > 0"
877
+ exit 1
878
+ end
879
+
880
+ unless number <= domains.length
881
+ print_empty_line
882
+ error "error: cannot connect to machine number: #{number}"
883
+ exit 1
884
+ end
885
+
886
+ target = domains[number - 1]
887
+ info "Connecting to number #{number}"
888
+ else
889
+ target = domains.sample
890
+ info "Connecting to a random machine (number #{domains.find_index(target) + 1})"
891
+ end
892
+ end
893
+ end
894
+
895
+ [target, hosting]
896
+ end
897
+
898
+ def check_for_target(target)
899
+ unless target
900
+ error "error: you must specify the target\n"
901
+ warn USAGE_BANNER
902
+ print_empty_line
903
+ warn EXAMPLES
904
+ exit 1
905
+ end
906
+ end
907
+
908
+ def check_for_additional_arguments(command, args)
909
+ unless args.empty?
910
+ error "error: #{command} doesn't support arguments: #{args}"
911
+ exit 1
912
+ end
913
+ end
914
+
915
+ def types
916
+ @types ||= {
917
+ "app-console" => proc do |target, environment, args, extra_args, _options|
918
+ check_for_target(target)
919
+ check_for_additional_arguments("app-console", args)
920
+ check_for_additional_arguments("app-console", extra_args)
921
+ govuk_app_command(target, environment, "console")
922
+ end,
923
+
924
+ "app-dbconsole" => proc do |target, environment, args, extra_args, _options|
925
+ check_for_target(target)
926
+ check_for_additional_arguments("app-dbconsole", args)
927
+ check_for_additional_arguments("app-dbconsole", extra_args)
928
+ govuk_app_command(target, environment, "dbconsole")
929
+ end,
930
+
931
+ "rabbitmq" => proc do |target, environment, args, extra_args, _options|
932
+ check_for_additional_arguments("rabbitmq", args)
933
+ check_for_additional_arguments("rabbitmq", extra_args)
934
+
935
+ target ||= "rabbitmq"
936
+
937
+ root_password_command = rabbitmq_root_password_command(
938
+ hosting_for_target_and_environment(target, environment),
939
+ environment,
940
+ )
941
+
942
+ info "You'll need to login as the RabbitMQ #{bold('root')} user."
943
+ info "Get the password from govuk-secrets, or example:\n\n"
944
+ info " #{bold(root_password_command)}"
945
+ print_empty_line
946
+
947
+ ssh(
948
+ target,
949
+ environment,
950
+ port_forward: RABBITMQ_PORT,
951
+ )
952
+ end,
953
+
954
+ "sidekiq-monitoring" => proc do |target, environment, args, extra_args, _options|
955
+ check_for_additional_arguments("sidekiq-monitoring", args)
956
+ check_for_additional_arguments("sidekiq-monitoring", extra_args)
957
+ ssh(
958
+ target || "backend",
959
+ environment,
960
+ port_forward: SIDEKIQ_MONITORING_PORT,
961
+ )
962
+ end,
963
+
964
+ "ssh" => proc do |target, environment, args, extra_args, options|
965
+ check_for_target(target)
966
+ target = target_from_options(target, options)
967
+
968
+ ssh(
969
+ target,
970
+ environment,
971
+ port_forward: options[:port_forward],
972
+ additional_arguments: [args, extra_args].flatten,
973
+ )
974
+ end,
975
+
976
+ "scp-pull" => proc do |target, environment, args, extra_args, options|
977
+ check_for_target(target)
978
+ target = target_from_options(target, options)
979
+
980
+ if args.length < 2
981
+ error "error: need at least two filenames"
982
+ exit 1
983
+ end
984
+
985
+ scp(
986
+ target,
987
+ environment,
988
+ args,
989
+ additional_arguments: extra_args,
990
+ )
991
+ end,
992
+
993
+ "scp-push" => proc do |target, environment, args, extra_args, options|
994
+ check_for_target(target)
995
+ target = target_from_options(target, options)
996
+
997
+ if args.length < 2
998
+ error "error: need at least two filenames"
999
+ exit 1
1000
+ end
1001
+
1002
+ scp(
1003
+ target,
1004
+ environment,
1005
+ args,
1006
+ push: true,
1007
+ additional_arguments: extra_args,
1008
+ )
1009
+ end,
1010
+ }
1011
+ end
1012
+
1013
+ def main(argv)
1014
+ check_ruby_version_greater_than(required_major: 2, required_minor: 0)
1015
+
1016
+ extra_arguments_after_double_dash = []
1017
+
1018
+ double_dash_index = argv.index "--"
1019
+ if double_dash_index
1020
+ # This is used in the case of passing extra options to ssh and
1021
+ # scp, the -- acts as a separator, so to avoid optparse
1022
+ # interpreting those as options, split argv around -- before
1023
+ # parsing the options
1024
+ extra_arguments_after_double_dash = argv[double_dash_index + 1, argv.length]
1025
+ argv = argv[0, double_dash_index]
1026
+ end
1027
+
1028
+ govuk_connect_options = parse_options(argv)
1029
+ type, target, *extra_arguments_before_double_dash = argv
1030
+
1031
+ unless type
1032
+ error "error: you must specify the connection type\n"
1033
+
1034
+ warn @option_parser.help
1035
+
1036
+ warn "\nValid connection types are:\n"
1037
+ types.keys.each do |x|
1038
+ warn " - #{x}"
1039
+ end
1040
+ print_empty_line
1041
+ warn "Example commands:"
1042
+ warn EXAMPLES
1043
+
1044
+ exit 1
1045
+ end
1046
+
1047
+ handler = types[type]
1048
+
1049
+ unless handler
1050
+ error "error: unknown connection type: #{type}\n"
1051
+
1052
+ warn "Valid connection types are:\n"
1053
+ types.keys.each do |x|
1054
+ warn " - #{x}"
1055
+ end
1056
+ print_empty_line
1057
+ warn "Example commands:"
1058
+ warn EXAMPLES
1059
+
1060
+ exit 1
1061
+ end
1062
+
1063
+ environment = govuk_connect_options[:environment]&.to_sym
1064
+
1065
+ unless environment
1066
+ error "error: you must specify the environment\n"
1067
+ warn @option_parser.help
1068
+ exit 1
1069
+ end
1070
+
1071
+ unless JUMPBOXES.key? environment
1072
+ error "error: unknown environment '#{environment}'"
1073
+ print_empty_line
1074
+ info "Valid environments are:"
1075
+ JUMPBOXES.keys.each { |e| info " - #{e}" }
1076
+ exit 1
1077
+ end
1078
+
1079
+ handler.call(
1080
+ target,
1081
+ environment,
1082
+ extra_arguments_before_double_dash,
1083
+ extra_arguments_after_double_dash,
1084
+ govuk_connect_options,
1085
+ )
1086
+ rescue Interrupt
1087
+ # Handle SIGTERM without printing a stacktrace
1088
+ exit 1
1089
+ end
1090
+ end