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
@@ -99,6 +99,7 @@ module Bolt
99
99
  # logic.
100
100
  class EvaluableString
101
101
  attr_reader :value
102
+
102
103
  def initialize(value)
103
104
  @value = value
104
105
  end
@@ -115,17 +115,13 @@ module Bolt
115
115
  end
116
116
 
117
117
  def boltdir
118
- @config.boltdir.path
118
+ @config.project.path
119
119
  end
120
120
  end
121
121
 
122
- def self.setup(config, pal, pdb_client, analytics)
122
+ def self.setup(config, pal, analytics = Bolt::Analytics::NoopClient.new)
123
123
  plugins = new(config, pal, analytics)
124
124
 
125
- # PDB is special because it needs the PDB client. Since it has no config,
126
- # we can just add it first.
127
- plugins.add_plugin(Bolt::Plugin::Puppetdb.new(pdb_client))
128
-
129
125
  # Initialize any plugins referenced in plugin config. This will also indirectly
130
126
  # initialize any plugins they depend on.
131
127
  if plugins.reference?(config.plugins)
@@ -142,7 +138,7 @@ module Bolt
142
138
  plugins
143
139
  end
144
140
 
145
- RUBY_PLUGINS = %w[task pkcs7 prompt env_var].freeze
141
+ RUBY_PLUGINS = %w[task prompt env_var puppetdb].freeze
146
142
  BUILTIN_PLUGINS = %w[task terraform pkcs7 prompt vault aws_inventory puppetdb azure_inventory
147
143
  yaml env_var gcloud_inventory].freeze
148
144
  DEFAULT_PLUGIN_HOOKS = { 'puppet_library' => { 'plugin' => 'puppet_agent', 'stop_service' => true } }.freeze
@@ -161,6 +157,13 @@ module Bolt
161
157
  @unknown = Set.new
162
158
  @resolution_stack = []
163
159
  @unresolved_plugin_configs = config.plugins.dup
160
+ # The puppetdb plugin config comes from the puppetdb section, not from
161
+ # the plugins section
162
+ if @unresolved_plugin_configs.key?('puppetdb')
163
+ msg = "Configuration for the PuppetDB plugin must be in the 'puppetdb' config section, not 'plugins'"
164
+ raise Bolt::Error.new(msg, 'bolt/plugin-error')
165
+ end
166
+ @unresolved_plugin_configs['puppetdb'] = config.puppetdb if config.puppetdb
164
167
  @plugin_hooks = DEFAULT_PLUGIN_HOOKS.dup
165
168
  end
166
169
 
@@ -168,7 +171,6 @@ module Bolt
168
171
  @modules ||= Bolt::Module.discover(@pal.modulepath)
169
172
  end
170
173
 
171
- # Generally this is private. Puppetdb is special though
172
174
  def add_plugin(plugin)
173
175
  @plugins[plugin.name] = plugin
174
176
  end
@@ -235,6 +237,10 @@ module Bolt
235
237
  end
236
238
  end
237
239
 
240
+ def puppetdb_client
241
+ by_name('puppetdb').puppetdb_client
242
+ end
243
+
238
244
  # Evaluate all _plugin references in a data structure. Leaves are
239
245
  # evaluated and then their parents are evaluated with references replaced
240
246
  # by their values. If the result of a reference contains more references,
@@ -30,6 +30,10 @@ module Bolt
30
30
  @module = mod
31
31
  @config = config
32
32
  @context = context
33
+
34
+ if @module.name == 'pkcs7'
35
+ @config = handle_deprecated_pkcs7_keys(@config)
36
+ end
33
37
  end
34
38
 
35
39
  # This method interacts with the module on disk so it's separate from initialize
@@ -155,7 +159,21 @@ module Bolt
155
159
  # handled previously. That may not always be the case so filter them
156
160
  # out now.
157
161
  meta, params = opts.partition { |key, _val| key.start_with?('_') }.map(&:to_h)
158
- params = config.merge(params)
162
+
163
+ if task.module_name == 'pkcs7'
164
+ params = handle_deprecated_pkcs7_keys(params)
165
+ end
166
+
167
+ # Reject parameters from config that are not accepted by the task and
168
+ # merge in parameter defaults
169
+ params = if task.parameters
170
+ task.parameter_defaults
171
+ .merge(config.slice(*task.parameters.keys))
172
+ .merge(params)
173
+ else
174
+ config.merge(params)
175
+ end
176
+
159
177
  validate_params(task, params)
160
178
 
161
179
  meta['_boltdir'] = @context.boltdir.to_s
@@ -163,6 +181,23 @@ module Bolt
163
181
  [params, meta]
164
182
  end
165
183
 
184
+ # Raises a deprecation warning if the pkcs7 plugin is using deprecated keys and
185
+ # modifies the keys so they are the correct format
186
+ def handle_deprecated_pkcs7_keys(params)
187
+ if (params.key?('private-key') || params.key?('public-key')) && !@deprecation_warning_issued
188
+ @deprecation_warning_issued = true
189
+
190
+ message = "pkcs7 keys 'private-key' and 'public-key' have been deprecated and will be "\
191
+ "removed in a future version of Bolt; use 'private_key' and 'public_key' instead."
192
+ Logging.logger[self].warn(message)
193
+ end
194
+
195
+ params['private_key'] = params.delete('private-key') if params.key?('private-key')
196
+ params['public_key'] = params.delete('public-key') if params.key?('public-key')
197
+
198
+ params
199
+ end
200
+
166
201
  def extract_task_parameter_schema
167
202
  # Get the intersection of expected types (using Set)
168
203
  type_set = @hook_map.each_with_object({}) do |(_hook, task), acc|
@@ -220,13 +255,11 @@ module Bolt
220
255
  end
221
256
 
222
257
  def validate_resolve_reference(opts)
223
- # Merge config with params
224
- merged = @config.merge(opts)
225
- params = merged.reject { |k, _v| k.start_with?('_') }
258
+ task = @hook_map[:resolve_reference]['task']
259
+ params, _metaparams = process_params(task, opts)
226
260
 
227
- sig = @hook_map[:resolve_reference]['task']
228
- if sig
229
- validate_params(sig, params)
261
+ if task
262
+ validate_params(task, params)
230
263
  end
231
264
 
232
265
  if @hook_map.include?(:validate_resolve_reference)
@@ -14,8 +14,11 @@ module Bolt
14
14
  TEMPLATE_OPTS = %w[alias config facts features name uri vars].freeze
15
15
  PLUGIN_OPTS = %w[_plugin query target_mapping].freeze
16
16
 
17
- def initialize(pdb_client)
18
- @puppetdb_client = pdb_client
17
+ attr_reader :puppetdb_client
18
+
19
+ def initialize(config:, context:)
20
+ pdb_config = Bolt::PuppetDB::Config.load_config(config, context.boltdir)
21
+ @puppetdb_client = Bolt::PuppetDB::Client.new(pdb_config)
19
22
  @logger = Logging.logger[self]
20
23
  end
21
24
 
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require 'bolt/pal'
5
+
6
+ module Bolt
7
+ class Project
8
+ BOLTDIR_NAME = 'Boltdir'
9
+ PROJECT_SETTINGS = {
10
+ "name" => "The name of the project",
11
+ "plans" => "An array of plan names to whitelist. Whitelisted plans are included in `bolt plan show` output",
12
+ "tasks" => "An array of task names to whitelist. Whitelisted plans are included in `bolt task show` output"
13
+ }.freeze
14
+
15
+ attr_reader :path, :config_file, :inventory_file, :modulepath, :hiera_config,
16
+ :puppetfile, :rerunfile, :type, :resource_types
17
+
18
+ def self.default_project
19
+ Project.new(File.join('~', '.puppetlabs', 'bolt'), 'user')
20
+ end
21
+
22
+ # Search recursively up the directory hierarchy for the Project. Look for a
23
+ # directory called Boltdir or a file called bolt.yaml (for a control repo
24
+ # type Project). Otherwise, repeat the check on each directory up the
25
+ # hierarchy, falling back to the default if we reach the root.
26
+ def self.find_boltdir(dir)
27
+ dir = Pathname.new(dir)
28
+ if (dir + BOLTDIR_NAME).directory?
29
+ new(dir + BOLTDIR_NAME, 'embedded')
30
+ elsif (dir + 'bolt.yaml').file? || (dir + 'bolt-project.yaml').file?
31
+ new(dir, 'local')
32
+ elsif dir.root?
33
+ default_project
34
+ else
35
+ find_boltdir(dir.parent)
36
+ end
37
+ end
38
+
39
+ def initialize(path, type = 'option')
40
+ @path = Pathname.new(path).expand_path
41
+ @config_file = @path + 'bolt.yaml'
42
+ @inventory_file = @path + 'inventory.yaml'
43
+ @modulepath = [(@path + 'modules').to_s, (@path + 'site-modules').to_s, (@path + 'site').to_s]
44
+ @hiera_config = @path + 'hiera.yaml'
45
+ @puppetfile = @path + 'Puppetfile'
46
+ @rerunfile = @path + '.rerun.json'
47
+ @resource_types = @path + '.resource_types'
48
+ @type = type
49
+
50
+ @project_file = @path + 'bolt-project.yaml'
51
+ @data = Bolt::Util.read_optional_yaml_hash(File.expand_path(@project_file), 'project') || {}
52
+ validate if load_as_module?
53
+ end
54
+
55
+ def to_s
56
+ @path.to_s
57
+ end
58
+
59
+ # This API is used to prepend the project as a module to Puppet's internal
60
+ # module_references list. CHANGE AT YOUR OWN RISK
61
+ def to_h
62
+ { path: @path, name: name }
63
+ end
64
+
65
+ def eql?(other)
66
+ path == other.path
67
+ end
68
+ alias == eql?
69
+
70
+ def load_as_module?
71
+ @project_file.file?
72
+ end
73
+
74
+ def name
75
+ # If the project is in mymod/Boltdir/bolt-project.yaml, use mymod as the project name
76
+ dirname = @path.basename.to_s == 'Boltdir' ? @path.parent.basename.to_s : @path.basename.to_s
77
+ pname = @data['name'] || dirname
78
+ pname.include?('-') ? pname.split('-', 2)[1] : pname
79
+ end
80
+
81
+ def tasks
82
+ @data['tasks']
83
+ end
84
+
85
+ def plans
86
+ @data['plans']
87
+ end
88
+
89
+ def project_directory_name?(name)
90
+ # it must match an installed project name according to forge validator
91
+ name =~ /^[a-z][a-z0-9_]*$/
92
+ end
93
+
94
+ def project_namespaced_name?(name)
95
+ # it must match the full project name according to forge validator
96
+ name =~ /^[a-zA-Z0-9]+[-][a-z][a-z0-9_]*$/
97
+ end
98
+
99
+ def validate
100
+ n = @data['name']
101
+ if n && !project_directory_name?(n) && !project_namespaced_name?(n)
102
+ raise Bolt::ValidationError, <<~ERROR_STRING
103
+ Invalid project name '#{n}' in bolt-project.yaml; project names must match either:
104
+ An installed project name (ex. projectname) matching the expression /^[a-z][a-z0-9_]*$/ -or-
105
+ A namespaced project name (ex. author-projectname) matching the expression /^[a-zA-Z0-9]+[-][a-z][a-z0-9_]*$/
106
+ ERROR_STRING
107
+ elsif !project_directory_name?(name) && !project_namespaced_name?(name)
108
+ raise Bolt::ValidationError, <<~ERROR_STRING
109
+ Invalid project name '#{name}'; project names must match either:
110
+ A project name (ex. projectname) matching the expression /^[a-z][a-z0-9_]*$/ -or-
111
+ A namespaced project name (ex. author-projectname) matching the expression /^[a-zA-Z0-9]+[-][a-z][a-z0-9_]*$/
112
+
113
+ Configure project name in <project_dir>/bolt-project.yaml
114
+ ERROR_STRING
115
+ # If the project name is the same as one of the built-in modules raise a warning
116
+ elsif Dir.children(Bolt::PAL::BOLTLIB_PATH).include?(name)
117
+ raise Bolt::ValidationError, "The project '#{name}' will not be loaded. The project name conflicts "\
118
+ "with a built-in Bolt module of the same name."
119
+ end
120
+
121
+ %w[tasks plans].each do |conf|
122
+ unless @data.fetch(conf, []).is_a?(Array)
123
+ raise Bolt::ValidationError, "'#{conf}' in bolt-project.yaml must be an array"
124
+ end
125
+ end
126
+ end
127
+
128
+ def check_deprecated_file
129
+ if (@path + 'project.yaml').file?
130
+ logger = Logging.logger[self]
131
+ logger.warn "Project configuration file 'project.yaml' is deprecated; use 'bolt-project.yaml' instead."
132
+ end
133
+ end
134
+ end
135
+ end
@@ -22,37 +22,29 @@ module Bolt
22
22
  File.expand_path(File.join(Dir::COMMON_APPDATA, 'PuppetLabs/client-tools/puppetdb.conf'))
23
23
  end
24
24
 
25
- def self.load_config(filename, options, boltdir_path = nil)
25
+ def self.load_config(options, project_path = nil)
26
26
  config = {}
27
27
  global_path = Bolt::Util.windows? ? default_windows_config : DEFAULT_CONFIG[:global]
28
- if filename
29
- if File.exist?(filename)
30
- config = JSON.parse(File.read(filename))
31
- else
32
- raise Bolt::PuppetDBError, "config file #{filename} does not exist"
33
- end
34
- else
35
- if File.exist?(DEFAULT_CONFIG[:user])
36
- filepath = DEFAULT_CONFIG[:user]
37
- elsif File.exist?(global_path)
38
- filepath = global_path
39
- end
40
28
 
41
- begin
42
- config = JSON.parse(File.read(filepath)) if filepath
43
- rescue StandardError => e
44
- Logging.logger[self].error("Could not load puppetdb.conf from #{filepath}: #{e.message}")
45
- end
29
+ if File.exist?(DEFAULT_CONFIG[:user])
30
+ filepath = DEFAULT_CONFIG[:user]
31
+ elsif File.exist?(global_path)
32
+ filepath = global_path
33
+ end
34
+
35
+ begin
36
+ config = JSON.parse(File.read(filepath)) if filepath
37
+ rescue StandardError => e
38
+ Logging.logger[self].error("Could not load puppetdb.conf from #{filepath}: #{e.message}")
46
39
  end
47
40
 
48
41
  config = config.fetch('puppetdb', {})
49
- new(config.merge(options), boltdir_path)
42
+ new(config.merge(options), project_path)
50
43
  end
51
44
 
52
- def initialize(settings, boltdir_path = nil)
45
+ def initialize(settings, project_path = nil)
53
46
  @settings = settings
54
- @boltdir_path = boltdir_path
55
- expand_paths
47
+ expand_paths(project_path)
56
48
  end
57
49
 
58
50
  def token
@@ -68,14 +60,10 @@ module Bolt
68
60
  @token = @token.strip if @token
69
61
  end
70
62
 
71
- def expand_paths
63
+ def expand_paths(project_path)
72
64
  %w[cacert cert key token].each do |file|
73
65
  next unless @settings[file]
74
- @settings[file] = if @boltdir_path
75
- File.expand_path(@settings[file], @boltdir_path)
76
- else
77
- File.expand_path(@settings[file])
78
- end
66
+ @settings[file] = File.expand_path(@settings[file], project_path)
79
67
  end
80
68
  end
81
69
 
@@ -45,7 +45,7 @@ module Bolt
45
45
  end
46
46
 
47
47
  if result_set.is_a?(Bolt::ResultSet)
48
- data = result_set.map { |res| res.status_hash.select { |k, _| %i[target status].include? k } }
48
+ data = result_set.map { |res| { target: res.target.name, status: res.status } }
49
49
  FileUtils.mkdir_p(File.dirname(@path))
50
50
  File.write(@path, data.to_json)
51
51
  elsif File.exist?(@path)
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Bolt
6
+ class ResourceInstance
7
+ attr_reader :target, :type, :title, :state, :desired_state
8
+ attr_accessor :events
9
+
10
+ # Needed by Puppet to recognize Bolt::ResourceInstance as a Puppet object when deserializing
11
+ def self._pcore_type
12
+ ResourceInstance
13
+ end
14
+
15
+ # Needed by Puppet to serialize with _pcore_init_hash instead of the object's attributes
16
+ def self._pcore_init_from_hash(_init_hash)
17
+ raise "ResourceInstance shouldn't be instantiated from a pcore_init class method. "\
18
+ "How did this get called?"
19
+ end
20
+
21
+ def _pcore_init_from_hash(init_hash)
22
+ initialize(init_hash)
23
+ end
24
+
25
+ # Parameters will already be validated when calling ResourceInstance.new or
26
+ # set_resources() from a plan. We don't perform any validation in the class
27
+ # itself since Puppet will pass an empty hash to the initializer as part of
28
+ # the deserialization process before passing the _pcore_init_hash.
29
+ def initialize(resource_hash)
30
+ @target = resource_hash['target']
31
+ @type = resource_hash['type'].to_s.capitalize
32
+ @title = resource_hash['title']
33
+ # get_resources() returns observed state under the 'parameters' key
34
+ @state = resource_hash['state'] || resource_hash['parameters'] || {}
35
+ @desired_state = resource_hash['desired_state'] || {}
36
+ @events = resource_hash['events'] || []
37
+ end
38
+
39
+ # Creates a ResourceInstance from a data hash in a plan when calling
40
+ # ResourceInstance.new($resource_hash) or $target.set_resources($resource_hash)
41
+ def self.from_asserted_hash(resource_hash)
42
+ new(resource_hash)
43
+ end
44
+
45
+ # Creates a ResourceInstance from positional arguments in a plan when
46
+ # calling ResourceInstance.new(target, type, title, ...)
47
+ def self.from_asserted_args(target,
48
+ type,
49
+ title,
50
+ state = nil,
51
+ desired_state = nil,
52
+ events = nil)
53
+ new(
54
+ 'target' => target,
55
+ 'type' => type,
56
+ 'title' => title,
57
+ 'state' => state,
58
+ 'desired_state' => desired_state,
59
+ 'events' => events
60
+ )
61
+ end
62
+
63
+ def eql?(other)
64
+ self.class.equal?(other.class) &&
65
+ target == other.target &&
66
+ type == other.type &&
67
+ title == other.title
68
+ end
69
+ alias == eql?
70
+
71
+ def to_hash
72
+ {
73
+ 'target' => target,
74
+ 'type' => type,
75
+ 'title' => title,
76
+ 'state' => state,
77
+ 'desired_state' => desired_state,
78
+ 'events' => events
79
+ }
80
+ end
81
+ alias _pcore_init_hash to_hash
82
+
83
+ def to_json(opts = nil)
84
+ to_hash.to_json(opts)
85
+ end
86
+
87
+ def reference
88
+ "#{type}[#{title}]"
89
+ end
90
+ alias to_s reference
91
+
92
+ def add_event(event)
93
+ @events << event
94
+ end
95
+
96
+ # rubocop:disable Naming/AccessorMethodName
97
+ def set_state(state)
98
+ assert_hash('state', state)
99
+ @state.merge!(state)
100
+ end
101
+ # rubocop:enable Naming/AccessorMethodName
102
+
103
+ def overwrite_state(state)
104
+ assert_hash('state', state)
105
+ @state = state
106
+ end
107
+
108
+ # rubocop:disable Naming/AccessorMethodName
109
+ def set_desired_state(desired_state)
110
+ assert_hash('desired_state', desired_state)
111
+ @desired_state.merge!(desired_state)
112
+ end
113
+ # rubocop:enable Naming/AccessorMethodName
114
+
115
+ def overwrite_desired_state(desired_state)
116
+ assert_hash('desired_state', desired_state)
117
+ @desired_state = desired_state
118
+ end
119
+
120
+ def assert_hash(loc, value)
121
+ unless value.is_a?(Hash)
122
+ raise Bolt::ValidationError, "#{loc} must be of type Hash; got #{value.class}"
123
+ end
124
+ end
125
+ end
126
+ end