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.
- data/.document +5 -0
- data/.yardopts +1 -0
- data/Gemfile +13 -0
- data/Gemfile.lock +52 -0
- data/LICENSE.txt +23 -0
- data/README.md +115 -0
- data/Rakefile +56 -0
- data/bin/pocketknife +15 -0
- data/lib/pocketknife.rb +252 -0
- data/lib/pocketknife/errors.rb +85 -0
- data/lib/pocketknife/node.rb +355 -0
- data/lib/pocketknife/node_manager.rb +93 -0
- data/lib/pocketknife/version.rb +13 -0
- data/pocketknife.gemspec +82 -0
- data/spec/pocketknife_execution_error_spec.rb +61 -0
- data/spec/pocketknife_node_manager_spec.rb +37 -0
- data/spec/pocketknife_node_spec.rb +299 -0
- data/spec/pocketknife_spec.rb +94 -0
- data/spec/spec_helper.rb +12 -0
- data/spec/support/libraries.rb +3 -0
- data/spec/support/mkproject.rb +32 -0
- metadata +214 -0
@@ -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
|