opsworks_interactor 0.0.2

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.
Files changed (3) hide show
  1. checksums.yaml +7 -0
  2. data/lib/opsworks_interactor.rb +336 -0
  3. metadata +59 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 2edb5040ef5bae49462f4ed304c48364612a81db
4
+ data.tar.gz: 6d9f5f5bbdaaf57a72da4a9518f155b5b254319b
5
+ SHA512:
6
+ metadata.gz: 9b993c23b0565320222cc693970707beed88b9bbb8848b6adb482f74525b24471e510e4d9b839f4c51154cda56d541451fa3145924ffe6348058602764145c69
7
+ data.tar.gz: 637318ed99bd11f344b339c858b0c66134cf68a32dbd057086c40e1a13be9f42eb37584b343ac0de2a2ae11f0fcc1d760836fd652274e2af581387db7bb5646b
@@ -0,0 +1,336 @@
1
+ require 'aws-sdk'
2
+ class OpsworksInteractor
3
+ begin
4
+ require 'redis-semaphore'
5
+ rescue LoadError
6
+ # suppress, this is handled at runtime in with_deploy_lock
7
+ end
8
+
9
+ DeployLockError = Class.new(StandardError)
10
+
11
+ # All opsworks endpoints are in the us-east-1 region, see:
12
+ # http://docs.aws.amazon.com/opsworks/latest/userguide/cli-examples.html
13
+ OPSWORKS_REGION = 'us-east-1'
14
+
15
+ def initialize(access_key_id, secret_access_key, redis: nil)
16
+ # All opsworks endpoints are always in the OPSWORKS_REGION
17
+ @opsworks_client = Aws::OpsWorks::Client.new(
18
+ access_key_id: access_key_id,
19
+ secret_access_key: secret_access_key,
20
+ region: OPSWORKS_REGION
21
+ )
22
+
23
+ @elb_client = Aws::ElasticLoadBalancing::Client.new(
24
+ access_key_id: access_key_id,
25
+ secret_access_key: secret_access_key,
26
+ region: ENV['AWS_REGION'] || OPSWORKS_REGION
27
+ )
28
+
29
+ # Redis host and port may be supplied if you want to run your deploys with
30
+ # mutual exclusive locking (recommended)
31
+ # Example redis config: { host: 'foo', port: 42 }
32
+ @redis = redis
33
+ end
34
+
35
+ # Runs only ONE rolling deploy at a time.
36
+ #
37
+ # If another one is currently running, waits for it to finish before starting
38
+ def rolling_deploy(**kwargs)
39
+ with_deploy_lock do
40
+ rolling_deploy_without_lock(**kwargs)
41
+ end
42
+ end
43
+
44
+ # Deploys the given app_id on the given instance_id in the given stack_id
45
+ #
46
+ # Blocks until AWS confirms that the deploy was successful
47
+ #
48
+ # Returns a Aws::OpsWorks::Types::CreateDeploymentResult
49
+ def deploy(stack_id:, app_id:, instance_id:)
50
+ response = @opsworks_client.create_deployment(
51
+ stack_id: stack_id,
52
+ app_id: app_id,
53
+ instance_ids: [instance_id],
54
+ command: {
55
+ name: 'deploy',
56
+ args: {
57
+ 'migrate' => ['true'],
58
+ }
59
+ }
60
+ )
61
+
62
+ log("Deploy process running (id: #{response[:deployment_id]})...")
63
+
64
+ @opsworks_client.wait_until(
65
+ :deployment_successful,
66
+ deployment_ids: [response[:deployment_id]]
67
+ )
68
+
69
+ log("✓ deploy completed")
70
+
71
+ response
72
+ end
73
+
74
+ private
75
+
76
+ # Loop through all instances in layer
77
+ # Deregister from ELB (elastic load balancer)
78
+ # Wait connection draining timeout (default up to maximum of 300s)
79
+ # Initiate deploy and run migrations
80
+ # Register instance back to ELB
81
+ # Wait for AWS to confirm the instance as registered and healthy
82
+ # Once complete, move onto the next instance and repeat
83
+ def rolling_deploy_without_lock(stack_id:, layer_id:, app_id:)
84
+ log("Starting opsworks deploy for app #{app_id}\n\n")
85
+
86
+ instances = @opsworks_client.describe_instances(layer_id: layer_id)[:instances]
87
+
88
+ instances.each do |instance|
89
+ log("=== Starting deploy for #{instance.hostname} ===")
90
+
91
+ load_balancers = detach_from_elbs(instance: instance)
92
+
93
+ deploy(
94
+ stack_id: stack_id,
95
+ app_id: app_id,
96
+ instance_id: instance.instance_id
97
+ )
98
+
99
+ attach_to_elbs(instance: instance, load_balancers: load_balancers)
100
+
101
+ log("=== Done deploying on #{instance.hostname} ===\n\n")
102
+ end
103
+
104
+ log("SUCCESS: completed opsworks deploy for all instances on app #{app_id}")
105
+ end
106
+
107
+ # Executes the given block only after obtaining an exclusive lock on the
108
+ # deploy semaphore.
109
+ #
110
+ # EXPLANATION
111
+ # ===========
112
+ #
113
+ # If two or more rolling deploys were to execute simultanously, there is a
114
+ # possibility that all instances could be detached from the load balancer
115
+ # at the same time.
116
+ #
117
+ # Although we check that other instances are attached before detaching, there
118
+ # could be a case where a deploy was running simultaneously on each instance
119
+ # of a pair. A race would then be possible where each machine sees the
120
+ # presence of the other instance and then both are detached. Now the load
121
+ # balancer has no instances to send traffic to
122
+ #
123
+ # Result: downtime and disaster.
124
+ #
125
+ # By executing the code within the context of a lock on a shared global deploy
126
+ # mutex, deploys are forced to run in serial, and only one machine is detached
127
+ # at a time.
128
+ #
129
+ # Result: disaster averted.
130
+ DEPLOY_WAIT_TIMEOUT = 600 # max seconds to wait in the queue, once this has expired the process will raise
131
+ def with_deploy_lock
132
+ if !defined?(Redis::Semaphore)
133
+ log(<<-MSG.squish)
134
+ Redis::Semaphore not found, will attempt to deploy without locking.\n
135
+ WARNING: this could cause undefined behavior if two or more deploys
136
+ are run simultanously!\n
137
+ It is recommended that you use semaphore locking. To fix this, add
138
+ `gem 'redis-semaphore'` to your Gemfile and run `bundle install`.
139
+ MSG
140
+
141
+ yield
142
+ elsif !@redis
143
+ log(<<-MSG.squish)
144
+ Redis::Semaphore was found but :redis was not set, will attempt to
145
+ deploy without locking.\n
146
+ WARNING: this could cause undefined behavior if two or more deploys
147
+ are run simultanously!\n
148
+ It is recommended that you use semaphore locking. To fix this, supply a
149
+ :redis hash like { host: 'foo', port: 42 } .
150
+ MSG
151
+
152
+ yield
153
+ else
154
+ s = Redis::Semaphore.new(:deploy, **@redis)
155
+
156
+ log("Waiting for deploy lock...")
157
+
158
+ success = s.lock(DEPLOY_WAIT_TIMEOUT) do
159
+ log("Got lock. Running deploy...")
160
+ yield
161
+ log("Deploy complete. Releasing lock...")
162
+ true
163
+ end
164
+
165
+ if success
166
+ log("Lock released")
167
+ true
168
+ else
169
+ fail(DeployLockError, "could not get deploy lock within #{DEPLOY_WAIT_TIMEOUT} seconds")
170
+ end
171
+ end
172
+ end
173
+
174
+ # Takes a Aws::OpsWorks::Types::Instance
175
+ #
176
+ # Detaches the provided instance from all of its load balancers
177
+ #
178
+ # Returns the detached load balancers as an array of
179
+ # Aws::ElasticLoadBalancing::Types::LoadBalancerDescription
180
+ #
181
+ # Blocks until AWS confirms that all instances successfully detached before
182
+ # returning
183
+ #
184
+ # Does not wait and instead returns an empty array if no load balancers were
185
+ # found for this instance
186
+ def detach_from_elbs(instance:)
187
+ unless instance.is_a?(Aws::OpsWorks::Types::Instance)
188
+ fail(ArgumentError, "instance must be a Aws::OpsWorks::Types::Instance struct")
189
+ end
190
+
191
+ all_load_balancers = @elb_client.describe_load_balancers
192
+ .load_balancer_descriptions
193
+
194
+ load_balancers = detach_from(all_load_balancers, instance)
195
+
196
+ lb_wait_params = []
197
+
198
+ load_balancers.each do |lb|
199
+ params = {
200
+ load_balancer_name: lb.load_balancer_name,
201
+ instances: [{ instance_id: instance.ec2_instance_id }]
202
+ }
203
+
204
+ remaining_instances = @elb_client
205
+ .deregister_instances_from_load_balancer(params)
206
+ .instances
207
+
208
+ log(<<-MSG.squish)
209
+ Will detach instance #{instance.ec2_instance_id} from
210
+ #{lb.load_balancer_name} (remaining attached instances:
211
+ #{remaining_instances.map(&:instance_id).join(', ')})
212
+ MSG
213
+
214
+ lb_wait_params << params
215
+ end
216
+
217
+ if lb_wait_params.any?
218
+ lb_wait_params.each do |params|
219
+ # wait for all load balancers to list the instance as deregistered
220
+ @elb_client.wait_until(:instance_deregistered, params)
221
+
222
+ log("✓ detached from #{params[:load_balancer_name]}")
223
+ end
224
+ else
225
+ log("No load balancers found for instance #{instance.ec2_instance_id}")
226
+ end
227
+
228
+ load_balancers
229
+ end
230
+
231
+ # Accepts load_balancers as array of
232
+ # Aws::ElasticLoadBalancing::Types::LoadBalancerDescription
233
+ # and instances as a Aws::OpsWorks::Types::Instance
234
+ #
235
+ # Returns only the LoadBalancerDescription objects that have the instance
236
+ # attached and should be detached from
237
+ #
238
+ # Will not include a load balancer in the returned collection if the
239
+ # supplied instance is the ONLY one connected. Detaching the sole remaining
240
+ # instance from a load balancer would probably cause undesired results.
241
+ def detach_from(load_balancers, instance)
242
+ check_arguments(instance: instance, load_balancers: load_balancers)
243
+
244
+ load_balancers.select do |lb|
245
+ matched_instance = lb.instances.any? do |lb_instance|
246
+ instance.ec2_instance_id == lb_instance.instance_id
247
+ end
248
+
249
+ if matched_instance && lb.instances.count > 1
250
+ # We can detach this instance safely because there is at least one other
251
+ # instance to handle traffic
252
+ true
253
+ elsif matched_instance && lb.instances.count == 1
254
+ # We can't detach this instance because it's the only one
255
+ log(<<-MSG.squish)
256
+ Will not detach #{instance.ec2_instance_id} from load balancer
257
+ #{lb.load_balancer_name} because it is the only instance connected
258
+ MSG
259
+
260
+ false
261
+ else
262
+ # This load balancer isn't attached to this instance
263
+ false
264
+ end
265
+ end
266
+ end
267
+
268
+ # Takes an instance as a Aws::OpsWorks::Types::Instance
269
+ # and load balancers as an array of
270
+ # Aws::ElasticLoadBalancing::Types::LoadBalancerDescription
271
+ #
272
+ # Attaches the provided instance to the supplied load balancers and blocks
273
+ # until AWS confirms that the instance is attached to all load balancers
274
+ # before returning
275
+ #
276
+ # Does nothing and instead returns an empty hash if load_balancers is empty
277
+ #
278
+ # Otherwise returns a hash of load balancer names each with a
279
+ # Aws::ElasticLoadBalancing::Types::RegisterEndPointsOutput
280
+ def attach_to_elbs(instance:, load_balancers:)
281
+ check_arguments(instance: instance, load_balancers: load_balancers)
282
+
283
+ if load_balancers.empty?
284
+ log("No load balancers to attach to")
285
+ return {}
286
+ end
287
+
288
+ lb_wait_params = []
289
+ registered_instances = {} # return this
290
+
291
+ load_balancers.each do |lb|
292
+ params = {
293
+ load_balancer_name: lb.load_balancer_name,
294
+ instances: [{ instance_id: instance.ec2_instance_id }]
295
+ }
296
+
297
+ result = @elb_client.register_instances_with_load_balancer(params)
298
+
299
+ registered_instances[lb.load_balancer_name] = result
300
+ lb_wait_params << params
301
+ end
302
+
303
+ log("Re-attaching instance #{instance.ec2_instance_id} to all load balancers")
304
+
305
+ # Wait for all load balancers to list the instance as registered
306
+ lb_wait_params.each do |params|
307
+ @elb_client.wait_until(:instance_in_service, params)
308
+
309
+ log("✓ re-attached to #{params[:load_balancer_name]}")
310
+ end
311
+
312
+ registered_instances
313
+ end
314
+
315
+ # Fails unless arguments are of the expected types
316
+ def check_arguments(instance:, load_balancers:)
317
+ unless instance.is_a?(Aws::OpsWorks::Types::Instance)
318
+ fail(ArgumentError,
319
+ ":instance must be a Aws::OpsWorks::Types::Instance struct")
320
+ end
321
+ unless load_balancers.respond_to?(:each) &&
322
+ load_balancers.all? do |lb|
323
+ lb.is_a?(Aws::ElasticLoadBalancing::Types::LoadBalancerDescription)
324
+ end
325
+ fail(ArgumentError, <<-MSG.squish)
326
+ :load_balancers must be a collection of
327
+ Aws::ElasticLoadBalancing::Types::LoadBalancerDescription objects
328
+ MSG
329
+ end
330
+ end
331
+
332
+ # Could use Rails logger here instead if you wanted to
333
+ def log(message)
334
+ puts message
335
+ end
336
+ end
metadata ADDED
@@ -0,0 +1,59 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: opsworks_interactor
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.2
5
+ platform: ruby
6
+ authors:
7
+ - Sam Davies
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2015-10-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: aws-sdk
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2'
27
+ description: A ruby class that allows concurrent-safe, synchronized, zero-downtime
28
+ rolling deploys to servers running on Amazon Opsworks
29
+ email: seivadmas@gmail.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - lib/opsworks_interactor.rb
35
+ homepage: https://github.org/fosubo/opworks_interactor
36
+ licenses:
37
+ - MIT
38
+ metadata: {}
39
+ post_install_message:
40
+ rdoc_options: []
41
+ require_paths:
42
+ - lib
43
+ required_ruby_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ required_rubygems_version: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '0'
53
+ requirements: []
54
+ rubyforge_project:
55
+ rubygems_version: 2.4.5.1
56
+ signing_key:
57
+ specification_version: 4
58
+ summary: Easily do zero-downtime deploys on Amazon Opsworks
59
+ test_files: []