sonic-screwdriver 0.0.1 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (110) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +11 -0
  3. data/Gemfile.lock +26 -2
  4. data/README.md +156 -23
  5. data/bin/sonic +8 -1
  6. data/docs/.gitignore +4 -0
  7. data/docs/CNAME +1 -0
  8. data/docs/Gemfile +3 -0
  9. data/docs/LICENSE +21 -0
  10. data/docs/README.md +21 -0
  11. data/docs/_config.yml +69 -0
  12. data/docs/_docs/commands.md +10 -0
  13. data/docs/_docs/how-it-works.md +34 -0
  14. data/docs/_docs/install.md +75 -0
  15. data/docs/_docs/next-steps.md +18 -0
  16. data/docs/_docs/settings.md +73 -0
  17. data/docs/_docs/sonic-ecs-exec.md +7 -0
  18. data/docs/_docs/sonic-ecs-run.md +7 -0
  19. data/docs/_docs/sonic-execute.md +7 -0
  20. data/docs/_docs/sonic-help.md +7 -0
  21. data/docs/_docs/sonic-list.md +7 -0
  22. data/docs/_docs/sonic-ssh.md +7 -0
  23. data/docs/_docs/tutorial-ecs-exec.md +69 -0
  24. data/docs/_docs/tutorial-ecs-run.md +94 -0
  25. data/docs/_docs/tutorial-execute.md +38 -0
  26. data/docs/_docs/tutorial-ssh.md +119 -0
  27. data/docs/_docs/tutorial.md +11 -0
  28. data/docs/_docs/why.md +27 -0
  29. data/docs/_includes/about.html +19 -0
  30. data/docs/_includes/commands.html +28 -0
  31. data/docs/_includes/contact.html +17 -0
  32. data/docs/_includes/contact_disqus.html +16 -0
  33. data/docs/_includes/contact_static.html +17 -0
  34. data/docs/_includes/content.html +21 -0
  35. data/docs/_includes/css/bootstrap.min.css +7 -0
  36. data/docs/_includes/css/main.css +481 -0
  37. data/docs/_includes/css/quotes.css +102 -0
  38. data/docs/_includes/css/sonic.css +163 -0
  39. data/docs/_includes/css/syntax.css +60 -0
  40. data/docs/_includes/css/table.css +53 -0
  41. data/docs/_includes/css/timeline.css +201 -0
  42. data/docs/_includes/edit-on-github.html +11 -0
  43. data/docs/_includes/example.html +21 -0
  44. data/docs/_includes/footer.html +49 -0
  45. data/docs/_includes/head.html +32 -0
  46. data/docs/_includes/header.html +15 -0
  47. data/docs/_includes/js.html +28 -0
  48. data/docs/_includes/js_disqus.html +21 -0
  49. data/docs/_includes/modals.html +40 -0
  50. data/docs/_includes/nav.html +27 -0
  51. data/docs/_includes/quotes.html +19 -0
  52. data/docs/_includes/subnav.html +35 -0
  53. data/docs/_includes/ufo-ship-options.md +13 -0
  54. data/docs/_includes/uses.html +19 -0
  55. data/docs/_layouts/default.html +11 -0
  56. data/docs/_layouts/style.css +6 -0
  57. data/docs/articles.md +5 -0
  58. data/docs/css/font-awesome/css/font-awesome.css +1566 -0
  59. data/docs/css/font-awesome/css/font-awesome.min.css +4 -0
  60. data/docs/css/font-awesome/fonts/FontAwesome.otf +0 -0
  61. data/docs/css/font-awesome/fonts/fontawesome-webfont.eot +0 -0
  62. data/docs/css/font-awesome/fonts/fontawesome-webfont.svg +504 -0
  63. data/docs/css/font-awesome/fonts/fontawesome-webfont.ttf +0 -0
  64. data/docs/css/font-awesome/fonts/fontawesome-webfont.woff +0 -0
  65. data/docs/docs.md +21 -0
  66. data/docs/img/logos/boltops-logo-full.png +0 -0
  67. data/docs/img/logos/boltops-logo.png +0 -0
  68. data/docs/img/sonic-screwdriver.jpg +0 -0
  69. data/docs/img/tutorials/ec2-console-public-ip.png +0 -0
  70. data/docs/img/ufo.jpg +0 -0
  71. data/docs/index.html +9 -0
  72. data/docs/js/bootstrap.js +2114 -0
  73. data/docs/js/bootstrap.min.js +6 -0
  74. data/docs/js/cbpAnimatedHeader.js +44 -0
  75. data/docs/js/cbpAnimatedHeader.min.js +11 -0
  76. data/docs/js/classie.js +80 -0
  77. data/docs/js/contact_me.js +70 -0
  78. data/docs/js/contact_me_static.js +23 -0
  79. data/docs/js/freelancer.js +37 -0
  80. data/docs/js/jqBootstrapValidation.js +912 -0
  81. data/docs/js/jquery-1.11.0.js +4 -0
  82. data/docs/js/jquery.easing.min.js +44 -0
  83. data/docs/js/nav.js +53 -0
  84. data/docs/quick-start.md +39 -0
  85. data/docs/style.css +3 -0
  86. data/lib/bash_scripts/docker-exec.sh +15 -0
  87. data/lib/bash_scripts/docker-run.sh +15 -0
  88. data/lib/sonic.rb +11 -2
  89. data/lib/sonic/aws_services.rb +19 -0
  90. data/lib/sonic/cli.rb +37 -8
  91. data/lib/sonic/cli/help.rb +123 -3
  92. data/lib/sonic/default/settings.yml +12 -0
  93. data/lib/sonic/docker.rb +128 -0
  94. data/lib/sonic/execute.rb +131 -0
  95. data/lib/sonic/list.rb +85 -0
  96. data/lib/sonic/settings.rb +80 -0
  97. data/lib/sonic/ssh.rb +136 -0
  98. data/lib/sonic/ssh/ec2_tag.rb +59 -0
  99. data/lib/sonic/ssh/identifier_detector.rb +145 -0
  100. data/lib/sonic/ui.rb +26 -0
  101. data/lib/sonic/version.rb +2 -2
  102. data/qa.md +21 -0
  103. data/sonic.gemspec +3 -1
  104. data/spec/fixtures/home/.gitkeep +0 -0
  105. data/spec/fixtures/project/.gitkeep +0 -0
  106. data/spec/fixtures/project/command.txt +2 -0
  107. data/spec/lib/cli_spec.rb +16 -6
  108. data/spec/lib/sonic/execute_spec.rb +35 -0
  109. data/spec/spec_helper.rb +5 -3
  110. metadata +133 -3
@@ -0,0 +1,131 @@
1
+ module Sonic
2
+ class Execute
3
+ include AwsServices
4
+
5
+ def initialize(command, options)
6
+ @command = command
7
+ @options = options
8
+ @filter = @options[:filter].split(',').map{|s| s.strip}
9
+ end
10
+
11
+ # aws ssm send-command \
12
+ # --instance-ids i-030033c20c54bf149 \
13
+ # --document-name "AWS-RunShellScript" \
14
+ # --comment "Demo run shell script on Linux Instances" \
15
+ # --parameters '{"commands":["#!/usr/bin/python","print \"Hello world from python\""]}' \
16
+ # --query "Command.CommandId"
17
+ def execute
18
+ ssm_options = build_ssm_options
19
+ if @options[:noop]
20
+ UI.noop = true
21
+ command_id = "fake command id"
22
+ else
23
+ resp = ssm.send_command(ssm_options)
24
+ command_id = resp.command.command_id
25
+ end
26
+ UI.say "Command sent to AWS SSM. To check the details of the command:"
27
+ list_command = "aws ssm list-commands --command-id #{command_id}"
28
+ UI.say list_command
29
+ if RUBY_PLATFORM =~ /darwin/
30
+ system("echo '#{list_command}' | pbcopy")
31
+ UI.say "Pro tip: the aws ssm command is already in your copy/paste clipboard."
32
+ end
33
+ end
34
+
35
+ def build_ssm_options
36
+ criteria = transform_filter(@filter)
37
+ command = build_command(@command)
38
+ criteria.merge(
39
+ document_name: "AWS-RunShellScript",
40
+ comment: "sonic #{ARGV.join(' ')}",
41
+ parameters: {
42
+ "commands" => command
43
+ }
44
+ )
45
+ end
46
+
47
+ def build_command(command)
48
+ if file_path?(command)
49
+ path = file_path(command)
50
+ if File.exist?(path)
51
+ IO.readlines(path).map {|s| s.strip}
52
+ else
53
+ UI.error("File #{path} could not be found. Are you sure it exist?")
54
+ exit 1
55
+ end
56
+ else
57
+ # The script is being feed inline so just join the command together into one script.
58
+ # Still keep in an array form because that's how ssn.send_command with AWS-RunShellScript
59
+ # usually reads the command.
60
+ [command.join(" ")]
61
+ end
62
+ end
63
+
64
+ def file_path?(command)
65
+ return false unless command.size == 1
66
+ possible_path = command.first
67
+ possible_path.include?("file://")
68
+ end
69
+
70
+ def file_path(command)
71
+ path = command.first
72
+ path = path.sub('file://', '')
73
+ path = "#{@options[:project_root]}/#{path}" if @options[:project_root]
74
+ path
75
+ end
76
+
77
+ #
78
+ # Public: Transform the filter to the ssm send_command equivalent options
79
+ #
80
+ # filter - CLI filter option. Example: hi-web-prod hi-worker-prod hi-clock-prod i-0f7f833131a51ce35
81
+ #
82
+ # Examples
83
+ #
84
+ # transform_filter(["hi-web-prod", "hi-worker-prod", "i-006a097bb10643e20"])
85
+ # # => {
86
+ # instance_ids: ["i-006a097bb10643e20"],
87
+ # targets: [{key: "Name", values: "hi-web-prod,hi-worker-prod"}]
88
+ # }
89
+ #
90
+ # Returns the duplicated String.
91
+ def transform_filter(filter)
92
+ valid = validate_filter(filter)
93
+ unless valid
94
+ UI.error("The filter you provided '#{filter.join(',')}' is not valid.")
95
+ UI.say("The filter must either be all instance ids or just a list of tag names.")
96
+ exit 1
97
+ end
98
+
99
+ if filter.detect { |i| instance_id?(i) }
100
+ instance_ids = filter
101
+ {instance_ids: instance_ids}
102
+ else
103
+ tags = filter
104
+ targets = [{
105
+ key: "tag:#{tag_name}",
106
+ values: tags
107
+ }]
108
+ {targets: targets}
109
+ end
110
+ end
111
+
112
+ # Either all instance ids are no instance ids is a valid filter
113
+ def validate_filter(filter)
114
+ if filter.detect { |i| instance_id?(i) }
115
+ instance_ids = filter.select { |i| instance_id?(i) }
116
+ instance_ids.size == filter.size
117
+ else
118
+ true
119
+ end
120
+ end
121
+
122
+ # TODO: make configurable
123
+ def tag_name
124
+ "Name"
125
+ end
126
+
127
+ def instance_id?(text)
128
+ text =~ /i-.{17}/
129
+ end
130
+ end
131
+ end
data/lib/sonic/list.rb ADDED
@@ -0,0 +1,85 @@
1
+ module Sonic
2
+ class List
3
+ include AwsServices
4
+
5
+ def initialize(options)
6
+ @options = options
7
+ @filter = @options[:filter] ? @options[:filter].split(',').map{|s| s.strip} : []
8
+ end
9
+
10
+ def run
11
+ options = transform_filter_option(@filter)
12
+ if @options[:noop]
13
+ instances = []
14
+ else
15
+ instances = ec2_resource.instances(options)
16
+ end
17
+ display(instances)
18
+ end
19
+
20
+ def display(instances)
21
+ if @options[:header]
22
+ UI.say "Instance Id\tPublic IP\tPrivate IP\tType".colorize(:green)
23
+ end
24
+
25
+ instances.each do |i|
26
+ line = [i.instance_id, i.public_ip_address, i.private_ip_address, i.instance_type].join("\t")
27
+ UI.say(line)
28
+ end
29
+ end
30
+
31
+ #
32
+ # Public: Transform the filter to the ssm send_command equivalent options
33
+ #
34
+ # filter - CLI filter option. Example: hi-web-prod hi-worker-prod hi-clock-prod i-0f7f833131a51ce35
35
+ #
36
+ # Examples
37
+ #
38
+ # transform_filter(["hi-web-prod", "hi-worker-prod", "i-006a097bb10643e20"])
39
+ # # => {
40
+ # instance_ids: ["i-006a097bb10643e20"],
41
+ # targets: [{key: "Name", values: "hi-web-prod,hi-worker-prod"}]
42
+ # }
43
+ #
44
+ # Note: method looks close to the Execute#transform_filter method but the criteria
45
+ # structure is slightly different.
46
+ #
47
+ # Returns the duplicated String.
48
+ def transform_filter_option(filter)
49
+ return {} if filter.empty?
50
+
51
+ valid = validate_filter(filter)
52
+ unless valid
53
+ UI.error("The filter you provided '#{filter.join(',')}' is not valid.")
54
+ UI.say("The filter must either be all instance ids or just a list of tag names.")
55
+ exit 1
56
+ end
57
+
58
+ if filter.detect { |i| instance_id?(i) }
59
+ instance_ids = filter
60
+ {instance_ids: instance_ids}
61
+ else
62
+ tags = filter
63
+ criteria = [{
64
+ name: "tag-value",
65
+ values: tags
66
+ }]
67
+ {filters: criteria}
68
+ end
69
+ end
70
+
71
+ # Either all instance ids are no instance ids is a valid filter
72
+ def validate_filter(filter)
73
+ if filter.detect { |i| instance_id?(i) }
74
+ instance_ids = filter.select { |i| instance_id?(i) }
75
+ instance_ids.size == filter.size
76
+ else
77
+ true
78
+ end
79
+ end
80
+
81
+ def instance_id?(text)
82
+ text =~ /i-.{17}/
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,80 @@
1
+ require 'yaml'
2
+
3
+ module Sonic
4
+ class Settings
5
+ def initialize(project_root=nil)
6
+ @project_root = project_root || '.'
7
+ end
8
+
9
+ def data
10
+ return @data if @data
11
+
12
+ project_file = "#{@project_root}/.sonic/settings.yml"
13
+ project = File.exist?(project_file) ? YAML.load_file(project_file) : {}
14
+
15
+ user_file = "#{home}/.sonic/settings.yml"
16
+ user = File.exist?(user_file) ? YAML.load_file(user_file) : {}
17
+
18
+ default_file = File.expand_path("../default/settings.yml", __FILE__)
19
+ default = YAML.load_file(default_file)
20
+
21
+ @data = default.merge(user.merge(project))
22
+ ensure_default_cluster(@data)
23
+ @data
24
+ end
25
+
26
+ # Public: Returns default cluster based on the ECS service name.
27
+ #
28
+ # service - ECS service
29
+ # count - The Integer number of times to duplicate the text.
30
+ #
31
+ # The settings.yml format:
32
+ #
33
+ # service_cluster:
34
+ # default: stag
35
+ # hi-web-prod: prod
36
+ # hi-clock-prod: prod
37
+ # hi-worker-prod: prod
38
+ # hi-web-stag: stag
39
+ # hi-clock-stag: stag
40
+ # hi-worker-stag: stag
41
+ #
42
+ # Examples
43
+ #
44
+ # default_cluster('hi-web-prod')
45
+ # # => 'prod'
46
+ # default_cluster('whatever')
47
+ # # => 'stag'
48
+ #
49
+ # Returns the ECS cluster name.
50
+ def default_cluster(service)
51
+ service_cluster = data["service_cluster"]
52
+ service_cluster[service] || service_cluster["default"]
53
+ end
54
+
55
+ # When user's .sonic/settings.yml lack the default cluster, we add it on.
56
+ # Otherwise the user get confusing and scary aws-sdk-core/param_validator errors:
57
+ # Example: https://gist.github.com/sonic/67b9a68a77363b908d1c36047bc2709a
58
+ def ensure_default_cluster(data)
59
+ unless data["service_cluster"]["default"]
60
+ data["service_cluster"]["default"] = "default"
61
+ end
62
+ data
63
+ end
64
+
65
+ def host_key_check_options
66
+ if data["host_key_check"]
67
+ # no options by default enables strict host key checking
68
+ []
69
+ else
70
+ # disables host key checking
71
+ %w[-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null]
72
+ end
73
+ end
74
+
75
+ def home
76
+ # hack but fast
77
+ ENV['TEST'] ? "spec/fixtures/home" : ENV['HOME']
78
+ end
79
+ end
80
+ end
data/lib/sonic/ssh.rb ADDED
@@ -0,0 +1,136 @@
1
+ require 'colorize'
2
+
3
+ module Sonic
4
+ class Ssh
5
+ autoload :IdentifierDetector, 'sonic/ssh/identifier_detector'
6
+
7
+ include AwsServices
8
+
9
+ def initialize(identifier, options)
10
+ @options = options
11
+
12
+ @user, @identifier = extract_user!(identifier) # extracts/strips user from identifier
13
+ # While --user option is supported at the class level, don't expose at the CLI level
14
+ # to encourage users to use user@host notation.
15
+ @user ||= options[:user] || settings.data["user"]
16
+
17
+ @service = @identifier # always set service even though it's not always used as the identifier
18
+ @cluster = options[:cluster] || settings.default_cluster(@service)
19
+ @bastion = options[:bastion] || settings.data["bastion"]
20
+ end
21
+
22
+ def run
23
+ ssh = build_ssh_command
24
+ kernel_exec(*ssh) # must splat the Array here
25
+ end
26
+
27
+ def bastion_host
28
+ return @identifier if @options[:noop] # for specs
29
+ @bastion_host ||= build_bastion_host
30
+ end
31
+
32
+ def build_bastion_host
33
+ host = @bastion
34
+ host = "#{@user}@#{host}" unless host.include?('@')
35
+ host
36
+ end
37
+
38
+ # used by child Classes
39
+ def ssh_host
40
+ return @identifier if @options[:noop] # for specs
41
+ @ssh_host ||= build_ssh_host
42
+ end
43
+
44
+ def build_ssh_host
45
+ detector = Ssh::IdentifierDetector.new(@cluster, @service, @identifier, @options)
46
+ instance_id = detector.detect!
47
+ instance_hostname(instance_id)
48
+ end
49
+
50
+ def instance_hostname(ec2_instance_id)
51
+ begin
52
+ resp = ec2.describe_instances(instance_ids: [ec2_instance_id])
53
+ rescue Aws::EC2::Errors::InvalidInstanceIDNotFound => e
54
+ # e.message: The instance ID 'i-027363802c6ff3141' does not exist
55
+ UI.error(e.message)
56
+ exit 1
57
+ end
58
+ instance = resp.reservations[0].instances[0]
59
+ # struct Aws::EC2::Types::Instance
60
+ # http://docs.aws.amazon.com/sdkforruby/api/Aws/EC2/Types/Instance.html
61
+ host = if @bastion
62
+ instance.private_ip_address
63
+ else
64
+ instance.public_ip_address
65
+ end
66
+ "#{@user}@#{host}"
67
+ end
68
+
69
+ # Will use Kernel.exec so that the ssh process takes over this ruby process.
70
+ def kernel_exec(*args)
71
+ # append the optional command that can be provided to the ssh command
72
+ full_command = args + @options[:command]
73
+ puts "=> #{full_command.join(' ')}".colorize(:green)
74
+ # https://ruby-doc.org/core-2.3.1/Kernel.html#method-i-exec
75
+ # Using 2nd form
76
+ Kernel.exec(*full_command) unless @options[:noop]
77
+ end
78
+
79
+ private
80
+ def settings
81
+ @settings ||= Settings.new(@options[:project_root])
82
+ end
83
+
84
+ # Returns Array of flags.
85
+ def ssh_options
86
+ settings.host_key_check_options
87
+ end
88
+
89
+ # Will prepend the bastion host if required
90
+ # When bastion set
91
+ # ssh [options] -At [bastion_host] ssh -At [ssh_host]
92
+ #
93
+ # When bastion not set
94
+ # ssh [options] -At [ssh_host]
95
+ #
96
+ # Builds up ssh command to be used with Kernel.exec. Will look something like this:
97
+ # ssh -At ec2-user@34.211.223.3 ssh ec2-user@10.10.110.135
98
+ # It is imporant to use an Array for the command so it gets intrepreted as if you are
99
+ # executing it from the shell directly. For example, globs gets expanded with the
100
+ # Array notation but not the String notation.
101
+ #
102
+ # ssh options:
103
+ # -A = Enables forwarding of the authentication agent connection
104
+ # -t = Force pseudo-terminal allocati
105
+ def build_ssh_command
106
+ ssh = ["ssh"] + ssh_options
107
+ ssh += ["-At", bastion_host, "ssh"] if @bastion
108
+ # ssh_host is internal ip when bastion is set
109
+ # ssh_host is public ip when bastion is not set
110
+ ssh += ["-At", ssh_host]
111
+ ssh
112
+ end
113
+
114
+ # Private: Extracts and strips the user from the identifier.
115
+ #
116
+ # identifier - Can be a variety of things: instance_id, ecs service, ecs task, etc.
117
+ #
118
+ # Examples
119
+ #
120
+ # extract_user!("i-0f7f833131a51ce35")
121
+ # # => [nil, "i-0f7f833131a51ce35"]
122
+ #
123
+ # extract_user!("ec2-user@i-0f7f833131a51ce35")
124
+ # # => ["ec2-user", "i-0f7f833131a51ce35"]
125
+ #
126
+ # Returns the a tuple cotaining the user and identifier
127
+ def extract_user!(identifier)
128
+ md = identifier.match(/(.*)@(.*)/)
129
+ if md
130
+ [md[1], md[2]]
131
+ else
132
+ [nil, identifier]
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,59 @@
1
+ require 'tty-prompt'
2
+
3
+ module Sonic
4
+ # ec2 tag related methods
5
+ module Ssh::Ec2Tag
6
+ def ec2_instances
7
+ return @ec2_instances if @ec2_instances
8
+
9
+ filters = [{ name: 'tag-value', values: tags_filter }]
10
+ @ec2_instances = ec2_resource.instances(filters: filters)
11
+ end
12
+
13
+ # matches any tag value
14
+ def ec2_tag_exists?
15
+ ec2_instances.count > 0
16
+ end
17
+
18
+ # If no instances found
19
+ # Exit immediately with error message
20
+ # If all instances found have the same tag name
21
+ # Immediately return the first instance id
22
+ # If multiple tag values
23
+ # Prompt user to select instance tag value of interest
24
+ def find_ec2_instance
25
+ tag_values = ec2_instances.map{ |i| matched_tag_value(i) }.uniq
26
+ case tag_values.size
27
+ when 0
28
+ UI.error("Unable to find an instance with a one of the tag values: #{@identifier}")
29
+ exit 1
30
+ when 1
31
+ ec2_instances.first.instance_id
32
+ else
33
+ # prompt
34
+ select_instance_type(tag_values).instance_id
35
+ end
36
+ end
37
+
38
+ def select_instance_type(tag_values)
39
+ UI.say("Found multiple instance types matching the tag filter: #{@identifier}")
40
+ prompt = TTY::Prompt.new
41
+ tag_value = prompt.select("Select an instance type tag:", tag_values)
42
+
43
+ # find the first instance with the tag_value
44
+ instance = ec2_instances.find do |i|
45
+ i.tags.find { |t| t.value == tag_value }
46
+ end
47
+ end
48
+
49
+ def matched_tag_value(instance)
50
+ tags = instance.tags
51
+ tag = tags.find {|t| tags_filter.include?(t.value) }
52
+ tag.value
53
+ end
54
+
55
+ def tags_filter
56
+ @identifier.split(',') # identifier from CLI could be a comma separated list
57
+ end
58
+ end
59
+ end