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.
- checksums.yaml +7 -0
- data/lib/opsworks_interactor.rb +336 -0
- 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: []
|