guignol 0.1.2.1 → 0.3.0

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.
Files changed (43) hide show
  1. data/.gitignore +2 -0
  2. data/.rspec +2 -1
  3. data/Gemfile.lock +29 -18
  4. data/LICENCE +26 -0
  5. data/README.md +67 -38
  6. data/Rakefile +0 -26
  7. data/bin/guignol +3 -33
  8. data/guignol.gemspec +4 -30
  9. data/lib/core_ext/array/collect_key.rb +6 -0
  10. data/lib/core_ext/hash/map_to_hash.rb +31 -0
  11. data/lib/guignol.rb +15 -35
  12. data/lib/guignol/commands/base.rb +53 -66
  13. data/lib/guignol/commands/clone.rb +49 -0
  14. data/lib/guignol/commands/create.rb +14 -33
  15. data/lib/guignol/commands/execute.rb +69 -0
  16. data/lib/guignol/commands/fix_dns.rb +12 -33
  17. data/lib/guignol/commands/kill.rb +18 -36
  18. data/lib/guignol/commands/list.rb +19 -45
  19. data/lib/guignol/commands/start.rb +14 -33
  20. data/lib/guignol/commands/stop.rb +18 -37
  21. data/lib/guignol/commands/uuid.rb +19 -32
  22. data/lib/guignol/configuration.rb +43 -0
  23. data/lib/guignol/connection.rb +33 -0
  24. data/lib/guignol/env.rb +19 -0
  25. data/lib/guignol/logger.rb +29 -0
  26. data/lib/guignol/models/base.rb +125 -0
  27. data/lib/guignol/models/instance.rb +244 -0
  28. data/lib/guignol/models/volume.rb +91 -0
  29. data/lib/guignol/shell.rb +27 -42
  30. data/lib/guignol/tty_spinner.rb +6 -29
  31. data/lib/guignol/version.rb +1 -27
  32. data/spec/guignol/configuration_spec.rb +72 -0
  33. data/spec/guignol/instance_spec.rb +48 -8
  34. data/spec/guignol/volume_spec.rb +17 -0
  35. data/spec/spec_helper.rb +12 -0
  36. data/tmp/.keepme +0 -0
  37. metadata +79 -52
  38. data/lib/guignol/array/collect_key.rb +0 -32
  39. data/lib/guignol/commands.rb +0 -48
  40. data/lib/guignol/commands/help.rb +0 -77
  41. data/lib/guignol/instance.rb +0 -270
  42. data/lib/guignol/shared.rb +0 -80
  43. data/lib/guignol/volume.rb +0 -124
@@ -0,0 +1,244 @@
1
+
2
+ require 'yaml'
3
+ require 'fog'
4
+ require 'active_support/core_ext/hash/slice'
5
+ require 'guignol'
6
+ require 'guignol/models/base'
7
+ require 'guignol/models/volume'
8
+
9
+ require 'pry'
10
+
11
+ module Guignol::Models
12
+ class Instance < Base
13
+ class Error < Exception; end
14
+
15
+ def initialize(name, options)
16
+ super
17
+ subject.username = options[:username] if options[:username] && exists?
18
+ end
19
+
20
+ def fqdn
21
+ name and domain and "#{name}.#{domain}"
22
+ end
23
+
24
+
25
+ def create
26
+ log "server already exists" and return self if exist?
27
+
28
+ create_options = Guignol::DefaultServerOptions.merge options.slice(:image_id, :flavor_id, :key_name, :security_group_ids, :user_data, :username)
29
+
30
+ # check for pre-existing volume(s). if any exist, add their AZ to the server's options
31
+ zones = create_options[:volumes].map { |name,volume_options| Volume.new(name, volume_options).availability_zone }.compact.uniq
32
+ if zones.size > 1
33
+ raise "pre-existing volumes volumes are in different availability zones"
34
+ elsif zones.size == 1
35
+ log "using AZ '#{zones.first}' since volumes already exist"
36
+ create_options[:availability_zone] = zones.first
37
+ end
38
+
39
+ log "building server..."
40
+ set_subject connection.servers.create(create_options)
41
+ setup
42
+ log "created as #{subject.dns_name}"
43
+
44
+ return self
45
+ rescue Exception => e
46
+ log "error while creating", :error => e
47
+ destroy
48
+ raise
49
+ end
50
+
51
+
52
+ def start
53
+ log "server doesn't exist (ignoring)" and return unless exist?
54
+ wait_for_state_transitions
55
+
56
+ if subject.state != "stopped"
57
+ log "server #{subject.state}."
58
+ else
59
+ log "starting server..."
60
+ subject.start
61
+ setup
62
+ log "server started"
63
+ end
64
+ return self
65
+ rescue Exception => e
66
+ log "error while starting", :error => e
67
+ stop
68
+ raise
69
+ end
70
+
71
+ def stop
72
+ wait_for_state_transitions
73
+ reload
74
+ if !exist?
75
+ log "server doesn't exist (ignoring)."
76
+ elsif subject.state != "running"
77
+ log "server #{subject.state}."
78
+ else
79
+ log "stopping server..."
80
+ remove_dns
81
+ subject.stop
82
+ wait_for_state 'stopped', 'terminated'
83
+ end
84
+ return self
85
+ end
86
+
87
+
88
+ def destroy
89
+ log "server doesn't exist (ignoring)." and return self unless exist?
90
+
91
+ log "tearing server down..."
92
+ remove_dns
93
+ subject.destroy
94
+ wait_for_state 'stopped', 'terminated', 'nonexistent'
95
+ # FIXME: remove tags here
96
+ set_subject nil
97
+ return self
98
+ end
99
+
100
+
101
+ def update_dns
102
+ return unless options[:domain]
103
+ if subject.nil? || subject.state != 'running'
104
+ log "server not running, not updating DNS"
105
+ return
106
+ end
107
+ log "updating DNS"
108
+
109
+ unless dns_zone
110
+ log "DNS zone does not exist"
111
+ return self
112
+ end
113
+
114
+ if record = dns_record
115
+ if dns_record_matches?(record)
116
+ log "DNS record already exists"
117
+ return self
118
+ else
119
+ log "warning, while creating, DNS record exists but points to wrong server (fixing)"
120
+ record.destroy
121
+ end
122
+ end
123
+
124
+ dns_zone.records.create(:name => fqdn, :type => 'CNAME', :value => subject.dns_name, :ttl => 5)
125
+ log "#{fqdn} -> #{subject.dns_name}"
126
+ return self
127
+ end
128
+
129
+
130
+ private
131
+
132
+
133
+ def default_options
134
+ { :volumes => {} }
135
+ end
136
+
137
+
138
+ def domain
139
+ options[:domain]
140
+ end
141
+
142
+
143
+ def update_tags
144
+ log "updating server tags"
145
+ tags = { 'Name' => name, 'Domain' => domain, 'UUID' => uuid }
146
+ response = connection.create_tags(subject.id, tags)
147
+ raise Error.new("updating server tags") unless response.status == 200
148
+ return self
149
+ end
150
+
151
+
152
+ def update_root_volume_tags
153
+ log "updating root volume tags"
154
+ tags = { 'Name' => "#{name}-root", 'UUID' => uuid }
155
+
156
+ # we assume the root volume is the first in the block device map
157
+ if blockdev = subject.block_device_mapping.first
158
+ root_volume_id = blockdev['volumeId']
159
+ response = connection.create_tags(root_volume_id, tags)
160
+ raise Error.new("updating root volume tags") unless response.status == 200
161
+ end
162
+ return self
163
+ end
164
+
165
+
166
+ def update_volumes
167
+ options[:volumes].each_pair do |name,options|
168
+ options[:availability_zone] = subject.availability_zone
169
+ Volume.new(name, options).attach(subject.id)
170
+ end
171
+ end
172
+
173
+
174
+ # shared between create and start
175
+ def setup
176
+ update_tags
177
+ log "waiting for public dns to be set up..."
178
+ wait_for { subject.dns_name }
179
+ update_dns
180
+ update_volumes
181
+ update_root_volume_tags
182
+ wait_for_state 'running'
183
+ return self
184
+ end
185
+
186
+
187
+ def remove_dns
188
+ return unless domain
189
+ log "removing dns record"
190
+
191
+ unless dns_zone
192
+ log "dns zone does not exist"
193
+ return self
194
+ end
195
+
196
+ if record = dns_record
197
+ unless dns_record_matches?(record)
198
+ log "warning, while removing, DNS record exist but does not point to the current server"
199
+ end
200
+ record.destroy
201
+ end
202
+
203
+ return self
204
+ end
205
+
206
+
207
+ def wait_for_state_transitions
208
+ return unless exists?
209
+ return unless %w(stopping pending).include? subject.state
210
+ log "waiting for state transition from '#{subject.state}' to complete"
211
+ wait_for { subject.state != 'pending' } if subject.state != 'pending'
212
+ wait_for { subject.state != 'stopping' } if subject.state != 'stopping'
213
+ end
214
+
215
+
216
+ def dns_connection
217
+ @@dns_connection ||= Fog::DNS.new(:provider => :aws)
218
+ end
219
+
220
+
221
+ def dns_zone
222
+ @dns_zone ||= dns_connection.zones.find { |zone| zone.domain == domain }
223
+ end
224
+
225
+ def dns_record
226
+ dns_zone.records.find { |record| record.name == fqdn }
227
+ end
228
+
229
+ def dns_record_matches?(record)
230
+ !!record and record.value.any? { |dns_name| dns_name == subject.dns_name }
231
+ end
232
+
233
+
234
+
235
+
236
+ # walks the connection for matching servers, return
237
+ # either the found server of nil
238
+ def find_subject
239
+ connection.servers.
240
+ select { |s| s.state != 'terminated' }.
241
+ find { |s| s.tags['UUID'] == uuid }
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,91 @@
1
+
2
+ require 'fog'
3
+ require 'active_support/core_ext/hash/slice'
4
+ require 'guignol'
5
+ require 'guignol/models/base'
6
+
7
+
8
+ module Guignol::Models
9
+ class Volume < Base
10
+ class Error < Exception; end
11
+
12
+
13
+ def availability_zone
14
+ subject && subject.availability_zone
15
+ end
16
+
17
+
18
+ def create
19
+ log "volume already exists" and return self if exist?
20
+
21
+ log "creating volume"
22
+ create_options = Guignol::DefaultVolumeOptions.merge options.slice(:availability_zone, :size, :snapshot, :delete_on_termination)
23
+ set_subject connection.volumes.create(create_options)
24
+ update_tags
25
+
26
+ wait_for_state 'available'
27
+ return self
28
+ rescue Exception => e
29
+ log "error while creating (#{e.class.name})"
30
+ destroy
31
+ raise
32
+ end
33
+
34
+
35
+ def destroy
36
+ return self unless exist?
37
+
38
+ log "destroying volume"
39
+ subject.destroy
40
+ wait_for_state 'deleted'
41
+ # FIXME: remove tags here
42
+
43
+ set_subject nil
44
+ return self
45
+ end
46
+
47
+
48
+ def attach(server_id)
49
+ exist? or create
50
+ subject.reload
51
+
52
+ if subject.server_id == server_id
53
+ if subject.device == options[:dev]
54
+ log "volume already attached"
55
+ return
56
+ else
57
+ log "error: volume attached to device #{subject.device} instead of options[:dev]"
58
+ raise Error.new('already attached')
59
+ end
60
+ end
61
+
62
+ response = connection.attach_volume(server_id, subject.id, options[:dev])
63
+ response.status == 200 or raise Error.new('failed to attach volume')
64
+ update_tags
65
+ return self
66
+ end
67
+
68
+
69
+ private
70
+
71
+
72
+ def find_subject
73
+ connection.volumes.
74
+ select { |s| %w(in-use available).include?(s.state) }.
75
+ find { |s| s.tags['UUID'] == uuid }
76
+ end
77
+
78
+
79
+ def update_tags
80
+ log "updating tags"
81
+ tags = { 'Name' => name, 'UUID' => uuid }
82
+ response = connection.create_tags(subject.id, tags)
83
+ unless response.status == 200
84
+ log "updating tags failed"
85
+ destroy and raise Error.new('updating tags failed')
86
+ end
87
+ end
88
+
89
+
90
+ end
91
+ end
@@ -1,50 +1,35 @@
1
- # Copyright (c) 2012, HouseTrip SA.
2
- # All rights reserved.
3
- #
4
- # Redistribution and use in source and binary forms, with or without
5
- # modification, are permitted provided that the following conditions are met:
6
- #
7
- # 1. Redistributions of source code must retain the above copyright notice, this
8
- # list of conditions and the following disclaimer.
9
- # 2. Redistributions in binary form must reproduce the above copyright notice,
10
- # this list of conditions and the following disclaimer in the documentation
11
- # and/or other materials provided with the distribution.
12
- #
13
- # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
14
- # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
15
- # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
16
- # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
17
- # ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
18
- # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
19
- # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
20
- # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
21
- # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
22
- # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
23
- #
24
- # The views and conclusions contained in the software and documentation are those
25
- # of the authors and should not be interpreted as representing official policies,
26
- # either expressed or implied, of the authors.
27
-
28
- require 'term/ansicolor'
29
- require 'guignol/commands'
1
+ require 'thor'
30
2
 
31
3
  module Guignol
32
- class Shell
33
- include Singleton
4
+ class Shell < Thor
5
+ def help(*args)
6
+ shell.say
7
+ shell.say "Guignol -- manipulate EC2 instances from your command line.", :cyan
8
+ shell.say
9
+ super
10
+ end
11
+
12
+ def self.start
13
+ super(ARGV, :shell => shared_shell)
14
+ end
34
15
 
35
- def execute(command_name, *argv)
36
- command_name ||= 'help'
37
- unless command = Commands::Map[command_name]
38
- Commands::Help.new.run
39
- die "no such command '#{command_name}'."
40
- end
41
- command.new(*argv).run
42
- exit 0
16
+ def self.shared_shell
17
+ @shared_shell ||= Thor::Base.shell.new
43
18
  end
44
19
 
45
- def die(message)
46
- puts Term::ANSIColor.red("fatal: #{message}")
47
- exit 1
20
+ def self.exit_on_failure?
21
+ true
48
22
  end
49
23
  end
50
24
  end
25
+
26
+
27
+ require 'guignol/commands/create'
28
+ require 'guignol/commands/kill'
29
+ require 'guignol/commands/start'
30
+ require 'guignol/commands/stop'
31
+ require 'guignol/commands/list'
32
+ require 'guignol/commands/uuid'
33
+ require 'guignol/commands/fix_dns'
34
+ require 'guignol/commands/clone'
35
+ require 'guignol/commands/execute'
@@ -1,31 +1,6 @@
1
- # Copyright (c) 2012, HouseTrip SA.
2
- # All rights reserved.
3
- #
4
- # Redistribution and use in source and binary forms, with or without
5
- # modification, are permitted provided that the following conditions are met:
6
- #
7
- # 1. Redistributions of source code must retain the above copyright notice, this
8
- # list of conditions and the following disclaimer.
9
- # 2. Redistributions in binary form must reproduce the above copyright notice,
10
- # this list of conditions and the following disclaimer in the documentation
11
- # and/or other materials provided with the distribution.
12
- #
13
- # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
14
- # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
15
- # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
16
- # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
17
- # ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
18
- # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
19
- # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
20
- # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
21
- # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
22
- # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
23
- #
24
- # The views and conclusions contained in the software and documentation are those
25
- # of the authors and should not be interpreted as representing official policies,
26
- # either expressed or implied, of the authors.
27
1
 
28
2
  require 'singleton'
3
+ require 'guignol'
29
4
 
30
5
  module Guignol
31
6
  class TtySpinner
@@ -38,9 +13,11 @@ module Guignol
38
13
  end
39
14
 
40
15
  def spin!
41
- $stderr.write(Chars[@state % Chars.size] + "\r")
42
- $stderr.flush
43
- @state += 1
16
+ if $stderr.tty? && !Guignol.env.test?
17
+ $stderr.write(Chars[@state % Chars.size] + "\r")
18
+ $stderr.flush
19
+ @state += 1
20
+ end
44
21
  Thread.pass
45
22
  end
46
23