moonshot 0.7.6 → 0.7.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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