pocketknife 0.1.0 → 0.2.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.
- data/.travis.yml +5 -0
- data/CHANGES.md +14 -0
- data/Gemfile +24 -9
- data/README.md +48 -18
- data/Rakefile +44 -4
- data/lib/pocketknife.rb +64 -15
- data/lib/pocketknife/errors.rb +101 -61
- data/lib/pocketknife/node.rb +131 -40
- data/lib/pocketknife/node_manager.rb +12 -11
- data/lib/pocketknife/version.rb +6 -1
- data/lib/shellwords.rb +153 -0
- data/pocketknife.gemspec +40 -33
- data/spec/pocketknife_node_spec.rb +64 -20
- data/spec/pocketknife_spec.rb +5 -1
- data/spec/spec_helper.rb +8 -1
- data/spec/support/silence_stream.rb +11 -0
- metadata +97 -43
- data/Gemfile.lock +0 -52
data/lib/pocketknife/node.rb
CHANGED
@@ -3,16 +3,16 @@ class Pocketknife
|
|
3
3
|
#
|
4
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
5
|
class Node
|
6
|
-
# String
|
6
|
+
# @return [String] Name of the node.
|
7
7
|
attr_accessor :name
|
8
8
|
|
9
|
-
#
|
9
|
+
# @return [Pocketknife] The Pocketknife this node is associated with.
|
10
10
|
attr_accessor :pocketknife
|
11
11
|
|
12
|
-
#
|
12
|
+
# @return [Rye::Box] The Rye::Box connection, cached by {#connection}.
|
13
13
|
attr_accessor :connection_cache
|
14
14
|
|
15
|
-
# Hash
|
15
|
+
# @return [Hash{Symbol => String, Numeric}] Information about platform, cached by {#platform}.
|
16
16
|
attr_accessor :platform_cache
|
17
17
|
|
18
18
|
# Initialize a new node.
|
@@ -25,9 +25,11 @@ class Pocketknife
|
|
25
25
|
self.connection_cache = nil
|
26
26
|
end
|
27
27
|
|
28
|
-
# Returns a
|
28
|
+
# Returns a connection.
|
29
29
|
#
|
30
30
|
# Caches result to {#connection_cache}.
|
31
|
+
#
|
32
|
+
# @return [Rye::Box]
|
31
33
|
def connection
|
32
34
|
return self.connection_cache ||= begin
|
33
35
|
rye = Rye::Box.new(self.name, :user => "root")
|
@@ -40,6 +42,7 @@ class Pocketknife
|
|
40
42
|
#
|
41
43
|
# @param [String] message The message to display.
|
42
44
|
# @param [Boolean] importance How important is this? +true+ means important, +nil+ means normal, +false+ means unimportant.
|
45
|
+
# @return [void]
|
43
46
|
def say(message, importance=nil)
|
44
47
|
self.pocketknife.say("* #{self.name}: #{message}", importance)
|
45
48
|
end
|
@@ -57,7 +60,7 @@ class Pocketknife
|
|
57
60
|
# @return [Boolean] Has executable?
|
58
61
|
def has_executable?(executable)
|
59
62
|
begin
|
60
|
-
self.connection.execute(%{which
|
63
|
+
self.connection.execute(%{which #{executable.shellescape} && test -x `which #{executable.shellescape}`})
|
61
64
|
return true
|
62
65
|
rescue Rye::Err
|
63
66
|
return false
|
@@ -68,14 +71,14 @@ class Pocketknife
|
|
68
71
|
#
|
69
72
|
# The information is formatted similar to this:
|
70
73
|
# {
|
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
|
74
|
+
# :distributor => "Ubuntu", # String with distributor name
|
75
|
+
# :codename => "maverick", # String with release codename
|
76
|
+
# :release => "10.10", # String with release number
|
77
|
+
# :version => 10.1 # Float with release number
|
75
78
|
# }
|
76
79
|
#
|
77
|
-
# @return [Hash
|
78
|
-
# @raise [UnsupportedInstallationPlatform]
|
80
|
+
# @return [Hash{Symbol => String, Numeric}] Return a hash describing the node, see above.
|
81
|
+
# @raise [UnsupportedInstallationPlatform] Don't know how to install on this platform.
|
79
82
|
def platform
|
80
83
|
return self.platform_cache ||= begin
|
81
84
|
lsb_release = "/etc/lsb-release"
|
@@ -100,8 +103,9 @@ class Pocketknife
|
|
100
103
|
|
101
104
|
# Installs Chef and its dependencies on a node if needed.
|
102
105
|
#
|
103
|
-
# @
|
104
|
-
# @raise [
|
106
|
+
# @return [void]
|
107
|
+
# @raise [NotInstalling] Can't install because user Chef isn't already present and user forbade automatic installation.
|
108
|
+
# @raise [UnsupportedInstallationPlatform] Don't know how to install on this platform.
|
105
109
|
def install
|
106
110
|
unless self.has_executable?("chef-solo")
|
107
111
|
case self.pocketknife.can_install
|
@@ -136,6 +140,8 @@ class Pocketknife
|
|
136
140
|
end
|
137
141
|
|
138
142
|
# Installs Chef on the remote node.
|
143
|
+
#
|
144
|
+
# @return [void]
|
139
145
|
def install_chef
|
140
146
|
self.say("Installing chef...")
|
141
147
|
self.execute("gem install --no-rdoc --no-ri chef", true)
|
@@ -143,6 +149,8 @@ class Pocketknife
|
|
143
149
|
end
|
144
150
|
|
145
151
|
# Installs Rubygems on the remote node.
|
152
|
+
#
|
153
|
+
# @return [void]
|
146
154
|
def install_rubygems
|
147
155
|
self.say("Installing rubygems...")
|
148
156
|
self.execute(<<-HERE, true)
|
@@ -158,6 +166,8 @@ cd /root &&
|
|
158
166
|
end
|
159
167
|
|
160
168
|
# Installs Ruby on the remote node.
|
169
|
+
#
|
170
|
+
# @return [void]
|
161
171
|
def install_ruby
|
162
172
|
command = \
|
163
173
|
case self.platform[:distributor].downcase
|
@@ -184,21 +194,26 @@ cd /root &&
|
|
184
194
|
# mynode.upload
|
185
195
|
# end
|
186
196
|
#
|
187
|
-
# @yield
|
197
|
+
# @yield to execute the block, will prepare upload before block is invoked, and cleanup the temporary files afterwards.
|
198
|
+
# @return [void]
|
188
199
|
def self.prepare_upload(&block)
|
200
|
+
# TODO make this an instance method so it can avoid creating a tarball if using :rsync
|
189
201
|
begin
|
190
202
|
# TODO either do this in memory or scope this to the PID to allow concurrency
|
191
203
|
TMP_SOLO_RB.open("w") {|h| h.write(SOLO_RB_CONTENT)}
|
192
204
|
TMP_CHEF_SOLO_APPLY.open("w") {|h| h.write(CHEF_SOLO_APPLY_CONTENT)}
|
193
205
|
TMP_TARBALL.open("w") do |handle|
|
206
|
+
items = [
|
207
|
+
VAR_POCKETKNIFE_COOKBOOKS.basename,
|
208
|
+
VAR_POCKETKNIFE_SITE_COOKBOOKS.basename,
|
209
|
+
VAR_POCKETKNIFE_ROLES.basename,
|
210
|
+
VAR_POCKETKNIFE_DATA_BAGS.basename,
|
211
|
+
TMP_SOLO_RB,
|
212
|
+
TMP_CHEF_SOLO_APPLY
|
213
|
+
].reject { |o| not File.exist?(o) }.map {|o| o.to_s}
|
214
|
+
|
194
215
|
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
|
-
],
|
216
|
+
items,
|
202
217
|
handle
|
203
218
|
)
|
204
219
|
end
|
@@ -217,7 +232,10 @@ cd /root &&
|
|
217
232
|
end
|
218
233
|
|
219
234
|
# Cleans up cache of shared files uploaded to all nodes. This cache is created by the {prepare_upload} method.
|
235
|
+
#
|
236
|
+
# @return [void]
|
220
237
|
def self.cleanup_upload
|
238
|
+
# TODO make this an instance method so it can avoid creating a tarball if using :rsync
|
221
239
|
[
|
222
240
|
TMP_TARBALL,
|
223
241
|
TMP_SOLO_RB,
|
@@ -227,52 +245,120 @@ cd /root &&
|
|
227
245
|
end
|
228
246
|
end
|
229
247
|
|
248
|
+
# Rsync files to a node.
|
249
|
+
#
|
250
|
+
# @param [Array] args Arguments to sent to +rsync+, e.g. options, filenames, and target.
|
251
|
+
# @return [void]
|
252
|
+
# @raise [RsyncError] Something went wrong with the rsync.
|
253
|
+
def rsync(*args)
|
254
|
+
command = ['rsync', *args.map{|o| o.shellescape}]
|
255
|
+
unless system *command
|
256
|
+
raise Pocketknife::RsyncError.new(command.join(' '), self.name)
|
257
|
+
end
|
258
|
+
end
|
259
|
+
|
260
|
+
# Rsync a file to a node with options: <tt>--update --copy-links</tt>
|
261
|
+
#
|
262
|
+
# @param [Array] args Arguments to sent to +rsync+, e.g. options, filename, and target.
|
263
|
+
# @return [void]
|
264
|
+
# @raise [RsyncError] Something went wrong with the rsync.
|
265
|
+
def rsync_file(*args)
|
266
|
+
self.rsync("-uL", *args)
|
267
|
+
end
|
268
|
+
|
269
|
+
# Rsync directory to a node with options: <tt>--recursive --update --copy-links --delete</tt>
|
270
|
+
#
|
271
|
+
# @param [Array] args Arguments to sent to +rsync+, e.g. options, directory name, and target.
|
272
|
+
# @return [void]
|
273
|
+
# @raise [RsyncError] Something went wrong with the rsync.
|
274
|
+
def rsync_directory(*args)
|
275
|
+
self.rsync("-ruL", "--delete", *args)
|
276
|
+
end
|
277
|
+
|
230
278
|
# Uploads configuration information to node.
|
231
279
|
#
|
232
280
|
# IMPORTANT: You must first call {prepare_upload} to create the shared files that will be uploaded.
|
281
|
+
#
|
282
|
+
# @return [void]
|
233
283
|
def upload
|
234
284
|
self.say("Uploading configuration...")
|
235
285
|
|
236
286
|
self.say("Removing old files...", false)
|
237
287
|
self.execute <<-HERE
|
238
288
|
umask 0377 &&
|
239
|
-
rm -rf
|
240
|
-
mkdir -p
|
289
|
+
rm -rf #{ETC_CHEF.shellescape} #{VAR_POCKETKNIFE.shellescape} #{VAR_POCKETKNIFE_CACHE.shellescape} #{CHEF_SOLO_APPLY.shellescape} #{CHEF_SOLO_APPLY_ALIAS.shellescape} &&
|
290
|
+
mkdir -p #{ETC_CHEF.shellescape} #{VAR_POCKETKNIFE.shellescape} #{VAR_POCKETKNIFE_CACHE.shellescape} #{CHEF_SOLO_APPLY.dirname.shellescape}
|
241
291
|
HERE
|
242
292
|
|
243
|
-
self.
|
244
|
-
|
245
|
-
|
293
|
+
case self.pocketknife.transfer_mechanism
|
294
|
+
when :tar
|
295
|
+
self.say("Uploading new files...", false)
|
296
|
+
self.connection.file_upload(self.local_node_json_pathname.to_s, NODE_JSON.to_s)
|
297
|
+
self.connection.file_upload(TMP_TARBALL.to_s, VAR_POCKETKNIFE_TARBALL.to_s)
|
246
298
|
|
247
|
-
|
248
|
-
|
249
|
-
cd
|
250
|
-
tar xf
|
299
|
+
self.say("Installing new files...", false)
|
300
|
+
self.execute <<-HERE, true
|
301
|
+
cd #{VAR_POCKETKNIFE_CACHE.shellescape} &&
|
302
|
+
tar xf #{VAR_POCKETKNIFE_TARBALL.shellescape} &&
|
251
303
|
chmod -R u+rwX,go= . &&
|
252
304
|
chown -R root:root . &&
|
253
|
-
mv
|
254
|
-
mv
|
255
|
-
chmod u+x
|
256
|
-
ln -s
|
257
|
-
rm
|
258
|
-
mv *
|
259
|
-
|
305
|
+
mv #{TMP_SOLO_RB.shellescape} #{SOLO_RB.shellescape} &&
|
306
|
+
mv #{TMP_CHEF_SOLO_APPLY.shellescape} #{CHEF_SOLO_APPLY.shellescape} &&
|
307
|
+
chmod u+x #{CHEF_SOLO_APPLY.shellescape} &&
|
308
|
+
ln -s #{CHEF_SOLO_APPLY.basename.shellescape} #{CHEF_SOLO_APPLY_ALIAS.shellescape} &&
|
309
|
+
rm #{VAR_POCKETKNIFE_TARBALL.shellescape} &&
|
310
|
+
mv * #{VAR_POCKETKNIFE.shellescape}
|
311
|
+
HERE
|
312
|
+
|
313
|
+
when :rsync
|
314
|
+
self.say("Uploading new files...", false)
|
315
|
+
|
316
|
+
self.rsync_file("#{self.local_node_json_pathname}", "root@#{self.name}:#{NODE_JSON}")
|
317
|
+
|
318
|
+
%w[SOLO_RB CHEF_SOLO_APPLY].each do |fragment|
|
319
|
+
source = self.class.const_get("TMP_#{fragment}")
|
320
|
+
target = self.class.const_get(fragment)
|
321
|
+
self.rsync_file("#{source}", "root@#{self.name}:#{target}")
|
322
|
+
end
|
323
|
+
|
324
|
+
%w[COOKBOOKS SITE_COOKBOOKS ROLES DATA_BAGS].each do |fragment|
|
325
|
+
target = self.class.const_get("VAR_POCKETKNIFE_#{fragment}")
|
326
|
+
source = target.basename
|
327
|
+
next unless source.exist?
|
328
|
+
self.rsync_directory("#{source}/", "root@#{self.name}:#{target}")
|
329
|
+
end
|
330
|
+
|
331
|
+
self.say("Modifying new files...", false)
|
332
|
+
self.execute <<-HERE, true
|
333
|
+
cd #{VAR_POCKETKNIFE_CACHE.shellescape} &&
|
334
|
+
chmod u+x #{CHEF_SOLO_APPLY.shellescape} &&
|
335
|
+
ln -s #{CHEF_SOLO_APPLY.basename.shellescape} #{CHEF_SOLO_APPLY_ALIAS.shellescape}
|
336
|
+
HERE
|
337
|
+
|
338
|
+
else
|
339
|
+
raise InvalidTransferMechanism.new(self.pocketknife.transfer_mechanism)
|
340
|
+
end
|
260
341
|
|
261
342
|
self.say("Finished uploading!", false)
|
262
343
|
end
|
263
344
|
|
264
345
|
# Applies the configuration to the node. Installs Chef, Ruby and Rubygems if needed.
|
346
|
+
#
|
347
|
+
# @return [void]
|
265
348
|
def apply
|
266
349
|
self.install
|
267
350
|
|
268
351
|
self.say("Applying configuration...", true)
|
269
|
-
command = "chef-solo -j #{NODE_JSON}"
|
352
|
+
command = "chef-solo -j #{NODE_JSON.shellescape}"
|
353
|
+
command << " -o #{self.pocketknife.runlist}" if self.pocketknife.runlist
|
270
354
|
command << " -l debug" if self.pocketknife.verbosity == true
|
271
355
|
self.execute(command, true)
|
272
356
|
self.say("Finished applying!")
|
273
357
|
end
|
274
358
|
|
275
359
|
# Deploys the configuration to the node, which calls {#upload} and {#apply}.
|
360
|
+
#
|
361
|
+
# @return [void]
|
276
362
|
def deploy
|
277
363
|
self.upload
|
278
364
|
self.apply
|
@@ -283,7 +369,7 @@ cd "#{VAR_POCKETKNIFE_CACHE}" &&
|
|
283
369
|
# @param [String] commands Shell commands to execute.
|
284
370
|
# @param [Boolean] immediate Display execution information immediately to STDOUT, rather than returning it as an object when done.
|
285
371
|
# @return [Rye::Rap] A result object describing the completed execution.
|
286
|
-
# @raise [ExecutionError]
|
372
|
+
# @raise [ExecutionError] Something went wrong with the execution, the cause is described in the exception.
|
287
373
|
def execute(commands, immediate=false)
|
288
374
|
self.say("Executing:\n#{commands}", false)
|
289
375
|
if immediate
|
@@ -323,12 +409,17 @@ cd "#{VAR_POCKETKNIFE_CACHE}" &&
|
|
323
409
|
# Remote path to pocketknife's roles
|
324
410
|
# @private
|
325
411
|
VAR_POCKETKNIFE_ROLES = VAR_POCKETKNIFE + "roles"
|
412
|
+
# Remote path to pocketknife's databags
|
413
|
+
# @private
|
414
|
+
VAR_POCKETKNIFE_DATA_BAGS = VAR_POCKETKNIFE + "data_bags"
|
415
|
+
|
326
416
|
# Content of the solo.rb file
|
327
417
|
# @private
|
328
418
|
SOLO_RB_CONTENT = <<-HERE
|
329
419
|
file_cache_path "#{VAR_POCKETKNIFE_CACHE}"
|
330
420
|
cookbook_path ["#{VAR_POCKETKNIFE_COOKBOOKS}", "#{VAR_POCKETKNIFE_SITE_COOKBOOKS}"]
|
331
421
|
role_path "#{VAR_POCKETKNIFE_ROLES}"
|
422
|
+
data_bag_path "#{VAR_POCKETKNIFE_DATA_BAGS}"
|
332
423
|
HERE
|
333
424
|
# Remote path to chef-solo-apply
|
334
425
|
# @private
|
@@ -3,16 +3,16 @@ class Pocketknife
|
|
3
3
|
#
|
4
4
|
# This class finds, validates and manages {Pocketknife::Node} instances for a {Pocketknife}.
|
5
5
|
class NodeManager
|
6
|
-
#
|
6
|
+
# @return [Pocketknife] The Pocketknife instance to manage.
|
7
7
|
attr_accessor :pocketknife
|
8
8
|
|
9
|
-
# Hash
|
9
|
+
# @return [Hash{String => Pocketknife::Node}] Node instances by their name.
|
10
10
|
attr_accessor :nodes
|
11
11
|
|
12
|
-
# Array
|
12
|
+
# @return [Array<Pocketknife::Node>] Known nodes, cached by {#known_nodes}.
|
13
13
|
attr_accessor :known_nodes_cache
|
14
14
|
|
15
|
-
#
|
15
|
+
# Instantiates a new manager.
|
16
16
|
#
|
17
17
|
# @param [Pocketknife] pocketknife
|
18
18
|
def initialize(pocketknife)
|
@@ -21,7 +21,7 @@ class Pocketknife
|
|
21
21
|
self.known_nodes_cache = nil
|
22
22
|
end
|
23
23
|
|
24
|
-
#
|
24
|
+
# Returns a node. Uses cached value in {#known_nodes_cache} if available.
|
25
25
|
#
|
26
26
|
# @param [String] name A node name to find, can be an abbrevation.
|
27
27
|
# @return [Pocketknife::Node]
|
@@ -38,9 +38,9 @@ class Pocketknife
|
|
38
38
|
#
|
39
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
40
|
#
|
41
|
-
# @param [String] abbreviated_name A node name, which may be abbreviated, e.g.
|
42
|
-
# @return [String] The complete node name, e.g.
|
43
|
-
# @raise [NoSuchNode]
|
41
|
+
# @param [String] abbreviated_name A node name, which may be abbreviated, e.g. <tt>henrietta</tt>.
|
42
|
+
# @return [String] The complete node name, e.g. <tt>henrietta.swa.gov.it</tt>.
|
43
|
+
# @raise [NoSuchNode] Couldn't find this node, either because it doesn't exist or the abbreviation isn't unique.
|
44
44
|
def hostname_for(abbreviated_name)
|
45
45
|
if self.known_nodes.include?(abbreviated_name)
|
46
46
|
return abbreviated_name
|
@@ -59,8 +59,9 @@ class Pocketknife
|
|
59
59
|
|
60
60
|
# Asserts that the specified nodes are known to Pocketknife.
|
61
61
|
#
|
62
|
-
# @param [Array<String>]
|
63
|
-
# @
|
62
|
+
# @param [Array<String>] names A list of node names.
|
63
|
+
# @return [void]
|
64
|
+
# @raise [Pocketknife::NoSuchNode] Couldn't find a node.
|
64
65
|
def assert_known(names)
|
65
66
|
for name in names
|
66
67
|
# This will raise a NoSuchNode exception if there's a problem.
|
@@ -73,7 +74,7 @@ class Pocketknife
|
|
73
74
|
# Caches results to {#known_nodes_cache}.
|
74
75
|
#
|
75
76
|
# @return [Array<String>] The node names.
|
76
|
-
# @raise [Errno::ENOENT]
|
77
|
+
# @raise [Errno::ENOENT] Can't find the +nodes+ directory.
|
77
78
|
def known_nodes
|
78
79
|
return(self.known_nodes_cache ||= begin
|
79
80
|
dir = Pathname.new("nodes")
|
data/lib/pocketknife/version.rb
CHANGED
@@ -3,11 +3,16 @@ class Pocketknife
|
|
3
3
|
#
|
4
4
|
# Information about the Pocketknife version.
|
5
5
|
module Version
|
6
|
+
# @return [Integer] Major version.
|
6
7
|
MAJOR = 0
|
7
|
-
|
8
|
+
# @return [Integer] Minor version.
|
9
|
+
MINOR = 2
|
10
|
+
# @return [Integer] Patch version.
|
8
11
|
PATCH = 0
|
12
|
+
# @return [Integer] Build version.
|
9
13
|
BUILD = nil
|
10
14
|
|
15
|
+
# @return [String] The version as a string.
|
11
16
|
STRING = [MAJOR, MINOR, PATCH, BUILD].compact.join('.')
|
12
17
|
end
|
13
18
|
end
|
data/lib/shellwords.rb
ADDED
@@ -0,0 +1,153 @@
|
|
1
|
+
#
|
2
|
+
# shellwords.rb: Manipulates strings a la UNIX Bourne shell
|
3
|
+
#
|
4
|
+
|
5
|
+
#
|
6
|
+
# This module manipulates strings according to the word parsing rules
|
7
|
+
# of the UNIX Bourne shell.
|
8
|
+
#
|
9
|
+
# The shellwords() function was originally a port of shellwords.pl,
|
10
|
+
# but modified to conform to POSIX / SUSv3 (IEEE Std 1003.1-2001).
|
11
|
+
#
|
12
|
+
# Authors:
|
13
|
+
# - Wakou Aoyama
|
14
|
+
# - Akinori MUSHA <knu@iDaemons.org>
|
15
|
+
#
|
16
|
+
# Contact:
|
17
|
+
# - Akinori MUSHA <knu@iDaemons.org> (current maintainer)
|
18
|
+
#
|
19
|
+
module Shellwords
|
20
|
+
# Splits a string into an array of tokens in the same way the UNIX
|
21
|
+
# Bourne shell does.
|
22
|
+
#
|
23
|
+
# argv = Shellwords.split('here are "two words"')
|
24
|
+
# argv #=> ["here", "are", "two words"]
|
25
|
+
#
|
26
|
+
# String#shellsplit is a shorthand for this function.
|
27
|
+
#
|
28
|
+
# argv = 'here are "two words"'.shellsplit
|
29
|
+
# argv #=> ["here", "are", "two words"]
|
30
|
+
def shellsplit(line)
|
31
|
+
words = []
|
32
|
+
field = ''
|
33
|
+
line.scan(/\G\s*(?>([^\s\\\'\"]+)|'([^\']*)'|"((?:[^\"\\]|\\.)*)"|(\\.?)|(\S))(\s|\z)?/m) do
|
34
|
+
|word, sq, dq, esc, garbage, sep|
|
35
|
+
raise ArgumentError, "Unmatched double quote: #{line.inspect}" if garbage
|
36
|
+
field << (word || sq || (dq || esc).gsub(/\\(.)/, '\\1'))
|
37
|
+
if sep
|
38
|
+
words << field
|
39
|
+
field = ''
|
40
|
+
end
|
41
|
+
end
|
42
|
+
words
|
43
|
+
end
|
44
|
+
|
45
|
+
alias shellwords shellsplit
|
46
|
+
|
47
|
+
module_function :shellsplit, :shellwords
|
48
|
+
|
49
|
+
class << self
|
50
|
+
alias split shellsplit
|
51
|
+
end
|
52
|
+
|
53
|
+
# Escapes a string so that it can be safely used in a Bourne shell
|
54
|
+
# command line.
|
55
|
+
#
|
56
|
+
# Note that a resulted string should be used unquoted and is not
|
57
|
+
# intended for use in double quotes nor in single quotes.
|
58
|
+
#
|
59
|
+
# open("| grep #{Shellwords.escape(pattern)} file") { |pipe|
|
60
|
+
# # ...
|
61
|
+
# }
|
62
|
+
#
|
63
|
+
# String#shellescape is a shorthand for this function.
|
64
|
+
#
|
65
|
+
# open("| grep #{pattern.shellescape} file") { |pipe|
|
66
|
+
# # ...
|
67
|
+
# }
|
68
|
+
#
|
69
|
+
# It is caller's responsibility to encode the string in the right
|
70
|
+
# encoding for the shell environment where this string is used.
|
71
|
+
# Multibyte characters are treated as multibyte characters, not
|
72
|
+
# bytes.
|
73
|
+
def shellescape(str)
|
74
|
+
# An empty argument will be skipped, so return empty quotes.
|
75
|
+
return "''" if str.empty?
|
76
|
+
|
77
|
+
str = str.dup
|
78
|
+
|
79
|
+
# Treat multibyte characters as is. It is caller's responsibility
|
80
|
+
# to encode the string in the right encoding for the shell
|
81
|
+
# environment.
|
82
|
+
str.gsub!(/([^A-Za-z0-9_\-.,:\/@\n])/, "\\\\\\1")
|
83
|
+
|
84
|
+
# A LF cannot be escaped with a backslash because a backslash + LF
|
85
|
+
# combo is regarded as line continuation and simply ignored.
|
86
|
+
str.gsub!(/\n/, "'\n'")
|
87
|
+
|
88
|
+
return str
|
89
|
+
end
|
90
|
+
|
91
|
+
module_function :shellescape
|
92
|
+
|
93
|
+
class << self
|
94
|
+
alias escape shellescape
|
95
|
+
end
|
96
|
+
|
97
|
+
# Builds a command line string from an argument list +array+ joining
|
98
|
+
# all elements escaped for Bourne shell and separated by a space.
|
99
|
+
#
|
100
|
+
# open('|' + Shellwords.join(['grep', pattern, *files])) { |pipe|
|
101
|
+
# # ...
|
102
|
+
# }
|
103
|
+
#
|
104
|
+
# Array#shelljoin is a shorthand for this function.
|
105
|
+
#
|
106
|
+
# open('|' + ['grep', pattern, *files].shelljoin) { |pipe|
|
107
|
+
# # ...
|
108
|
+
# }
|
109
|
+
#
|
110
|
+
def shelljoin(array)
|
111
|
+
array.map { |arg| shellescape(arg) }.join(' ')
|
112
|
+
end
|
113
|
+
|
114
|
+
module_function :shelljoin
|
115
|
+
|
116
|
+
class << self
|
117
|
+
alias join shelljoin
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
# @!visibility private
|
122
|
+
class String
|
123
|
+
# call-seq:
|
124
|
+
# str.shellsplit => array
|
125
|
+
#
|
126
|
+
# Splits +str+ into an array of tokens in the same way the UNIX
|
127
|
+
# Bourne shell does. See Shellwords::shellsplit for details.
|
128
|
+
def shellsplit
|
129
|
+
Shellwords.split(self)
|
130
|
+
end
|
131
|
+
|
132
|
+
# call-seq:
|
133
|
+
# str.shellescape => string
|
134
|
+
#
|
135
|
+
# Escapes +str+ so that it can be safely used in a Bourne shell
|
136
|
+
# command line. See Shellwords::shellescape for details.
|
137
|
+
def shellescape
|
138
|
+
Shellwords.escape(self)
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
# @!visibility private
|
143
|
+
class Array
|
144
|
+
# call-seq:
|
145
|
+
# array.shelljoin => string
|
146
|
+
#
|
147
|
+
# Builds a command line string from an argument list +array+ joining
|
148
|
+
# all elements escaped for Bourne shell and separated by a space.
|
149
|
+
# See Shellwords::shelljoin for details.
|
150
|
+
def shelljoin
|
151
|
+
Shellwords.join(self)
|
152
|
+
end
|
153
|
+
end
|