govuk-connect 0.0.1

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