bolt 2.8.0 → 2.12.0

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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/Puppetfile +2 -2
  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_plan.rb +61 -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/analytics.rb +21 -2
  17. data/lib/bolt/applicator.rb +3 -1
  18. data/lib/bolt/apply_result.rb +1 -1
  19. data/lib/bolt/apply_target.rb +3 -2
  20. data/lib/bolt/bolt_option_parser.rb +18 -8
  21. data/lib/bolt/cli.rb +35 -5
  22. data/lib/bolt/config.rb +45 -13
  23. data/lib/bolt/config/transport/docker.rb +2 -0
  24. data/lib/bolt/config/transport/local.rb +2 -0
  25. data/lib/bolt/config/transport/orch.rb +2 -0
  26. data/lib/bolt/config/transport/remote.rb +2 -0
  27. data/lib/bolt/config/transport/ssh.rb +50 -1
  28. data/lib/bolt/config/transport/winrm.rb +2 -0
  29. data/lib/bolt/inventory.rb +2 -1
  30. data/lib/bolt/inventory/group.rb +1 -0
  31. data/lib/bolt/inventory/inventory.rb +5 -0
  32. data/lib/bolt/inventory/target.rb +17 -1
  33. data/lib/bolt/node/output.rb +1 -1
  34. data/lib/bolt/outputter/human.rb +5 -4
  35. data/lib/bolt/outputter/json.rb +1 -1
  36. data/lib/bolt/pal.rb +4 -1
  37. data/lib/bolt/pal/yaml_plan.rb +1 -0
  38. data/lib/bolt/plugin.rb +13 -7
  39. data/lib/bolt/plugin/puppetdb.rb +5 -2
  40. data/lib/bolt/project.rb +25 -7
  41. data/lib/bolt/puppetdb/config.rb +14 -26
  42. data/lib/bolt/rerun.rb +1 -1
  43. data/lib/bolt/resource_instance.rb +126 -0
  44. data/lib/bolt/result.rb +46 -23
  45. data/lib/bolt/result_set.rb +2 -5
  46. data/lib/bolt/shell/bash.rb +1 -1
  47. data/lib/bolt/shell/powershell.rb +12 -4
  48. data/lib/bolt/target.rb +12 -1
  49. data/lib/bolt/transport/ssh.rb +6 -2
  50. data/lib/bolt/transport/ssh/connection.rb +4 -0
  51. data/lib/bolt/transport/ssh/exec_connection.rb +110 -0
  52. data/lib/bolt/transport/winrm/connection.rb +6 -2
  53. data/lib/bolt/version.rb +1 -1
  54. data/lib/bolt_server/pe/pal.rb +1 -38
  55. data/lib/bolt_server/transport_app.rb +7 -7
  56. data/lib/bolt_spec/bolt_context.rb +1 -4
  57. data/lib/bolt_spec/plans/mock_executor.rb +1 -0
  58. data/lib/bolt_spec/run.rb +2 -5
  59. metadata +6 -2
@@ -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
@@ -353,9 +353,9 @@ module Bolt
353
353
  # Chunks of this size will be read in one iteration
354
354
  index = 0
355
355
  timeout = 0.1
356
+ result_output = Bolt::Node::Output.new
356
357
 
357
358
  inp, out, err, t = conn.execute(command_str)
358
- result_output = Bolt::Node::Output.new
359
359
  read_streams = { out => String.new,
360
360
  err => String.new }
361
361
  write_stream = in_buffer.empty? ? [] : [inp]
@@ -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,
@@ -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
@@ -108,8 +108,8 @@ module Bolt
108
108
  # it will fail if the shell attempts to provide stdin
109
109
  inp.close
110
110
 
111
- out_rd, out_wr = IO.pipe
112
- err_rd, err_wr = IO.pipe
111
+ out_rd, out_wr = IO.pipe('UTF-8')
112
+ err_rd, err_wr = IO.pipe('UTF-8')
113
113
  th = Thread.new do
114
114
  result = @session.run(command)
115
115
  out_wr << result.stdout
@@ -120,6 +120,10 @@ module Bolt
120
120
  end
121
121
 
122
122
  [inp, out_rd, err_rd, th]
123
+ rescue Errno::EMFILE => e
124
+ msg = "#{e.message}. This may be resolved by increasing your user limit "\
125
+ "with 'ulimit -n 1024'. See https://puppet.com/docs/bolt/latest/bolt_known_issues.html for details."
126
+ raise Bolt::Error.new(msg, 'bolt/too-many-files')
123
127
  rescue StandardError
124
128
  @logger.debug { "Command aborted" }
125
129
  raise
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bolt
4
- VERSION = '2.8.0'
4
+ VERSION = '2.12.0'
5
5
  end
@@ -51,50 +51,13 @@ module BoltServer
51
51
  basemodulepath = plan_executor_config['basemodulepath'] || "#{codedir}/modules:/opt/puppetlabs/puppet/modules"
52
52
 
53
53
  with_pe_pal_init_settings(codedir, environmentpath, basemodulepath) do
54
- modulepath_dirs = []
55
- modulepath_setting_from_bolt = nil
56
54
  environment = Puppet.lookup(:environments).get!(environment_name)
57
- path_to_env = environment.configuration.path_to_env
58
-
59
- # In the instance where the environment is "production" but no production dir
60
- # exists, the lookup will succeed, but the configuration will be mostly empty.
61
- # For other environments the lookup will fail, but for production we don't
62
- # want cryptic messages sent to the user about combining `nil` with a string.
63
- # Thus if we do get here and `path_to_env` is empty, just assume it's the
64
- # default production environment and continue.
65
- #
66
- # This should hopefully match puppet's behavior for the default 'production'
67
- # environment: _technically_ that environment always exists, but if the dir
68
- # isn't there it won't find the module and fail with "plan not found" rather
69
- # than "environment doesn't exist"
70
- if path_to_env
71
- bolt_yaml = File.join(environment.configuration.path_to_env, 'bolt.yaml')
72
- modulepath_setting_from_bolt = Bolt::Util.read_optional_yaml_hash(bolt_yaml, 'config')['modulepath']
73
- end
74
-
75
- # If we loaded a bolt.yaml in the environment root and it contained a modulepath setting:
76
- # we will use that modulepath rather than the one loaded through puppet. modulepath will
77
- # be the _only_ setting that will work from bolt.yaml in plans in PE.
78
- if modulepath_setting_from_bolt
79
- modulepath_setting_from_bolt.split(File::PATH_SEPARATOR).each do |path|
80
- if Pathname.new(path).absolute? && File.exist?(path)
81
- modulepath_dirs << path
82
- elsif File.exist?(File.join(path_to_env, path))
83
- modulepath_dirs << File.join(path_to_env, path)
84
- end
85
- end
86
-
87
- # Append the basemodulepath to include "built-in" modules.
88
- modulepath_dirs.concat(basemodulepath.split(File::PATH_SEPARATOR))
89
- else
90
- modulepath_dirs = environment.modulepath
91
- end
92
-
93
55
  # A new modulepath is created from scratch (rather than using super's @modulepath)
94
56
  # so that we can have full control over all the entries in modulepath. In the future
95
57
  # it's likely we will need to preceed _both_ Bolt::PAL::BOLTLIB_PATH _and_
96
58
  # Bolt::PAL::MODULES_PATH which would be more complex if we tried to use @modulepath since
97
59
  # we need to append our modulepaths and exclude modules shiped in bolt gem code
60
+ modulepath_dirs = environment.modulepath
98
61
  @original_modulepath = modulepath_dirs
99
62
  @modulepath = [PE_BOLTLIB_PATH, Bolt::PAL::BOLTLIB_PATH, *modulepath_dirs]
100
63
  end
@@ -57,8 +57,8 @@ module BoltServer
57
57
  end
58
58
 
59
59
  def scrub_stack_trace(result)
60
- if result.dig(:value, '_error', 'details', 'stack_trace')
61
- result[:value]['_error']['details'].reject! { |k| k == 'stack_trace' }
60
+ if result.dig('value', '_error', 'details', 'stack_trace')
61
+ result['value']['_error']['details'].reject! { |k| k == 'stack_trace' }
62
62
  end
63
63
  result
64
64
  end
@@ -87,14 +87,14 @@ module BoltServer
87
87
  # If the `result_set` contains only one item, it will be returned
88
88
  # as a single result object. Set `aggregate` to treat it as a set
89
89
  # of results with length 1 instead.
90
- def result_set_to_status_hash(result_set, aggregate: false)
90
+ def result_set_to_data(result_set, aggregate: false)
91
91
  scrubbed_results = result_set.map do |result|
92
- scrub_stack_trace(result.status_hash)
92
+ scrub_stack_trace(result.to_data)
93
93
  end
94
94
 
95
95
  if aggregate || scrubbed_results.length > 1
96
96
  # For actions that act on multiple targets, construct a status hash for the aggregate result
97
- all_succeeded = scrubbed_results.all? { |r| r[:status] == 'success' }
97
+ all_succeeded = scrubbed_results.all? { |r| r['status'] == 'success' }
98
98
  {
99
99
  status: all_succeeded ? 'success' : 'failure',
100
100
  result: scrubbed_results
@@ -297,7 +297,7 @@ module BoltServer
297
297
  return [400, error.to_json] unless error.nil?
298
298
 
299
299
  aggregate = body['target'].nil?
300
- [200, result_set_to_status_hash(result_set, aggregate: aggregate).to_json]
300
+ [200, result_set_to_data(result_set, aggregate: aggregate).to_json]
301
301
  end
302
302
 
303
303
  def make_winrm_target(target_hash)
@@ -337,7 +337,7 @@ module BoltServer
337
337
  return [400, error.to_json] if error
338
338
 
339
339
  aggregate = body['target'].nil?
340
- [200, result_set_to_status_hash(result_set, aggregate: aggregate).to_json]
340
+ [200, result_set_to_data(result_set, aggregate: aggregate).to_json]
341
341
  end
342
342
 
343
343
  # Fetches the metadata for a single plan