bolt 2.25.0 → 2.30.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/Puppetfile +4 -3
  3. data/bolt-modules/boltlib/lib/puppet/datatypes/result.rb +2 -1
  4. data/bolt-modules/boltlib/lib/puppet/functions/download_file.rb +1 -1
  5. data/bolt-modules/dir/lib/puppet/functions/dir/children.rb +1 -1
  6. data/lib/bolt/analytics.rb +7 -3
  7. data/lib/bolt/applicator.rb +21 -21
  8. data/lib/bolt/bolt_option_parser.rb +116 -26
  9. data/lib/bolt/catalog.rb +5 -3
  10. data/lib/bolt/cli.rb +194 -185
  11. data/lib/bolt/config.rb +61 -26
  12. data/lib/bolt/config/options.rb +35 -2
  13. data/lib/bolt/executor.rb +2 -2
  14. data/lib/bolt/inventory.rb +8 -1
  15. data/lib/bolt/inventory/group.rb +1 -1
  16. data/lib/bolt/inventory/inventory.rb +1 -1
  17. data/lib/bolt/inventory/target.rb +1 -1
  18. data/lib/bolt/logger.rb +35 -21
  19. data/lib/bolt/module_installer.rb +172 -0
  20. data/lib/bolt/outputter.rb +4 -0
  21. data/lib/bolt/outputter/human.rb +53 -11
  22. data/lib/bolt/outputter/json.rb +7 -1
  23. data/lib/bolt/outputter/logger.rb +3 -3
  24. data/lib/bolt/pal.rb +29 -20
  25. data/lib/bolt/pal/yaml_plan/evaluator.rb +1 -1
  26. data/lib/bolt/plugin/module.rb +1 -1
  27. data/lib/bolt/plugin/puppetdb.rb +1 -1
  28. data/lib/bolt/project.rb +89 -28
  29. data/lib/bolt/project_migrator.rb +80 -0
  30. data/lib/bolt/project_migrator/base.rb +39 -0
  31. data/lib/bolt/project_migrator/config.rb +67 -0
  32. data/lib/bolt/project_migrator/inventory.rb +67 -0
  33. data/lib/bolt/project_migrator/modules.rb +198 -0
  34. data/lib/bolt/puppetdb/client.rb +1 -1
  35. data/lib/bolt/puppetdb/config.rb +1 -1
  36. data/lib/bolt/puppetfile.rb +142 -0
  37. data/lib/bolt/puppetfile/installer.rb +43 -0
  38. data/lib/bolt/puppetfile/module.rb +90 -0
  39. data/lib/bolt/r10k_log_proxy.rb +1 -1
  40. data/lib/bolt/rerun.rb +2 -2
  41. data/lib/bolt/result.rb +23 -0
  42. data/lib/bolt/shell.rb +1 -1
  43. data/lib/bolt/shell/bash.rb +1 -1
  44. data/lib/bolt/task.rb +1 -1
  45. data/lib/bolt/transport/base.rb +5 -5
  46. data/lib/bolt/transport/docker/connection.rb +1 -1
  47. data/lib/bolt/transport/local/connection.rb +1 -1
  48. data/lib/bolt/transport/ssh.rb +1 -1
  49. data/lib/bolt/transport/ssh/connection.rb +1 -1
  50. data/lib/bolt/transport/ssh/exec_connection.rb +1 -1
  51. data/lib/bolt/transport/winrm.rb +1 -1
  52. data/lib/bolt/transport/winrm/connection.rb +1 -1
  53. data/lib/bolt/util.rb +52 -11
  54. data/lib/bolt/version.rb +1 -1
  55. data/lib/bolt_server/acl.rb +2 -2
  56. data/lib/bolt_server/base_config.rb +3 -3
  57. data/lib/bolt_server/config.rb +1 -1
  58. data/lib/bolt_server/file_cache.rb +12 -12
  59. data/lib/bolt_server/transport_app.rb +125 -26
  60. data/lib/bolt_spec/bolt_context.rb +4 -4
  61. data/lib/bolt_spec/plans/mock_executor.rb +1 -1
  62. metadata +15 -13
  63. data/lib/bolt/project_migrate.rb +0 -138
@@ -35,6 +35,10 @@ module Bolt
35
35
  raise NotImplementedError, "print_message() must be implemented by the outputter class"
36
36
  end
37
37
 
38
+ def print_error
39
+ raise NotImplementedError, "print_error() must be implemented by the outputter class"
40
+ end
41
+
38
42
  def stringify(message)
39
43
  formatted = format_message(message)
40
44
  if formatted.is_a?(Hash) || formatted.is_a?(Array)
@@ -5,9 +5,12 @@ require 'bolt/pal'
5
5
  module Bolt
6
6
  class Outputter
7
7
  class Human < Bolt::Outputter
8
- COLORS = { red: "31",
9
- green: "32",
10
- yellow: "33" }.freeze
8
+ COLORS = {
9
+ red: "31",
10
+ green: "32",
11
+ yellow: "33",
12
+ cyan: "36"
13
+ }.freeze
11
14
 
12
15
  def print_head; end
13
16
 
@@ -31,6 +34,10 @@ module Bolt
31
34
  string.sub(/\s\z/, '')
32
35
  end
33
36
 
37
+ def wrap(string, width = 80)
38
+ string.gsub(/(.{1,#{width}})(\s+|\Z)/, "\\1\n")
39
+ end
40
+
34
41
  def handle_event(event)
35
42
  case event[:type]
36
43
  when :enable_default_output
@@ -48,9 +55,9 @@ module Bolt
48
55
  when :node_result
49
56
  print_result(event[:result]) if @verbose
50
57
  when :step_start
51
- print_step_start(event) if plan_logging?
58
+ print_step_start(**event) if plan_logging?
52
59
  when :step_finish
53
- print_step_finish(event) if plan_logging?
60
+ print_step_finish(**event) if plan_logging?
54
61
  when :plan_start
55
62
  print_plan_start(event)
56
63
  when :plan_finish
@@ -188,7 +195,7 @@ module Bolt
188
195
  @stream.puts total_msg
189
196
  end
190
197
 
191
- def print_table(results)
198
+ def print_table(results, padding_left = 0, padding_right = 3)
192
199
  # lazy-load expensive gem code
193
200
  require 'terminal-table'
194
201
 
@@ -198,8 +205,8 @@ module Bolt
198
205
  border_x: '',
199
206
  border_y: '',
200
207
  border_i: '',
201
- padding_left: 0,
202
- padding_right: 3,
208
+ padding_left: padding_left,
209
+ padding_right: padding_right,
203
210
  border_top: false,
204
211
  border_bottom: false
205
212
  }
@@ -299,9 +306,9 @@ module Bolt
299
306
  def print_module_list(module_list)
300
307
  module_list.each do |path, modules|
301
308
  if (mod = modules.find { |m| m[:internal_module_group] })
302
- @stream.puts(mod[:internal_module_group])
309
+ @stream.puts(colorize(:cyan, mod[:internal_module_group]))
303
310
  else
304
- @stream.puts(path)
311
+ @stream.puts(colorize(:cyan, path))
305
312
  end
306
313
 
307
314
  if modules.empty?
@@ -317,7 +324,7 @@ module Bolt
317
324
  [m[:name], version]
318
325
  end
319
326
 
320
- print_table(module_info)
327
+ print_table(module_info, 2, 1)
321
328
  end
322
329
 
323
330
  @stream.write("\n")
@@ -394,6 +401,41 @@ module Bolt
394
401
  @stream.puts(message)
395
402
  end
396
403
 
404
+ def print_error(message)
405
+ @stream.puts(colorize(:red, message))
406
+ end
407
+
408
+ def print_prompt(prompt)
409
+ @stream.print(colorize(:cyan, indent(4, prompt)))
410
+ end
411
+
412
+ def print_prompt_error(message)
413
+ @stream.puts(colorize(:red, indent(4, message)))
414
+ end
415
+
416
+ def print_migrate_step(step)
417
+ first, *remaining = wrap(step, 76).lines
418
+
419
+ first = indent(2, "→ #{first}")
420
+ remaining = remaining.map { |line| indent(4, line) }
421
+ step = [first, *remaining, "\n"].join
422
+
423
+ @stream.puts(step)
424
+ end
425
+
426
+ def print_migrate_error(error)
427
+ # Running everything through 'wrap' messes with newlines. Separating
428
+ # into lines and wrapping each individually ensures separate errors are
429
+ # distinguishable.
430
+ first, *remaining = error.lines
431
+ first = colorize(:red, indent(2, "→ #{wrap(first, 76)}"))
432
+ wrapped = remaining.map { |l| wrap(l) }
433
+ to_print = wrapped.map { |line| colorize(:red, indent(4, line)) }
434
+ step = [first, *to_print, "\n"].join
435
+
436
+ @stream.puts(step)
437
+ end
438
+
397
439
  def duration_to_string(duration)
398
440
  hrs = (duration / 3600).floor
399
441
  mins = ((duration % 3600) / 60).floor
@@ -97,7 +97,7 @@ module Bolt
97
97
  def print_puppetfile_result(success, puppetfile, moduledir)
98
98
  @stream.puts({ "success": success,
99
99
  "puppetfile": puppetfile,
100
- "moduledir": moduledir }.to_json)
100
+ "moduledir": moduledir.to_s }.to_json)
101
101
  end
102
102
 
103
103
  def print_targets(targets)
@@ -135,6 +135,12 @@ module Bolt
135
135
  def print_message(message)
136
136
  $stderr.puts(message)
137
137
  end
138
+ alias print_error print_message
139
+
140
+ def print_migrate_step(step)
141
+ $stderr.puts(step)
142
+ end
143
+ alias print_migrate_error print_migrate_step
138
144
  end
139
145
  end
140
146
  end
@@ -7,15 +7,15 @@ module Bolt
7
7
  class Logger < Bolt::Outputter
8
8
  def initialize(verbose, trace)
9
9
  super(false, verbose, trace)
10
- @logger = Logging.logger[self]
10
+ @logger = Bolt::Logger.logger(self)
11
11
  end
12
12
 
13
13
  def handle_event(event)
14
14
  case event[:type]
15
15
  when :step_start
16
- log_step_start(event)
16
+ log_step_start(**event)
17
17
  when :step_finish
18
- log_step_finish(event)
18
+ log_step_finish(**event)
19
19
  when :plan_start
20
20
  log_plan_start(event)
21
21
  when :plan_finish
@@ -29,15 +29,12 @@ module Bolt
29
29
  message = err.cause ? err.cause.message : err.message
30
30
 
31
31
  # Provide the location of an error if it came from a plan
32
- details = if defined?(err.file) && err.file
33
- { file: err.file,
34
- line: err.line,
35
- column: err.pos }.compact
36
- else
37
- {}
38
- end
32
+ details = {}
33
+ details[:file] = err.file if defined?(err.file)
34
+ details[:line] = err.line if defined?(err.line)
35
+ details[:column] = err.pos if defined?(err.pos)
39
36
 
40
- e = new(message, details)
37
+ e = new(message, details.compact)
41
38
 
42
39
  e.set_backtrace(err.backtrace)
43
40
  e
@@ -65,7 +62,7 @@ module Bolt
65
62
  @resource_types = resource_types
66
63
  @project = project
67
64
 
68
- @logger = Logging.logger[self]
65
+ @logger = Bolt::Logger.logger(self)
69
66
  if modulepath && !modulepath.empty?
70
67
  @logger.debug("Loading modules from #{@modulepath.join(File::PATH_SEPARATOR)}")
71
68
  end
@@ -76,7 +73,7 @@ module Bolt
76
73
  # Puppet logging is global so this is class method to avoid confusion
77
74
  def self.configure_logging
78
75
  Puppet::Util::Log.destinations.clear
79
- Puppet::Util::Log.newdestination(Logging.logger['Puppet'])
76
+ Puppet::Util::Log.newdestination(Bolt::Logger.logger('Puppet'))
80
77
  # Defer all log level decisions to the Logging library by telling Puppet
81
78
  # to log everything
82
79
  Puppet.settings[:log_level] = 'debug'
@@ -141,6 +138,19 @@ module Bolt
141
138
  end
142
139
  end
143
140
 
141
+ def detect_project_conflict(project, environment)
142
+ return unless project && project.load_as_module?
143
+ # The environment modulepath has stripped out non-existent directories,
144
+ # so we don't need to check for them
145
+ modules = environment.modulepath.flat_map do |path|
146
+ Dir.children(path).select { |name| Puppet::Module.is_module_directory?(name, path) }
147
+ end
148
+ if modules.include?(project.name)
149
+ Bolt::Logger.warn_once("project shadows module",
150
+ "The project '#{project.name}' shadows an existing module of the same name")
151
+ end
152
+ end
153
+
144
154
  # Runs a block in a PAL script compiler configured for Bolt. Catches
145
155
  # exceptions thrown by the block and re-raises them ensuring they are
146
156
  # Bolt::Errors since the script compiler block will squash all exceptions.
@@ -149,15 +159,13 @@ module Bolt
149
159
  setup
150
160
  r = Puppet::Pal.in_tmp_environment('bolt', modulepath: @modulepath, facts: {}) do |pal|
151
161
  # Only load the project if it a) exists, b) has a name it can be loaded with
152
- bolt_project = @project if @project&.name
153
- # Puppet currently won't receive the project unless it is a named project. Since
154
- # the download_file plan function needs access to the project path, add it to the
155
- # context.
156
- bolt_project_data = @project
157
- Puppet.override(bolt_project: bolt_project,
158
- bolt_project_data: bolt_project_data,
162
+ Puppet.override(bolt_project: @project,
159
163
  yaml_plan_instantiator: Bolt::PAL::YamlPlan::Loader) do
160
- pal.with_script_compiler do |compiler|
164
+ # Because this has the side effect of loading and caching the list
165
+ # of modules, it must happen *after* we have overridden
166
+ # bolt_project or the project will be ignored
167
+ detect_project_conflict(@project, Puppet.lookup(:environments).get('bolt'))
168
+ pal.with_script_compiler(set_local_facts: false) do |compiler|
161
169
  alias_types(compiler)
162
170
  register_resource_types(Puppet.lookup(:loaders)) if @resource_types
163
171
  begin
@@ -430,13 +438,14 @@ module Bolt
430
438
  # Returns a mapping of all modules available to the Bolt compiler
431
439
  #
432
440
  # @return [Hash{String => Array<Hash{Symbol => String,nil}>}]
433
- # A hash that associates each directory on the module path with an array
441
+ # A hash that associates each directory on the modulepath with an array
434
442
  # containing a hash of information for each module in that directory.
435
443
  # The information hash provides the name, version, and a string
436
444
  # indicating whether the module belongs to an internal module group.
437
445
  def list_modules
438
446
  internal_module_groups = { BOLTLIB_PATH => 'Plan Language Modules',
439
- MODULES_PATH => 'Packaged Modules' }
447
+ MODULES_PATH => 'Packaged Modules',
448
+ @project.managed_moduledir.to_s => 'Project Dependencies' }
440
449
 
441
450
  in_bolt_compiler do
442
451
  # NOTE: Can replace map+to_h with transform_values when Ruby 2.4
@@ -7,7 +7,7 @@ module Bolt
7
7
  class YamlPlan
8
8
  class Evaluator
9
9
  def initialize(analytics = Bolt::Analytics::NoopClient.new)
10
- @logger = Logging.logger[self]
10
+ @logger = Bolt::Logger.logger(self)
11
11
  @analytics = analytics
12
12
  @evaluator = Puppet::Pops::Parser::EvaluatingParser.new
13
13
  end
@@ -16,7 +16,7 @@ module Bolt
16
16
  mod = modules[name]
17
17
  if mod&.plugin?
18
18
  opts[:mod] = mod
19
- plugin = Bolt::Plugin::Module.new(opts)
19
+ plugin = Bolt::Plugin::Module.new(**opts)
20
20
  plugin.setup
21
21
  plugin
22
22
  else
@@ -19,7 +19,7 @@ module Bolt
19
19
  def initialize(config:, context:)
20
20
  pdb_config = Bolt::PuppetDB::Config.load_config(config, context.boltdir)
21
21
  @puppetdb_client = Bolt::PuppetDB::Client.new(pdb_config)
22
- @logger = Logging.logger[self]
22
+ @logger = Bolt::Logger.logger(self)
23
23
  end
24
24
 
25
25
  def name
@@ -16,38 +16,55 @@ module Bolt
16
16
  "These tasks are included in `bolt task show` output"
17
17
  }.freeze
18
18
 
19
- attr_reader :path, :data, :config_file, :inventory_file, :modulepath, :hiera_config,
20
- :puppetfile, :rerunfile, :type, :resource_types, :warnings, :project_file,
21
- :deprecations, :downloads, :plans_path
19
+ attr_reader :path, :data, :config_file, :inventory_file, :hiera_config,
20
+ :puppetfile, :rerunfile, :type, :resource_types, :logs, :project_file,
21
+ :deprecations, :downloads, :plans_path, :modulepath, :managed_moduledir,
22
+ :backup_dir
22
23
 
23
- def self.default_project
24
- create_project(File.expand_path(File.join('~', '.puppetlabs', 'bolt')), 'user')
24
+ def self.default_project(logs = [])
25
+ create_project(File.expand_path(File.join('~', '.puppetlabs', 'bolt')), 'user', logs)
25
26
  # If homedir isn't defined use the system config path
26
27
  rescue ArgumentError
27
- create_project(Bolt::Config.system_path, 'system')
28
+ create_project(Bolt::Config.system_path, 'system', logs)
28
29
  end
29
30
 
30
31
  # Search recursively up the directory hierarchy for the Project. Look for a
31
32
  # directory called Boltdir or a file called bolt.yaml (for a control repo
32
33
  # type Project). Otherwise, repeat the check on each directory up the
33
34
  # hierarchy, falling back to the default if we reach the root.
34
- def self.find_boltdir(dir)
35
+ def self.find_boltdir(dir, logs = [])
35
36
  dir = Pathname.new(dir)
36
37
 
37
38
  if (dir + BOLTDIR_NAME).directory?
38
- create_project(dir + BOLTDIR_NAME, 'embedded')
39
+ create_project(dir + BOLTDIR_NAME, 'embedded', logs)
39
40
  elsif (dir + 'bolt.yaml').file? || (dir + 'bolt-project.yaml').file?
40
- create_project(dir, 'local')
41
+ create_project(dir, 'local', logs)
41
42
  elsif dir.root?
42
- default_project
43
+ default_project(logs)
43
44
  else
44
- find_boltdir(dir.parent)
45
+ logs << { debug: "Did not detect Boltdir, bolt.yaml, or bolt-project.yaml at '#{dir}'. "\
46
+ "This directory won't be loaded as a project." }
47
+ find_boltdir(dir.parent, logs)
45
48
  end
46
49
  end
47
50
 
48
- def self.create_project(path, type = 'option')
51
+ def self.create_project(path, type = 'option', logs = [])
49
52
  fullpath = Pathname.new(path).expand_path
50
53
 
54
+ if type == 'user'
55
+ begin
56
+ # This is already expanded if the type is user
57
+ FileUtils.mkdir_p(path)
58
+ rescue StandardError
59
+ logs << { warn: "Could not create default project at #{path}. Continuing without a writeable project. "\
60
+ "Log and rerun files will not be written." }
61
+ end
62
+ end
63
+
64
+ if type == 'option' && !File.directory?(path)
65
+ raise Bolt::Error.new("Could not find project at #{path}", "bolt/project-error")
66
+ end
67
+
51
68
  if !Bolt::Util.windows? && type != 'environment' && fullpath.world_writable?
52
69
  raise Bolt::Error.new(
53
70
  "Project directory '#{fullpath}' is world-writable which poses a security risk. Set "\
@@ -58,15 +75,18 @@ module Bolt
58
75
 
59
76
  project_file = File.join(fullpath, 'bolt-project.yaml')
60
77
  data = Bolt::Util.read_optional_yaml_hash(File.expand_path(project_file), 'project')
61
- new(data, path, type)
78
+ default = type =~ /user|system/ ? 'default ' : ''
79
+ exist = File.exist?(File.expand_path(project_file))
80
+ logs << { info: "Loaded #{default}project from '#{fullpath}'" } if exist
81
+ new(data, path, type, logs)
62
82
  end
63
83
 
64
- def initialize(raw_data, path, type = 'option')
84
+ def initialize(raw_data, path, type = 'option', logs = [])
65
85
  @path = Pathname.new(path).expand_path
66
86
 
67
87
  @project_file = @path + 'bolt-project.yaml'
68
88
 
69
- @warnings = []
89
+ @logs = logs
70
90
  @deprecations = []
71
91
  if (@path + 'bolt.yaml').file? && project_file?
72
92
  msg = "Project-level configuration in bolt.yaml is deprecated if using bolt-project.yaml. "\
@@ -75,30 +95,39 @@ module Bolt
75
95
  @deprecations << { type: 'Using bolt.yaml for project configuration', msg: msg }
76
96
  end
77
97
 
78
- @inventory_file = @path + 'inventory.yaml'
79
- @modulepath = [(@path + 'modules').to_s, (@path + 'site-modules').to_s, (@path + 'site').to_s]
80
- @hiera_config = @path + 'hiera.yaml'
81
- @puppetfile = @path + 'Puppetfile'
82
- @rerunfile = @path + '.rerun.json'
83
- @resource_types = @path + '.resource_types'
84
- @type = type
85
- @downloads = @path + 'downloads'
86
- @plans_path = @path + 'plans'
98
+ @inventory_file = @path + 'inventory.yaml'
99
+ @hiera_config = @path + 'hiera.yaml'
100
+ @puppetfile = @path + 'Puppetfile'
101
+ @rerunfile = @path + '.rerun.json'
102
+ @resource_types = @path + '.resource_types'
103
+ @type = type
104
+ @downloads = @path + 'downloads'
105
+ @plans_path = @path + 'plans'
106
+ @managed_moduledir = @path + '.modules'
107
+ @backup_dir = @path + '.bolt-bak'
87
108
 
88
109
  tc = Bolt::Config::INVENTORY_OPTIONS.keys & raw_data.keys
89
110
  if tc.any?
90
111
  msg = "Transport configuration isn't supported in bolt-project.yaml. Ignoring keys #{tc}"
91
- @warnings << { msg: msg }
112
+ @logs << { warn: msg }
92
113
  end
93
114
 
94
115
  @data = raw_data.reject { |k, _| Bolt::Config::INVENTORY_OPTIONS.include?(k) }
95
116
 
117
+ # If the 'modules' key is present in the project configuration file,
118
+ # use the new, shorter modulepath.
119
+ @modulepath = if @data.key?('modules')
120
+ [(@path + 'modules').to_s]
121
+ else
122
+ [(@path + 'modules').to_s, (@path + 'site-modules').to_s, (@path + 'site').to_s]
123
+ end
124
+
96
125
  # Once bolt.yaml deprecation is removed, this attribute should be removed
97
126
  # and replaced with .project_file in lib/bolt/config.rb
98
127
  @config_file = if (Bolt::Config::BOLT_OPTIONS & @data.keys).any?
99
128
  if (@path + 'bolt.yaml').file?
100
129
  msg = "bolt-project.yaml contains valid config keys, bolt.yaml will be ignored"
101
- @warnings << { msg: msg }
130
+ @logs << { warn: msg }
102
131
  end
103
132
  @project_file
104
133
  else
@@ -114,7 +143,9 @@ module Bolt
114
143
  # This API is used to prepend the project as a module to Puppet's internal
115
144
  # module_references list. CHANGE AT YOUR OWN RISK
116
145
  def to_h
117
- { path: @path.to_s, name: name }
146
+ { path: @path.to_s,
147
+ name: name,
148
+ load_as_module?: load_as_module? }
118
149
  end
119
150
 
120
151
  def eql?(other)
@@ -126,6 +157,10 @@ module Bolt
126
157
  @project_file.file?
127
158
  end
128
159
 
160
+ def load_as_module?
161
+ !name.nil?
162
+ end
163
+
129
164
  def name
130
165
  @data['name']
131
166
  end
@@ -138,6 +173,16 @@ module Bolt
138
173
  @data['plans']
139
174
  end
140
175
 
176
+ def modules
177
+ @modules ||= @data['modules']&.map do |mod|
178
+ if mod.is_a?(String)
179
+ { 'name' => mod }
180
+ else
181
+ mod
182
+ end
183
+ end
184
+ end
185
+
141
186
  def validate
142
187
  if name
143
188
  if name !~ Bolt::Module::MODULE_NAME_REGEX
@@ -151,7 +196,7 @@ module Bolt
151
196
  end
152
197
  else
153
198
  message = "No project name is specified in bolt-project.yaml. Project-level content will not be available."
154
- @warnings << { msg: message }
199
+ @logs << { warn: message }
155
200
  end
156
201
 
157
202
  %w[tasks plans].each do |conf|
@@ -159,6 +204,22 @@ module Bolt
159
204
  raise Bolt::ValidationError, "'#{conf}' in bolt-project.yaml must be an array"
160
205
  end
161
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 |mod|
214
+ next if (mod.is_a?(Hash) && mod.key?('name')) || mod.is_a?(String)
215
+ raise Bolt::ValidationError, "Module declaration #{mod.inspect} must be a hash with a name key"
216
+ end
217
+
218
+ unknown_keys = modules.flat_map(&:keys).uniq - %w[name version_requirement]
219
+ if unknown_keys.any?
220
+ @logs << { warn: "Ignoring unknown keys in module declarations: #{unknown_keys.join(', ')}." }
221
+ end
222
+ end
162
223
  end
163
224
 
164
225
  def check_deprecated_file