syncwrap 1.5.0 → 1.5.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.
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