judo 0.0.9 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,513 @@
1
+ ### NEEDED for new gem launch
2
+
3
+ ### [ ] return right away.. (1 hr)
4
+ ### [ ] two phase delete (1 hr)
5
+ ### [-] refactor availability_zone (2 hrs)
6
+ ### [ ] pick availability zone from config "X":"Y" or "X":["Y","Z"]
7
+ ### [ ] assign to state on creation ( could delay till volume creation )
8
+ ### [ ] implement auto security_group creation and setup (6 hrs)
9
+ ### [ ] write some examples - simple postgres/redis/couchdb server (5hrs)
10
+ ### [ ] write new README (4 hrs)
11
+ ### [ ] bind kuzushi gem version version
12
+ ### [ ] realase new gem! (1 hr)
13
+
14
+ ### [ ] should be able to do ALL actions except commit without the repo!
15
+ ### [ ] store git commit hash with commit to block a judo commit if there is newer material stored
16
+ ### [ ] remove the tarball - store files a sha hashes in the bucket - makes for faster commits if the files have not changed
17
+
18
+ ### [ ] use a logger service (1 hr)
19
+ ### [ ] write specs (5 hr)
20
+
21
+ ### Error Handling
22
+ ### [ ] no availability zone before making disks
23
+ ### [ ] security group does not exists
24
+
25
+ ### Do Later
26
+ ### [ ] use amazon's new conditional write tools so we never have problems from concurrent updates
27
+ ### [ ] is thor really what we want to use here?
28
+ ### [ ] need to be able to pin a config to a version of kuzushi - gem updates can/will break a lot of things
29
+ ### [ ] I want a "judo monitor" command that will make start servers if they go down, and poke a listed port to make sure a service is listening, would be cool if it also detects wrong ami, wrong secuirity group, missing/extra volumes, missing/extra elastic_ip - might not want to force a reboot quite yet in these cases
30
+ ### [ ] Implement "judo snapshot [NAME]" to take a snapshot of the ebs's blocks
31
+ ### [ ] ruby 1.9.1 support
32
+ ### [ ] find a good way to set the hostname or prompt to :name
33
+ ### [ ] remove fog/s3 dependancy
34
+ ### [ ] enforce template files end in .erb to make room for other possible templates as defined by the extensions
35
+ ### [ ] zerigo integration for automatic DNS setup
36
+ ### [ ] How cool would it be if this was all reimplemented in eventmachine and could start lots of boxes in parallel? Would need to evented AWS api calls... Never seen a library to do that - would have to write our own... "Fog Machine?"
37
+
38
+ module Judo
39
+ class Server
40
+ attr_accessor :name
41
+
42
+ def initialize(base, name, group)
43
+ @base = base
44
+ @name = name
45
+ @group_name = group
46
+ end
47
+
48
+ def create
49
+ raise JudoError, "no group specified" unless @group_name
50
+
51
+ if @name.nil?
52
+ index = @base.servers.map { |s| (s.name =~ /^#{s.group.name}.(\d*)$/); $1.to_i }.sort.last.to_i + 1
53
+ @name = "#{group.name}.#{index}"
54
+ end
55
+
56
+ raise JudoError, "there is already a server named #{name}" if @base.servers.detect { |s| s.name == @name and s != self}
57
+
58
+ task("Creating server #{name}") do
59
+ update "name" => name, "group" => @group_name, "virgin" => true, "secret" => rand(2 ** 128).to_s(36)
60
+ @base.sdb.put_attributes("judo_config", "groups", @group_name => name)
61
+ end
62
+
63
+ allocate_resources
64
+
65
+ self
66
+ end
67
+
68
+ def group
69
+ @group ||= @base.groups.detect { |g| g.name == @group_name }
70
+ end
71
+
72
+ def fetch_state
73
+ @base.sdb.get_attributes(self.class.domain, name)[:attributes]
74
+ end
75
+
76
+ def state
77
+ @base.servers_state[name] ||= fetch_state
78
+ end
79
+
80
+ def get(key)
81
+ state[key] && [state[key]].flatten.first
82
+ end
83
+
84
+ def instance_id
85
+ get "instance_id"
86
+ end
87
+
88
+ def elastic_ip
89
+ get "elastic_ip"
90
+ end
91
+
92
+ def size_desc
93
+ if not running? or ec2_instance_type == instance_size
94
+ instance_size
95
+ else
96
+ "#{ec2_instance_type}/#{instance_size}"
97
+ end
98
+ end
99
+
100
+ def version_desc
101
+ return "" unless running?
102
+ if version == group.version
103
+ "v#{version}"
104
+ else
105
+ "v#{version}/#{group.version}"
106
+ end
107
+ end
108
+
109
+ def version
110
+ get("version").to_i
111
+ end
112
+
113
+ def virgin?
114
+ get("virgin").to_s == "true" ## I'm going to set it to true and it will come back from the db as "true" -> could be "false" or false or nil also
115
+ end
116
+
117
+ def secret
118
+ get "secret"
119
+ end
120
+
121
+ def volumes
122
+ Hash[ (state["volumes"] || []).map { |a| a.split(":") } ]
123
+ end
124
+
125
+ def self.domain
126
+ "judo_servers"
127
+ end
128
+
129
+ def update(attrs)
130
+ @base.sdb.put_attributes(self.class.domain, name, attrs, :replace)
131
+ state.merge! attrs
132
+ end
133
+
134
+ def add(key, value)
135
+ @base.sdb.put_attributes(self.class.domain, name, { key => value })
136
+ (state[key] ||= []) << value
137
+ end
138
+
139
+ def remove(key, value = nil)
140
+ if value
141
+ @base.sdb.delete_attributes(self.class.domain, name, key => value)
142
+ state[key] - [value]
143
+ else
144
+ @base.sdb.delete_attributes(self.class.domain, name, [ key ])
145
+ state.delete(key)
146
+ end
147
+ end
148
+
149
+ def delete
150
+ group.delete_server(self)
151
+ @base.sdb.delete_attributes(self.class.domain, name)
152
+ end
153
+
154
+ ######## end simple DB access #######
155
+
156
+ def instance_size
157
+ config["instance_size"]
158
+ end
159
+
160
+ def config
161
+ group.config
162
+ end
163
+
164
+ def to_s
165
+ "#{name}:#{@group_name}"
166
+ end
167
+
168
+ def allocate_resources
169
+ if config["volumes"]
170
+ [config["volumes"]].flatten.each do |volume_config|
171
+ device = volume_config["device"]
172
+ if volume_config["media"] == "ebs"
173
+ size = volume_config["size"]
174
+ if not volumes[device]
175
+ task("Creating EC2 Volume #{device} #{size}") do
176
+ ### EC2 create_volume
177
+ volume_id = @base.ec2.create_volume(nil, size, config["availability_zone"])[:aws_id]
178
+ add_volume(volume_id, device)
179
+ end
180
+ else
181
+ puts "Volume #{device} already exists."
182
+ end
183
+ else
184
+ puts "device #{device || volume_config["mount"]} is not of media type 'ebs', skipping..."
185
+ end
186
+ end
187
+ end
188
+
189
+ begin
190
+ if config["elastic_ip"] and not elastic_ip
191
+ ### EC2 allocate_address
192
+ task("Adding an elastic ip") do
193
+ ip = @base.ec2.allocate_address
194
+ add_ip(ip)
195
+ end
196
+ end
197
+ rescue Aws::AwsError => e
198
+ if e.message =~ /AddressLimitExceeded/
199
+ invalid "Failed to allocate ip address: Limit Exceeded"
200
+ else
201
+ raise
202
+ end
203
+ end
204
+ end
205
+
206
+ def task(msg, &block)
207
+ @base.task(msg, &block)
208
+ end
209
+
210
+ def self.task(msg, &block)
211
+ printf "---> %-24s ", "#{msg}..."
212
+ STDOUT.flush
213
+ start = Time.now
214
+ result = block.call
215
+ result = "done" unless result.is_a? String
216
+ finish = Time.now
217
+ time = sprintf("%0.1f", finish - start)
218
+ puts "#{result} (#{time}s)"
219
+ result
220
+ end
221
+
222
+ def has_ip?
223
+ !!elastic_ip
224
+ end
225
+
226
+ def has_volumes?
227
+ not volumes.empty?
228
+ end
229
+
230
+ def ec2_volumes
231
+ return [] if volumes.empty?
232
+ @base.ec2.describe_volumes( volumes.values )
233
+ end
234
+
235
+ def remove_ip
236
+ @base.ec2.release_address(elastic_ip) rescue nil
237
+ remove "elastic_ip"
238
+ end
239
+
240
+ def destroy
241
+ stop if running?
242
+ ### EC2 release_address
243
+ task("Deleting Elastic Ip") { remove_ip } if has_ip?
244
+ volumes.each { |dev,v| remove_volume(v,dev) }
245
+ task("Destroying server #{name}") { delete }
246
+ end
247
+
248
+ def ec2_state
249
+ ec2_instance[:aws_state] rescue "offline"
250
+ end
251
+
252
+ def ec2_instance
253
+ ### EC2 describe_instances
254
+ @base.ec2_instances.detect { |e| e[:aws_instance_id] == instance_id } or {}
255
+ end
256
+
257
+ def running?
258
+ ## other options are "terminated" and "nil"
259
+ ["pending", "running", "shutting_down", "degraded"].include?(ec2_state)
260
+ end
261
+
262
+ def start
263
+ invalid "Already running" if running?
264
+ invalid "No config has been commited yet, type 'judo commit'" unless group.version > 0
265
+ task("Starting server #{name}") { launch_ec2 }
266
+ task("Wait for server") { wait_for_running } if elastic_ip or has_volumes?
267
+ task("Attaching ip") { attach_ip } if elastic_ip
268
+ task("Attaching volumes") { attach_volumes } if has_volumes?
269
+ end
270
+
271
+ def restart
272
+ stop if running?
273
+ start
274
+ end
275
+
276
+ def generic_name?
277
+ name =~ /^#{group}[.]\d*$/
278
+ end
279
+
280
+ def generic?
281
+ volumes.empty? and not has_ip? and generic_name?
282
+ end
283
+
284
+ def invalid(str)
285
+ raise JudoInvalid, str
286
+ end
287
+
288
+ def stop
289
+ invalid "not running" unless running?
290
+ ## EC2 terminate_isntaces
291
+ task("Terminating instance") { @base.ec2.terminate_instances([ instance_id ]) }
292
+ task("Wait for volumes to detach") { wait_for_volumes_detached } if volumes.size > 0
293
+ remove "instance_id"
294
+ end
295
+
296
+ def launch_ec2
297
+ # validate
298
+
299
+ ## EC2 launch_instances
300
+ ud = user_data
301
+ debug(ud)
302
+ result = @base.ec2.launch_instances(ami,
303
+ :instance_type => config["instance_size"],
304
+ :availability_zone => config["availability_zone"],
305
+ :key_name => config["key_name"],
306
+ :group_ids => security_groups,
307
+ :user_data => ud).first
308
+
309
+ update "instance_id" => result[:aws_instance_id], "virgin" => false, "version" => group.version
310
+ end
311
+
312
+ def debug(str)
313
+ return unless ENV['JUDO_DEBUG'] == "1"
314
+ puts "<JUDO_DEBUG>#{str}</JUDO_DEBUG>"
315
+ end
316
+
317
+ def security_groups
318
+ [ config["security_group"] ].flatten
319
+ end
320
+
321
+ def console_output
322
+ invalid "not running" unless running?
323
+ @base.ec2.get_console_output(instance_id)[:aws_output]
324
+ end
325
+
326
+ def ami
327
+ ia32? ? config["ami32"] : config["ami64"]
328
+ end
329
+
330
+ def ia32?
331
+ ["m1.small", "c1.medium"].include?(instance_size)
332
+ end
333
+
334
+ def ia64?
335
+ not ia32?
336
+ end
337
+
338
+ def hostname
339
+ ec2_instance[:dns_name] == "" ? nil : ec2_instance[:dns_name]
340
+ end
341
+
342
+ def wait_for_running
343
+ loop do
344
+ return if ec2_state == "running"
345
+ reload
346
+ sleep 1
347
+ end
348
+ end
349
+
350
+ def wait_for_hostname
351
+ loop do
352
+ reload
353
+ return hostname if hostname
354
+ sleep 1
355
+ end
356
+ end
357
+
358
+ def wait_for_volumes_detached
359
+ ## FIXME - force if it takes too long
360
+ loop do
361
+ break if ec2_volumes.reject { |v| v[:aws_status] == "available" }.empty?
362
+ sleep 2
363
+ end
364
+ end
365
+
366
+ def wait_for_termination
367
+ loop do
368
+ reload
369
+ break if ec2_instance[:aws_state] == "terminated"
370
+ sleep 1
371
+ end
372
+ end
373
+
374
+ def wait_for_ssh
375
+ invalid "not running" unless running?
376
+ loop do
377
+ begin
378
+ reload
379
+ Timeout::timeout(4) do
380
+ TCPSocket.new(hostname, 22)
381
+ return
382
+ end
383
+ rescue SocketError, Timeout::Error, Errno::ECONNREFUSED, Errno::EHOSTUNREACH
384
+ end
385
+ end
386
+ end
387
+
388
+ def add_ip(public_ip)
389
+ update "elastic_ip" => public_ip
390
+ attach_ip
391
+ end
392
+
393
+ def attach_ip
394
+ return unless running? and elastic_ip
395
+ ### EC2 associate_address
396
+ @base.ec2.associate_address(instance_id, elastic_ip)
397
+ end
398
+
399
+ def dns_name
400
+ return nil unless elastic_ip
401
+ `dig +short -x #{elastic_ip}`.strip
402
+ end
403
+
404
+ def attach_volumes
405
+ return unless running?
406
+ volumes.each do |device,volume_id|
407
+ ### EC2 attach_volume
408
+ @base.ec2.attach_volume(volume_id, instance_id, device)
409
+ end
410
+ end
411
+
412
+ def remove_volume(volume_id, device)
413
+ task("Deleting #{device} #{volume_id}") do
414
+ ### EC2 delete_volume
415
+ @base.ec2.delete_volume(volume_id)
416
+ remove "volumes", "#{device}:#{volume_id}"
417
+ end
418
+ end
419
+
420
+ def add_volume(volume_id, device)
421
+ invalid("Server already has a volume on that device") if volumes[device]
422
+
423
+ add "volumes", "#{device}:#{volume_id}"
424
+
425
+ @base.ec2.attach_volume(volume_id, instance_id, device) if running?
426
+
427
+ volume_id
428
+ end
429
+
430
+ def connect_ssh
431
+ wait_for_ssh
432
+ system "chmod 600 #{group.keypair_file}"
433
+ system "ssh -i #{group.keypair_file} #{config["user"]}@#{hostname}"
434
+ end
435
+
436
+ def self.commit
437
+ ## FIXME
438
+ Config.group_dirs.each do |group_dir|
439
+ group = File.basename(group_dir)
440
+ next if Config.group and Config.group != group
441
+ puts "commiting #{group}"
442
+ doc = Config.couchdb.get(group) rescue {}
443
+ config = Config.read_config(group)
444
+ config['_id'] = group
445
+ config['_rev'] = doc['_rev'] if doc.has_key?('_rev')
446
+ response = Config.couchdb.save_doc(config)
447
+ doc = Config.couchdb.get(response['id'])
448
+
449
+ # walk subdirs and save as _attachments
450
+ ['files', 'templates', 'packages', 'scripts'].each { |subdir|
451
+ Dir["#{group_dir}/#{subdir}/*"].each do |f|
452
+ puts "storing attachment #{f}"
453
+ doc.put_attachment("#{subdir}/#{File.basename(f)}", File.read(f))
454
+ end
455
+ }
456
+ end
457
+ end
458
+
459
+ def ec2_instance_type
460
+ ec2_instance[:aws_instance_type] rescue nil
461
+ end
462
+
463
+ def ip
464
+ hostname || config["state_ip"]
465
+ end
466
+
467
+ def reload
468
+ @base.reload_ec2_instances
469
+ @base.servers_state.delete(name)
470
+ end
471
+
472
+ def user_data
473
+ <<USER_DATA
474
+ #!/bin/sh
475
+
476
+ export DEBIAN_FRONTEND="noninteractive"
477
+ export DEBIAN_PRIORITY="critical"
478
+ export SECRET='#{secret}'
479
+ apt-get update
480
+ apt-get install ruby rubygems ruby-dev irb libopenssl-ruby libreadline-ruby -y
481
+ gem install kuzushi --no-rdoc --no-ri
482
+ GEM_BIN=`ruby -r rubygems -e "puts Gem.bindir"`
483
+ echo "$GEM_BIN/kuzushi #{virgin? && "init" || "start"} '#{url}'" > /var/log/kuzushi.log
484
+ $GEM_BIN/kuzushi #{virgin? && "init" || "start"} '#{url}' >> /var/log/kuzushi.log 2>&1
485
+ USER_DATA
486
+ end
487
+
488
+ def url
489
+ @url ||= group.s3_url
490
+ end
491
+
492
+ def validate
493
+ ### EC2 create_security_group
494
+ @base.create_security_group
495
+
496
+ ### EC2 desctibe_key_pairs
497
+ k = @base.ec2.describe_key_pairs.detect { |kp| kp[:aws_key_name] == config["key_name"] }
498
+
499
+ if k.nil?
500
+ if config["key_name"] == "judo"
501
+ @base.create_keypair
502
+ else
503
+ raise "cannot use key_pair #{config["key_name"]} b/c it does not exist"
504
+ end
505
+ end
506
+ end
507
+
508
+ def <=>(s)
509
+ [group.name, name] <=> [s.group.name, s.name]
510
+ end
511
+
512
+ end
513
+ end