bolt 2.7.0 → 2.11.1

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of bolt might be problematic. Click here for more details.

Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/Puppetfile +4 -3
  3. data/bolt-modules/boltlib/lib/puppet/datatypes/applyresult.rb +2 -0
  4. data/bolt-modules/boltlib/lib/puppet/datatypes/resourceinstance.rb +27 -0
  5. data/bolt-modules/boltlib/lib/puppet/datatypes/result.rb +2 -0
  6. data/bolt-modules/boltlib/lib/puppet/datatypes/resultset.rb +2 -0
  7. data/bolt-modules/boltlib/lib/puppet/datatypes/target.rb +4 -3
  8. data/bolt-modules/boltlib/lib/puppet/functions/run_task_with.rb +192 -0
  9. data/bolt-modules/boltlib/lib/puppet/functions/set_resources.rb +122 -0
  10. data/bolt-modules/boltlib/types/planresult.pp +12 -1
  11. data/bolt-modules/file/lib/puppet/functions/file/exists.rb +3 -1
  12. data/bolt-modules/file/lib/puppet/functions/file/join.rb +1 -1
  13. data/bolt-modules/file/lib/puppet/functions/file/read.rb +2 -1
  14. data/bolt-modules/file/lib/puppet/functions/file/readable.rb +3 -1
  15. data/bolt-modules/file/lib/puppet/functions/file/write.rb +3 -1
  16. data/lib/bolt/applicator.rb +3 -2
  17. data/lib/bolt/apply_inventory.rb +1 -1
  18. data/lib/bolt/apply_result.rb +1 -1
  19. data/lib/bolt/apply_target.rb +11 -2
  20. data/lib/bolt/bolt_option_parser.rb +22 -6
  21. data/lib/bolt/cli.rb +52 -22
  22. data/lib/bolt/config.rb +57 -27
  23. data/lib/bolt/config/transport/base.rb +3 -3
  24. data/lib/bolt/config/transport/docker.rb +2 -0
  25. data/lib/bolt/config/transport/local.rb +2 -0
  26. data/lib/bolt/config/transport/orch.rb +4 -2
  27. data/lib/bolt/config/transport/remote.rb +2 -0
  28. data/lib/bolt/config/transport/ssh.rb +51 -2
  29. data/lib/bolt/config/transport/winrm.rb +3 -1
  30. data/lib/bolt/executor.rb +16 -0
  31. data/lib/bolt/inventory.rb +2 -1
  32. data/lib/bolt/inventory/group.rb +1 -0
  33. data/lib/bolt/inventory/inventory.rb +5 -0
  34. data/lib/bolt/inventory/target.rb +17 -1
  35. data/lib/bolt/node/output.rb +1 -1
  36. data/lib/bolt/outputter/human.rb +5 -4
  37. data/lib/bolt/outputter/json.rb +1 -1
  38. data/lib/bolt/pal.rb +32 -14
  39. data/lib/bolt/pal/yaml_plan.rb +1 -0
  40. data/lib/bolt/plugin.rb +14 -8
  41. data/lib/bolt/plugin/module.rb +40 -7
  42. data/lib/bolt/plugin/puppetdb.rb +5 -2
  43. data/lib/bolt/project.rb +135 -0
  44. data/lib/bolt/puppetdb/config.rb +16 -28
  45. data/lib/bolt/rerun.rb +1 -1
  46. data/lib/bolt/resource_instance.rb +126 -0
  47. data/lib/bolt/result.rb +46 -23
  48. data/lib/bolt/result_set.rb +2 -5
  49. data/lib/bolt/secret.rb +20 -4
  50. data/lib/bolt/shell/bash.rb +12 -5
  51. data/lib/bolt/shell/powershell.rb +12 -4
  52. data/lib/bolt/target.rb +16 -1
  53. data/lib/bolt/transport/base.rb +24 -8
  54. data/lib/bolt/transport/orch.rb +4 -0
  55. data/lib/bolt/transport/ssh.rb +6 -2
  56. data/lib/bolt/transport/ssh/connection.rb +4 -0
  57. data/lib/bolt/transport/ssh/exec_connection.rb +110 -0
  58. data/lib/bolt/transport/winrm/connection.rb +6 -2
  59. data/lib/bolt/version.rb +1 -1
  60. data/lib/bolt_server/pe/pal.rb +1 -38
  61. data/lib/bolt_server/transport_app.rb +7 -7
  62. data/lib/bolt_spec/bolt_context.rb +3 -6
  63. data/lib/bolt_spec/plans.rb +1 -1
  64. data/lib/bolt_spec/plans/mock_executor.rb +1 -0
  65. data/lib/bolt_spec/run.rb +10 -13
  66. metadata +10 -7
  67. data/lib/bolt/boltdir.rb +0 -54
  68. data/lib/bolt/plugin/pkcs7.rb +0 -104
  69. data/lib/bolt/secret/base.rb +0 -41
@@ -40,18 +40,23 @@ module Bolt
40
40
  end
41
41
 
42
42
  def self.for_task(target, stdout, stderr, exit_code, task)
43
- begin
44
- value = JSON.parse(stdout)
45
- unless value.is_a? Hash
46
- value = nil
47
- end
48
- rescue JSON::ParserError
49
- value = nil
50
- end
51
- value ||= { '_output' => stdout }
43
+ stdout.force_encoding('utf-8') unless stdout.encoding == Encoding::UTF_8
44
+ value = if stdout.valid_encoding?
45
+ parse_hash(stdout) || { '_output' => stdout }
46
+ else
47
+ { '_error' => { 'kind' => 'puppetlabs.tasks/task-error',
48
+ 'issue_code' => 'TASK_ERROR',
49
+ 'msg' => 'The task result contained invalid UTF-8 on stdout',
50
+ 'details' => {} } }
51
+ end
52
+
52
53
  if exit_code != 0 && value['_error'].nil?
53
54
  msg = if stdout.empty?
54
- "The task failed with exit code #{exit_code}:\n#{stderr}"
55
+ if stderr.empty?
56
+ "The task failed with exit code #{exit_code} and no output"
57
+ else
58
+ "The task failed with exit code #{exit_code} and no stdout, but stderr contained:\n#{stderr}"
59
+ end
55
60
  else
56
61
  "The task failed with exit code #{exit_code}"
57
62
  end
@@ -63,6 +68,13 @@ module Bolt
63
68
  new(target, value: value, action: 'task', object: task)
64
69
  end
65
70
 
71
+ def self.parse_hash(string)
72
+ value = JSON.parse(string)
73
+ value if value.is_a? Hash
74
+ rescue JSON::ParserError
75
+ nil
76
+ end
77
+
66
78
  def self.for_upload(target, source, destination)
67
79
  new(target, message: "Uploaded '#{source}' to '#{target.host}:#{destination}'", action: 'upload', object: source)
68
80
  end
@@ -110,18 +122,8 @@ module Bolt
110
122
  message && !message.strip.empty?
111
123
  end
112
124
 
113
- def status_hash
114
- {
115
- target: @target.name,
116
- action: action,
117
- object: object,
118
- status: status,
119
- value: @value
120
- }
121
- end
122
-
123
125
  def generic_value
124
- value.reject { |k, _| %w[_error _output].include? k }
126
+ safe_value.reject { |k, _| %w[_error _output].include? k }
125
127
  end
126
128
 
127
129
  def eql?(other)
@@ -139,15 +141,36 @@ module Bolt
139
141
  end
140
142
 
141
143
  def to_json(opts = nil)
142
- status_hash.to_json(opts)
144
+ to_data.to_json(opts)
143
145
  end
144
146
 
145
147
  def to_s
146
148
  to_json
147
149
  end
148
150
 
151
+ # This is the value with all non-UTF-8 characters removed, suitable for
152
+ # printing or converting to JSON. It *should* only be possible to have
153
+ # non-UTF-8 characters in stdout/stderr keys as they are not allowed from
154
+ # tasks but we scrub the whole thing just in case.
155
+ def safe_value
156
+ Bolt::Util.walk_vals(value) do |val|
157
+ if val.is_a?(String)
158
+ # Replace invalid bytes with hex codes, ie. \xDE\xAD\xBE\xEF
159
+ val.scrub { |c| c.bytes.map { |b| "\\x" + b.to_s(16).upcase }.join }
160
+ else
161
+ val
162
+ end
163
+ end
164
+ end
165
+
149
166
  def to_data
150
- Bolt::Util.walk_keys(status_hash, &:to_s)
167
+ {
168
+ "target" => @target.name,
169
+ "action" => action,
170
+ "object" => object,
171
+ "status" => status,
172
+ "value" => safe_value
173
+ }
151
174
  end
152
175
 
153
176
  def status
@@ -99,17 +99,14 @@ module Bolt
99
99
  self.class == other.class && @results == other.results
100
100
  end
101
101
 
102
- def to_a
103
- @results.map(&:status_hash)
104
- end
105
-
106
102
  def to_json(opts = nil)
107
- @results.map(&:status_hash).to_json(opts)
103
+ to_data.to_json(opts)
108
104
  end
109
105
 
110
106
  def to_data
111
107
  @results.map(&:to_data)
112
108
  end
109
+ alias to_a to_data
113
110
 
114
111
  def to_s
115
112
  to_json
@@ -1,17 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'bolt/plugin'
4
+
3
5
  module Bolt
4
6
  class Secret
7
+ KNOWN_KEYS = {
8
+ 'createkeys' => %w[keysize private_key public_key],
9
+ 'encrypt' => %w[public_key],
10
+ 'decrypt' => %w[private_key public_key]
11
+ }.freeze
12
+
5
13
  def self.execute(plugins, outputter, options)
6
- plugin = options[:plugin] || 'pkcs7'
14
+ name = options[:plugin] || 'pkcs7'
15
+ plugin = plugins.by_name(name)
16
+
17
+ unless plugin
18
+ raise Bolt::Plugin::PluginError::Unknown, name
19
+ end
20
+
7
21
  case options[:action]
8
22
  when 'createkeys'
9
- plugins.get_hook(plugin, :secret_createkeys).call
23
+ opts = { 'force' => options[:force] }.compact
24
+ result = plugins.get_hook(name, :secret_createkeys).call(opts)
25
+ outputter.print_message(result)
10
26
  when 'encrypt'
11
- encrypted = plugins.get_hook(plugin, :secret_encrypt).call('plaintext_value' => options[:object])
27
+ encrypted = plugins.get_hook(name, :secret_encrypt).call('plaintext_value' => options[:object])
12
28
  outputter.print_message(encrypted)
13
29
  when 'decrypt'
14
- decrypted = plugins.get_hook(plugin, :secret_decrypt).call('encrypted_value' => options[:object])
30
+ decrypted = plugins.get_hook(name, :secret_decrypt).call('encrypted_value' => options[:object])
15
31
  outputter.print_message(decrypted)
16
32
  end
17
33
 
@@ -150,8 +150,14 @@ module Bolt
150
150
  end
151
151
  elsif err =~ /^#{@sudo_id}/
152
152
  if sudo_stdin
153
- stdin.write("#{sudo_stdin}\n")
154
- stdin.close
153
+ begin
154
+ stdin.write("#{sudo_stdin}\n")
155
+ stdin.close
156
+ # If a task has stdin as an input_method but doesn't actually read
157
+ # from stdin, the task may return and close the input stream before
158
+ # we finish writing
159
+ rescue Errno::EPIPE
160
+ end
155
161
  end
156
162
  ''
157
163
  else
@@ -347,9 +353,9 @@ module Bolt
347
353
  # Chunks of this size will be read in one iteration
348
354
  index = 0
349
355
  timeout = 0.1
356
+ result_output = Bolt::Node::Output.new
350
357
 
351
358
  inp, out, err, t = conn.execute(command_str)
352
- result_output = Bolt::Node::Output.new
353
359
  read_streams = { out => String.new,
354
360
  err => String.new }
355
361
  write_stream = in_buffer.empty? ? [] : [inp]
@@ -399,8 +405,9 @@ module Bolt
399
405
  write_stream = []
400
406
  end
401
407
  end
402
- # If a task has stdin as an input_method but doesn't actually
403
- # read from stdin, the task may return and close the input stream
408
+ # If a task has stdin as an input_method but doesn't actually read
409
+ # from stdin, the task may return and close the input stream before
410
+ # we finish writing
404
411
  rescue Errno::EPIPE
405
412
  write_stream = []
406
413
  end
@@ -267,10 +267,18 @@ module Bolt
267
267
 
268
268
  result = Bolt::Node::Output.new
269
269
  inp.close
270
- out.binmode
271
- err.binmode
272
- stdout = Thread.new { result.stdout << out.read }
273
- stderr = Thread.new { result.stderr << err.read }
270
+ stdout = Thread.new do
271
+ # Set to binmode to preserve \r\n line endings, but save and restore
272
+ # the proper encoding so the string isn't later misinterpreted
273
+ encoding = out.external_encoding
274
+ out.binmode
275
+ result.stdout << out.read.force_encoding(encoding)
276
+ end
277
+ stderr = Thread.new do
278
+ encoding = err.external_encoding
279
+ err.binmode
280
+ result.stderr << err.read.force_encoding(encoding)
281
+ end
274
282
 
275
283
  stdout.join
276
284
  stderr.join
@@ -31,7 +31,8 @@ module Bolt
31
31
  facts = nil,
32
32
  vars = nil,
33
33
  features = nil,
34
- plugin_hooks = nil)
34
+ plugin_hooks = nil,
35
+ resources = nil)
35
36
  from_asserted_hash('uri' => uri)
36
37
  end
37
38
  # rubocop:enable Lint/UnusedMethodArgument
@@ -75,6 +76,16 @@ module Bolt
75
76
  inventory_target.target_alias
76
77
  end
77
78
 
79
+ def resources
80
+ inventory_target.resources
81
+ end
82
+
83
+ # rubocop:disable Naming/AccessorMethodName
84
+ def set_resource(resource)
85
+ inventory_target.set_resource(resource)
86
+ end
87
+ # rubocop:enable Naming/AccessorMethodName
88
+
78
89
  def to_h
79
90
  options.to_h.merge(
80
91
  'name' => name,
@@ -155,5 +166,9 @@ module Bolt
155
166
  self.class.equal?(other.class) && @name == other.name
156
167
  end
157
168
  alias == eql?
169
+
170
+ def hash
171
+ @name.hash
172
+ end
158
173
  end
159
174
  end
@@ -32,7 +32,7 @@ module Bolt
32
32
  # Transports that need their own batching, like the Orch transport, can
33
33
  # instead override the batches() method to split Targets into sets that can
34
34
  # be executed together, and override the batch_task() and related methods
35
- # to execute a batch of nodes. In that case, those Transports should accept
35
+ # to execute a batch of targets. In that case, those Transports should accept
36
36
  # a block argument and call it with a :node_start event for each Target
37
37
  # before executing, and a :node_result event for each Target after
38
38
  # execution.
@@ -90,12 +90,12 @@ module Bolt
90
90
  # case and raises an error if it's not.
91
91
  def assert_batch_size_one(method, targets)
92
92
  if targets.length > 1
93
- message = "#{self.class.name} must implement #{method} to support batches (got #{targets.length} nodes)"
93
+ message = "#{self.class.name} must implement #{method} to support batches (got #{targets.length} targets)"
94
94
  raise NotImplementedError, message
95
95
  end
96
96
  end
97
97
 
98
- # Runs the given task on a batch of nodes.
98
+ # Runs the given task on a batch of targets.
99
99
  #
100
100
  # The default implementation only supports batches of size 1 and will fail otherwise.
101
101
  #
@@ -104,12 +104,28 @@ module Bolt
104
104
  assert_batch_size_one("batch_task()", targets)
105
105
  target = targets.first
106
106
  with_events(target, callback, 'task') do
107
- @logger.debug { "Running task run '#{task}' on #{target.safe_name}" }
107
+ @logger.debug { "Running task '#{task.name}' on #{target.safe_name}" }
108
108
  run_task(target, task, arguments, options)
109
109
  end
110
110
  end
111
111
 
112
- # Runs the given command on a batch of nodes.
112
+ # Runs the given task on a batch of targets with variable parameters.
113
+ #
114
+ # The default implementation only supports batches of size 1 and will fail otherwise.
115
+ #
116
+ # Transports may override this method to implment their own batch processing.
117
+ def batch_task_with(targets, task, target_mapping, options = {}, &callback)
118
+ assert_batch_size_one("batch_task_with()", targets)
119
+ target = targets.first
120
+ arguments = target_mapping[target]
121
+
122
+ with_events(target, callback, 'task') do
123
+ @logger.debug { "Running task '#{task.name}' on #{target.safe_name} with '#{arguments.to_json}'" }
124
+ run_task(target, task, arguments, options)
125
+ end
126
+ end
127
+
128
+ # Runs the given command on a batch of targets.
113
129
  #
114
130
  # The default implementation only supports batches of size 1 and will fail otherwise.
115
131
  #
@@ -123,7 +139,7 @@ module Bolt
123
139
  end
124
140
  end
125
141
 
126
- # Runs the given script on a batch of nodes.
142
+ # Runs the given script on a batch of targets.
127
143
  #
128
144
  # The default implementation only supports batches of size 1 and will fail otherwise.
129
145
  #
@@ -137,7 +153,7 @@ module Bolt
137
153
  end
138
154
  end
139
155
 
140
- # Uploads the given source file to the destination location on a batch of nodes.
156
+ # Uploads the given source file to the destination location on a batch of targets.
141
157
  #
142
158
  # The default implementation only supports batches of size 1 and will fail otherwise.
143
159
  #
@@ -157,7 +173,7 @@ module Bolt
157
173
  end
158
174
 
159
175
  # Split the given list of targets into a list of batches. The default
160
- # implementation returns single-node batches.
176
+ # implementation returns single-target batches.
161
177
  #
162
178
  # Transports may override this method, and the corresponding batch_*
163
179
  # methods, to implement their own batch processing.
@@ -210,6 +210,10 @@ module Bolt
210
210
  end
211
211
  end
212
212
 
213
+ def batch_task_with(_targets, _task, _target_mapping, _options = {})
214
+ raise NotImplementedError, "pcp transport does not support run_task_with()"
215
+ end
216
+
213
217
  def batch_connected?(targets)
214
218
  resp = get_connection(targets.first.options).query_inventory(targets)
215
219
  resp['items'].all? { |node| node['connected'] }
@@ -16,13 +16,16 @@ module Bolt
16
16
  rescue LoadError
17
17
  logger.debug("Authentication method 'gssapi-with-mic' (Kerberos) is not available.")
18
18
  end
19
-
20
19
  @transport_logger = Logging.logger[Net::SSH]
21
20
  @transport_logger.level = :warn
22
21
  end
23
22
 
24
23
  def with_connection(target)
25
- conn = Connection.new(target, @transport_logger)
24
+ conn = if target.transport_config['ssh-command']
25
+ ExecConnection.new(target)
26
+ else
27
+ Connection.new(target, @transport_logger)
28
+ end
26
29
  conn.connect
27
30
  yield conn
28
31
  ensure
@@ -37,3 +40,4 @@ module Bolt
37
40
  end
38
41
 
39
42
  require 'bolt/transport/ssh/connection'
43
+ require 'bolt/transport/ssh/exec_connection'
@@ -207,6 +207,10 @@ module Bolt
207
207
  err_wr.close
208
208
  end
209
209
  [in_wr, out_rd, err_rd, th]
210
+ rescue Errno::EMFILE => e
211
+ msg = "#{e.message}. This may be resolved by increasing your user limit "\
212
+ "with 'ulimit -n 1024'. See https://puppet.com/docs/bolt/latest/bolt_known_issues.html for details."
213
+ raise Bolt::Error.new(msg, 'bolt/too-many-files')
210
214
  end
211
215
 
212
216
  def copy_file(source, destination)
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module Bolt
6
+ module Transport
7
+ class SSH < Simple
8
+ class ExecConnection
9
+ attr_reader :user, :target
10
+
11
+ def initialize(target)
12
+ raise Bolt::ValidationError, "Target #{target.safe_name} does not have a host" unless target.host
13
+
14
+ @target = target
15
+ ssh_config = Net::SSH::Config.for(target.host)
16
+ @user = @target.user || ssh_config[:user] || Etc.getlogin
17
+ @logger = Logging.logger[self]
18
+ end
19
+
20
+ # This is used to verify we can connect to targets with `connected?`
21
+ def connect
22
+ cmd = build_ssh_command('exit')
23
+ _, err, stat = Open3.capture3(*cmd)
24
+ unless stat.success?
25
+ raise Bolt::Node::ConnectError.new(
26
+ "Failed to connect to #{@target.safe_name}: #{err}",
27
+ 'CONNECT_ERROR'
28
+ )
29
+ end
30
+ end
31
+
32
+ def disconnect; end
33
+
34
+ def shell
35
+ Bolt::Shell::Bash.new(@target, self)
36
+ end
37
+
38
+ def userhost
39
+ "#{@user}@#{@target.host}"
40
+ end
41
+
42
+ def ssh_opts
43
+ cmd = []
44
+ # BatchMode is SSH's noninteractive option: if key authentication
45
+ # fails it will error out instead of falling back to password prompt
46
+ cmd += %w[-o BatchMode=yes]
47
+ cmd += %W[-o Port=#{@target.port}] if @target.port
48
+
49
+ if @target.transport_config.key?('host-key-check')
50
+ hkc = @target.transport_config['host-key-check'] ? 'yes' : 'no'
51
+ cmd += %W[-o StrictHostKeyChecking=#{hkc}]
52
+ end
53
+
54
+ if (key = target.transport_config['private-key'])
55
+ cmd += ['-i', key]
56
+ end
57
+ cmd
58
+ end
59
+
60
+ def build_ssh_command(command)
61
+ ssh_conf = @target.transport_config['ssh-command']
62
+ ssh_cmd = Array(ssh_conf)
63
+ ssh_cmd += ssh_opts
64
+ ssh_cmd << userhost
65
+ ssh_cmd << command
66
+ end
67
+
68
+ def copy_file(source, dest)
69
+ @logger.debug { "Uploading #{source}, to #{userhost}:#{dest}" } unless source.is_a?(StringIO)
70
+
71
+ cp_conf = @target.transport_config['copy-command'] || ["scp", "-r"]
72
+ cp_cmd = Array(cp_conf)
73
+ cp_cmd += ssh_opts
74
+
75
+ _, err, stat = if source.is_a?(StringIO)
76
+ Tempfile.create(File.basename(dest)) do |f|
77
+ f.write(source.read)
78
+ f.close
79
+ cp_cmd << f.path
80
+ cp_cmd << "#{userhost}:#{Shellwords.escape(dest)}"
81
+ Open3.capture3(*cp_cmd)
82
+ end
83
+ else
84
+ cp_cmd << source
85
+ cp_cmd << "#{userhost}:#{Shellwords.escape(dest)}"
86
+ Open3.capture3(*cp_cmd)
87
+ end
88
+
89
+ if stat.success?
90
+ @logger.debug "Successfully uploaded #{source} to #{dest}"
91
+ else
92
+ message = "Could not copy file to #{dest}: #{err}"
93
+ raise Bolt::Node::FileError.new(message, 'COPY_ERROR')
94
+ end
95
+ end
96
+
97
+ def execute(command)
98
+ cmd_array = build_ssh_command(command)
99
+ Open3.popen3(*cmd_array)
100
+ end
101
+
102
+ # This is used by the Bash shell to decide whether to `cd` before
103
+ # executing commands as a run-as user
104
+ def reset_cwd?
105
+ true
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end