pocketknife 0.0.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.
@@ -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