Skip to content

Commit 59d045b

Browse files
author
gimmy
committed
(PUP-5704) allow array commands in exec resource
This change updates the exec resource to accept arrays as command. The new behaviour is enabled for the following parameters: `:comand`, `:onlyif`, `:unless`, `:refresh`. ``` Changing the command to accept an array: command: "/bin/echo *" # executes through shell command: ["/bin/echo *"] # non-existing command "bin/echo *" command: ["/bin/echo *", "*"] # non-existing command "bin/echo *" command: ["/bin/echo", "*"] # executes directly onlyif/unless: '/bin/echo *' # executes one command through shell onlyif/unless: ['/bin/echo *', '/bin/echo $SHELL'] # executes two commands through shell onlyif/unless: [["/bin/echo", "*"]] # executes one command directly onlyif/unless: [["/bin/echo", "*"], ["/bin/echo", "$SHELL"]] # executes two commands directly ```
1 parent 31515a6 commit 59d045b

File tree

5 files changed

+201
-81
lines changed

5 files changed

+201
-81
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
test_name "Be able to execute array commands" do
2+
tag 'audit:high',
3+
'audit:acceptance'
4+
5+
agents.each do |agent|
6+
if agent.platform =~ /windows/
7+
cmd = ['C:\Windows\System32\cmd.exe', '/c', 'echo', '*']
8+
else
9+
cmd = ['/bin/echo', '*']
10+
end
11+
12+
exec_manifest = <<~MANIFEST
13+
exec { "test exec":
14+
command => #{cmd},
15+
logoutput => true,
16+
}
17+
MANIFEST
18+
19+
apply_manifest_on(agent, exec_manifest) do |output|
20+
assert_match('Notice: /Stage[main]/Main/Exec[test exec]/returns: *', output.stdout)
21+
end
22+
end
23+
end

lib/puppet/provider/exec/posix.rb

+16-4
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,22 @@
66
defaultfor :feature => :posix
77

88
desc <<-EOT
9-
Executes external binaries directly, without passing through a shell or
10-
performing any interpolation. This is a safer and more predictable way
11-
to execute most commands, but prevents the use of globbing and shell
12-
built-ins (including control logic like "for" and "if" statements).
9+
Executes external binaries by invoking Ruby's `Kernel.exec`.
10+
When the command is a string, it will be executed directly,
11+
without a shell, if it follows these rules:
12+
- no meta characters
13+
- no shell reserved word and no special built-in
14+
15+
When the command is an Array of Strings, passed as `[cmdname, arg1, ...]`
16+
it will be executed directly(the first element is taken as a command name
17+
and the rest are passed as parameters to command with no shell expansion)
18+
This is a safer and more predictable way to execute most commands,
19+
but prevents the use of globbing and shell built-ins (including control
20+
logic like "for" and "if" statements).
21+
22+
If the use of globbing and shell built-ins is desired, please check
23+
the `shell` provider
24+
1325
EOT
1426

1527
# Verify that we have the executable

lib/puppet/type/exec.rb

+16-3
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,9 @@ def sync
201201
only uses the resource title to ensure `exec`s are unique."
202202

203203
validate do |command|
204-
raise ArgumentError, _("Command must be a String, got value of class %{klass}") % { klass: command.class } unless command.is_a? String
204+
unless command.is_a?(String) || command.is_a?(Array)
205+
raise ArgumentError, _("Command must be a String or Array<String>, got value of class %{klass}") % { klass: command.class }
206+
end
205207
end
206208
end
207209

@@ -458,6 +460,10 @@ def check(value)
458460
459461
unless => ['test -f /tmp/file1', 'test -f /tmp/file2'],
460462
463+
or an array of arrays. For example:
464+
465+
unless => [['test', '-f', '/tmp/file1'], 'test -f /tmp/file2']
466+
461467
This `exec` would only run if every command in the array has a
462468
non-zero exit code.
463469
EOT
@@ -514,6 +520,10 @@ def check(value)
514520
515521
onlyif => ['test -f /tmp/file1', 'test -f /tmp/file2'],
516522
523+
or an array of arrays. For example:
524+
525+
onlyif => [['test', '-f', '/tmp/file1'], 'test -f /tmp/file2']
526+
517527
This `exec` would only run if every command in the array has an
518528
exit code of 0 (success).
519529
EOT
@@ -562,12 +572,14 @@ def check(value)
562572
reqs << self[:cwd] if self[:cwd]
563573

564574
file_regex = Puppet::Util::Platform.windows? ? %r{^([a-zA-Z]:[\\/]\S+)} : %r{^(/\S+)}
575+
cmd = self[:command]
576+
cmd = cmd[0] if cmd.is_a? Array
565577

566-
self[:command].scan(file_regex) { |str|
578+
cmd.scan(file_regex) { |str|
567579
reqs << str
568580
}
569581

570-
self[:command].scan(/^"([^"]+)"/) { |str|
582+
cmd.scan(/^"([^"]+)"/) { |str|
571583
reqs << str
572584
}
573585

@@ -583,6 +595,7 @@ def check(value)
583595
# fully qualified. It might not be a bad idea to add
584596
# unqualified files, but, well, that's a bit more annoying
585597
# to do.
598+
line = line[0] if line.is_a? Array
586599
reqs += line.scan(file_regex)
587600
end
588601
}

spec/integration/type/exec_spec.rb

+70-45
Original file line numberDiff line numberDiff line change
@@ -7,70 +7,95 @@
77

88
let(:catalog) { Puppet::Resource::Catalog.new }
99
let(:path) { tmpfile('exec_provider') }
10-
let(:command) { "ruby -e 'File.open(\"#{path}\", \"w\") { |f| f.print \"foo\" }'" }
1110

1211
before :each do
1312
catalog.host_config = false
1413
end
1514

16-
it "should execute the command" do
17-
exec = described_class.new :command => command, :path => ENV['PATH']
15+
shared_examples_for 'a valid exec resource' do
16+
it "should execute the command" do
17+
exec = described_class.new :command => command, :path => ENV['PATH']
1818

19-
catalog.add_resource exec
20-
catalog.apply
19+
catalog.add_resource exec
20+
catalog.apply
2121

22-
expect(File.read(path)).to eq('foo')
23-
end
22+
expect(File.read(path)).to eq('foo')
23+
end
2424

25-
it "should not execute the command if onlyif returns non-zero" do
26-
exec = described_class.new(
27-
:command => command,
28-
:onlyif => "ruby -e 'exit 44'",
29-
:path => ENV['PATH']
30-
)
25+
it "should not execute the command if onlyif returns non-zero" do
26+
exec = described_class.new(
27+
:command => command,
28+
:onlyif => "ruby -e 'exit 44'",
29+
:path => ENV['PATH']
30+
)
3131

32-
catalog.add_resource exec
33-
catalog.apply
32+
catalog.add_resource exec
33+
catalog.apply
3434

35-
expect(Puppet::FileSystem.exist?(path)).to be_falsey
36-
end
35+
expect(Puppet::FileSystem.exist?(path)).to be_falsey
36+
end
3737

38-
it "should execute the command if onlyif returns zero" do
39-
exec = described_class.new(
40-
:command => command,
41-
:onlyif => "ruby -e 'exit 0'",
42-
:path => ENV['PATH']
43-
)
38+
it "should execute the command if onlyif returns zero" do
39+
exec = described_class.new(
40+
:command => command,
41+
:onlyif => "ruby -e 'exit 0'",
42+
:path => ENV['PATH']
43+
)
4444

45-
catalog.add_resource exec
46-
catalog.apply
45+
catalog.add_resource exec
46+
catalog.apply
4747

48-
expect(File.read(path)).to eq('foo')
49-
end
48+
expect(File.read(path)).to eq('foo')
49+
end
50+
51+
it "should execute the command if unless returns non-zero" do
52+
exec = described_class.new(
53+
:command => command,
54+
:unless => "ruby -e 'exit 45'",
55+
:path => ENV['PATH']
56+
)
57+
58+
catalog.add_resource exec
59+
catalog.apply
60+
61+
expect(File.read(path)).to eq('foo')
62+
end
5063

51-
it "should execute the command if unless returns non-zero" do
52-
exec = described_class.new(
53-
:command => command,
54-
:unless => "ruby -e 'exit 45'",
55-
:path => ENV['PATH']
56-
)
64+
it "should not execute the command if unless returns zero" do
65+
exec = described_class.new(
66+
:command => command,
67+
:unless => "ruby -e 'exit 0'",
68+
:path => ENV['PATH']
69+
)
5770

58-
catalog.add_resource exec
59-
catalog.apply
71+
catalog.add_resource exec
72+
catalog.apply
6073

61-
expect(File.read(path)).to eq('foo')
74+
expect(Puppet::FileSystem.exist?(path)).to be_falsey
75+
end
6276
end
6377

64-
it "should not execute the command if unless returns zero" do
65-
exec = described_class.new(
66-
:command => command,
67-
:unless => "ruby -e 'exit 0'",
68-
:path => ENV['PATH']
69-
)
78+
context 'when command is a string' do
79+
let(:command) { "ruby -e 'File.open(\"#{path}\", \"w\") { |f| f.print \"foo\" }'" }
80+
81+
it_behaves_like 'a valid exec resource'
82+
end
83+
84+
context 'when command is an array' do
85+
let(:command) { ['ruby', '-e', "File.open(\"#{path}\", \"w\") { |f| f.print \"foo\" }"] }
86+
87+
it_behaves_like 'a valid exec resource'
88+
89+
context 'when is invalid' do
90+
let(:command) { [ "ruby -e 'puts 1'" ] }
7091

71-
catalog.add_resource exec
72-
catalog.apply
92+
it 'logs error' do
93+
exec = described_class.new :command => command, :path => ENV['PATH']
94+
catalog.add_resource exec
95+
logs = catalog.apply.report.logs
7396

74-
expect(Puppet::FileSystem.exist?(path)).to be_falsey
97+
expect(logs[0].message).to eql("Could not find command 'ruby -e 'puts 1''")
98+
end
99+
end
75100
end
76101
end

spec/unit/type/exec_spec.rb

+76-29
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,19 @@ def exec_stub(options = {})
239239
expect(dependencies.collect(&:to_s)).to eq([Puppet::Relationship.new(tmp, execer).to_s])
240240
end
241241

242+
it "should be able to autorequire files mentioned in the array command" do
243+
foo = make_absolute('/bin/foo')
244+
catalog = Puppet::Resource::Catalog.new
245+
tmp = Puppet::Type.type(:file).new(:name => foo)
246+
execer = Puppet::Type.type(:exec).new(:name => 'test array', :command => [foo, 'bar'])
247+
248+
catalog.add_resource tmp
249+
catalog.add_resource execer
250+
dependencies = execer.autorequire(catalog)
251+
252+
expect(dependencies.collect(&:to_s)).to eq([Puppet::Relationship.new(tmp, execer).to_s])
253+
end
254+
242255
describe "when handling the path parameter" do
243256
expect = %w{one two three four}
244257
{ "an array" => expect,
@@ -346,7 +359,13 @@ def instance(path)
346359
end
347360

348361
shared_examples_for "all exec command parameters" do |param|
349-
{ "relative" => "example", "absolute" => "/bin/example" }.sort.each do |name, command|
362+
array_cmd = ["/bin/example", "*"]
363+
array_cmd = [["/bin/example", "*"]] if [:onlyif, :unless].include?(param)
364+
365+
commands = { "relative" => "example", "absolute" => "/bin/example" }
366+
commands["array"] = array_cmd
367+
368+
commands.sort.each do |name, command|
350369
describe "if command is #{name}" do
351370
before :each do
352371
@param = param
@@ -379,45 +398,44 @@ def test(command, valid)
379398
end
380399

381400
shared_examples_for "all exec command parameters that take arrays" do |param|
382-
describe "when given an array of inputs" do
383-
before :each do
384-
@test = Puppet::Type.type(:exec).new(:name => @executable)
385-
end
401+
[
402+
%w{one two three},
403+
[%w{one -a}, %w{two, -b}, 'three']
404+
].each do |input|
405+
context "when given #{input.inspect} as input" do
406+
let(:resource) { Puppet::Type.type(:exec).new(:name => @executable) }
386407

387-
it "should accept the array when all commands return valid" do
388-
input = %w{one two three}
389-
expect(@test.provider).to receive(:validatecmd).exactly(input.length).times.and_return(true)
390-
@test[param] = input
391-
expect(@test[param]).to eq(input)
392-
end
408+
it "accepts the array when all commands return valid" do
409+
input = %w{one two three}
410+
allow(resource.provider).to receive(:validatecmd).exactly(input.length).times.and_return(true)
411+
resource[param] = input
412+
expect(resource[param]).to eq(input)
413+
end
393414

394-
it "should reject the array when any commands return invalid" do
395-
input = %w{one two three}
396-
expect(@test.provider).to receive(:validatecmd).with(input.first).and_return(false)
397-
input[1..-1].each do |cmd|
398-
expect(@test.provider).to receive(:validatecmd).with(cmd).and_return(true)
415+
it "rejects the array when any commands return invalid" do
416+
input = %w{one two three}
417+
allow(resource.provider).to receive(:validatecmd).with(input[0]).and_return(true)
418+
allow(resource.provider).to receive(:validatecmd).with(input[1]).and_raise(Puppet::Error)
419+
420+
expect { resource[param] = input }.to raise_error(Puppet::ResourceError, /Parameter #{param} failed/)
399421
end
400-
@test[param] = input
401-
expect(@test[param]).to eq(input)
402-
end
403422

404-
it "should reject the array when all commands return invalid" do
405-
input = %w{one two three}
406-
expect(@test.provider).to receive(:validatecmd).exactly(input.length).times.and_return(false)
407-
@test[param] = input
408-
expect(@test[param]).to eq(input)
423+
it "stops at the first invalid command" do
424+
input = %w{one two three}
425+
allow(resource.provider).to receive(:validatecmd).with(input[0]).and_raise(Puppet::Error)
426+
427+
expect(resource.provider).not_to receive(:validatecmd).with(input[1])
428+
expect(resource.provider).not_to receive(:validatecmd).with(input[2])
429+
expect { resource[param] = input }.to raise_error(Puppet::ResourceError, /Parameter #{param} failed/)
430+
end
409431
end
410432
end
411433
end
412434

413435
describe "when setting command" do
414436
subject { described_class.new(:name => @command) }
415-
it "fails when passed an Array" do
416-
expect { subject[:command] = [] }.to raise_error Puppet::Error, /Command must be a String/
417-
end
418-
419437
it "fails when passed a Hash" do
420-
expect { subject[:command] = {} }.to raise_error Puppet::Error, /Command must be a String/
438+
expect { subject[:command] = {} }.to raise_error Puppet::Error, /Command must be a String or Array<String>/
421439
end
422440
end
423441

@@ -759,6 +777,35 @@ def instance(path)
759777
end
760778
end
761779

780+
context 'with an array of arrays with multiple items' do
781+
before do
782+
[true, false].each do |check|
783+
allow(@test.provider).to receive(:run).with([@pass, '--flag'], check).
784+
and_return(['test output', @pass_status])
785+
allow(@test.provider).to receive(:run).with([@fail, '--flag'], check).
786+
and_return(['test output', @fail_status])
787+
allow(@test.provider).to receive(:run).with([@pass], check).
788+
and_return(['test output', @pass_status])
789+
allow(@test.provider).to receive(:run).with([@fail], check).
790+
and_return(['test output', @fail_status])
791+
end
792+
end
793+
it "runs if all the commands exits non-zero" do
794+
@test[param] = [[@fail, '--flag'], [@fail], [@fail, '--flag']]
795+
expect(@test.check_all_attributes).to eq(true)
796+
end
797+
798+
it "does not run if one command exits zero" do
799+
@test[param] = [[@pass, '--flag'], [@pass], [@fail, '--flag']]
800+
expect(@test.check_all_attributes).to eq(false)
801+
end
802+
803+
it "does not run if all command exits zero" do
804+
@test[param] = [[@pass, '--flag'], [@pass], [@pass, '--flag']]
805+
expect(@test.check_all_attributes).to eq(false)
806+
end
807+
end
808+
762809
it "should emit output to debug" do
763810
Puppet::Util::Log.level = :debug
764811
@test[param] = @fail

0 commit comments

Comments
 (0)