syncwrap 1.5.0 → 1.5.1

Sign up to get free protection for your applications and to get access to all the features.
data/History.rdoc CHANGED
@@ -1,3 +1,6 @@
1
+ === 1.5.1 (2013-4-3)
2
+ * Include syncwrap/aws.rb for real.
3
+
1
4
  === 1.5.0 (2013-4-3)
2
5
  * Extended shell_escape_command for sudo sh command.
3
6
  * New SyncWrap::AWS for EC2 instance creation, mdraid over EBS
data/Manifest.txt CHANGED
@@ -11,6 +11,7 @@ etc/sysconfig/pgsql/postgresql
11
11
  etc/sysctl.d/61-postgresql-shm.conf
12
12
  lib/syncwrap/base.rb
13
13
  lib/syncwrap.rb
14
+ lib/syncwrap/aws.rb
14
15
  lib/syncwrap/common.rb
15
16
  lib/syncwrap/distro.rb
16
17
  lib/syncwrap/ec2.rb
@@ -0,0 +1,400 @@
1
+ #--
2
+ # Copyright (c) 2011-2013 David Kellum
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License"); you
5
+ # may not use this file except in compliance with the License. You may
6
+ # obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
13
+ # implied. See the License for the specific language governing
14
+ # permissions and limitations under the License.
15
+ #++
16
+
17
+ require 'json'
18
+ require 'aws-sdk'
19
+
20
+ require 'syncwrap/common'
21
+
22
+ # Supports host provisioning in EC2 via AWS APIs, creating and
23
+ # attaching EBS volumes, creating Route53 record sets, and as a remote
24
+ # task: building mdraid volumes.
25
+ #
26
+ # This module also includes a disk based cache of meta-data on created
27
+ # instances which allows automated role assignment (i.e. create an
28
+ # instance and run deploy tasks on it in a single pass.)
29
+ module SyncWrap::AWS
30
+ include SyncWrap::Common
31
+
32
+ # The json configuration file, parsed and passed directly to
33
+ # AWS::config method. This file should contain a json object with
34
+ # the minimal required keys: access_key_id, secret_access_key
35
+ # (default: ./private/aws.json)
36
+ attr_accessor :aws_config_json
37
+
38
+ # The cached aws instance json file (default: ./aws_instances.json)
39
+ attr_accessor :aws_instances_json
40
+
41
+ # Array of region strings to check for aws_import_instances
42
+ attr_accessor :aws_regions
43
+
44
+ # Array of imported or read instances represented as hashes.
45
+ attr_accessor :aws_instances
46
+
47
+ # Default options (which may be overridden) in call to
48
+ # aws_create_instance.
49
+ attr_accessor :aws_default_instance_options
50
+
51
+ # Default options (which may be overridden) in call to
52
+ # aws_create_security_group.
53
+ attr_accessor :aws_default_sg_options
54
+
55
+ # Default options for Route53 record set creation
56
+ attr_accessor :route53_default_rs_options
57
+
58
+ def initialize
59
+ @aws_config_json = 'private/aws.json'
60
+ @aws_regions = %w[ us-east-1 ]
61
+ @aws_instances_json = 'aws_instances.json'
62
+ @aws_instances = []
63
+ @aws_default_instance_options = {
64
+ :ebs_volumes => 0,
65
+ :ebs_volume_options => { :size => 16 }, #gb
66
+ :lvm_volumes => [ [ 1.00, '/data' ] ],
67
+ :security_groups => :default,
68
+ :instance_type => 'm1.medium',
69
+ :region => 'us-east-1'
70
+ }
71
+ @aws_default_sg_options = {
72
+ :region => 'us-east-1'
73
+ }
74
+
75
+ @route53_default_rs_options = {}
76
+
77
+ super
78
+
79
+ aws_configure
80
+ aws_read_instances if File.exist?( @aws_instances_json )
81
+ end
82
+
83
+ def aws_configure
84
+ AWS.config( JSON.parse( IO.read( @aws_config_json ),
85
+ :symbolize_names => true ) )
86
+ end
87
+
88
+ # Create a security_group given name and options. :region is the
89
+ # only required option, :description is a good to have. Currently
90
+ # this is a no-op if the security group already exists.
91
+ def aws_create_security_group( name, opts = {} )
92
+ opts = deep_merge_hashes( @aws_default_sg_options, opts )
93
+ region = opts.delete( :region )
94
+ ec2 = AWS::EC2.new.regions[ region ]
95
+ unless ec2.security_groups.find { |sg| sg.name == name }
96
+ ec2.security_groups.create( name, opts )
97
+ end
98
+ end
99
+
100
+ # Create an instance, using name as the Name tag and assumed
101
+ # host name. For options see
102
+ # {AWS::EC2::InstanceCollection.create}[http://docs.aws.amazon.com/AWSRubySDK/latest/AWS/EC2/InstanceCollection.html#create-instance_method]
103
+ # with the following additions/differences:
104
+ #
105
+ # :count:: must be 1 or unspecified.
106
+ # :region:: Default 'us-east-1'
107
+ # :security_groups:: As per aws-sdk, but the special :default value
108
+ # is replaced with a single security group with
109
+ # same name as the :region option.
110
+ # :ebs_volumes:: The number of EBS volumes to create an attach to this instance.
111
+ # :ebs_volume_options:: A nested Hash of options, as per
112
+ # {AWS::EC2::VolumeCollection.create}[http://docs.aws.amazon.com/AWSRubySDK/latest/AWS/EC2/VolumeCollection.html#create-instance_method]
113
+ # with custom default :size 16 GB, and the same
114
+ # :availibility_zone as the instance.
115
+ # :lvm_volumes:: Ignored here.
116
+ # :roles:: Array of role Strings or Symbols (applied as Roles tag)
117
+ def aws_create_instance( name, opts = {} )
118
+ opts = deep_merge_hashes( @aws_default_instance_options, opts )
119
+ region = opts.delete( :region )
120
+ opts.delete( :lvm_volumes ) #unused here
121
+
122
+ ec2 = AWS::EC2.new.regions[ region ]
123
+
124
+ iopts = opts.dup
125
+ iopts.delete( :ebs_volumes )
126
+ iopts.delete( :ebs_volume_options )
127
+ iopts.delete( :roles )
128
+
129
+ if iopts[ :count ] && iopts[ :count ] != 1
130
+ raise ":count #{iopts[ :count ]} != 1 is not supported"
131
+ end
132
+
133
+ if iopts[ :security_groups ] == :default
134
+ iopts[ :security_groups ] = [ region ]
135
+ end
136
+
137
+ inst = ec2.instances.create( iopts )
138
+
139
+ inst.add_tag( 'Name', :value => name )
140
+
141
+ if opts[ :roles ]
142
+ inst.add_tag( 'Roles', :value => opts[ :roles ].join(' ') )
143
+ end
144
+
145
+ wait_for_running( inst )
146
+
147
+ if opts[ :ebs_volumes ] > 0
148
+ vopts = { :availability_zone => inst.availability_zone }.
149
+ merge( opts[ :ebs_volume_options ] )
150
+
151
+ attachments = opts[ :ebs_volumes ].times.map do |i|
152
+ vol = ec2.volumes.create( vopts )
153
+ vol.attach_to( inst, "/dev/sdh#{i+1}" ) #=> Attachment
154
+ end
155
+
156
+ sleep 1 while attachments.any? { |a| a.status == :attaching }
157
+ end
158
+
159
+ iprops = aws_instance_to_props( region, inst )
160
+ aws_instance_added( iprops )
161
+ aws_write_instances
162
+ iprops
163
+ end
164
+
165
+ # Create a Route53 DNS CNAME from iprops :name to :internet_name.
166
+ # Options are per {AWS::Route53::ResourceRecordSetCollection.create}[http://docs.aws.amazon.com/AWSRubySDK/latest/AWS/Route53/ResourceRecordSetCollection.html#create-instance_method]
167
+ # (currently undocumented) with the following addition:
168
+ #
169
+ # :domain_name:: name of the hosted zone and suffix for
170
+ # CNAME. Should terminate in a DOT '.'
171
+ def route53_create_host_cname( iprops, opts = {} )
172
+ opts = deep_merge_hashes( @route53_default_rs_options, opts )
173
+ dname = opts.delete( :domain_name ) #with terminal '.'
174
+ rs_opts =
175
+ { :ttl => 300 }.
176
+ merge( opts ).
177
+ merge( :resource_records => [ { :value => iprops[:internet_name] } ] )
178
+
179
+ r53 = AWS::Route53.new
180
+ zone = r53.hosted_zones.find { |hz| hz.name == dname } or
181
+ raise "Route53 Hosted Zone name #{dname} not found"
182
+ zone.rrsets.create( [ iprops[:name], dname ].join('.'), 'CNAME', rs_opts )
183
+ end
184
+
185
+ # Terminates an instance and permanently deletes any EBS volumes
186
+ # attached to /dev/sdh like via create. WARNING: potential for data
187
+ # loss! The minimum required propererties in iprops hash are :region
188
+ # and :id.
189
+ def aws_terminate_instance_and_ebs_volumes( iprops )
190
+ ec2 = AWS::EC2.new.regions[ iprops[ :region ] ]
191
+ inst = ec2.instances[ iprops[ :id ] ]
192
+ unless inst.exists?
193
+ raise "Instance #{iprops[:id]} does not exist in #{iprops[:region]}"
194
+ end
195
+
196
+ ebs_volumes = inst.block_devices.map do |dev|
197
+ ebs = dev[ :ebs ]
198
+ if ebs && dev[:device_name] =~ /dh\d+$/ && !ebs[:delete_on_termination]
199
+ ebs[ :volume_id ]
200
+ end
201
+ end.compact
202
+
203
+ puts "Terminating instance #{inst.id}"
204
+ inst.terminate
205
+ sleep 1 while inst.status != :terminated
206
+
207
+ ebs_volumes = ebs_volumes.map do |vid|
208
+ volume = ec2.volumes[ vid ]
209
+ if volume.exists?
210
+ volume
211
+ else
212
+ puts "WARN: #{volume} doesn't exist"
213
+ nil
214
+ end
215
+ end.compact
216
+
217
+ ebs_volumes.each do |vol|
218
+ puts "Deleting vol #{vol.id} (#{vol.status})"
219
+ sleep 1 until vol.status == :available || vol.status == :deleted
220
+ vol.delete if vol.status == :available
221
+ end
222
+
223
+ found = aws_find_instance( iprops )
224
+ if found
225
+ aws_instance_removed( found )
226
+ aws_write_instances
227
+ end
228
+ end
229
+
230
+ # Return instance properties, by example via iprops, either by [
231
+ # :id, :region ], :internet_name, :internet_ip, or :name, attempted
232
+ # in that order.
233
+ def aws_find_instance( iprops )
234
+ if iprops[:id]
235
+ @aws_instances.find do |r|
236
+ r[:id] == iprops[:id] && r[:region] == iprops[:region]
237
+ end
238
+ else
239
+ [ :internet_name, :internet_ip, :name ].inject( nil ) do |found, key|
240
+ found || @aws_instances.find { |r| r[key] == iprops[key] }
241
+ end
242
+ end
243
+ end
244
+
245
+ def aws_instance_added( inst )
246
+ @aws_instances << inst
247
+ @aws_instances.sort! { |p,n| p[:name] <=> n[:name] }
248
+ end
249
+
250
+ def aws_instance_removed( iprops )
251
+ @aws_instances.reject! do |row|
252
+ row[:id] == iprops[:id] && row[:region] == iprops[:region]
253
+ end
254
+ end
255
+
256
+ def wait_for_running( inst )
257
+ while ( stat = inst.status ) == :pending
258
+ puts "Waiting 1s for instance to run"
259
+ sleep 1
260
+ end
261
+ unless stat == :running
262
+ raise "Instance #{inst.id} has status #{stat}"
263
+ end
264
+ nil
265
+ end
266
+
267
+ # Find running/pending instances in each of aws_regions, convert to
268
+ # props, and save in aws_instances list.
269
+ def aws_import_instances
270
+
271
+ instances = []
272
+
273
+ aws_regions.each do |region|
274
+ ec2 = AWS::EC2.new.regions[ region ]
275
+
276
+ found = ec2.instances.map do |inst|
277
+ next unless [ :running, :pending ].include?( inst.status )
278
+ aws_instance_to_props( region, inst )
279
+ end
280
+
281
+ instances += found.compact
282
+ end
283
+
284
+ @aws_instances = instances
285
+ end
286
+
287
+ def aws_instance_to_props( region, inst )
288
+ tags = inst.tags.to_h
289
+ name = tags[ 'Name' ]
290
+ roles = decode_roles( tags[ 'Roles' ] )
291
+
292
+ { :id => inst.id,
293
+ :region => region,
294
+ :ami => inst.image_id,
295
+ :name => name,
296
+ :internet_name => inst.dns_name,
297
+ :internet_ip => inst.ip_address,
298
+ :internal_ip => inst.private_ip_address,
299
+ :instance_type => inst.instance_type,
300
+ :roles => roles }
301
+ end
302
+
303
+ # Read the aws_instances_json file, replacing in RAM AWS instances
304
+ def aws_read_instances
305
+ list = JSON.parse( IO.read( aws_instances_json ), :symbolize_names => true )
306
+ list.each do |inst|
307
+ inst[:roles] = ( inst[:roles] || [] ).map { |r| r.to_sym }
308
+ end
309
+ @aws_instances = list.sort { |p,n| p[:name] <=> n[:name] }
310
+ end
311
+
312
+ # Overwrite aws_instances_json file with the current AWS instances.
313
+ def aws_write_instances
314
+ File.open( aws_instances_json, 'w' ) do |fout|
315
+ aws_dump_instances( fout )
316
+ end
317
+ end
318
+
319
+ # Dump AWS instances as JSON but with custom layout of host per line.
320
+ def aws_dump_instances( fout = $stdout )
321
+ fout.puts '['
322
+ rows = @aws_instances.sort { |p,n| p[:name] <=> n[:name] }
323
+ rows.each_with_index do |row, i|
324
+ fout.puts( " " + JSON.generate( row, :space => ' ', :object_nl => ' ' ) +
325
+ ( ( i == ( rows.length - 1 ) ) ? '' : ',' ) )
326
+ end
327
+ fout.puts ']'
328
+ end
329
+
330
+ def decode_roles( roles )
331
+ ( roles || "" ).split( /\s+/ ).map { |r| r.to_sym }
332
+ end
333
+
334
+ # Runs create_lvm_volumes! if the first :lvm_volumes mount path does
335
+ # not yet exist.
336
+ def create_lvm_volumes( opts = {} )
337
+ opts = deep_merge_hashes( @aws_default_instance_options, opts )
338
+ unless exist?( opts[ :lvm_volumes ].first[1] )
339
+ create_lvm_volumes!( opts )
340
+ end
341
+ end
342
+
343
+ # Create an mdraid array from previously attached EBS volumes, then
344
+ # divvy up across N lvm mount points by ratio, creating filesystems and
345
+ # mounting. This mdadm and lvm recipe was originally derived from
346
+ # {Install MongoDB on Amazon EC2}[http://docs.mongodb.org/ecosystem/tutorial/install-mongodb-on-amazon-ec2/]
347
+ #
348
+ # === Options
349
+ # :ebs_volumes:: The number of EBS volumes previously created and
350
+ # attached to this instance.
351
+ # :lvm_volumes:: A table of [ slice, path (,name) ] rows where;
352
+ # slice is a floating point ratio in range (0.0,1.0],
353
+ # path is the mount point, and name is the lvm name,
354
+ # defaulting if omitted to the last path element. The
355
+ # sum of all slice values in the table should be 1.0.
356
+ def create_lvm_volumes!( opts = {} )
357
+ opts = deep_merge_hashes( @aws_default_instance_options, opts )
358
+
359
+ vol_count = opts[ :ebs_volumes ]
360
+ devices = vol_count.times.map { |i| "/dev/xvdh#{i+1}" }
361
+
362
+ cmd = <<-SH
363
+ #{dist_install_s( "mdadm", "lvm2", :minimal => true )}
364
+ mdadm --create /dev/md0 --level=10 --chunk=256 --raid-devices=#{vol_count} \
365
+ #{devices.join ' '}
366
+ echo "DEVICE #{devices.join ' '}" > /etc/mdadm.conf
367
+ mdadm --detail --scan >> /etc/mdadm.conf
368
+ SH
369
+
370
+ devices.each do |d|
371
+ cmd << <<-SH
372
+ blockdev --setra 128 #{d}
373
+ SH
374
+ end
375
+
376
+ cmd << <<-SH
377
+ blockdev --setra 128 /dev/md0
378
+ dd if=/dev/zero of=/dev/md0 bs=512 count=1
379
+ pvcreate /dev/md0
380
+ vgcreate vg0 /dev/md0
381
+ SH
382
+
383
+ opts[ :lvm_volumes ].each do | slice, path, name |
384
+ name ||= ( path =~ /\/(\w+)$/ ) && $1
385
+ cmd << <<-SH
386
+ lvcreate -l #{(slice * 100).round}%vg -n #{name} vg0
387
+ mke2fs -t ext4 -F /dev/vg0/#{name}
388
+ if [ -d #{path} ]; then
389
+ rm -rf #{path}
390
+ fi
391
+ mkdir -p #{path}
392
+ echo '/dev/vg0/#{name} #{path} ext4 defaults,auto,noatime,noexec 0 0' >> /etc/fstab
393
+ mount #{path}
394
+ SH
395
+ end
396
+
397
+ sudo cmd
398
+ end
399
+
400
+ end
data/lib/syncwrap/base.rb CHANGED
@@ -15,5 +15,5 @@
15
15
  #++
16
16
 
17
17
  module SyncWrap
18
- VERSION='1.5.0'
18
+ VERSION='1.5.1'
19
19
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: syncwrap
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.0
4
+ version: 1.5.1
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -84,6 +84,7 @@ files:
84
84
  - etc/sysctl.d/61-postgresql-shm.conf
85
85
  - lib/syncwrap/base.rb
86
86
  - lib/syncwrap.rb
87
+ - lib/syncwrap/aws.rb
87
88
  - lib/syncwrap/common.rb
88
89
  - lib/syncwrap/distro.rb
89
90
  - lib/syncwrap/ec2.rb
@@ -127,7 +128,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
127
128
  version: '0'
128
129
  segments:
129
130
  - 0
130
- hash: -4179496661499854471
131
+ hash: -4463767626501128078
131
132
  required_rubygems_version: !ruby/object:Gem::Requirement
132
133
  none: false
133
134
  requirements:
@@ -136,7 +137,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
136
137
  version: '0'
137
138
  segments:
138
139
  - 0
139
- hash: -4179496661499854471
140
+ hash: -4463767626501128078
140
141
  requirements: []
141
142
  rubyforge_project:
142
143
  rubygems_version: 1.8.25