furnish-aws 0.0.1
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 +15 -0
- data/.gitignore +20 -0
- data/Gemfile +4 -0
- data/Guardfile +10 -0
- data/LICENSE.txt +22 -0
- data/README.md +59 -0
- data/Rakefile +32 -0
- data/furnish-aws.gemspec +30 -0
- data/lib/furnish/aws/version.rb +6 -0
- data/lib/furnish/provisioners/aws.rb +101 -0
- data/lib/furnish/provisioners/ec2.rb +372 -0
- data/lib/furnish/provisioners/security_group.rb +380 -0
- data/test/helper.rb +61 -0
- data/test/test_aws_base_class.rb +30 -0
- data/test/test_ec2.rb +225 -0
- data/test/test_security_group.rb +322 -0
- metadata +188 -0
@@ -0,0 +1,380 @@
|
|
1
|
+
require 'furnish/provisioners/aws'
|
2
|
+
|
3
|
+
module Furnish # :nodoc:
|
4
|
+
module Provisioner # :nodoc:
|
5
|
+
#
|
6
|
+
# Provision an EC2 Security Group. Currently receives no input and yields
|
7
|
+
# the group id created as output.
|
8
|
+
#
|
9
|
+
# See the attributes and constructor for more information about creating
|
10
|
+
# this provisioner.
|
11
|
+
#
|
12
|
+
class SecurityGroup < AWS
|
13
|
+
|
14
|
+
##
|
15
|
+
# :attr: region
|
16
|
+
#
|
17
|
+
# Provided by Furnish::Provisioner::AWS#region -- required for this provisioner.
|
18
|
+
#
|
19
|
+
|
20
|
+
##
|
21
|
+
# :attr: group_name
|
22
|
+
#
|
23
|
+
# Name of the group. Supply a string to name it explicitly, or :auto (the
|
24
|
+
# default). If :auto, will pick a name for you based on #group_prefix
|
25
|
+
# and a random string of numbers that do not already correspond to a
|
26
|
+
# group.
|
27
|
+
#
|
28
|
+
furnish_property :group_name,
|
29
|
+
"Name of the group. Supply a string to name it. Default is :auto -- will pick a name for you and overwrite this accessor."
|
30
|
+
|
31
|
+
##
|
32
|
+
# :attr: group_prefix
|
33
|
+
#
|
34
|
+
# If #group_name is :auto, will use this value as a prefix. See
|
35
|
+
# #group_name for more information.
|
36
|
+
#
|
37
|
+
furnish_property :group_prefix,
|
38
|
+
"If :group_name is :auto, will use this value as prefix, and a string of random numbers as the postfix. Default is 'furnish-auto-'",
|
39
|
+
String
|
40
|
+
|
41
|
+
##
|
42
|
+
# :attr: description
|
43
|
+
#
|
44
|
+
# String description of the group. Optional.
|
45
|
+
#
|
46
|
+
furnish_property :description,
|
47
|
+
"Description of the group. Optional",
|
48
|
+
String
|
49
|
+
|
50
|
+
##
|
51
|
+
# :attr: vpc
|
52
|
+
#
|
53
|
+
# VPC identifier. Optional, has significant effect on how security groups
|
54
|
+
# work. See the EC2 and VPC documentation for details.
|
55
|
+
#
|
56
|
+
furnish_property :vpc,
|
57
|
+
"VPC identifier. Optional, see EC2 and VPC documentation for details.",
|
58
|
+
String
|
59
|
+
|
60
|
+
##
|
61
|
+
# :attr: kill_instances
|
62
|
+
#
|
63
|
+
# If true (the default), at shutdown time will terminate any instances
|
64
|
+
# that belong to the group provisioned by this provisioner so the
|
65
|
+
# security group can be destroyed.
|
66
|
+
#
|
67
|
+
furnish_property :kill_instances,
|
68
|
+
"If true (default), on shutdown will force termination of all instances in the security group."
|
69
|
+
|
70
|
+
##
|
71
|
+
# :attr: ingress
|
72
|
+
#
|
73
|
+
# Hash containing two keys: :authorize and :revoke, which each contain
|
74
|
+
# array of arrays. Passed to authorize_ingress and revoke_ingress
|
75
|
+
# respectively.
|
76
|
+
#
|
77
|
+
# Example:
|
78
|
+
#
|
79
|
+
# Furnish::Provisioners::SecurityGroup.new(
|
80
|
+
# :ingress => {
|
81
|
+
# :authorize => [
|
82
|
+
# [:tcp, 22]
|
83
|
+
# [:tcp, (1024..1234)]
|
84
|
+
# [:tcp, 53, "127.0.0.1/32"]
|
85
|
+
# ]
|
86
|
+
# }
|
87
|
+
# )
|
88
|
+
#
|
89
|
+
furnish_property :ingress,
|
90
|
+
"Hash containing two keys: :authorize and :revoke, which each contain array of arrays. Passed to authorize_ingress and revoke_ingress respectively.",
|
91
|
+
Hash
|
92
|
+
|
93
|
+
##
|
94
|
+
# :attr: egress
|
95
|
+
#
|
96
|
+
# Same as #ingress but for egress rules. Note that egress rules only work
|
97
|
+
# against groups that belong to a VPCs.
|
98
|
+
#
|
99
|
+
furnish_property :egress,
|
100
|
+
"Same as :ingress, but only work on VPCs.",
|
101
|
+
Hash
|
102
|
+
|
103
|
+
##
|
104
|
+
# :attr: allow_ping
|
105
|
+
#
|
106
|
+
# If true, allow ping from all hosts. If an array of strings, allow ping
|
107
|
+
# from that set of CIDR networks.
|
108
|
+
#
|
109
|
+
furnish_property :allow_ping,
|
110
|
+
"If true, allow ping from all hosts, if array of strings, treats them as CIDR networks."
|
111
|
+
|
112
|
+
##
|
113
|
+
# :attr: disallow_ping
|
114
|
+
#
|
115
|
+
# Inverse of #allow_ping.
|
116
|
+
#
|
117
|
+
furnish_property :disallow_ping,
|
118
|
+
"Inverse of :allow_ping."
|
119
|
+
|
120
|
+
##
|
121
|
+
# the group_id as returned from EC2, only set after provisioning has
|
122
|
+
# occurred.
|
123
|
+
attr_reader :group_id
|
124
|
+
|
125
|
+
#
|
126
|
+
# Construct a new security group provisioner.
|
127
|
+
#
|
128
|
+
# Example:
|
129
|
+
#
|
130
|
+
# obj = Furnish::Provisioners::SecurityGroup.new(
|
131
|
+
# :access_key => "foo",
|
132
|
+
# :secret_key => "bar",
|
133
|
+
# :region => "my-region"
|
134
|
+
# )
|
135
|
+
#
|
136
|
+
# See Furnish::Provisioner::AWS for how constructor arguments and
|
137
|
+
# attributes interact.
|
138
|
+
#
|
139
|
+
def initialize(args)
|
140
|
+
super
|
141
|
+
check_region
|
142
|
+
|
143
|
+
@ingress ||= { :authorize => [], :revoke => [] }
|
144
|
+
@egress ||= { :authorize => [], :revoke => [] }
|
145
|
+
@allow_ping ||= []
|
146
|
+
@disallow_ping ||= []
|
147
|
+
@group_name ||= :auto
|
148
|
+
@group_prefix ||= "furnish-auto-"
|
149
|
+
@kill_instances = args.has_key?(:kill_instances) ? args[:kill_instances] : true
|
150
|
+
@group_id = nil
|
151
|
+
end
|
152
|
+
|
153
|
+
#
|
154
|
+
# Generate a group name (only used if :auto is assigned to #group_name).
|
155
|
+
# Ensures generated names are not already in use. Returns the value it
|
156
|
+
# ends up with.
|
157
|
+
#
|
158
|
+
def generate_group_name
|
159
|
+
unless group_prefix
|
160
|
+
raise ArgumentError, "group_prefix must be set when auto-generating security group names"
|
161
|
+
end
|
162
|
+
|
163
|
+
tmp_name = nil
|
164
|
+
|
165
|
+
loop do
|
166
|
+
tmp_name = group_prefix + (0..rand(10).to_i).map { rand(0..9).to_s }.join("")
|
167
|
+
|
168
|
+
if_debug(3) do
|
169
|
+
puts "Seeing if security group name #{tmp_name} is taken"
|
170
|
+
end
|
171
|
+
|
172
|
+
break unless find_group_by_name(tmp_name)
|
173
|
+
|
174
|
+
if_debug(3) do
|
175
|
+
puts "group exists, waiting to try again"
|
176
|
+
end
|
177
|
+
|
178
|
+
sleep 0.3
|
179
|
+
end
|
180
|
+
|
181
|
+
return tmp_name
|
182
|
+
end
|
183
|
+
|
184
|
+
#
|
185
|
+
# Applies network rules (see #ingress, #egress, #allow_ping,
|
186
|
+
# #disallow_ping attributes) to a created security group.
|
187
|
+
#
|
188
|
+
# Raises ArgumentError if security groups do not belong to a VPC and
|
189
|
+
# egress filters are set.
|
190
|
+
#
|
191
|
+
def apply_group_rules(group)
|
192
|
+
# XXX this meta programs passing the contents set on the object to the
|
193
|
+
# group object.
|
194
|
+
[:allow_ping, :disallow_ping].each do |sym|
|
195
|
+
if send(sym).kind_of?(Array)
|
196
|
+
ary = send(sym)
|
197
|
+
|
198
|
+
if_debug(3) do
|
199
|
+
puts "group #{group.id}/#{group.name}: applying #{sym} to #{ary.inspect}"
|
200
|
+
end
|
201
|
+
|
202
|
+
group.send(sym, *ary)
|
203
|
+
elsif send(sym)
|
204
|
+
if_debug(3) do
|
205
|
+
puts "group #{group.id}/#{group.name}: applying #{sym} to all instances"
|
206
|
+
end
|
207
|
+
|
208
|
+
group.send(sym) # XXX "everybody"
|
209
|
+
end
|
210
|
+
end
|
211
|
+
|
212
|
+
# XXX this mess is similar to above, but generates the call_name as well.
|
213
|
+
# e.g.: "authorize_egress" comes from @egress[:authorize], and each
|
214
|
+
# inner array in the data structure is expanded as args in the call.
|
215
|
+
[:authorize, :revoke].each do |part|
|
216
|
+
[:ingress, :egress].each do |type|
|
217
|
+
if send(type) and ary = send(type)[part] and ary.kind_of?(Array) and !ary.empty?
|
218
|
+
if type == :egress and !group.vpc?
|
219
|
+
raise ArgumentError, "egress filtering was configured for #{group.id}/#{group.name} and it is not a VPC security group"
|
220
|
+
end
|
221
|
+
|
222
|
+
ary.each do |rule|
|
223
|
+
if_debug(3) do
|
224
|
+
puts "group #{group.id}/#{group.name}: applying #{part}_#{type} with values #{rule.inspect}"
|
225
|
+
end
|
226
|
+
|
227
|
+
group.send("#{part}_#{type}", *rule.flatten)
|
228
|
+
end
|
229
|
+
end
|
230
|
+
end
|
231
|
+
end
|
232
|
+
end
|
233
|
+
|
234
|
+
#
|
235
|
+
# Queries the API for all instances, returns any that are not in the
|
236
|
+
# terminated state.
|
237
|
+
#
|
238
|
+
def find_secgroup_running_instances(group)
|
239
|
+
group.instances.select { |i| i.status != :terminated }
|
240
|
+
end
|
241
|
+
|
242
|
+
#
|
243
|
+
# Searches for a group by its name, returns the AWS::EC2::SecurityGroup
|
244
|
+
# instance.
|
245
|
+
#
|
246
|
+
def find_group_by_name(group_name)
|
247
|
+
return nil unless group_name
|
248
|
+
ec2.security_groups.filter('group-name', [group_name.to_s]).first
|
249
|
+
end
|
250
|
+
|
251
|
+
#
|
252
|
+
# Only used during shutdown; returns the group if it exists, otherwise
|
253
|
+
# logs and returns false. Intended to isolate idempotent function.
|
254
|
+
#
|
255
|
+
def load_group_for_shutdown
|
256
|
+
unless group_id
|
257
|
+
prov_name = furnish_group_name
|
258
|
+
this_group_name = group_name
|
259
|
+
|
260
|
+
if_debug(2) do
|
261
|
+
puts "group id for #{this_group_name} did not exist during provisioner shutdown for #{prov_name || "unknown"} -- did this get provisioned?"
|
262
|
+
end
|
263
|
+
|
264
|
+
return false
|
265
|
+
end
|
266
|
+
|
267
|
+
group = ec2.security_groups[group_id]
|
268
|
+
|
269
|
+
unless group and group.exists?
|
270
|
+
this_group_name = group_name
|
271
|
+
|
272
|
+
if_debug(2) do
|
273
|
+
puts "group #{this_group_name} did not exist during shutdown; not attempting to deprovision it and returning success"
|
274
|
+
end
|
275
|
+
|
276
|
+
return false
|
277
|
+
end
|
278
|
+
|
279
|
+
return group
|
280
|
+
end
|
281
|
+
|
282
|
+
#
|
283
|
+
# only used during shutdown; if kill_instances is true, terminates all
|
284
|
+
# the non-terminated machines in the group so it can be deleted. Will
|
285
|
+
# keep trying this until all machines are in a terminated state.
|
286
|
+
#
|
287
|
+
def terminate_instances_for_shutdown(group)
|
288
|
+
running_instances = find_secgroup_running_instances(group)
|
289
|
+
|
290
|
+
if running_instances.count > 0
|
291
|
+
if kill_instances
|
292
|
+
if_debug(3) do
|
293
|
+
puts "group #{group.id}/#{group.name} is flagged to kill instances in its group on deprovision"
|
294
|
+
end
|
295
|
+
|
296
|
+
until (instances = find_secgroup_running_instances(group)).empty?
|
297
|
+
if_debug(1) do
|
298
|
+
puts "Trying to destroy security group #{group.name}, but instances are still bound to it."
|
299
|
+
puts instances.map(&:id).inspect
|
300
|
+
puts "Terminating instances, sleeping, and trying again."
|
301
|
+
end
|
302
|
+
|
303
|
+
instances.each do |i|
|
304
|
+
i.terminate rescue nil
|
305
|
+
end
|
306
|
+
|
307
|
+
sleep 10
|
308
|
+
end
|
309
|
+
else
|
310
|
+
raise "instances #{running_instances.map(&:id).inspect} are still running for group #{group.id}/#{group.name} and kill_instances is off. Cannot continue."
|
311
|
+
end
|
312
|
+
end
|
313
|
+
end
|
314
|
+
|
315
|
+
#
|
316
|
+
# Provision a security group. Group name generation happens here if
|
317
|
+
# #group_name is set to :auto, if the group already exists, it will be
|
318
|
+
# used and then rules will be applied to it. Returns (and sets on the
|
319
|
+
# object) the group_id as returned by EC2.
|
320
|
+
#
|
321
|
+
def startup(args={})
|
322
|
+
# FIXME VPC as argument?
|
323
|
+
|
324
|
+
@group_name = generate_group_name if group_name == :auto
|
325
|
+
|
326
|
+
begin
|
327
|
+
group =
|
328
|
+
ec2.security_groups.create(
|
329
|
+
group_name,
|
330
|
+
:description => description,
|
331
|
+
:vpc => vpc
|
332
|
+
)
|
333
|
+
rescue ::AWS::EC2::Errors::InvalidGroup::Duplicate => e
|
334
|
+
group = find_group_by_name(group_name)
|
335
|
+
raise e unless group
|
336
|
+
|
337
|
+
my_name = furnish_group_name # yay instance_eval
|
338
|
+
|
339
|
+
if_debug(1) do
|
340
|
+
puts "#{group.id}/#{group.name} already existed during #{my_name || "ungrouped"} provision -- applying rules and continuing"
|
341
|
+
end
|
342
|
+
end
|
343
|
+
|
344
|
+
apply_group_rules(group)
|
345
|
+
@group_id = group.id
|
346
|
+
|
347
|
+
return({ :security_group_ids => (args[:security_group_ids] || []) + [group_id] })
|
348
|
+
end
|
349
|
+
|
350
|
+
#
|
351
|
+
# Deprovision a security group. If the group does not exist, immediately
|
352
|
+
# returns a true result.
|
353
|
+
#
|
354
|
+
# If #kill_instances is true, it will terminate (and wait for the
|
355
|
+
# termination to succeed) any machines that are using the security group
|
356
|
+
# that are not yet terminated.
|
357
|
+
#
|
358
|
+
# After deleting the group via the API, it will return true if the group
|
359
|
+
# no longer exists.
|
360
|
+
#
|
361
|
+
def shutdown(args={})
|
362
|
+
return { } unless group = load_group_for_shutdown
|
363
|
+
|
364
|
+
terminate_instances_for_shutdown(group)
|
365
|
+
group.delete
|
366
|
+
|
367
|
+
return group.exists? ? false : { }
|
368
|
+
end
|
369
|
+
|
370
|
+
#
|
371
|
+
# Report method as required by Furnish: yields the name of the group,
|
372
|
+
# it's group id, and any vpc information if VPC is in use.
|
373
|
+
#
|
374
|
+
def report
|
375
|
+
vpc_str = vpc ? " vpc_id: #{vpc}" : ""
|
376
|
+
["name: #{group_name}, id: #{group_id}#{vpc_str}"]
|
377
|
+
end
|
378
|
+
end
|
379
|
+
end
|
380
|
+
end
|
data/test/helper.rb
ADDED
@@ -0,0 +1,61 @@
|
|
1
|
+
require 'bundler/setup'
|
2
|
+
|
3
|
+
if ENV["COVERAGE"]
|
4
|
+
require 'simplecov'
|
5
|
+
SimpleCov.start do
|
6
|
+
add_filter '/test/'
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
unless File.exist?(".acknowledged")
|
11
|
+
$stderr.puts <<-'EOF'
|
12
|
+
/.--------------.\
|
13
|
+
// \\
|
14
|
+
// \\
|
15
|
+
|| .-..----. .-. .--. ||
|
16
|
+
||( ( '-..-'|.-.||.-.|||
|
17
|
+
|| \ \ || || ||||_||||
|
18
|
+
||._) ) || \'-'/||-' ||
|
19
|
+
\\'-' `' `-' `' //
|
20
|
+
\\ //
|
21
|
+
\\______________//
|
22
|
+
'--------------'
|
23
|
+
|
24
|
+
These tests have no mocks -- they provision real AWS services (machines,
|
25
|
+
security groups, etc) and tear them down.
|
26
|
+
|
27
|
+
It's your job to set your AWS environment variables to an environment that's
|
28
|
+
safe, and bound to a credit card you're comfortable spending money on.
|
29
|
+
Likewise, if the tests fail, there's a high likelihood you'll need to clean
|
30
|
+
machines up manually.
|
31
|
+
|
32
|
+
If you're ok with this, `touch .acknowledged` in the root of this repository
|
33
|
+
and run the tests again.
|
34
|
+
|
35
|
+
EOF
|
36
|
+
|
37
|
+
exit 1
|
38
|
+
end
|
39
|
+
|
40
|
+
eval File.read('.aws-env') if File.exist?('.aws-env')
|
41
|
+
|
42
|
+
unless ENV["AWS_ACCESS_KEY_ID"] and ENV["AWS_SECRET_ACCESS_KEY"]
|
43
|
+
if ENV["AWS_ACCESS_KEY"] and ENV["AWS_SECRET_KEY"]
|
44
|
+
# XXX the above vars are the "new school" and so if we have them and not
|
45
|
+
# the "old school", copy them.
|
46
|
+
ENV["AWS_ACCESS_KEY_ID"] = ENV["AWS_ACCESS_KEY"]
|
47
|
+
ENV["AWS_SECRET_ACCESS_KEY"] = ENV["AWS_SECRET_KEY"]
|
48
|
+
else
|
49
|
+
$stderr.puts <<-EOF
|
50
|
+
These tests cannot run without AWS environment variables set.
|
51
|
+
|
52
|
+
You can create a file in the root of the repository called .aws-env that
|
53
|
+
will be loaded as a ruby file to set these values.
|
54
|
+
EOF
|
55
|
+
|
56
|
+
exit 1
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
require 'furnish/test'
|
61
|
+
require 'minitest/autorun'
|
@@ -0,0 +1,30 @@
|
|
1
|
+
require 'helper'
|
2
|
+
require 'furnish/provisioners/aws'
|
3
|
+
|
4
|
+
class TestAWSProvisioner < Furnish::RunningSchedulerTestCase
|
5
|
+
def setup
|
6
|
+
@klass = Furnish::Provisioner::AWS
|
7
|
+
super
|
8
|
+
end
|
9
|
+
|
10
|
+
def test_constructor
|
11
|
+
assert_raises(ArgumentError, "arguments must be a hash") { @klass.new }
|
12
|
+
assert_raises(ArgumentError, "AWS credentials must be provided to the provisioner.") { @klass.new({}) }
|
13
|
+
|
14
|
+
# XXX should not raise
|
15
|
+
obj = @klass.new(:access_key => "qwe123", :secret_key => "password1")
|
16
|
+
|
17
|
+
assert_equal("qwe123", obj.access_key, "access key is available from object after set in constructor")
|
18
|
+
assert_equal("password1", obj.secret_key, "secret key is available from object after set in constructor")
|
19
|
+
end
|
20
|
+
|
21
|
+
def test_ec2
|
22
|
+
obj = @klass.new(:access_key => "qwe123", :secret_key => "password1", :region => "us-east-1")
|
23
|
+
assert_kind_of(AWS::EC2::Region, obj.ec2)
|
24
|
+
refute_nil(obj.region, "region is non-nil")
|
25
|
+
assert_equal(obj.region, obj.ec2.name, "region propogates to ec2 object")
|
26
|
+
|
27
|
+
obj.region = "us-west-1"
|
28
|
+
assert_equal(obj.region, obj.ec2.name, "region propogates to ec2 object after being changed")
|
29
|
+
end
|
30
|
+
end
|