pocketknife 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,85 @@
1
+ class Pocketknife
2
+ # == NodeError
3
+ #
4
+ # An error with a {Pocketknife::Node}. This is meant to be subclassed by a more specific error.
5
+ class NodeError < StandardError
6
+ # The name of the node.
7
+ attr_accessor :node
8
+
9
+ # Instantiate a new exception.
10
+ #
11
+ # @param [String] message The message to display.
12
+ # @param [String] node The name of the unknown node.
13
+ def initialize(message, node)
14
+ self.node = node
15
+ super(message)
16
+ end
17
+ end
18
+
19
+ # == NoSuchNode
20
+ #
21
+ # Exception raised when asked to perform an operation on an unknown node.
22
+ class NoSuchNode < NodeError
23
+ end
24
+
25
+ # == UnsupportedInstallationPlatform
26
+ #
27
+ # Exception raised when asked to install Chef on a node with an unsupported platform.
28
+ class UnsupportedInstallationPlatform < NodeError
29
+ end
30
+
31
+ # == NotInstalling
32
+ #
33
+ # Exception raised when Chef is not available ohn a node, but user asked not to install it.
34
+ class NotInstalling < NodeError
35
+ end
36
+
37
+ # == ExecutionError
38
+ #
39
+ # Exception raised when something goes wrong executing commands against remote host.
40
+ class ExecutionError < NodeError
41
+ # Command that failed.
42
+ attr_accessor :command
43
+
44
+ # Cause of exception, a {Rye:Err}.
45
+ attr_accessor :cause
46
+
47
+ # Was execution's output shown immediately? If so, don't include output in message.
48
+ attr_accessor :immediate
49
+
50
+ # Instantiates a new exception.
51
+ #
52
+ # @param [String] node The name of the unknown node.
53
+ # @param [String] command The command that failed.
54
+ # @param [Rye::Err] cause The actual exception thrown.
55
+ # @param [Boolean] immediate Was execution's output shown immediately? If so, don't include output in message.
56
+ def initialize(node, command, cause, immediate)
57
+ self.command = command
58
+ self.cause = cause
59
+ self.immediate = immediate
60
+
61
+ message = <<-HERE.chomp
62
+ Failed while executing commands on node '#{node}'
63
+ - COMMAND: #{command}
64
+ - EXIT STATUS: #{cause.exit_status}
65
+ HERE
66
+
67
+ unless immediate
68
+ message << <<-HERE.chomp
69
+
70
+ - STDOUT: #{cause.stdout.to_s.strip}
71
+ - STDERR: #{cause.stderr.to_s.strip}
72
+ HERE
73
+ end
74
+
75
+ super(message, node)
76
+ end
77
+
78
+ # Returns exit status.
79
+ #
80
+ # @return [Integer] Exit status from execution.
81
+ def exit_status
82
+ return self.cause.exit_status
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,355 @@
1
+ class Pocketknife
2
+ # == Node
3
+ #
4
+ # A node represents a remote computer that will be managed with Pocketknife and <tt>chef-solo</tt>. It can connect to a node, execute commands on it, install the stack, and upload and apply configurations to it.
5
+ class Node
6
+ # String name of the node.
7
+ attr_accessor :name
8
+
9
+ # Instance of a {Pocketknife}.
10
+ attr_accessor :pocketknife
11
+
12
+ # Instance of Rye::Box connection, cached by {#connection}.
13
+ attr_accessor :connection_cache
14
+
15
+ # Hash with information about platform, cached by {#platform}.
16
+ attr_accessor :platform_cache
17
+
18
+ # Initialize a new node.
19
+ #
20
+ # @param [String] name A node name.
21
+ # @param [Pocketknife] pocketknife
22
+ def initialize(name, pocketknife)
23
+ self.name = name
24
+ self.pocketknife = pocketknife
25
+ self.connection_cache = nil
26
+ end
27
+
28
+ # Returns a Rye::Box connection.
29
+ #
30
+ # Caches result to {#connection_cache}.
31
+ def connection
32
+ return self.connection_cache ||= begin
33
+ rye = Rye::Box.new(self.name, :user => "root")
34
+ rye.disable_safe_mode
35
+ rye
36
+ end
37
+ end
38
+
39
+ # Displays status message.
40
+ #
41
+ # @param [String] message The message to display.
42
+ # @param [Boolean] importance How important is this? +true+ means important, +nil+ means normal, +false+ means unimportant.
43
+ def say(message, importance=nil)
44
+ self.pocketknife.say("* #{self.name}: #{message}", importance)
45
+ end
46
+
47
+ # Returns path to this node's <tt>nodes/NAME.json</tt> file, used as <tt>node.json</tt> by <tt>chef-solo</tt>.
48
+ #
49
+ # @return [Pathname]
50
+ def local_node_json_pathname
51
+ return Pathname.new("nodes") + "#{self.name}.json"
52
+ end
53
+
54
+ # Does this node have the given executable?
55
+ #
56
+ # @param [String] executable A name of an executable, e.g. <tt>chef-solo</tt>.
57
+ # @return [Boolean] Has executable?
58
+ def has_executable?(executable)
59
+ begin
60
+ self.connection.execute(%{which "#{executable}" && test -x `which "#{executable}"`})
61
+ return true
62
+ rescue Rye::Err
63
+ return false
64
+ end
65
+ end
66
+
67
+ # Returns information describing the node.
68
+ #
69
+ # The information is formatted similar to this:
70
+ # {
71
+ # :distributor=>"Ubuntu", # String with distributor name
72
+ # :codename=>"maverick", # String with release codename
73
+ # :release=>"10.10", # String with release number
74
+ # :version=>10.1 # Float with release number
75
+ # }
76
+ #
77
+ # @return [Hash<String, Object] Return a hash describing the node, see above.
78
+ # @raise [UnsupportedInstallationPlatform] Raised if there's no installation information for this platform.
79
+ def platform
80
+ return self.platform_cache ||= begin
81
+ lsb_release = "/etc/lsb-release"
82
+ begin
83
+ output = self.connection.cat(lsb_release).to_s
84
+ result = {}
85
+ result[:distributor] = output[/DISTRIB_ID\s*=\s*(.+?)$/, 1]
86
+ result[:release] = output[/DISTRIB_RELEASE\s*=\s*(.+?)$/, 1]
87
+ result[:codename] = output[/DISTRIB_CODENAME\s*=\s*(.+?)$/, 1]
88
+ result[:version] = result[:release].to_f
89
+
90
+ if result[:distributor] && result[:release] && result[:codename] && result[:version]
91
+ return result
92
+ else
93
+ raise UnsupportedInstallationPlatform.new("Can't install on node '#{self.name}' with invalid '#{lsb_release}' file", self.name)
94
+ end
95
+ rescue Rye::Err
96
+ raise UnsupportedInstallationPlatform.new("Can't install on node '#{self.name}' without '#{lsb_release}'", self.name)
97
+ end
98
+ end
99
+ end
100
+
101
+ # Installs Chef and its dependencies on a node if needed.
102
+ #
103
+ # @raise [NotInstalling] Raised if Chef isn't installed, but user didn't allow installation.
104
+ # @raise [UnsupportedInstallationPlatform] Raised if there's no installation information for this platform.
105
+ def install
106
+ unless self.has_executable?("chef-solo")
107
+ case self.pocketknife.can_install
108
+ when nil
109
+ # Prompt for installation
110
+ print "? #{self.name}: Chef not found. Install it and its dependencies? (Y/n) "
111
+ STDOUT.flush
112
+ answer = STDIN.gets.chomp
113
+ case answer
114
+ when /^y/i, ''
115
+ # Continue with install
116
+ else
117
+ raise NotInstalling.new("Chef isn't installed on node '#{self.name}', but user doesn't want to install it.", self.name)
118
+ end
119
+ when true
120
+ # User wanted us to install
121
+ else
122
+ # Don't install
123
+ raise NotInstalling.new("Chef isn't installed on node '#{self.name}', but user doesn't want to install it.", self.name)
124
+ end
125
+
126
+ unless self.has_executable?("ruby")
127
+ self.install_ruby
128
+ end
129
+
130
+ unless self.has_executable?("gem")
131
+ self.install_rubygems
132
+ end
133
+
134
+ self.install_chef
135
+ end
136
+ end
137
+
138
+ # Installs Chef on the remote node.
139
+ def install_chef
140
+ self.say("Installing chef...")
141
+ self.execute("gem install --no-rdoc --no-ri chef", true)
142
+ self.say("Installed chef", false)
143
+ end
144
+
145
+ # Installs Rubygems on the remote node.
146
+ def install_rubygems
147
+ self.say("Installing rubygems...")
148
+ self.execute(<<-HERE, true)
149
+ cd /root &&
150
+ rm -rf rubygems-1.3.7 rubygems-1.3.7.tgz &&
151
+ wget http://production.cf.rubygems.org/rubygems/rubygems-1.3.7.tgz &&
152
+ tar zxf rubygems-1.3.7.tgz &&
153
+ cd rubygems-1.3.7 &&
154
+ ruby setup.rb --no-format-executable &&
155
+ rm -rf rubygems-1.3.7 rubygems-1.3.7.tgz
156
+ HERE
157
+ self.say("Installed rubygems", false)
158
+ end
159
+
160
+ # Installs Ruby on the remote node.
161
+ def install_ruby
162
+ command = \
163
+ case self.platform[:distributor].downcase
164
+ when /ubuntu/, /debian/, /gnu\/linux/
165
+ "DEBIAN_FRONTEND=noninteractive apt-get --yes install ruby ruby-dev libopenssl-ruby irb build-essential wget ssl-cert"
166
+ when /centos/, /red hat/, /scientific linux/
167
+ "yum -y install ruby ruby-shadow gcc gcc-c++ ruby-devel wget"
168
+ else
169
+ raise UnsupportedInstallationPlatform.new("Can't install on node '#{self.name}' with unknown distrubtor: `#{self.platform[:distrubtor]}`", self.name)
170
+ end
171
+
172
+ self.say("Installing ruby...")
173
+ self.execute(command, true)
174
+ self.say("Installed ruby", false)
175
+ end
176
+
177
+ # Prepares an upload, by creating a cache of shared files used by all nodes.
178
+ #
179
+ # IMPORTANT: This will create files and leave them behind. You should use the block syntax or manually call {cleanup_upload} when done.
180
+ #
181
+ # If an optional block is supplied, calls {cleanup_upload} automatically when done. This is typically used like:
182
+ #
183
+ # Node.prepare_upload do
184
+ # mynode.upload
185
+ # end
186
+ #
187
+ # @yield [] Prepares the upload, executes the block, and cleans up the upload when done.
188
+ def self.prepare_upload(&block)
189
+ begin
190
+ # TODO either do this in memory or scope this to the PID to allow concurrency
191
+ TMP_SOLO_RB.open("w") {|h| h.write(SOLO_RB_CONTENT)}
192
+ TMP_CHEF_SOLO_APPLY.open("w") {|h| h.write(CHEF_SOLO_APPLY_CONTENT)}
193
+ TMP_TARBALL.open("w") do |handle|
194
+ Archive::Tar::Minitar.pack(
195
+ [
196
+ VAR_POCKETKNIFE_COOKBOOKS.basename.to_s,
197
+ VAR_POCKETKNIFE_SITE_COOKBOOKS.basename.to_s,
198
+ VAR_POCKETKNIFE_ROLES.basename.to_s,
199
+ TMP_SOLO_RB.to_s,
200
+ TMP_CHEF_SOLO_APPLY.to_s
201
+ ],
202
+ handle
203
+ )
204
+ end
205
+ rescue Exception => e
206
+ cleanup_upload
207
+ raise e
208
+ end
209
+
210
+ if block
211
+ begin
212
+ yield(self)
213
+ ensure
214
+ cleanup_upload
215
+ end
216
+ end
217
+ end
218
+
219
+ # Cleans up cache of shared files uploaded to all nodes. This cache is created by the {prepare_upload} method.
220
+ def self.cleanup_upload
221
+ [
222
+ TMP_TARBALL,
223
+ TMP_SOLO_RB,
224
+ TMP_CHEF_SOLO_APPLY
225
+ ].each do |path|
226
+ path.unlink if path.exist?
227
+ end
228
+ end
229
+
230
+ # Uploads configuration information to node.
231
+ #
232
+ # IMPORTANT: You must first call {prepare_upload} to create the shared files that will be uploaded.
233
+ def upload
234
+ self.say("Uploading configuration...")
235
+
236
+ self.say("Removing old files...", false)
237
+ self.execute <<-HERE
238
+ umask 0377 &&
239
+ rm -rf "#{ETC_CHEF}" "#{VAR_POCKETKNIFE}" "#{VAR_POCKETKNIFE_CACHE}" "#{CHEF_SOLO_APPLY}" "#{CHEF_SOLO_APPLY_ALIAS}" &&
240
+ mkdir -p "#{ETC_CHEF}" "#{VAR_POCKETKNIFE}" "#{VAR_POCKETKNIFE_CACHE}" "#{CHEF_SOLO_APPLY.dirname}"
241
+ HERE
242
+
243
+ self.say("Uploading new files...", false)
244
+ self.connection.file_upload(self.local_node_json_pathname.to_s, NODE_JSON.to_s)
245
+ self.connection.file_upload(TMP_TARBALL.to_s, VAR_POCKETKNIFE_TARBALL.to_s)
246
+
247
+ self.say("Installing new files...", false)
248
+ self.execute <<-HERE, true
249
+ cd "#{VAR_POCKETKNIFE_CACHE}" &&
250
+ tar xf "#{VAR_POCKETKNIFE_TARBALL}" &&
251
+ chmod -R u+rwX,go= . &&
252
+ chown -R root:root . &&
253
+ mv "#{TMP_SOLO_RB}" "#{SOLO_RB}" &&
254
+ mv "#{TMP_CHEF_SOLO_APPLY}" "#{CHEF_SOLO_APPLY}" &&
255
+ chmod u+x "#{CHEF_SOLO_APPLY}" &&
256
+ ln -s "#{CHEF_SOLO_APPLY.basename}" "#{CHEF_SOLO_APPLY_ALIAS}" &&
257
+ rm "#{VAR_POCKETKNIFE_TARBALL}" &&
258
+ mv * "#{VAR_POCKETKNIFE}"
259
+ HERE
260
+
261
+ self.say("Finished uploading!", false)
262
+ end
263
+
264
+ # Applies the configuration to the node. Installs Chef, Ruby and Rubygems if needed.
265
+ def apply
266
+ self.install
267
+
268
+ self.say("Applying configuration...", true)
269
+ command = "chef-solo -j #{NODE_JSON}"
270
+ command << " -l debug" if self.pocketknife.verbosity == true
271
+ self.execute(command, true)
272
+ self.say("Finished applying!")
273
+ end
274
+
275
+ # Deploys the configuration to the node, which calls {#upload} and {#apply}.
276
+ def deploy
277
+ self.upload
278
+ self.apply
279
+ end
280
+
281
+ # Executes commands on the external node.
282
+ #
283
+ # @param [String] commands Shell commands to execute.
284
+ # @param [Boolean] immediate Display execution information immediately to STDOUT, rather than returning it as an object when done.
285
+ # @return [Rye::Rap] A result object describing the completed execution.
286
+ # @raise [ExecutionError] Raised if something goes wrong with execution.
287
+ def execute(commands, immediate=false)
288
+ self.say("Executing:\n#{commands}", false)
289
+ if immediate
290
+ self.connection.stdout_hook {|line| puts line}
291
+ end
292
+ return self.connection.execute("(#{commands}) 2>&1")
293
+ rescue Rye::Err => e
294
+ raise Pocketknife::ExecutionError.new(self.name, commands, e, immediate)
295
+ ensure
296
+ self.connection.stdout_hook = nil
297
+ end
298
+
299
+ # Remote path to Chef's settings
300
+ # @private
301
+ ETC_CHEF = Pathname.new("/etc/chef")
302
+ # Remote path to solo.rb
303
+ # @private
304
+ SOLO_RB = ETC_CHEF + "solo.rb"
305
+ # Remote path to node.json
306
+ # @private
307
+ NODE_JSON = ETC_CHEF + "node.json"
308
+ # Remote path to pocketknife's deployed configuration
309
+ # @private
310
+ VAR_POCKETKNIFE = Pathname.new("/var/local/pocketknife")
311
+ # Remote path to pocketknife's cache
312
+ # @private
313
+ VAR_POCKETKNIFE_CACHE = VAR_POCKETKNIFE + "cache"
314
+ # Remote path to temporary tarball containing uploaded files.
315
+ # @private
316
+ VAR_POCKETKNIFE_TARBALL = VAR_POCKETKNIFE_CACHE + "pocketknife.tmp"
317
+ # Remote path to pocketknife's cookbooks
318
+ # @private
319
+ VAR_POCKETKNIFE_COOKBOOKS = VAR_POCKETKNIFE + "cookbooks"
320
+ # Remote path to pocketknife's site-cookbooks
321
+ # @private
322
+ VAR_POCKETKNIFE_SITE_COOKBOOKS = VAR_POCKETKNIFE + "site-cookbooks"
323
+ # Remote path to pocketknife's roles
324
+ # @private
325
+ VAR_POCKETKNIFE_ROLES = VAR_POCKETKNIFE + "roles"
326
+ # Content of the solo.rb file
327
+ # @private
328
+ SOLO_RB_CONTENT = <<-HERE
329
+ file_cache_path "#{VAR_POCKETKNIFE_CACHE}"
330
+ cookbook_path ["#{VAR_POCKETKNIFE_COOKBOOKS}", "#{VAR_POCKETKNIFE_SITE_COOKBOOKS}"]
331
+ role_path "#{VAR_POCKETKNIFE_ROLES}"
332
+ HERE
333
+ # Remote path to chef-solo-apply
334
+ # @private
335
+ CHEF_SOLO_APPLY = Pathname.new("/usr/local/sbin/chef-solo-apply")
336
+ # Remote path to csa
337
+ # @private
338
+ CHEF_SOLO_APPLY_ALIAS = CHEF_SOLO_APPLY.dirname + "csa"
339
+ # Content of the chef-solo-apply file
340
+ # @private
341
+ CHEF_SOLO_APPLY_CONTENT = <<-HERE
342
+ #!/bin/sh
343
+ chef-solo -j #{NODE_JSON} "$@"
344
+ HERE
345
+ # Local path to solo.rb that will be included in the tarball
346
+ # @private
347
+ TMP_SOLO_RB = Pathname.new("solo.rb.tmp")
348
+ # Local path to chef-solo-apply.rb that will be included in the tarball
349
+ # @private
350
+ TMP_CHEF_SOLO_APPLY = Pathname.new("chef-solo-apply.tmp")
351
+ # Local path to the tarball to upload to the remote node containing shared files
352
+ # @private
353
+ TMP_TARBALL = Pathname.new("pocketknife.tmp")
354
+ end
355
+ end
@@ -0,0 +1,93 @@
1
+ class Pocketknife
2
+ # == NodeManager
3
+ #
4
+ # This class finds, validates and manages {Pocketknife::Node} instances for a {Pocketknife}.
5
+ class NodeManager
6
+ # Instance of a Pocketknife.
7
+ attr_accessor :pocketknife
8
+
9
+ # Hash of Node instances by their name.
10
+ attr_accessor :nodes
11
+
12
+ # Array of known nodes, used as cache by {#known_nodes}.
13
+ attr_accessor :known_nodes_cache
14
+
15
+ # Instantiate a new manager.
16
+ #
17
+ # @param [Pocketknife] pocketknife
18
+ def initialize(pocketknife)
19
+ self.pocketknife = pocketknife
20
+ self.nodes = {}
21
+ self.known_nodes_cache = nil
22
+ end
23
+
24
+ # Return a node. Uses cached value in {#known_nodes_cache} if available.
25
+ #
26
+ # @param [String] name A node name to find, can be an abbrevation.
27
+ # @return [Pocketknife::Node]
28
+ def find(name)
29
+ hostname = self.hostname_for(name)
30
+ return self.nodes[hostname] ||= begin
31
+ node = Node.new(hostname, self.pocketknife)
32
+ end
33
+ end
34
+
35
+ # Returns a node's hostname based on its abbreviated node name.
36
+ #
37
+ # The hostname is derived from the filename that defines it. For example, the <tt>nodes/henrietta.swa.gov.it.json</tt> file defines a node with the hostname <tt>henrietta.swa.gov.it</tt>. This node can can be also be referred to as <tt>henrietta.swa.gov</tt>, <tt>henrietta.swa</tt>, or <tt>henrietta</tt>.
38
+ #
39
+ # The abbreviated node name given must match only one node exactly. For example, you'll get a {Pocketknife::NoSuchNode} if you ask for an abbreviated node by the name of <tt>giovanni</tt> when there are nodes called <tt>giovanni.boldini.it</tt> and <tt>giovanni.bellini.it</tt> -- you'd need to ask using a more specific name, such as <tt>giovanni.boldini</tt>.
40
+ #
41
+ # @param [String] abbreviated_name A node name, which may be abbreviated, e.g. "henrietta".
42
+ # @return [String] The complete node name, e.g. "henrietta.swa.gov.it"
43
+ # @raise [NoSuchNode] A hostname could not be found for this node, either because the node doesn't exist or the abbreviated form isn't unique enough.
44
+ def hostname_for(abbreviated_name)
45
+ if self.known_nodes.include?(abbreviated_name)
46
+ return abbreviated_name
47
+ else
48
+ matches = self.known_nodes.grep(/^#{abbreviated_name}\./)
49
+ case matches.length
50
+ when 1
51
+ return matches.first
52
+ when 0
53
+ raise NoSuchNode.new("Can't find node named '#{abbreviated_name}'", abbreviated_name)
54
+ else
55
+ raise NoSuchNode.new("Can't find unique node named '#{abbreviated_name}', this matches nodes: #{matches.join(', ')}", abbreviated_name)
56
+ end
57
+ end
58
+ end
59
+
60
+ # Asserts that the specified nodes are known to Pocketknife.
61
+ #
62
+ # @param [Array<String>] nodes A list of node names.
63
+ # @raise [Pocketknife::NoSuchNode] Raised if there's an unknown node.
64
+ def assert_known(names)
65
+ for name in names
66
+ # This will raise a NoSuchNode exception if there's a problem.
67
+ self.hostname_for(name)
68
+ end
69
+ end
70
+
71
+ # Returns the known node names for this project.
72
+ #
73
+ # Caches results to {#known_nodes_cache}.
74
+ #
75
+ # @return [Array<String>] The node names.
76
+ # @raise [Errno::ENOENT] Raised if can't find the +nodes+ directory.
77
+ def known_nodes
78
+ return(self.known_nodes_cache ||= begin
79
+ dir = Pathname.new("nodes")
80
+ json_extension = /\.json$/
81
+ if dir.directory?
82
+ dir.entries.select do |path|
83
+ path.to_s =~ json_extension
84
+ end.map do |path|
85
+ path.to_s.sub(json_extension, "")
86
+ end
87
+ else
88
+ raise Errno::ENOENT, "Can't find 'nodes' directory."
89
+ end
90
+ end)
91
+ end
92
+ end
93
+ end