bolt 2.34.0 → 2.40.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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/Puppetfile +1 -1
  3. data/bolt-modules/boltlib/lib/puppet/datatypes/applyresult.rb +1 -0
  4. data/bolt-modules/boltlib/lib/puppet/functions/catch_errors.rb +1 -3
  5. data/bolt-modules/boltlib/lib/puppet/functions/download_file.rb +17 -6
  6. data/bolt-modules/boltlib/lib/puppet/functions/parallelize.rb +56 -0
  7. data/bolt-modules/boltlib/lib/puppet/functions/run_command.rb +24 -6
  8. data/bolt-modules/boltlib/lib/puppet/functions/run_script.rb +27 -8
  9. data/bolt-modules/boltlib/lib/puppet/functions/run_task.rb +21 -1
  10. data/bolt-modules/boltlib/lib/puppet/functions/run_task_with.rb +18 -1
  11. data/bolt-modules/boltlib/lib/puppet/functions/upload_file.rb +24 -6
  12. data/lib/bolt/analytics.rb +27 -8
  13. data/lib/bolt/apply_result.rb +3 -3
  14. data/lib/bolt/bolt_option_parser.rb +45 -18
  15. data/lib/bolt/cli.rb +98 -116
  16. data/lib/bolt/config.rb +184 -80
  17. data/lib/bolt/config/options.rb +148 -87
  18. data/lib/bolt/config/transport/base.rb +10 -19
  19. data/lib/bolt/config/transport/local.rb +1 -7
  20. data/lib/bolt/config/transport/options.rb +12 -69
  21. data/lib/bolt/config/transport/ssh.rb +8 -19
  22. data/lib/bolt/error.rb +24 -0
  23. data/lib/bolt/executor.rb +92 -18
  24. data/lib/bolt/inventory.rb +25 -0
  25. data/lib/bolt/inventory/group.rb +0 -8
  26. data/lib/bolt/inventory/options.rb +130 -0
  27. data/lib/bolt/inventory/target.rb +10 -11
  28. data/lib/bolt/module_installer.rb +21 -13
  29. data/lib/bolt/module_installer/resolver.rb +1 -1
  30. data/lib/bolt/outputter.rb +19 -5
  31. data/lib/bolt/outputter/human.rb +22 -3
  32. data/lib/bolt/outputter/json.rb +1 -1
  33. data/lib/bolt/outputter/logger.rb +1 -1
  34. data/lib/bolt/outputter/rainbow.rb +13 -2
  35. data/lib/bolt/pal.rb +18 -6
  36. data/lib/bolt/pal/yaml_plan.rb +7 -0
  37. data/lib/bolt/plugin.rb +41 -12
  38. data/lib/bolt/plugin/cache.rb +76 -0
  39. data/lib/bolt/plugin/module.rb +4 -4
  40. data/lib/bolt/plugin/puppetdb.rb +1 -1
  41. data/lib/bolt/project.rb +59 -40
  42. data/lib/bolt/project_manager.rb +201 -0
  43. data/lib/bolt/{project_migrator/config.rb → project_manager/config_migrator.rb} +49 -4
  44. data/lib/bolt/{project_migrator/inventory.rb → project_manager/inventory_migrator.rb} +3 -3
  45. data/lib/bolt/{project_migrator/base.rb → project_manager/migrator.rb} +2 -2
  46. data/lib/bolt/{project_migrator/modules.rb → project_manager/module_migrator.rb} +5 -3
  47. data/lib/bolt/puppetdb/client.rb +11 -2
  48. data/lib/bolt/puppetdb/config.rb +4 -3
  49. data/lib/bolt/rerun.rb +1 -5
  50. data/lib/bolt/shell/bash.rb +8 -2
  51. data/lib/bolt/shell/powershell.rb +21 -3
  52. data/lib/bolt/target.rb +4 -0
  53. data/lib/bolt/task/run.rb +1 -1
  54. data/lib/bolt/transport/local.rb +13 -0
  55. data/lib/bolt/transport/orch.rb +0 -5
  56. data/lib/bolt/transport/orch/connection.rb +10 -3
  57. data/lib/bolt/transport/ssh/exec_connection.rb +6 -2
  58. data/lib/bolt/util.rb +36 -7
  59. data/lib/bolt/validator.rb +227 -0
  60. data/lib/bolt/version.rb +1 -1
  61. data/lib/bolt/yarn.rb +23 -0
  62. data/lib/bolt_server/base_config.rb +3 -1
  63. data/lib/bolt_server/config.rb +3 -1
  64. data/lib/bolt_server/plugin.rb +13 -0
  65. data/lib/bolt_server/plugin/puppet_connect_data.rb +37 -0
  66. data/lib/bolt_server/schemas/connect-data.json +22 -0
  67. data/lib/bolt_server/schemas/partials/task.json +2 -2
  68. data/lib/bolt_server/transport_app.rb +82 -23
  69. data/lib/bolt_spec/plans/mock_executor.rb +4 -1
  70. data/libexec/apply_catalog.rb +1 -1
  71. data/libexec/custom_facts.rb +1 -1
  72. data/libexec/query_resources.rb +1 -1
  73. metadata +22 -14
  74. data/lib/bolt/project_migrator.rb +0 -80
@@ -26,7 +26,7 @@ module Bolt
26
26
  details[:line] = err.line if defined?(err.line)
27
27
  details[:column] = err.pos if defined?(err.pos)
28
28
 
29
- error.add_filelineno(details)
29
+ error.add_filelineno(details.compact)
30
30
  error
31
31
  end
32
32
 
@@ -286,15 +286,26 @@ module Bolt
286
286
  raise Bolt::PAL::PALError, "Failed to parse manifest: #{e}"
287
287
  end
288
288
 
289
- def list_tasks
289
+ # Filters content by a list of names and glob patterns specified in project
290
+ # configuration.
291
+ def filter_content(content, patterns)
292
+ return content unless content && patterns
293
+
294
+ content.select do |name,|
295
+ patterns.any? { |pattern| File.fnmatch?(pattern, name, File::FNM_EXTGLOB) }
296
+ end
297
+ end
298
+
299
+ def list_tasks(filter_content: false)
290
300
  in_bolt_compiler do |compiler|
291
- tasks = compiler.list_tasks
292
- tasks.map(&:name).sort.each_with_object([]) do |task_name, data|
301
+ tasks = compiler.list_tasks.map(&:name).sort.each_with_object([]) do |task_name, data|
293
302
  task_sig = compiler.task_signature(task_name)
294
303
  unless task_sig.task_hash['metadata']['private']
295
304
  data << [task_name, task_sig.task_hash['metadata']['description']]
296
305
  end
297
306
  end
307
+
308
+ filter_content ? filter_content(tasks, @project&.tasks) : tasks
298
309
  end
299
310
  end
300
311
 
@@ -346,14 +357,15 @@ module Bolt
346
357
  Bolt::Task.from_task_signature(task)
347
358
  end
348
359
 
349
- def list_plans
360
+ def list_plans(filter_content: false)
350
361
  in_bolt_compiler do |compiler|
351
362
  errors = []
352
363
  plans = compiler.list_plans(nil, errors).map { |plan| [plan.name] }.sort
353
364
  errors.each do |error|
354
365
  @logger.warn(error.details['original_error'])
355
366
  end
356
- plans
367
+
368
+ filter_content ? filter_content(plans, @project&.plans) : plans
357
369
  end
358
370
  end
359
371
 
@@ -45,6 +45,13 @@ module Bolt
45
45
  used_names = Set.new(@parameters.map(&:name))
46
46
 
47
47
  @steps = plan['steps'].each_with_index.map do |step, index|
48
+ unless step.is_a?(Hash)
49
+ raise Bolt::Error.new(
50
+ "Parse error in step number #{index + 1}: Plan step must be an object with valid step keys.",
51
+ 'bolt/invalid-plan'
52
+ )
53
+ end
54
+
48
55
  # Step keys also aren't allowed to be code and neither is the value of "name"
49
56
  stringified_step = Bolt::Util.walk_keys(step) { |key| stringify(key) }
50
57
  stringified_step['name'] = stringify(stringified_step['name']) if stringified_step.key?('name')
@@ -4,6 +4,7 @@ require 'bolt/inventory'
4
4
  require 'bolt/executor'
5
5
  require 'bolt/module'
6
6
  require 'bolt/pal'
7
+ require 'bolt/plugin/cache'
7
8
  require 'bolt/plugin/puppetdb'
8
9
 
9
10
  module Bolt
@@ -36,6 +37,13 @@ module Bolt
36
37
  super("Plugin #{plugin_name} does not support #{hook}", 'bolt/unsupported-hook')
37
38
  end
38
39
  end
40
+
41
+ class LoadingDisabled < PluginError
42
+ def initialize(plugin_name)
43
+ msg = "Cannot load plugin #{plugin_name}: plugin loading is disabled"
44
+ super(msg, 'bolt/plugin-loading-disabled', { 'plugin_name' => plugin_name })
45
+ end
46
+ end
39
47
  end
40
48
 
41
49
  class PluginContext
@@ -119,15 +127,8 @@ module Bolt
119
127
  end
120
128
  end
121
129
 
122
- def self.setup(config, pal, analytics = Bolt::Analytics::NoopClient.new)
123
- plugins = new(config, pal, analytics)
124
-
125
- # Initialize any plugins referenced in plugin config. This will also indirectly
126
- # initialize any plugins they depend on.
127
- if plugins.reference?(config.plugins)
128
- msg = "The 'plugins' setting cannot be set by a plugin reference"
129
- raise PluginError.new(msg, 'bolt/plugin-error')
130
- end
130
+ def self.setup(config, pal, analytics = Bolt::Analytics::NoopClient.new, **opts)
131
+ plugins = new(config, pal, analytics, **opts)
131
132
 
132
133
  config.plugins.each_key do |plugin|
133
134
  plugins.by_name(plugin)
@@ -148,12 +149,13 @@ module Bolt
148
149
 
149
150
  private_class_method :new
150
151
 
151
- def initialize(config, pal, analytics)
152
+ def initialize(config, pal, analytics, load_plugins: true)
152
153
  @config = config
153
154
  @analytics = analytics
154
155
  @plugin_context = PluginContext.new(config, pal, self)
155
156
  @plugins = {}
156
157
  @pal = pal
158
+ @load_plugins = load_plugins
157
159
  @unknown = Set.new
158
160
  @resolution_stack = []
159
161
  @unresolved_plugin_configs = config.plugins.dup
@@ -176,6 +178,8 @@ module Bolt
176
178
  end
177
179
 
178
180
  def add_ruby_plugin(plugin_name)
181
+ raise PluginError::LoadingDisabled, plugin_name unless @load_plugins
182
+
179
183
  cls_name = Bolt::Util.snake_name_to_class_name(plugin_name)
180
184
  filename = "bolt/plugin/#{plugin_name}"
181
185
  require filename
@@ -192,10 +196,17 @@ module Bolt
192
196
  def add_module_plugin(plugin_name)
193
197
  opts = {
194
198
  context: @plugin_context,
199
+ # Make sure that the plugin's config is validated _before_ the unknown-plugin
200
+ # and loading-disabled checks. This way, we can fail early on invalid plugin
201
+ # config instead of _after_ loading the modulepath (which can be expensive).
195
202
  config: config_for_plugin(plugin_name)
196
203
  }
197
204
 
198
- plugin = Bolt::Plugin::Module.load(plugin_name, modules, opts)
205
+ mod = modules[plugin_name]
206
+ raise PluginError::Unknown, plugin_name unless mod&.plugin?
207
+ raise PluginError::LoadingDisabled, plugin_name unless @load_plugins
208
+
209
+ plugin = Bolt::Plugin::Module.load(mod, opts)
199
210
  add_plugin(plugin)
200
211
  end
201
212
 
@@ -284,6 +295,16 @@ module Bolt
284
295
  # Evaluates a single reference. The value returned may be another
285
296
  # reference.
286
297
  def resolve_single_reference(reference)
298
+ plugin_cache = if cache?(reference)
299
+ cache = Bolt::Plugin::Cache.new(reference,
300
+ @config.project.cache_file,
301
+ @config.plugin_cache)
302
+ entry = cache.read_and_clean_cache
303
+ return entry unless entry.nil?
304
+
305
+ cache
306
+ end
307
+
287
308
  plugin_name = reference['_plugin']
288
309
  hook = get_hook(plugin_name, :resolve_reference)
289
310
 
@@ -295,16 +316,24 @@ module Bolt
295
316
 
296
317
  validate_proc.call(reference)
297
318
 
298
- begin
319
+ result = begin
299
320
  # Evaluate the plugin and then recursively evaluate any plugin returned by it.
300
321
  hook.call(reference)
301
322
  rescue StandardError => e
302
323
  loc = "resolve_reference in #{plugin_name}"
303
324
  raise PluginError::ExecutionError.new(e.message, plugin_name, loc)
304
325
  end
326
+
327
+ plugin_cache.write_cache(result) if cache?(reference)
328
+
329
+ result
305
330
  end
306
331
  private :resolve_single_reference
307
332
 
333
+ private def cache?(reference)
334
+ reference.key?('_cache') || @config.plugin_cache.key?('ttl')
335
+ end
336
+
308
337
  # Checks whether a given value is a _plugin reference
309
338
  def reference?(input)
310
339
  input.is_a?(Hash) && input.key?('_plugin')
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'bolt/error'
5
+ require 'bolt/util'
6
+
7
+ module Bolt
8
+ class Plugin
9
+ class Cache
10
+ attr_reader :reference, :cache_file, :default_config, :id
11
+
12
+ def initialize(reference, cache_file, default_config)
13
+ @reference = reference
14
+ @cache_file = cache_file
15
+ @default_config = default_config
16
+ end
17
+
18
+ def read_and_clean_cache
19
+ return if ttl == 0
20
+ validate
21
+
22
+ # Luckily we don't need to use a serious hash algorithm
23
+ require 'digest/bubblebabble'
24
+ r = reference.reject { |k, _| k == '_cache' }.sort.to_s
25
+ @id = Digest::SHA2.bubblebabble(r)[0..20]
26
+
27
+ unmodified = true
28
+ # First remove any cache entries past their ttl
29
+ # This prevents removing plugins from leaving orphaned cache entries
30
+ cache.delete_if do |_, entry|
31
+ expired = Time.now - Time.parse(entry['mtime']) >= entry['ttl']
32
+ unmodified = false if expired
33
+ expired
34
+ end
35
+ File.write(cache_file, cache.to_json) unless cache.empty? || unmodified
36
+
37
+ cache.dig(id, 'result')
38
+ end
39
+
40
+ private def cache
41
+ @cache ||= Bolt::Util.read_optional_json_file(@cache_file, 'cache')
42
+ end
43
+
44
+ def write_cache(result)
45
+ cache.merge!({ id => { 'result' => result,
46
+ 'mtime' => Time.now,
47
+ 'ttl' => ttl } })
48
+ FileUtils.touch(cache_file)
49
+ File.write(cache_file, cache.to_json)
50
+ end
51
+
52
+ def validate
53
+ # The default cache `plugin-cache` will be validated by the config
54
+ # validator
55
+ return if reference['_cache'].nil?
56
+ r = reference['_cache']
57
+ unless r.is_a?(Hash)
58
+ raise Bolt::ValidationError,
59
+ "_cache must be a Hash, received #{r.class}: #{r.inspect}"
60
+ end
61
+
62
+ unless r.key?('ttl')
63
+ raise Bolt::ValidationError, "_cache must set 'ttl' key."
64
+ end
65
+
66
+ unless r['ttl'] >= 0
67
+ raise Bolt::ValidationError, "'ttl' key under '_cache' must be a minimum of 0."
68
+ end
69
+ end
70
+
71
+ private def ttl
72
+ @ttl ||= reference.dig('_cache', 'ttl') || default_config['ttl']
73
+ end
74
+ end
75
+ end
76
+ end
@@ -12,15 +12,15 @@ module Bolt
12
12
  end
13
13
  end
14
14
 
15
- def self.load(name, modules, opts)
16
- mod = modules[name]
17
- if mod&.plugin?
15
+ # mod should not be nil
16
+ def self.load(mod, opts)
17
+ if mod.plugin?
18
18
  opts[:mod] = mod
19
19
  plugin = Bolt::Plugin::Module.new(**opts)
20
20
  plugin.setup
21
21
  plugin
22
22
  else
23
- raise PluginError::Unknown, name
23
+ raise PluginError::Unknown, mod.name
24
24
  end
25
25
  end
26
26
 
@@ -12,7 +12,7 @@ module Bolt
12
12
  end
13
13
 
14
14
  TEMPLATE_OPTS = %w[alias config facts features name uri vars].freeze
15
- PLUGIN_OPTS = %w[_plugin query target_mapping].freeze
15
+ PLUGIN_OPTS = %w[_plugin _cache query target_mapping].freeze
16
16
 
17
17
  attr_reader :puppetdb_client
18
18
 
@@ -2,24 +2,19 @@
2
2
 
3
3
  require 'pathname'
4
4
  require 'bolt/config'
5
+ require 'bolt/validator'
5
6
  require 'bolt/pal'
6
7
  require 'bolt/module'
7
8
 
8
9
  module Bolt
9
10
  class Project
10
11
  BOLTDIR_NAME = 'Boltdir'
11
- PROJECT_SETTINGS = {
12
- "name" => "The name of the project",
13
- "plans" => "An array of plan names to show, if they exist in the project."\
14
- "These plans are included in `bolt plan show` and `Get-BoltPlan` output",
15
- "tasks" => "An array of task names to show, if they exist in the project."\
16
- "These tasks are included in `bolt task show` and `Get-BoltTask` output"
17
- }.freeze
12
+ CONFIG_NAME = 'bolt-project.yaml'
18
13
 
19
14
  attr_reader :path, :data, :config_file, :inventory_file, :hiera_config,
20
15
  :puppetfile, :rerunfile, :type, :resource_types, :logs, :project_file,
21
16
  :deprecations, :downloads, :plans_path, :modulepath, :managed_moduledir,
22
- :backup_dir
17
+ :backup_dir, :cache_file
23
18
 
24
19
  def self.default_project(logs = [])
25
20
  create_project(File.expand_path(File.join('~', '.puppetlabs', 'bolt')), 'user', logs)
@@ -32,23 +27,31 @@ module Bolt
32
27
  # directory called Boltdir or a file called bolt.yaml (for a control repo
33
28
  # type Project). Otherwise, repeat the check on each directory up the
34
29
  # hierarchy, falling back to the default if we reach the root.
35
- def self.find_boltdir(dir, logs = [])
30
+ def self.find_boltdir(dir, logs = [], deprecations = [])
36
31
  dir = Pathname.new(dir)
37
32
 
38
33
  if (dir + BOLTDIR_NAME).directory?
39
34
  create_project(dir + BOLTDIR_NAME, 'embedded', logs)
40
- elsif (dir + 'bolt.yaml').file? || (dir + 'bolt-project.yaml').file?
35
+ elsif (dir + 'bolt.yaml').file?
36
+ command = Bolt::Util.powershell? ? 'Update-BoltProject' : 'bolt project migrate'
37
+ msg = "Configuration file #{dir + 'bolt.yaml'} is deprecated and will be "\
38
+ "removed in Bolt 3.0.\nUpdate your Bolt project to the latest Bolt practices "\
39
+ "using #{command}"
40
+ deprecations << { type: "Project level bolt.yaml",
41
+ msg: msg }
42
+ create_project(dir, 'local', logs, deprecations)
43
+ elsif (dir + CONFIG_NAME).file?
41
44
  create_project(dir, 'local', logs)
42
45
  elsif dir.root?
43
46
  default_project(logs)
44
47
  else
45
48
  logs << { debug: "Did not detect Boltdir, bolt.yaml, or bolt-project.yaml at '#{dir}'. "\
46
49
  "This directory won't be loaded as a project." }
47
- find_boltdir(dir.parent, logs)
50
+ find_boltdir(dir.parent, logs, deprecations)
48
51
  end
49
52
  end
50
53
 
51
- def self.create_project(path, type = 'option', logs = [])
54
+ def self.create_project(path, type = 'option', logs = [], deprecations = [])
52
55
  fullpath = Pathname.new(path).expand_path
53
56
 
54
57
  if type == 'user'
@@ -73,21 +76,42 @@ module Bolt
73
76
  )
74
77
  end
75
78
 
76
- project_file = File.join(fullpath, 'bolt-project.yaml')
77
- data = Bolt::Util.read_optional_yaml_hash(File.expand_path(project_file), 'project')
78
- default = type =~ /user|system/ ? 'default ' : ''
79
- exist = File.exist?(File.expand_path(project_file))
79
+ project_file = File.join(fullpath, CONFIG_NAME)
80
+ data = Bolt::Util.read_optional_yaml_hash(File.expand_path(project_file), 'project')
81
+ default = type =~ /user|system/ ? 'default ' : ''
82
+ exist = File.exist?(File.expand_path(project_file))
83
+
80
84
  logs << { info: "Loaded #{default}project from '#{fullpath}'" } if exist
81
- new(data, path, type, logs)
85
+
86
+ Bolt::Validator.new.tap do |validator|
87
+ validator.validate(data, schema, project_file)
88
+
89
+ validator.warnings.each { |warning| logs << { warn: warning } }
90
+
91
+ validator.deprecations.each do |dep|
92
+ deprecations << { type: "#{CONFIG_NAME} #{dep[:option]}", msg: dep[:message] }
93
+ end
94
+ end
95
+
96
+ new(data, path, type, logs, deprecations)
82
97
  end
83
98
 
84
- def initialize(raw_data, path, type = 'option', logs = [])
85
- @path = Pathname.new(path).expand_path
99
+ # Builds the schema for bolt-project.yaml used by the validator.
100
+ #
101
+ def self.schema
102
+ {
103
+ type: Hash,
104
+ properties: Bolt::Config::BOLT_PROJECT_OPTIONS.map { |opt| [opt, _ref: opt] }.to_h,
105
+ definitions: Bolt::Config::OPTIONS
106
+ }
107
+ end
86
108
 
87
- @project_file = @path + 'bolt-project.yaml'
109
+ def initialize(raw_data, path, type = 'option', logs = [], deprecations = [])
110
+ @path = Pathname.new(path).expand_path
111
+ @project_file = @path + CONFIG_NAME
112
+ @logs = logs
113
+ @deprecations = deprecations
88
114
 
89
- @logs = logs
90
- @deprecations = []
91
115
  if (@path + 'bolt.yaml').file? && project_file?
92
116
  msg = "Project-level configuration in bolt.yaml is deprecated if using bolt-project.yaml. "\
93
117
  "Transport config should be set in inventory.yaml, all other config should be set in "\
@@ -105,6 +129,7 @@ module Bolt
105
129
  @plans_path = @path + 'plans'
106
130
  @managed_moduledir = @path + '.modules'
107
131
  @backup_dir = @path + '.bolt-bak'
132
+ @cache_file = @path + '.plugin_cache.json'
108
133
 
109
134
  tc = Bolt::Config::INVENTORY_OPTIONS.keys & raw_data.keys
110
135
  if tc.any?
@@ -173,6 +198,14 @@ module Bolt
173
198
  @data['plans']
174
199
  end
175
200
 
201
+ def plugin_cache
202
+ @data['plugin-cache']
203
+ end
204
+
205
+ def module_install
206
+ @data['module-install']
207
+ end
208
+
176
209
  def modules
177
210
  @modules ||= @data['modules']&.map do |mod|
178
211
  if mod.is_a?(String)
@@ -194,27 +227,13 @@ module Bolt
194
227
  raise Bolt::ValidationError, "The project '#{name}' will not be loaded. The project name conflicts "\
195
228
  "with a built-in Bolt module of the same name."
196
229
  end
197
- else
230
+ elsif name.nil? &&
231
+ (File.directory?(plans_path) ||
232
+ File.directory?(@path + 'tasks') ||
233
+ File.directory?(@path + 'files'))
198
234
  message = "No project name is specified in bolt-project.yaml. Project-level content will not be available."
199
235
  @logs << { warn: message }
200
236
  end
201
-
202
- %w[tasks plans].each do |conf|
203
- unless @data.fetch(conf, []).is_a?(Array)
204
- raise Bolt::ValidationError, "'#{conf}' in bolt-project.yaml must be an array"
205
- end
206
- end
207
-
208
- if @data['modules']
209
- unless @data['modules'].is_a?(Array)
210
- raise Bolt::ValidationError, "'modules' in bolt-project.yaml must be an array"
211
- end
212
-
213
- @data['modules'].each do |spec|
214
- next if spec.is_a?(Hash) || spec.is_a?(String)
215
- raise Bolt::ValidationError, "Module specification #{spec.inspect} must be a hash or string"
216
- end
217
- end
218
237
  end
219
238
 
220
239
  def check_deprecated_file