bolt 2.36.0 → 2.42.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/Puppetfile +8 -8
  3. data/lib/bolt/bolt_option_parser.rb +7 -3
  4. data/lib/bolt/cli.rb +67 -23
  5. data/lib/bolt/config.rb +70 -45
  6. data/lib/bolt/config/options.rb +104 -79
  7. data/lib/bolt/config/transport/base.rb +2 -2
  8. data/lib/bolt/config/transport/local.rb +1 -0
  9. data/lib/bolt/config/transport/options.rb +11 -68
  10. data/lib/bolt/config/transport/ssh.rb +0 -5
  11. data/lib/bolt/inventory.rb +26 -0
  12. data/lib/bolt/inventory/group.rb +29 -9
  13. data/lib/bolt/inventory/inventory.rb +1 -1
  14. data/lib/bolt/inventory/options.rb +130 -0
  15. data/lib/bolt/inventory/target.rb +10 -11
  16. data/lib/bolt/module.rb +10 -2
  17. data/lib/bolt/module_installer.rb +21 -13
  18. data/lib/bolt/module_installer/resolver.rb +13 -5
  19. data/lib/bolt/outputter.rb +19 -5
  20. data/lib/bolt/outputter/human.rb +20 -1
  21. data/lib/bolt/outputter/json.rb +1 -1
  22. data/lib/bolt/outputter/logger.rb +1 -1
  23. data/lib/bolt/outputter/rainbow.rb +12 -1
  24. data/lib/bolt/pal/yaml_plan/transpiler.rb +5 -1
  25. data/lib/bolt/plugin.rb +42 -6
  26. data/lib/bolt/plugin/cache.rb +76 -0
  27. data/lib/bolt/plugin/module.rb +4 -4
  28. data/lib/bolt/plugin/puppetdb.rb +1 -1
  29. data/lib/bolt/project.rb +38 -13
  30. data/lib/bolt/project_manager.rb +2 -0
  31. data/lib/bolt/project_manager/config_migrator.rb +9 -1
  32. data/lib/bolt/project_manager/module_migrator.rb +2 -0
  33. data/lib/bolt/puppetdb/client.rb +8 -0
  34. data/lib/bolt/rerun.rb +1 -5
  35. data/lib/bolt/shell/bash.rb +7 -1
  36. data/lib/bolt/shell/powershell.rb +21 -3
  37. data/lib/bolt/target.rb +4 -0
  38. data/lib/bolt/transport/local.rb +13 -0
  39. data/lib/bolt/util.rb +22 -0
  40. data/lib/bolt/validator.rb +227 -0
  41. data/lib/bolt/version.rb +1 -1
  42. data/lib/bolt_server/plugin.rb +13 -0
  43. data/lib/bolt_server/plugin/puppet_connect_data.rb +37 -0
  44. data/lib/bolt_server/schemas/connect-data.json +22 -0
  45. data/lib/bolt_server/schemas/partials/task.json +1 -1
  46. data/lib/bolt_server/transport_app.rb +64 -36
  47. metadata +24 -5
  48. data/lib/bolt/config/validator.rb +0 -231
@@ -103,7 +103,9 @@ module Bolt
103
103
  # early here and not initialize the project if the modules cannot be
104
104
  # resolved and installed.
105
105
  if modules
106
+ @outputter.start_spin
106
107
  Bolt::ModuleInstaller.new(@outputter, @pal).install(modules, puppetfile, moduledir)
108
+ @outputter.stop_spin
107
109
  end
108
110
 
109
111
  data = { 'name' => project_name }
@@ -72,7 +72,15 @@ module Bolt
72
72
  data = Bolt::Util.read_yaml_hash(project_file, 'config')
73
73
  modified = false
74
74
 
75
- [%w[apply_settings apply-settings], %w[plugin_hooks plugin-hooks]].each do |old, new|
75
+ # Keys to update. The first element is the old key, while the second is
76
+ # the key update it to.
77
+ to_update = [
78
+ %w[apply_settings apply-settings],
79
+ %w[puppetfile module-install],
80
+ %w[plugin_hooks plugin-hooks]
81
+ ]
82
+
83
+ to_update.each do |old, new|
76
84
  next unless data.key?(old)
77
85
 
78
86
  if data.key?(new)
@@ -61,6 +61,7 @@ module Bolt
61
61
  # Create specs to resolve from
62
62
  specs = Bolt::ModuleInstaller::Specs.new(modules.map(&:to_hash))
63
63
 
64
+ @outputter.start_spin
64
65
  # Attempt to resolve dependencies
65
66
  begin
66
67
  @outputter.print_message('')
@@ -72,6 +73,7 @@ module Bolt
72
73
  end
73
74
 
74
75
  migrate_managed_modules(puppetfile, puppetfile_path, managed_moduledir)
76
+ @outputter.stop_spin
75
77
 
76
78
  # Move remaining modules to 'modules'
77
79
  consolidate_modules(modulepath)
@@ -18,6 +18,7 @@ module Bolt
18
18
  def query_certnames(query)
19
19
  return [] unless query
20
20
 
21
+ @logger.debug("Querying certnames")
21
22
  results = make_query(query)
22
23
 
23
24
  if results&.first && !results.first&.key?('certname')
@@ -34,6 +35,8 @@ module Bolt
34
35
  certnames.uniq!
35
36
  name_query = certnames.map { |c| ["=", "certname", c] }
36
37
  name_query.insert(0, "or")
38
+
39
+ @logger.debug("Querying certnames")
37
40
  result = make_query(name_query, 'inventory')
38
41
 
39
42
  result&.each_with_object({}) do |node, coll|
@@ -52,6 +55,8 @@ module Bolt
52
55
  facts_query.insert(0, "or")
53
56
 
54
57
  query = ['and', name_query, facts_query]
58
+
59
+ @logger.debug("Querying certnames")
55
60
  result = make_query(query, 'fact-contents')
56
61
  result.map! { |h| h.delete_if { |k, _v| %w[environment name].include?(k) } }
57
62
  result.group_by { |c| c['certname'] }
@@ -63,11 +68,13 @@ module Bolt
63
68
  url += "/#{path}" if path
64
69
 
65
70
  begin
71
+ @logger.debug("Sending PuppetDB query to #{url}")
66
72
  response = http_client.post(url, body: body, header: headers)
67
73
  rescue StandardError => e
68
74
  raise Bolt::PuppetDBFailoverError, "Failed to query PuppetDB: #{e}"
69
75
  end
70
76
 
77
+ @logger.debug("Got response code #{response.code} from PuppetDB")
71
78
  if response.code != 200
72
79
  msg = "Failed to query PuppetDB: #{response.body}"
73
80
  if response.code == 400
@@ -92,6 +99,7 @@ module Bolt
92
99
  return @http if @http
93
100
  # lazy-load expensive gem code
94
101
  require 'httpclient'
102
+ @logger.trace("Creating HTTP Client")
95
103
  @http = HTTPClient.new
96
104
  @http.ssl_config.set_client_cert_file(@config.cert, @config.key) if @config.cert
97
105
  @http.ssl_config.add_trust_ca(@config.cacert)
@@ -12,15 +12,11 @@ module Bolt
12
12
  end
13
13
 
14
14
  def data
15
- @data ||= JSON.parse(File.read(@path))
15
+ @data ||= Bolt::Util.read_json_file(@path, 'rerun')
16
16
  unless @data.is_a?(Array) && @data.all? { |r| r['target'] && r['status'] }
17
17
  raise Bolt::FileError.new("Missing data in rerun file: #{@path}", @path)
18
18
  end
19
19
  @data
20
- rescue JSON::ParserError
21
- raise Bolt::FileError.new("Could not parse rerun file: #{@path}", @path)
22
- rescue IOError, SystemCallError
23
- raise Bolt::FileError.new("Could not read rerun file: #{@path}", @path)
24
20
  end
25
21
 
26
22
  def get_targets(filter)
@@ -436,8 +436,14 @@ module Bolt
436
436
  result_output.stderr << read_streams[err]
437
437
  result_output.exit_code = t.value.respond_to?(:exitstatus) ? t.value.exitstatus : t.value
438
438
 
439
- if result_output.exit_code == 0
439
+ case result_output.exit_code
440
+ when 0
440
441
  @logger.trace { "Command `#{command_str}` returned successfully" }
442
+ when 126
443
+ msg = "\n\nThis may be caused by the default tmpdir being mounted "\
444
+ "using 'noexec'. See http://pup.pt/task-failure for details and workarounds."
445
+ result_output.stderr << msg
446
+ @logger.trace { "Command #{command_str} failed with exit code #{result_output.exit_code}" }
441
447
  else
442
448
  @logger.trace { "Command #{command_str} failed with exit code #{result_output.exit_code}" }
443
449
  end
@@ -14,6 +14,22 @@ module Bolt
14
14
  extensions = [target.options['extensions'] || []].flatten.map { |ext| ext[0] == '.' ? ext : '.' + ext }
15
15
  extensions += target.options['interpreters'].keys if target.options['interpreters']
16
16
  @extensions = DEFAULT_EXTENSIONS + extensions
17
+ validate_ps_version
18
+ end
19
+
20
+ def validate_ps_version
21
+ version = execute("$PSVersionTable.PSVersion.Major").stdout.string.chomp
22
+ if !version.empty? && version.to_i < 3
23
+ # This lets us know how many targets have Powershell 2, and lets the
24
+ # user know how many targets they have with PS2
25
+ msg = "Detected PowerShell 2 on one or more targets.\nPowerShell 2 "\
26
+ "is deprecated, and support will be removed in Bolt 3.0. See "\
27
+ "bolt-debug.log or run with '--log-level debug' to see the full "\
28
+ "list of targets with PowerShell 2."
29
+
30
+ Bolt::Logger.deprecation_warning("PowerShell 2", msg)
31
+ @logger.debug("Detected PowerShell 2 on #{target}.")
32
+ end
17
33
  end
18
34
 
19
35
  def provided_features
@@ -177,7 +193,8 @@ module Bolt
177
193
  def run_command(command, options = {}, position = [])
178
194
  command = [*env_declarations(options[:env_vars]), command].join("\r\n") if options[:env_vars]
179
195
 
180
- output = execute(command)
196
+ wrap_command = conn.is_a?(Bolt::Transport::Local::Connection)
197
+ output = execute(command, wrap_command)
181
198
  Bolt::Result.for_command(target,
182
199
  output.stdout.string,
183
200
  output.stderr.string,
@@ -268,8 +285,9 @@ module Bolt
268
285
  end
269
286
  end
270
287
 
271
- def execute(command)
272
- if conn.max_command_length && command.length > conn.max_command_length
288
+ def execute(command, wrap_command = false)
289
+ if (conn.max_command_length && command.length > conn.max_command_length) ||
290
+ wrap_command
273
291
  return with_tmpdir do |dir|
274
292
  command += "\r\nif (!$?) { if($LASTEXITCODE) { exit $LASTEXITCODE } else { exit 1 } }"
275
293
  script_file = File.join(dir, "#{SecureRandom.uuid}_wrapper.ps1")
@@ -80,6 +80,10 @@ module Bolt
80
80
  inventory_target.resources
81
81
  end
82
82
 
83
+ def set_local_defaults
84
+ inventory_target.set_local_defaults
85
+ end
86
+
83
87
  # rubocop:disable Naming/AccessorMethodName
84
88
  def set_resource(resource)
85
89
  inventory_target.set_resource(resource)
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'bolt/logger'
3
4
  require 'bolt/transport/simple'
4
5
 
5
6
  module Bolt
@@ -10,6 +11,18 @@ module Bolt
10
11
  end
11
12
 
12
13
  def with_connection(target)
14
+ if target.transport_config['bundled-ruby'] || target.name == 'localhost'
15
+ target.set_local_defaults
16
+ end
17
+
18
+ if target.name != 'localhost' &&
19
+ !target.transport_config.key?('bundled-ruby')
20
+ msg = "The local transport will default to using Bolt's Ruby interpreter and "\
21
+ "setting the 'puppet-agent' feature in Bolt 3.0. Enable or disable these "\
22
+ "defaults by setting 'bundled-ruby' in the local transport config."
23
+ Bolt::Logger.warn_once('local default config', msg)
24
+ end
25
+
13
26
  yield Connection.new(target)
14
27
  end
15
28
  end
@@ -22,6 +22,28 @@ module Bolt
22
22
  raise Bolt::FileError.new("Error attempting to read #{file}: #{e}", file)
23
23
  end
24
24
 
25
+ def read_json_file(path, filename)
26
+ require 'json'
27
+
28
+ logger = Bolt::Logger.logger(self)
29
+ path = File.expand_path(path)
30
+ content = JSON.parse(File.read(path))
31
+ logger.trace("Loaded #{filename} from #{path}")
32
+ content
33
+ rescue Errno::ENOENT
34
+ raise Bolt::FileError.new("Could not read #{filename} file at #{path}", path)
35
+ rescue JSON::ParserError => e
36
+ msg = "Unable to parse #{filename} file at #{path} as JSON: #{e.message}"
37
+ raise Bolt::FileError.new(msg, path)
38
+ rescue IOError, SystemCallError => e
39
+ raise Bolt::FileError.new("Could not read #{filename} file at #{path}\n#{e.message}",
40
+ path)
41
+ end
42
+
43
+ def read_optional_json_file(path, file_name)
44
+ File.exist?(path) && !File.zero?(path) ? read_yaml_hash(path, file_name) : {}
45
+ end
46
+
25
47
  def read_yaml_hash(path, file_name)
26
48
  require 'yaml'
27
49
 
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bolt/error'
4
+
5
+ # This class validates config against a schema, raising an error that includes
6
+ # details about any invalid configuration.
7
+ #
8
+ module Bolt
9
+ class Validator
10
+ attr_reader :deprecations, :warnings
11
+
12
+ def initialize
13
+ @errors = []
14
+ @deprecations = []
15
+ @warnings = []
16
+ @path = []
17
+ end
18
+
19
+ # This is the entry method for validating data against the schema.
20
+ #
21
+ def validate(data, schema, location = nil)
22
+ @schema = schema
23
+ @location = location
24
+
25
+ validate_value(data, schema)
26
+
27
+ raise_error
28
+ end
29
+
30
+ # Raises a ValidationError if there are any errors. All error messages
31
+ # created during validation are concatenated into a single error
32
+ # message.
33
+ #
34
+ private def raise_error
35
+ return unless @errors.any?
36
+
37
+ message = "Invalid configuration"
38
+ message += " at #{@location}" if @location
39
+ message += ":\n"
40
+ message += @errors.map { |error| "\s\s#{error}" }.join("\n")
41
+
42
+ raise Bolt::ValidationError, message
43
+ end
44
+
45
+ # Validate an individual value. This performs validation that is
46
+ # common to all values, including type validation. After validating
47
+ # the value's type, the value is passed off to an individual
48
+ # validation method for the value's type.
49
+ #
50
+ private def validate_value(value, definition, plugin_supported = false)
51
+ definition = @schema.dig(:definitions, definition[:_ref]) if definition[:_ref]
52
+ plugin_supported = definition[:_plugin] if definition.key?(:_plugin)
53
+
54
+ return if plugin_reference?(value, plugin_supported)
55
+ return unless valid_type?(value, definition)
56
+
57
+ case value
58
+ when Hash
59
+ validate_hash(value, definition, plugin_supported)
60
+ when Array
61
+ validate_array(value, definition, plugin_supported)
62
+ when String
63
+ validate_string(value, definition)
64
+ when Numeric
65
+ validate_number(value, definition)
66
+ end
67
+ end
68
+
69
+ # Validates a hash value, logging errors for any validations that fail.
70
+ # This will enumerate each key-value pair in the hash and validate each
71
+ # value individually.
72
+ #
73
+ private def validate_hash(value, definition, plugin_supported)
74
+ properties = definition[:properties] ? definition[:properties].keys : []
75
+
76
+ if definition[:properties] && definition[:additionalProperties].nil?
77
+ validate_keys(value.keys, properties)
78
+ end
79
+
80
+ if definition[:required] && (definition[:required] - value.keys).any?
81
+ missing = definition[:required] - value.keys
82
+ @errors << "Value at '#{path}' is missing required keys #{missing.join(', ')}"
83
+ end
84
+
85
+ value.each_pair do |key, val|
86
+ @path.push(key)
87
+
88
+ if properties.include?(key)
89
+ check_deprecated(key, definition[:properties][key])
90
+ validate_value(val, definition[:properties][key], plugin_supported)
91
+ elsif definition[:additionalProperties].is_a?(Hash)
92
+ validate_value(val, definition[:additionalProperties], plugin_supported)
93
+ end
94
+ ensure
95
+ @path.pop
96
+ end
97
+ end
98
+
99
+ # Validates an array value, logging errors for any validations that fail.
100
+ # This will enumerate the items in the array and validate each item
101
+ # individually.
102
+ #
103
+ private def validate_array(value, definition, plugin_supported)
104
+ if definition[:uniqueItems] && value.size != value.uniq.size
105
+ @errors << "Value at '#{path}' must not include duplicate elements"
106
+ return
107
+ end
108
+
109
+ return unless definition.key?(:items)
110
+
111
+ value.each_with_index do |item, index|
112
+ @path.push(index)
113
+ validate_value(item, definition[:items], plugin_supported)
114
+ ensure
115
+ @path.pop
116
+ end
117
+ end
118
+
119
+ # Validates a string value, logging errors for any validations that fail.
120
+ #
121
+ private def validate_string(value, definition)
122
+ if definition.key?(:enum) && !definition[:enum].include?(value)
123
+ message = "Value at '#{path}' must be "
124
+ message += "one of " if definition[:enum].count > 1
125
+ message += definition[:enum].join(', ')
126
+ multitype_error(message, value, definition)
127
+ end
128
+ end
129
+
130
+ # Validates a numeric value, logging errors for any validations that fail.
131
+ #
132
+ private def validate_number(value, definition)
133
+ if definition.key?(:minimum) && value < definition[:minimum]
134
+ @errors << "Value at '#{path}' must be a minimum of #{definition[:minimum]}"
135
+ end
136
+
137
+ if definition.key?(:maximum) && value > definition[:maximum]
138
+ @errors << "Value at '#{path}' must be a maximum of #{definition[:maximum]}"
139
+ end
140
+ end
141
+
142
+ # Adds warnings for unknown config options.
143
+ #
144
+ private def validate_keys(keys, known_keys)
145
+ (keys - known_keys).each do |key|
146
+ message = "Unknown option '#{key}'"
147
+ message += " at '#{path}'" if @path.any?
148
+ message += " at #{@location}" if @location
149
+ message += "."
150
+ @warnings << message
151
+ end
152
+ end
153
+
154
+ # Adds a warning if the given option is deprecated.
155
+ #
156
+ private def check_deprecated(key, definition)
157
+ definition = @schema.dig(:definitions, definition[:_ref]) if definition[:_ref]
158
+
159
+ if definition.key?(:_deprecation)
160
+ message = "Option '#{path}' "
161
+ message += "at #{@location} " if @location
162
+ message += "is deprecated. #{definition[:_deprecation]}"
163
+ @deprecations << { option: key, message: message }
164
+ end
165
+ end
166
+
167
+ # Returns true if a value is a plugin reference. This also validates whether
168
+ # a value can be a plugin reference in the first place. If the value is a
169
+ # plugin reference but cannot be one according to the schema, then this will
170
+ # log an error.
171
+ #
172
+ private def plugin_reference?(value, plugin_supported)
173
+ if value.is_a?(Hash) && value.key?('_plugin')
174
+ unless plugin_supported
175
+ @errors << "Value at '#{path}' is a plugin reference, which is unsupported at "\
176
+ "this location"
177
+ end
178
+
179
+ true
180
+ else
181
+ false
182
+ end
183
+ end
184
+
185
+ # Asserts the type for each option against the type specified in the schema
186
+ # definition. The schema definition can specify multiple valid types, so the
187
+ # value needs to only match one of the types to be valid. Returns early if
188
+ # there is no type in the definition (in practice this shouldn't happen, but
189
+ # this will safeguard against any dev mistakes).
190
+ #
191
+ private def valid_type?(value, definition)
192
+ return unless definition.key?(:type)
193
+
194
+ types = Array(definition[:type])
195
+
196
+ if types.include?(value.class)
197
+ true
198
+ else
199
+ if types.include?(TrueClass) || types.include?(FalseClass)
200
+ types = types - [TrueClass, FalseClass] + ['Boolean']
201
+ end
202
+
203
+ @errors << "Value at '#{path}' must be of type #{types.join(' or ')}"
204
+
205
+ false
206
+ end
207
+ end
208
+
209
+ # Adds an error that includes additional helpful information for values
210
+ # that accept multiple types.
211
+ #
212
+ private def multitype_error(message, value, definition)
213
+ if Array(definition[:type]).count > 1
214
+ types = Array(definition[:type]) - [value.class]
215
+ message += " or must be of type #{types.join(' or ')}"
216
+ end
217
+
218
+ @errors << message
219
+ end
220
+
221
+ # Returns the formatted path for the key.
222
+ #
223
+ private def path
224
+ @path.join('.')
225
+ end
226
+ end
227
+ end