moonshot 0.7.6 → 0.7.7

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: d67cb66c5dd32a605e63ad9c2878ba6148786aeb
4
- data.tar.gz: ca5974cfe33c7a40d1040984017d5180e67a1da7
3
+ metadata.gz: a022cf1a54f893264b6ee7487303d79b8c29fb44
4
+ data.tar.gz: 7425456a02ec0451c431c6dc1863616fae9200e5
5
5
  SHA512:
6
- metadata.gz: 970d41d6ae16a11f8285381efe2f22a8b9c47fae952e9a860c713bb3aabf57af6e4fc241ccbe72e1640b5a06edea0fc6acdc88db1148a24d03c685eabaecb92f
7
- data.tar.gz: dde9fcf2087fdd0aa7918079aa51944b9409567866ab174a0e0c8ab5320b2dbc9ec6adb6257d6edc807b7351063e1c61e640d50c6dee9f0a7d545c0746425370
6
+ metadata.gz: 8857003d9882c8fa70b559ba182887af90c5bd0b6cf2a62bc60e6bd4109c4e26e1f4ab6fd74bbc5764286cb592a1f70347659f0dfc79b8e13df104cc8356bc40
7
+ data.tar.gz: 36b9bb90372c8ae2bacf1253061d243c2333f57095fb49f2741de6d2bab602bfaed19652b2bcb86a22df67dc8e45af1ea583b0a760105dc8474c40a570dfa538
@@ -118,7 +118,7 @@ module Moonshot::BuildMechanism
118
118
  end
119
119
 
120
120
  def hub_create_release(semver, commitish, changelog_entry)
121
- return if hub_release_exists(semver, commitish)
121
+ return if hub_release_exists(semver)
122
122
 
123
123
  message = "#{semver}\n\n#{changelog_entry}"
124
124
  cmd = "hub release create #{semver} --commitish=#{commitish}"
@@ -47,7 +47,7 @@ class Moonshot::BuildMechanism::Script
47
47
 
48
48
  private
49
49
 
50
- def run_script(step, env: {}) # rubocop:disable AbcSize
50
+ def run_script(step, env: {})
51
51
  popen2e(env, @script) do |_, out, wait|
52
52
  output = []
53
53
 
@@ -66,9 +66,7 @@ class Moonshot::BuildMechanism::Script
66
66
  end
67
67
  unless result.exitstatus == 0
68
68
  ilog.error "Build script failed with exit status #{result.exitstatus}!"
69
- ilog.error 'Last 10 lines of output follows:'
70
- output.pop(10).each { |l| ilog.error l }
71
-
69
+ ilog.error output.join("\n")
72
70
  step.failure "Build script #{@script} failed with exit status #{result.exitstatus}!"
73
71
  end
74
72
  end
@@ -1,4 +1,7 @@
1
1
  require 'moonshot/shell'
2
+ require 'travis'
3
+ require 'travis/pro'
4
+ require 'travis/client/auto_login'
2
5
 
3
6
  module Moonshot::BuildMechanism
4
7
  # This simply waits for Travis-CI to finish building a job matching the
@@ -12,9 +15,13 @@ module Moonshot::BuildMechanism
12
15
 
13
16
  attr_reader :output_file
14
17
 
15
- def initialize(slug, pro: false)
18
+ def initialize(slug, pro: false, timeout: 900)
16
19
  @slug = slug
20
+ @pro = pro
21
+ @timeout = timeout
22
+
17
23
  @endpoint = pro ? '--pro' : '--org'
24
+ @travis_base = @pro ? Travis::Pro : Travis
18
25
  @cli_args = "-r #{@slug} #{@endpoint}"
19
26
  end
20
27
 
@@ -32,6 +39,18 @@ module Moonshot::BuildMechanism
32
39
 
33
40
  private
34
41
 
42
+ # Authenticates with the proper travis service.
43
+ def authenticate
44
+ Travis::Client::AutoLogin.new(@travis_base).authenticate
45
+ end
46
+
47
+ # Retrieves the travis repository.
48
+ #
49
+ # @return [Travis::Client::Repository]
50
+ def repo
51
+ @repo ||= @travis_base::Repository.find(@slug)
52
+ end
53
+
35
54
  def find_build_and_job(version)
36
55
  job_number = nil
37
56
  ilog.start_threaded('Find Travis CI build') do |step|
@@ -78,16 +97,35 @@ module Moonshot::BuildMechanism
78
97
  job_number
79
98
  end
80
99
 
100
+ # Waits for a job to complete, within the defined timeout.
101
+ #
102
+ # @param job_number [String] The job number to wait for.
81
103
  def wait_for_job(job_number)
82
- cmd = "bundle exec travis logs #{@cli_args} #{job_number}"
83
- # This log tailing fails at the end of the file. travis bug.
84
- sh_step(cmd, fail: false)
104
+ authenticate
105
+
106
+ # Wait for the job to complete or hit the timeout.
107
+ start = Time.new
108
+ job = repo.job(job_number)
109
+ ilog.start_threaded("Waiting for job #{job_number} to complete.") do |s|
110
+ while !job.finished? && Time.new - start < @timeout
111
+ s.continue("Job status: #{job.state}")
112
+ sleep 10
113
+ job.reload
114
+ end
115
+
116
+ if job.finished?
117
+ s.success
118
+ else
119
+ s.failure("Job #{job_number} did not complete within time limit of " \
120
+ "#{@timeout} seconds")
121
+ end
122
+ end
85
123
  end
86
124
 
87
125
  def check_build(version)
88
126
  cmd = "bundle exec travis show #{@cli_args} #{version}"
89
127
  sh_step(cmd) do |step, out|
90
- raise "Build didn't pass.\n#{build_out}" \
128
+ raise "Build didn't pass.\n#{out}" \
91
129
  if out =~ /^#(\d+\.\d+) (?!passed).+BUILD=1.+/
92
130
 
93
131
  step.success("Travis CI build for #{version} passed.")
data/lib/moonshot/cli.rb CHANGED
@@ -110,8 +110,8 @@ module Moonshot
110
110
  config.parameter_strategy = parameter_strategy_factory(parameter_strategy) \
111
111
  unless parameter_strategy.nil?
112
112
 
113
- config.ssh_user = options[:user]
114
- config.ssh_identity_file = options[:identity_file]
113
+ config.ssh_config.ssh_user = options[:user]
114
+ config.ssh_config.ssh_identity_file = options[:identity_file]
115
115
  config.ssh_instance = options[:instance]
116
116
  config.ssh_command = options[:command]
117
117
  config.ssh_auto_scaling_group_name = options[:auto_scaling_group]
@@ -1,3 +1,6 @@
1
+ require_relative 'ssh_target_selector'
2
+ require_relative 'ssh_command_builder'
3
+
1
4
  module Moonshot
2
5
  # The Controller coordinates and performs all Moonshot actions.
3
6
  class Controller # rubocop:disable ClassLength
@@ -82,8 +85,13 @@ module Moonshot
82
85
 
83
86
  def ssh
84
87
  run_plugins(:pre_ssh)
85
- stack.ssh
86
- run_plugins(:post_ssh)
88
+ @config.ssh_instance ||= SSHTargetSelector.new(
89
+ stack, asg_name: @config.ssh_auto_scaling_group_name).choose!
90
+ cb = SSHCommandBuilder.new(@config.ssh_config, @config.ssh_instance)
91
+ result = cb.build(@config.ssh_command)
92
+
93
+ puts "Opening SSH connection to #{@config.ssh_instance} (#{result.ip})..."
94
+ exec(result.cmd)
87
95
  end
88
96
 
89
97
  def stack
@@ -94,11 +102,6 @@ module Moonshot
94
102
  config.parent_stacks = @config.parent_stacks
95
103
  config.show_all_events = @config.show_all_stack_events
96
104
  config.parameter_strategy = @config.parameter_strategy
97
- config.ssh_user = @config.ssh_user
98
- config.ssh_identity_file = @config.ssh_identity_file
99
- config.ssh_instance = @config.ssh_instance
100
- config.ssh_command = @config.ssh_command
101
- config.ssh_auto_scaling_group_name = @config.ssh_auto_scaling_group_name
102
105
  end
103
106
  end
104
107
 
@@ -1,4 +1,5 @@
1
1
  require_relative 'default_strategy'
2
+ require_relative 'ssh_config'
2
3
 
3
4
  module Moonshot
4
5
  # Holds configuration for Moonshot::Controller
@@ -15,11 +16,10 @@ module Moonshot
15
16
  attr_accessor :plugins
16
17
  attr_accessor :show_all_stack_events
17
18
  attr_accessor :parameter_strategy
18
- attr_accessor :ssh_instance
19
- attr_accessor :ssh_identity_file
20
- attr_accessor :ssh_user
19
+ attr_accessor :ssh_config
21
20
  attr_accessor :ssh_command
22
21
  attr_accessor :ssh_auto_scaling_group_name
22
+ attr_accessor :ssh_instance
23
23
 
24
24
  def initialize
25
25
  @auto_prefix_stack = true
@@ -29,6 +29,7 @@ module Moonshot
29
29
  @plugins = []
30
30
  @show_all_stack_events = false
31
31
  @parameter_strategy = Moonshot::ParameterStrategy::DefaultStrategy.new
32
+ @ssh_config = SSHConfig.new
32
33
  end
33
34
  end
34
35
  end
@@ -13,20 +13,33 @@ class Moonshot::DeploymentMechanism::CodeDeploy # rubocop:disable ClassLength
13
13
  include Moonshot::CredsHelper
14
14
  include Moonshot::DoctorHelper
15
15
 
16
- # @param asg [String]
16
+ # @param asg [Array, String]
17
17
  # The logical name of the AutoScalingGroup to create and manage a Deployment
18
18
  # Group for in CodeDeploy.
19
19
  # @param role [String]
20
20
  # IAM role with AWSCodeDeployRole policy. CodeDeployRole is considered as
21
21
  # default role if its not specified.
22
22
  # @param app_name [String, nil] (nil)
23
- # The name of the CodeDeploy Application and Deployment Group. By default,
24
- # this is the same as the stack name, and probably what you want. If you
25
- # have multiple deployments in a single Stack, they must have unique names.
26
- def initialize(asg:, role: 'CodeDeployRole', app_name: nil)
27
- @asg_logical_id = asg
23
+ # The name of the CodeDeploy Application. By default, this is the same as
24
+ # the stack name, and probably what you want. If you have multiple
25
+ # deployments in a single Stack, they must have unique names.
26
+ # @param group_name [String, nil] (nil)
27
+ # The name of the CodeDeploy Deployment Group. By default, this is the same
28
+ # as app_name.
29
+ # @param config_name [String]
30
+ # Name of the Deployment Config to use for CodeDeploy, By default we use
31
+ # CodeDeployDefault.OneAtATime.
32
+ def initialize(
33
+ asg: [],
34
+ role: 'CodeDeployRole',
35
+ app_name: nil,
36
+ group_name: nil,
37
+ config_name: 'CodeDeployDefault.OneAtATime')
38
+ @asg_logical_ids = asg.is_a?(Array) ? asg : [asg]
28
39
  @app_name = app_name
40
+ @group_name = group_name
29
41
  @codedeploy_role = role
42
+ @codedeploy_config = config_name
30
43
  end
31
44
 
32
45
  def post_create_hook
@@ -56,9 +69,9 @@ class Moonshot::DeploymentMechanism::CodeDeploy # rubocop:disable ClassLength
56
69
  ilog.start_threaded 'Creating Deployment' do |s|
57
70
  res = cd_client.create_deployment(
58
71
  application_name: app_name,
59
- deployment_group_name: app_name,
72
+ deployment_group_name: group_name,
60
73
  revision: revision_for_artifact_repo(artifact_repo, version_name),
61
- deployment_config_name: 'CodeDeployDefault.OneAtATime',
74
+ deployment_config_name: @codedeploy_config,
62
75
  description: "Deploying version #{version_name}"
63
76
  )
64
77
  deployment_id = res.deployment_id
@@ -80,12 +93,18 @@ class Moonshot::DeploymentMechanism::CodeDeploy # rubocop:disable ClassLength
80
93
 
81
94
  private
82
95
 
83
- # By default, use the stack name as the application and deployment group
84
- # names, unless one has been provided.
96
+ # By default, use the stack name as the application name, unless one has been
97
+ # provided.
85
98
  def app_name
86
99
  @app_name || stack.name
87
100
  end
88
101
 
102
+ # By default, use the stack name as the deployment group name, unless one has
103
+ # been provided.
104
+ def group_name
105
+ @group_name || stack.name
106
+ end
107
+
89
108
  def pretty_app_name
90
109
  "CodeDeploy Application #{app_name.blue}"
91
110
  end
@@ -131,27 +150,35 @@ class Moonshot::DeploymentMechanism::CodeDeploy # rubocop:disable ClassLength
131
150
  end
132
151
  end
133
152
 
134
- def auto_scaling_group
135
- @auto_scaling_group ||= load_auto_scaling_group
153
+ def auto_scaling_groups
154
+ @auto_scaling_groups ||= load_auto_scaling_groups
136
155
  end
137
156
 
138
- def load_auto_scaling_group
139
- asg_name = stack.physical_id_for(@asg_logical_id)
140
- unless asg_name
141
- raise Thor::Error, "Could not find #{@asg_logical_id} resource in Stack."
142
- end
157
+ def load_auto_scaling_groups
158
+ autoscaling_groups = []
159
+ @asg_logical_ids.each do |asg_logical_id|
160
+ asg_name = stack.physical_id_for(asg_logical_id)
161
+ unless asg_name
162
+ raise Thor::Error, "Could not find #{asg_logical_id} resource in Stack."
163
+ end
143
164
 
144
- groups = as_client.describe_auto_scaling_groups(
145
- auto_scaling_group_names: [asg_name])
146
- if groups.auto_scaling_groups.empty?
147
- raise Thor::Error, "Could not find ASG #{asg_name}."
148
- end
165
+ groups = as_client.describe_auto_scaling_groups(
166
+ auto_scaling_group_names: [asg_name])
167
+ if groups.auto_scaling_groups.empty?
168
+ raise Thor::Error, "Could not find ASG #{asg_name}."
169
+ end
149
170
 
150
- groups.auto_scaling_groups.first
171
+ autoscaling_groups.push(groups.auto_scaling_groups.first)
172
+ end
173
+ autoscaling_groups
151
174
  end
152
175
 
153
- def asg_name
154
- auto_scaling_group.auto_scaling_group_name
176
+ def asg_names
177
+ names = []
178
+ auto_scaling_groups.each do |auto_scaling_group|
179
+ names.push(auto_scaling_group.auto_scaling_group_name)
180
+ end
181
+ names
155
182
  end
156
183
 
157
184
  def application_exists?
@@ -163,13 +190,13 @@ class Moonshot::DeploymentMechanism::CodeDeploy # rubocop:disable ClassLength
163
190
 
164
191
  def deployment_group
165
192
  cd_client.get_deployment_group(
166
- application_name: app_name, deployment_group_name: app_name)
193
+ application_name: app_name, deployment_group_name: group_name)
167
194
  .deployment_group_info
168
195
  end
169
196
 
170
197
  def deployment_group_exists?
171
198
  cd_client.get_deployment_group(
172
- application_name: app_name, deployment_group_name: app_name)
199
+ application_name: app_name, deployment_group_name: group_name)
173
200
  true
174
201
  rescue Aws::CodeDeploy::Errors::ApplicationDoesNotExistException,
175
202
  Aws::CodeDeploy::Errors::DeploymentGroupDoesNotExistException
@@ -178,9 +205,15 @@ class Moonshot::DeploymentMechanism::CodeDeploy # rubocop:disable ClassLength
178
205
 
179
206
  def deployment_group_ok?
180
207
  return false unless deployment_group_exists?
181
- asg = deployment_group.auto_scaling_groups.first
182
- return false unless asg
183
- asg.name == auto_scaling_group.auto_scaling_group_name
208
+ asgs = deployment_group.auto_scaling_groups
209
+ return false unless asgs
210
+ return false unless asgs.count == auto_scaling_groups.count
211
+ asgs.each do |asg|
212
+ if (auto_scaling_groups.find_index { |a| a.auto_scaling_group_name == asg.name }).nil?
213
+ return false
214
+ end
215
+ end
216
+ true
184
217
  end
185
218
 
186
219
  def role
@@ -193,7 +226,7 @@ class Moonshot::DeploymentMechanism::CodeDeploy # rubocop:disable ClassLength
193
226
  ilog.start "Deleting #{pretty_deploy_group}." do |s|
194
227
  cd_client.delete_deployment_group(
195
228
  application_name: app_name,
196
- deployment_group_name: app_name)
229
+ deployment_group_name: group_name)
197
230
  s.success
198
231
  end
199
232
  end
@@ -201,22 +234,28 @@ class Moonshot::DeploymentMechanism::CodeDeploy # rubocop:disable ClassLength
201
234
  def create_deployment_group
202
235
  cd_client.create_deployment_group(
203
236
  application_name: app_name,
204
- deployment_group_name: app_name,
237
+ deployment_group_name: group_name,
205
238
  service_role_arn: role.arn,
206
- auto_scaling_groups: [asg_name])
239
+ auto_scaling_groups: asg_names)
207
240
  end
208
241
 
209
242
  def wait_for_asg_capacity
210
- ilog.start 'Waiting for AutoScaling Group to reach capacity...' do |s|
243
+ ilog.start 'Waiting for AutoScaling Group(s) to reach capacity...' do |s|
211
244
  loop do
212
- asg = load_auto_scaling_group
213
- count = asg.instances.count { |i| i.lifecycle_state == 'InService' }
214
- break if asg.desired_capacity == count
215
- s.continue "DesiredCapacity is #{asg.desired_capacity}, currently #{count} instance(s) are InService." # rubocop:disable LineLength
245
+ asgs_at_capacity = 0
246
+ asgs = load_auto_scaling_groups
247
+ asgs.each do |asg|
248
+ count = asg.instances.count { |i| i.lifecycle_state == 'InService' }
249
+ if asg.desired_capacity == count
250
+ asgs_at_capacity += 1
251
+ s.continue "#{asg.auto_scaling_group_name} DesiredCapacity is #{asg.desired_capacity}, currently #{count} instance(s) are InService." # rubocop:disable LineLength
252
+ end
253
+ end
254
+ break if asgs.count == asgs_at_capacity
216
255
  sleep 5
217
256
  end
218
257
 
219
- s.success 'AutoScaling Group up to capacity!'
258
+ s.success 'AutoScaling Group(s) up to capacity!'
220
259
  end
221
260
  end
222
261
 
@@ -239,7 +278,7 @@ class Moonshot::DeploymentMechanism::CodeDeploy # rubocop:disable ClassLength
239
278
  end
240
279
  end
241
280
 
242
- def handle_deployment_failure(deployment_id) # rubocop:disable AbcSize
281
+ def handle_deployment_failure(deployment_id)
243
282
  instances = cd_client.list_deployment_instances(deployment_id: deployment_id)
244
283
  .instances_list.map do |instance_id|
245
284
  cd_client.get_deployment_instance(deployment_id: deployment_id,
@@ -298,10 +337,12 @@ http://docs.aws.amazon.com/codedeploy/latest/userguide/how-to-create-service-rol
298
337
  end
299
338
 
300
339
  def doctor_check_auto_scaling_resource_defined
301
- if stack.template.resource_names.include?(@asg_logical_id)
302
- success("Resource '#{@asg_logical_id}' exists in the CloudFormation template.") # rubocop:disable LineLength
303
- else
304
- critical("Resource '#{@asg_logical_id}' does not exist in the CloudFormation template!") # rubocop:disable LineLength
340
+ @asg_logical_ids.each do |asg_logical_id|
341
+ if stack.template.resource_names.include?(asg_logical_id)
342
+ success("Resource '#{asg_logical_id}' exists in the CloudFormation template.") # rubocop:disable LineLength
343
+ else
344
+ critical("Resource '#{asg_logical_id}' does not exist in the CloudFormation template!") # rubocop:disable LineLength
345
+ end
305
346
  end
306
347
  end
307
348
  end
@@ -2,7 +2,7 @@
2
2
  module Moonshot::Shell
3
3
  # Run a command, returning stdout. Stderr is suppressed unless the command
4
4
  # returns non-zero.
5
- def sh_out(cmd, fail: true, stdin: '') # rubocop:disable AbcSize
5
+ def sh_out(cmd, fail: true, stdin: '')
6
6
  r_in, w_in = IO.pipe
7
7
  r_out, w_out = IO.pipe
8
8
  r_err, w_err = IO.pipe
@@ -0,0 +1,32 @@
1
+ require 'shellwords'
2
+
3
+ module Moonshot
4
+ # Create an ssh command from configuration.
5
+ class SSHCommandBuilder
6
+ Result = Struct.new(:cmd, :ip)
7
+
8
+ def initialize(ssh_config, instance_id)
9
+ @config = ssh_config
10
+ @instance_id = instance_id
11
+ end
12
+
13
+ def build(command = nil)
14
+ cmd = ['ssh', '-t']
15
+ cmd << "-i #{@config.ssh_identity_file}" if @config.ssh_identity_file
16
+ cmd << "-l #{@config.ssh_user}" if @config.ssh_user
17
+ cmd << instance_ip
18
+ cmd << Shellwords.escape(command) if command
19
+ Result.new(cmd.join(' '), instance_ip)
20
+ end
21
+
22
+ private
23
+
24
+ def instance_ip
25
+ @instance_ip ||= Aws::EC2::Client.new
26
+ .describe_instances(instance_ids: [@instance_id])
27
+ .reservations.first.instances.first.public_ip_address
28
+ rescue
29
+ raise "Failed to determine public IP address for instance #{@instance_id}!"
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,6 @@
1
+ module Moonshot
2
+ class SSHConfig # rubocop:disable Documentation
3
+ attr_accessor :ssh_identity_file
4
+ attr_accessor :ssh_user
5
+ end
6
+ end
@@ -0,0 +1,20 @@
1
+ require 'open3'
2
+
3
+ module Moonshot
4
+ # Run an SSH command via fork/exec.
5
+ class SSHForkExecutor
6
+ Result = Struct.new(:output, :exitstatus)
7
+
8
+ def run(cmd)
9
+ output = StringIO.new
10
+
11
+ exit_status = nil
12
+ Open3.popen3(cmd) do |_, stdout, _, wt|
13
+ output << stdout.read until stdout.eof?
14
+ exit_status = wt.value.exitstatus
15
+ end
16
+
17
+ Result.new(output.string.chomp, exit_status)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,30 @@
1
+ module Moonshot
2
+ # Choose a publically accessible instance to run commands on, given a Moonshot::Stack.
3
+ class SSHTargetSelector
4
+ def initialize(stack, asg_name: nil)
5
+ @asg_name = asg_name
6
+ @stack = stack
7
+ end
8
+
9
+ def choose!
10
+ groups = @stack.resources_of_type('AWS::AutoScaling::AutoScalingGroup')
11
+
12
+ asg = if groups.count == 1
13
+ groups.first
14
+ elsif asgs.count > 1
15
+ unless @asg_name
16
+ raise 'Multiple Auto Scaling Groups found in the stack. Please specify which '\
17
+ 'one to SSH into using the --auto-scaling-group (-g) option.'
18
+ end
19
+ groups.detect { |x| x.logical_resource_id == @config.ssh_auto_scaling_group_name }
20
+ end
21
+ raise 'Failed to find the Auto Scaling Group.' unless asg
22
+
23
+ Aws::AutoScaling::Client.new.describe_auto_scaling_groups(
24
+ auto_scaling_group_names: [asg.physical_resource_id]
25
+ ).auto_scaling_groups.first.instances.map(&:instance_id).first
26
+ rescue
27
+ raise 'Failed to find instances in the Auto Scaling Group!'
28
+ end
29
+ end
30
+ end
@@ -93,18 +93,6 @@ module Moonshot
93
93
  end
94
94
  end
95
95
 
96
- def ssh
97
- box_id = @config.ssh_instance || instances.sort.first
98
- box_ip = instance_ip(box_id)
99
- cmd = ['ssh', '-t']
100
- cmd << "-i #{@config.ssh_identity_file}" if @config.ssh_identity_file
101
- cmd << "-l #{@config.ssh_user}" if @config.ssh_user
102
- cmd << box_ip
103
- cmd << @config.ssh_command if @config.ssh_command
104
- puts "Opening SSH connection to #{box_id} (#{box_ip})..."
105
- exec(cmd.join(' '))
106
- end
107
-
108
96
  def parameters
109
97
  get_stack(@name)
110
98
  .parameters
@@ -188,37 +176,6 @@ module Moonshot
188
176
 
189
177
  private
190
178
 
191
- def asgs
192
- resources_of_type('AWS::AutoScaling::AutoScalingGroup')
193
- end
194
-
195
- def instance_ip(instance_id)
196
- Aws::EC2::Client.new.describe_instances(instance_ids: [instance_id])
197
- .reservations.first.instances.first.public_ip_address
198
- rescue
199
- raise "Failed to determine public IP address for instance #{instance_id}."
200
- end
201
-
202
- def instances # rubocop:disable Metrics/AbcSize
203
- groups = asgs
204
- asg = if groups.count == 1
205
- groups.first
206
- elsif asgs.count > 1
207
- unless @config.ssh_auto_scaling_group_name
208
- raise 'Multiple Auto Scaling Groups found in the stack. Please specify which '\
209
- 'one to SSH into using the --auto-scaling-group (-g) option.'
210
- end
211
- groups.detect { |x| x.logical_resource_id == @config.ssh_auto_scaling_group_name }
212
- end
213
- raise 'Failed to find the Auto Scaling Group.' unless asg
214
-
215
- Aws::AutoScaling::Client.new.describe_auto_scaling_groups(
216
- auto_scaling_group_names: [asg.physical_resource_id]
217
- ).auto_scaling_groups.first.instances.map(&:instance_id)
218
- rescue
219
- raise 'Failed to find instances in the Auto Scaling Group.'
220
- end
221
-
222
179
  def stack_name
223
180
  "CloudFormation Stack #{@name.blue}"
224
181
  end
@@ -5,7 +5,7 @@ require 'ruby-duration'
5
5
  module Moonshot
6
6
  # Display information about the AutoScaling Groups, associated ELBs, and
7
7
  # managed instances to the user.
8
- class StackASGPrinter # rubocop:disable ClassLength
8
+ class StackASGPrinter
9
9
  include CredsHelper
10
10
 
11
11
  def initialize(stack, table)
@@ -79,7 +79,7 @@ module Moonshot
79
79
  data
80
80
  end
81
81
 
82
- def add_asg_info(table, asg_info) # rubocop:disable AbcSize
82
+ def add_asg_info(table, asg_info)
83
83
  name = asg_info.auto_scaling_group_name.blue
84
84
  table.add_line "Name: #{name}"
85
85
 
@@ -4,11 +4,6 @@ module Moonshot
4
4
  attr_accessor :parent_stacks
5
5
  attr_accessor :show_all_events
6
6
  attr_accessor :parameter_strategy
7
- attr_accessor :ssh_instance
8
- attr_accessor :ssh_identity_file
9
- attr_accessor :ssh_user
10
- attr_accessor :ssh_command
11
- attr_accessor :ssh_auto_scaling_group_name
12
7
 
13
8
  def initialize
14
9
  @parent_stacks = []
@@ -0,0 +1,164 @@
1
+ require_relative 'asg_instance'
2
+ require_relative 'instance_health'
3
+
4
+ module Moonshot
5
+ module Tools
6
+ class ASGRollout
7
+ # Abstration layer with AWS Auto Scaling Groups, for the Rollout tool.
8
+ class ASG
9
+ attr_reader :name
10
+
11
+ def initialize(name)
12
+ @name = name
13
+ @last_seen_ids = []
14
+ end
15
+
16
+ def current_max_and_desired
17
+ asg = load_asg
18
+
19
+ [asg.max_size, asg.desired_capacity]
20
+ end
21
+
22
+ def set_max_and_desired(max, desired)
23
+ autoscaling.update_auto_scaling_group(
24
+ auto_scaling_group_name: @name,
25
+ max_size: max,
26
+ desired_capacity: desired)
27
+ end
28
+
29
+ def non_conforming_instances
30
+ asg = load_asg
31
+
32
+ asg.instances
33
+ .select { |i| i.launch_configuration_name != asg.launch_configuration_name }
34
+ .map(&:instance_id)
35
+ end
36
+
37
+ def wait_for_new_instance
38
+ # Query the ASG until an instance appears which is not in
39
+ # @last_seen_ids, then add it to @last_seen_ids and return
40
+ # it.
41
+ previous_ids = @last_seen_ids
42
+
43
+ loop do
44
+ load_asg
45
+
46
+ new_ids = @last_seen_ids - previous_ids
47
+ previous_ids = @last_seen_ids
48
+
49
+ unless new_ids.empty?
50
+ @last_seen_ids << new_ids.first
51
+ return new_ids.first
52
+ end
53
+
54
+ sleep 3
55
+ end
56
+ end
57
+
58
+ def detach_instance(id, decrement:)
59
+ # Store the current instance IDs for the next call to wait_for_new_instance.
60
+ load_asg
61
+
62
+ resp = autoscaling.detach_instances(
63
+ auto_scaling_group_name: @name,
64
+ instance_ids: [id],
65
+ should_decrement_desired_capacity: decrement)
66
+
67
+ activity = resp.activities.first
68
+ unless activity
69
+ raise 'Did not receive Activity from DetachInstances call!'
70
+ end
71
+
72
+ # Wait for the detach activity to complete:
73
+ loop do
74
+ resp = autoscaling.describe_scaling_activities(
75
+ auto_scaling_group_name: @name)
76
+
77
+ current_status = resp.activities
78
+ .find { |a| a.activity_id == activity.activity_id }
79
+ .status_code
80
+
81
+ case current_status
82
+ when 'Failed', 'Cancelled'
83
+ raise 'Detachment did not complete successfully!'
84
+ when 'Successful'
85
+ return
86
+ end
87
+
88
+ sleep 1
89
+ end
90
+ end
91
+
92
+ def instance_health(id)
93
+ elb_status = nil
94
+ elb_status = elb_instance_state(id) if elb_name
95
+
96
+ InstanceHealth.new(asg_instance_state(id), elb_status)
97
+ end
98
+
99
+ private
100
+
101
+ def asg_instance_state(id)
102
+ resp = autoscaling.describe_auto_scaling_instances(
103
+ instance_ids: [id])
104
+
105
+ instance_info = resp.auto_scaling_instances.first
106
+ return 'Missing' unless instance_info
107
+
108
+ instance_info.lifecycle_state
109
+ end
110
+
111
+ def elb_instance_state(id)
112
+ resp = loadbalancing.describe_instance_health(
113
+ load_balancer_name: elb_name,
114
+ instances: [
115
+ { instance_id: id }
116
+ ])
117
+
118
+ instance_info = resp.instance_states.first
119
+ unless instance_info
120
+ raise "Failed to call DescribeInstanceHealth for #{id}!"
121
+ end
122
+
123
+ instance_info.state
124
+ rescue Aws::ElasticLoadBalancing::Errors::InvalidInstance
125
+ # We expect the instance to be in an ELB, eventually.
126
+ 'Missing'
127
+ end
128
+
129
+ def autoscaling
130
+ @autoscaling ||= Aws::AutoScaling::Client.new
131
+ end
132
+
133
+ def loadbalancing
134
+ @loadbalancing ||= Aws::ElasticLoadBalancing::Client.new
135
+ end
136
+
137
+ def load_asg
138
+ resp = autoscaling.describe_auto_scaling_groups(
139
+ auto_scaling_group_names: [@name])
140
+
141
+ if resp.auto_scaling_groups.empty?
142
+ raise "Failed to call DescribeAutoScalingGroups for #{@name}!"
143
+ end
144
+
145
+ asg = resp.auto_scaling_groups.first
146
+ @last_seen_ids = asg.instances.map(&:instance_id)
147
+
148
+ asg
149
+ end
150
+
151
+ def elb_name
152
+ return @elb_name if @elb_name
153
+
154
+ asg = load_asg
155
+ if asg.load_balancer_names.size > 1
156
+ raise 'ASGRollout does not support configurations with multiple ELBs!'
157
+ end
158
+
159
+ @elb_name ||= asg.load_balancer_names.first
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,16 @@
1
+ module Moonshot
2
+ module Tools
3
+ class ASGRollout
4
+ # Provides an abstraction around an Auto Scaling Group's
5
+ # relationship with an instance.
6
+ class ASGInstance
7
+ attr_reader :asg_name, :id
8
+
9
+ def initialize(asg_name, id, _config)
10
+ @asg_name = asg_name
11
+ @instance_id = id
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,43 @@
1
+ require 'moonshot/ssh_fork_executor'
2
+
3
+ module Moonshot
4
+ module Tools
5
+ class ASGRollout
6
+ # This object is passed into hooks defined in the ASGRollout
7
+ # process, to give them access to instances and logging
8
+ # facilities.
9
+ class HookExecEnvironment
10
+ attr_reader :instance_id
11
+
12
+ def initialize(config, instance_id)
13
+ @ilog = config.interactive_logger
14
+ @command_builder = Moonshot::SSHCommandBuilder.new(config.ssh_config, instance_id)
15
+ @instance_id = instance_id
16
+ end
17
+
18
+ def exec(cmd)
19
+ cb = @command_builder.build(cmd)
20
+ fe = SSHForkExecutor.new
21
+ fe.run(cb.cmd)
22
+ end
23
+
24
+ def ec2
25
+ Aws::EC2::Client.new
26
+ end
27
+
28
+ def ec2_instance
29
+ res = Aws::EC2::Resource.new(client: ec2)
30
+ res.instance(@instance_id)
31
+ end
32
+
33
+ def debug(msg)
34
+ @ilog.debug(msg)
35
+ end
36
+
37
+ def info(msg)
38
+ @ilog.info(msg)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,36 @@
1
+ module Moonshot
2
+ module Tools
3
+ class ASGRollout
4
+ class InstanceHealth # rubocop:disable Documentation
5
+ attr_reader :asg_status, :elb_status
6
+
7
+ VALID_ASG_IN_SERVICE_STATES = ['InService'].freeze
8
+ VALID_ELB_IN_SERVICE_STATES = [nil, 'InService'].freeze
9
+
10
+ VALID_ASG_OUT_OF_SERVICE_STATES = [nil, 'Missing', 'Detached'].freeze
11
+ VALID_ELB_OUT_OF_SERVICE_STATES = [nil, 'Missing', 'OutOfService'].freeze
12
+
13
+ def initialize(asg_status, elb_status)
14
+ @asg_status = asg_status
15
+ @elb_status = elb_status
16
+ end
17
+
18
+ def to_s
19
+ result = "ASG:#{@asg_status}"
20
+ result << "/ELB:#{@elb_status}" if @elb_status
21
+ result
22
+ end
23
+
24
+ def in_service?
25
+ VALID_ASG_IN_SERVICE_STATES.include?(@asg_status) &&
26
+ VALID_ELB_IN_SERVICE_STATES.include?(@elb_status)
27
+ end
28
+
29
+ def out_of_service?
30
+ VALID_ASG_OUT_OF_SERVICE_STATES.include?(@asg_status) &&
31
+ VALID_ELB_OUT_OF_SERVICE_STATES.include?(@elb_status)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,170 @@
1
+ require_relative 'asg_rollout_config'
2
+ require_relative 'asg_rollout/asg'
3
+ require_relative 'asg_rollout/hook_exec_environment'
4
+
5
+ module Moonshot
6
+ module Tools
7
+ class ASGRollout # rubocop:disable Documentation
8
+ attr_accessor :config
9
+
10
+ def initialize(controller:, logical_id:)
11
+ @config = ASGRolloutConfig.new
12
+ @controller = controller
13
+ @logical_id = logical_id
14
+ yield @config if block_given?
15
+ end
16
+
17
+ def run!
18
+ increase_max_and_desired
19
+ new_instance = wait_for_new_instance
20
+ wait_for_in_service(new_instance)
21
+
22
+ targets = asg.non_conforming_instances
23
+ last_instance = targets.last
24
+
25
+ targets.each do |instance|
26
+ run_pre_detach(instance) if @config.pre_detach
27
+ detach(instance, decrement: instance == last_instance)
28
+ wait_for_out_of_service(instance)
29
+
30
+ unless instance == last_instance
31
+ new_instance = wait_for_new_instance
32
+ wait_for_in_service(new_instance)
33
+ end
34
+
35
+ wait_for_terminate_when_hook(instance) if @config.terminate_when
36
+ terminate(instance)
37
+ end
38
+ ensure
39
+ log.start_threaded 'Restoring MaxSize/DesiredCapacity values to normal...' do |s|
40
+ asg.set_max_and_desired(@max, @desired)
41
+
42
+ s.success 'Restored MaxSize/DesiredCapacity values to normal!'
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def increase_max_and_desired
49
+ log.start_threaded 'Increasing MaxSize/DesiredCapacity by 1.' do |s|
50
+ @max, @desired = asg.current_max_and_desired
51
+
52
+ asg.set_max_and_desired(@max + 1, @desired + 1)
53
+ s.success 'Increased MaxSize/DesiredCapacity by 1.'
54
+ end
55
+ end
56
+
57
+ def wait_for_new_instance
58
+ new_instance = nil
59
+ log.start_threaded 'Waiting for a new instance to join Auto Scaling Group...' do |s|
60
+ new_instance = asg.wait_for_new_instance
61
+ s.success "A wild #{new_instance.blue} appears!"
62
+ end
63
+ new_instance
64
+ end
65
+
66
+ def wait_for_in_service(new_instance)
67
+ log.start_threaded "Waiting for #{new_instance.blue} to be InService..." do |s|
68
+ instance_health = nil
69
+
70
+ loop do
71
+ instance_health = asg.instance_health(new_instance)
72
+ break if instance_health.in_service?
73
+
74
+ s.continue "Instance #{new_instance.blue} is #{instance_health}..."
75
+
76
+ sleep @config.instance_health_delay
77
+ end
78
+
79
+ s.success "Instance #{new_instance.blue} is #{instance_health}!"
80
+ end
81
+ end
82
+
83
+ def run_pre_detach(instance)
84
+ if @config.pre_detach
85
+ log.start_threaded "Running PreDetach hook on #{instance.blue}..." do |s|
86
+ he = HookExecEnvironment.new(@controller.config, instance)
87
+ if false == @config.pre_detach.call(he)
88
+ s.failure "PreDetach hook failed for #{instance.blue}!"
89
+ raise "PreDetach hook failed for #{instance.blue}!"
90
+ end
91
+
92
+ s.success "PreDetach hook complete for #{instance.blue}!"
93
+ end
94
+ end
95
+ end
96
+
97
+ def detach(instance, decrement:)
98
+ log.start_threaded "Detaching instance #{instance.blue}..." do |s|
99
+ asg.detach_instance(instance, decrement: decrement)
100
+
101
+ if decrement
102
+ s.success "Detached instance #{instance.blue}, and decremented DesiredCapacity."
103
+ else
104
+ s.success "Detached instance #{instance.blue}."
105
+ end
106
+ end
107
+ end
108
+
109
+ def wait_for_out_of_service(instance)
110
+ log.start_threaded "Waiting for #{instance.blue} to be OutOfService..." do |s|
111
+ instance_health = nil
112
+
113
+ loop do
114
+ instance_health = asg.instance_health(instance)
115
+ break if instance_health.out_of_service?
116
+
117
+ s.continue "Instance #{instance.blue} is #{instance_health}..."
118
+
119
+ sleep @config.instance_health_delay
120
+ end
121
+
122
+ s.success "Instance #{instance.blue} is #{instance_health}!"
123
+ end
124
+ end
125
+
126
+ def wait_for_terminate_when_hook(instance)
127
+ log.start_threaded "Waiting for TerminateWhen hook for #{instance.blue}..." do |s|
128
+ start = Time.now.to_f
129
+ he = HookExecEnvironment.new(@controller.config, instance)
130
+ timeout = @config.terminate_when_timeout
131
+
132
+ loop do
133
+ break if @config.terminate_when.call(he)
134
+ sleep @config.terminate_when_delay
135
+
136
+ if Time.now.to_f - start > timeout
137
+ s.failure "TerminateWhen for #{instance.blue} did not complete in #{timeout} seconds!"
138
+ raise "TerminateWhen for #{instance.blue} did not complete in #{timeout} seconds!"
139
+ end
140
+ end
141
+
142
+ s.success "Completed TerminateWhen check for #{instance.blue}!"
143
+ end
144
+ end
145
+
146
+ def terminate(instance)
147
+ log.start_threaded "Terminating #{instance.blue}..." do |s|
148
+ he = HookExecEnvironment.new(@controller.config, instance)
149
+ @config.terminate.call(he)
150
+ s.success "Terminated #{instance.blue}!"
151
+ end
152
+ end
153
+
154
+ def asg
155
+ return @asg if @asg
156
+
157
+ asg_name = @controller.stack.physical_id_for(@logical_id)
158
+ unless asg_name
159
+ raise "Could not find Auto Scaling Group #{@logical_id}!"
160
+ end
161
+
162
+ @asg ||= ASGRollout::ASG.new(asg_name)
163
+ end
164
+
165
+ def log
166
+ @controller.config.interactive_logger
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,39 @@
1
+ module Moonshot
2
+ module Tools
3
+ class ASGRolloutConfig # rubocop:disable Documentation
4
+ attr_reader :pre_detach, :terminate_when, :terminate_when_timeout, :terminate
5
+ attr_accessor :terminate_when_delay, :instance_health_delay
6
+
7
+ def initialize
8
+ @instance_health_delay = 2
9
+ @terminate_when_delay = 1
10
+ @terminate_when_timeout = 300
11
+ @terminate = proc do |h|
12
+ h.ec2_instance.terminate
13
+ end
14
+ end
15
+
16
+ def pre_detach=(value)
17
+ raise ArgumentError, 'pre_detach must be callable' unless value.respond_to?(:call)
18
+
19
+ @pre_detach = value
20
+ end
21
+
22
+ def terminate_when=(value)
23
+ raise ArgumentError, 'terminate_when must be callable' unless value.respond_to?(:call)
24
+
25
+ @terminate_when = value
26
+ end
27
+
28
+ def terminate_when_timeout=(value)
29
+ @terminate_when_timeout = Float(value)
30
+ end
31
+
32
+ def terminate=(value)
33
+ raise ArgumentError, 'terminate must be callable' unless value.respond_to?(:call)
34
+
35
+ @terminate = value
36
+ end
37
+ end
38
+ end
39
+ end
data/lib/moonshot.rb CHANGED
@@ -39,7 +39,10 @@ end
39
39
  'build_mechanism/github_release',
40
40
  'build_mechanism/travis_deploy',
41
41
  'build_mechanism/version_proxy',
42
- 'deployment_mechanism/code_deploy'
42
+ 'deployment_mechanism/code_deploy',
43
+
44
+ # Core Tools
45
+ 'tools/asg_rollout'
43
46
  ].each { |f| require_relative "moonshot/#{f}" }
44
47
 
45
48
  # Bundled plugins
@@ -5,7 +5,7 @@ require_relative '../moonshot/creds_helper'
5
5
  module Moonshot
6
6
  module Plugins
7
7
  # Moonshot plugin class for deflating and uploading files on given hooks
8
- class Backup # rubocop:disable Metrics/ClassLength
8
+ class Backup
9
9
  include Moonshot::CredsHelper
10
10
 
11
11
  attr_accessor :bucket,
@@ -42,7 +42,7 @@ module Moonshot
42
42
  # to an S3 bucket.
43
43
  #
44
44
  # @param resources [Resources] injected Moonshot resources
45
- def backup(resources) # rubocop:disable Metrics/AbcSize
45
+ def backup(resources)
46
46
  raise ArgumentError if resources.nil?
47
47
 
48
48
  @app_name = resources.stack.app_name
metadata CHANGED
@@ -1,20 +1,23 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: moonshot
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.6
4
+ version: 0.7.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cloud Engineering <engineering@acquia.com>
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2016-07-11 00:00:00.000000000 Z
11
+ date: 2016-09-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: aws-sdk
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
17
  - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ - - ">="
18
21
  - !ruby/object:Gem::Version
19
22
  version: 2.2.0
20
23
  type: :runtime
@@ -22,6 +25,9 @@ dependencies:
22
25
  version_requirements: !ruby/object:Gem::Requirement
23
26
  requirements:
24
27
  - - "~>"
28
+ - !ruby/object:Gem::Version
29
+ version: '2.0'
30
+ - - ">="
25
31
  - !ruby/object:Gem::Version
26
32
  version: 2.2.0
27
33
  - !ruby/object:Gem::Dependency
@@ -58,14 +64,14 @@ dependencies:
58
64
  requirements:
59
65
  - - "~>"
60
66
  - !ruby/object:Gem::Version
61
- version: 0.1.1
67
+ version: 0.1.2
62
68
  type: :runtime
63
69
  prerelease: false
64
70
  version_requirements: !ruby/object:Gem::Requirement
65
71
  requirements:
66
72
  - - "~>"
67
73
  - !ruby/object:Gem::Version
68
- version: 0.1.1
74
+ version: 0.1.2
69
75
  - !ruby/object:Gem::Dependency
70
76
  name: rotp
71
77
  requirement: !ruby/object:Gem::Requirement
@@ -136,6 +142,20 @@ dependencies:
136
142
  - - ">="
137
143
  - !ruby/object:Gem::Version
138
144
  version: '0'
145
+ - !ruby/object:Gem::Dependency
146
+ name: travis
147
+ requirement: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - ">="
150
+ - !ruby/object:Gem::Version
151
+ version: '0'
152
+ type: :runtime
153
+ prerelease: false
154
+ version_requirements: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - ">="
157
+ - !ruby/object:Gem::Version
158
+ version: '0'
139
159
  - !ruby/object:Gem::Dependency
140
160
  name: vandamme
141
161
  requirement: !ruby/object:Gem::Requirement
@@ -218,6 +238,10 @@ files:
218
238
  - lib/moonshot/resources.rb
219
239
  - lib/moonshot/resources_helper.rb
220
240
  - lib/moonshot/shell.rb
241
+ - lib/moonshot/ssh_command_builder.rb
242
+ - lib/moonshot/ssh_config.rb
243
+ - lib/moonshot/ssh_fork_executor.rb
244
+ - lib/moonshot/ssh_target_selector.rb
221
245
  - lib/moonshot/stack.rb
222
246
  - lib/moonshot/stack_asg_printer.rb
223
247
  - lib/moonshot/stack_config.rb
@@ -226,6 +250,12 @@ files:
226
250
  - lib/moonshot/stack_output_printer.rb
227
251
  - lib/moonshot/stack_parameter_printer.rb
228
252
  - lib/moonshot/stack_template.rb
253
+ - lib/moonshot/tools/asg_rollout.rb
254
+ - lib/moonshot/tools/asg_rollout/asg.rb
255
+ - lib/moonshot/tools/asg_rollout/asg_instance.rb
256
+ - lib/moonshot/tools/asg_rollout/hook_exec_environment.rb
257
+ - lib/moonshot/tools/asg_rollout/instance_health.rb
258
+ - lib/moonshot/tools/asg_rollout_config.rb
229
259
  - lib/moonshot/unicode_table.rb
230
260
  - lib/plugins/backup.rb
231
261
  homepage: https://github.com/acquia/moonshot