bolt 1.49.0 → 2.0.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/Puppetfile +6 -6
  3. data/bolt-modules/boltlib/lib/puppet/datatypes/target.rb +24 -45
  4. data/bolt-modules/boltlib/lib/puppet/functions/add_facts.rb +3 -3
  5. data/bolt-modules/boltlib/lib/puppet/functions/add_to_group.rb +1 -1
  6. data/bolt-modules/boltlib/lib/puppet/functions/apply_prep.rb +10 -12
  7. data/bolt-modules/boltlib/lib/puppet/functions/catch_errors.rb +1 -1
  8. data/bolt-modules/boltlib/lib/puppet/functions/fail_plan.rb +3 -3
  9. data/bolt-modules/boltlib/lib/puppet/functions/get_resources.rb +5 -4
  10. data/bolt-modules/boltlib/lib/puppet/functions/get_target.rb +1 -3
  11. data/bolt-modules/boltlib/lib/puppet/functions/get_targets.rb +1 -2
  12. data/bolt-modules/boltlib/lib/puppet/functions/puppetdb_fact.rb +2 -2
  13. data/bolt-modules/boltlib/lib/puppet/functions/remove_from_group.rb +2 -2
  14. data/bolt-modules/boltlib/lib/puppet/functions/resolve_references.rb +1 -1
  15. data/bolt-modules/boltlib/lib/puppet/functions/run_command.rb +7 -3
  16. data/bolt-modules/boltlib/lib/puppet/functions/run_plan.rb +15 -31
  17. data/bolt-modules/boltlib/lib/puppet/functions/run_script.rb +9 -5
  18. data/bolt-modules/boltlib/lib/puppet/functions/run_task.rb +9 -3
  19. data/bolt-modules/boltlib/lib/puppet/functions/set_config.rb +4 -3
  20. data/bolt-modules/boltlib/lib/puppet/functions/set_feature.rb +6 -6
  21. data/bolt-modules/boltlib/lib/puppet/functions/set_var.rb +2 -2
  22. data/bolt-modules/boltlib/lib/puppet/functions/upload_file.rb +7 -3
  23. data/bolt-modules/boltlib/lib/puppet/functions/wait_until_available.rb +6 -2
  24. data/bolt-modules/boltlib/lib/puppet/functions/without_default_logging.rb +2 -2
  25. data/bolt-modules/ctrl/lib/puppet/functions/ctrl/do_until.rb +2 -1
  26. data/bolt-modules/file/lib/puppet/functions/file/exists.rb +2 -1
  27. data/bolt-modules/file/lib/puppet/functions/file/join.rb +1 -0
  28. data/bolt-modules/file/lib/puppet/functions/file/read.rb +1 -0
  29. data/bolt-modules/file/lib/puppet/functions/file/readable.rb +2 -1
  30. data/bolt-modules/out/lib/puppet/functions/out/message.rb +1 -1
  31. data/bolt-modules/system/lib/puppet/functions/system/env.rb +1 -0
  32. data/lib/bolt/applicator.rb +70 -118
  33. data/lib/bolt/apply_target.rb +1 -1
  34. data/lib/bolt/bolt_option_parser.rb +21 -37
  35. data/lib/bolt/catalog.rb +5 -22
  36. data/lib/bolt/catalog/logging.rb +1 -1
  37. data/lib/bolt/cli.rb +33 -44
  38. data/lib/bolt/config.rb +15 -18
  39. data/lib/bolt/error.rb +2 -2
  40. data/lib/bolt/executor.rb +32 -40
  41. data/lib/bolt/inventory.rb +9 -367
  42. data/lib/bolt/inventory/group.rb +293 -182
  43. data/lib/bolt/inventory/{inventory2.rb → inventory.rb} +25 -14
  44. data/lib/bolt/inventory/target.rb +1 -1
  45. data/lib/bolt/module.rb +4 -4
  46. data/lib/bolt/outputter/human.rb +11 -6
  47. data/lib/bolt/outputter/json.rb +3 -11
  48. data/lib/bolt/pal.rb +1 -2
  49. data/lib/bolt/pal/yaml_plan/step/resources.rb +1 -1
  50. data/lib/bolt/plugin.rb +1 -1
  51. data/lib/bolt/plugin/module.rb +19 -36
  52. data/lib/bolt/plugin/prompt.rb +2 -4
  53. data/lib/bolt/puppetdb/config.rb +1 -3
  54. data/lib/bolt/result.rb +3 -6
  55. data/lib/bolt/secret/base.rb +0 -6
  56. data/lib/bolt/target.rb +8 -219
  57. data/lib/bolt/transport/local/shell.rb +9 -13
  58. data/lib/bolt/transport/orch.rb +3 -5
  59. data/lib/bolt/transport/ssh.rb +1 -0
  60. data/lib/bolt/transport/ssh/connection.rb +1 -4
  61. data/lib/bolt/transport/winrm/connection.rb +1 -1
  62. data/lib/bolt/util.rb +2 -8
  63. data/lib/bolt/version.rb +1 -1
  64. data/lib/bolt_server/transport_app.rb +35 -17
  65. data/lib/bolt_spec/plans.rb +8 -2
  66. data/libexec/bolt_catalog +19 -5
  67. metadata +4 -8
  68. data/exe/bolt-inventory-pdb +0 -13
  69. data/lib/bolt/inventory/group2.rb +0 -403
  70. data/lib/bolt_ext/puppetdb_inventory.rb +0 -129
@@ -3,9 +3,10 @@
3
3
  require 'set'
4
4
  require 'bolt/config'
5
5
  require 'bolt/inventory/group'
6
- require 'bolt/inventory/inventory2'
6
+ require 'bolt/inventory/inventory'
7
7
  require 'bolt/target'
8
8
  require 'bolt/util'
9
+ require 'bolt/plugin'
9
10
  require 'yaml'
10
11
 
11
12
  module Bolt
@@ -43,7 +44,7 @@ module Bolt
43
44
  end
44
45
  end
45
46
 
46
- def self.from_config(config, plugins = nil)
47
+ def self.from_config(config, plugins)
47
48
  if ENV.include?(ENVIRONMENT_VAR)
48
49
  begin
49
50
  data = YAML.safe_load(ENV[ENVIRONMENT_VAR])
@@ -65,378 +66,19 @@ module Bolt
65
66
  end
66
67
 
67
68
  def self.create_version(data, config, plugins)
68
- version = (data || {}).delete('version') { 1 }
69
+ version = (data || {}).delete('version') { 2 }
69
70
  case version
70
- when 1
71
- new(data, config, plugins: plugins)
72
71
  when 2
73
- Bolt::Inventory::Inventory2.new(data, config, plugins: plugins)
72
+ Bolt::Inventory::Inventory.new(data, config, plugins: plugins)
74
73
  else
75
74
  raise ValidationError.new("Unsupported version #{version} specified in inventory", nil)
76
75
  end
77
76
  end
78
77
 
79
- attr_reader :plugins, :config
80
-
81
- def initialize(data, config = nil, plugins: nil, target_vars: {},
82
- target_facts: {}, target_features: {}, target_plugin_hooks: {})
83
- @logger = Logging.logger[self]
84
- # Config is saved to add config options to targets
85
- @config = config || Bolt::Config.default
86
- @data = data ||= {}
87
- @groups = Group.new(data.merge('name' => 'all'))
88
- @group_lookup = {}
89
- @target_vars = target_vars
90
- @target_facts = target_facts
91
- @target_features = target_features
92
- @plugins = plugins
93
- @target_plugin_hooks = target_plugin_hooks
94
-
95
- @groups.resolve_aliases(@groups.node_aliases)
96
- collect_groups
97
-
98
- deprecation unless @data.empty?
99
- end
100
-
101
- def validate
102
- @groups.validate
103
- end
104
-
105
- def version
106
- 1
107
- end
108
-
109
- def deprecation
110
- msg = <<~MSG
111
- Deprecation Warning: Starting with Bolt 2.0, inventory file v1 will no longer
112
- be supported. v1 inventory files can be automatically migrated to v2 using
113
- 'bolt project migrate'. Inventory files are modified in place and will not
114
- preserve formatting or comments.
115
- MSG
116
- @logger.warn(msg)
117
- end
118
- private :deprecation
119
-
120
- def collect_groups
121
- # Provide a lookup map for finding a group by name
122
- @group_lookup = @groups.collect_groups
123
- end
124
-
125
- def group_names
126
- @group_lookup.keys
127
- end
128
-
129
- def node_names
130
- @groups.node_names
131
- end
132
-
133
- def plugin_hooks(target)
134
- @target_plugin_hooks[target.name] || {}
135
- end
136
-
137
- def get_targets(targets)
138
- targets = expand_targets(targets)
139
- targets = if targets.is_a? Array
140
- targets.flatten.uniq(&:name)
141
- else
142
- [targets]
143
- end
144
- targets.map { |t| update_target(t) }
145
- end
146
-
147
- def add_to_group(targets, desired_group)
148
- if group_names.include?(desired_group)
149
- targets.each do |target|
150
- if group_names.include?(target.name)
151
- raise ValidationError.new("Group #{target.name} conflicts with node of the same name", target.name)
152
- end
153
- add_node(@groups, target, desired_group)
154
- end
155
- else
156
- raise ValidationError.new("Group #{desired_group} does not exist in inventory", nil)
157
- end
158
- end
159
-
160
- def remove_from_group(target, desired_group)
161
- unless target.length == 1
162
- raise ValidationError.new("'remove_from_group' expects a single Target, got #{target.length}", nil)
163
- end
164
-
165
- if desired_group == 'all'
166
- raise ValidationError.new("Cannot remove Target from Group 'all'", nil)
167
- end
168
-
169
- if group_names.include?(desired_group)
170
- invalidate_group_cache!(target.first)
171
- remove_node(@groups, target.first, desired_group)
172
- else
173
- raise ValidationError.new("Group #{desired_group} does not exist in inventory", nil)
174
- end
175
- end
176
-
177
- def set_var(target, var_hash)
178
- set_vars_from_hash(target.name, var_hash)
179
- end
180
-
181
- def vars(target)
182
- @target_vars[target.name] || {}
183
- end
184
-
185
- def add_facts(target, new_facts = {})
186
- @logger.warn("No facts to add") if new_facts.empty?
187
- facts = set_facts(target.name, new_facts)
188
- # rubocop:disable Style/GlobalVars
189
- $future ? target : facts
190
- # rubocop:enable Style/GlobalVars
191
- end
192
-
193
- def facts(target)
194
- @target_facts[target.name] || {}
195
- end
196
-
197
- def set_feature(target, feature, value = true)
198
- @target_features[target.name] ||= Set.new
199
- if value
200
- @target_features[target.name] << feature
201
- else
202
- @target_features[target.name].delete(feature)
203
- end
204
- end
205
-
206
- def features(target)
207
- @target_features[target.name] || Set.new
208
- end
209
-
210
- def target_alias(target)
211
- @groups.node_aliases.each_with_object([]) do |(alia, name), acc|
212
- if target.name == name
213
- acc << alia
214
- end
215
- end.uniq
216
- end
217
-
218
- def data_hash
219
- {
220
- data: @data,
221
- target_hash: {
222
- target_vars: @target_vars,
223
- target_facts: @target_facts,
224
- target_features: @target_features
225
- },
226
- config: @config.transport_data_get
227
- }
228
- end
229
-
230
- #### PRIVATE ####
231
- #
232
- # For debugging only now
233
- def groups_in(node_name)
234
- @groups.data_for(node_name)['groups'] || {}
235
- end
236
- private :groups_in
237
-
238
- # TODO: Possibly refactor this once inventory v2 is more stable
239
- def self.localhost_defaults(data)
240
- defaults = {
241
- 'config' => {
242
- 'transport' => 'local',
243
- 'local' => { 'interpreters' => { '.rb' => RbConfig.ruby } }
244
- },
245
- 'features' => ['puppet-agent']
246
- }
247
- data = Bolt::Util.deep_merge(defaults, data)
248
- # If features is an empty array deep_merge won't add the puppet-agent
249
- data['features'] += ['puppet-agent'] if data['features'].empty?
250
- data
251
- end
252
-
253
- # Pass a target to get_targets for a public version of this
254
- # Should this reconfigure configured targets?
255
- def update_target(target)
256
- data = @groups.data_for(target.name)
257
- data ||= {}
258
-
259
- unless data['config']
260
- @logger.debug("Did not find config for #{target.name} in inventory")
261
- data['config'] = {}
262
- end
263
-
264
- data = self.class.localhost_defaults(data) if target.name == 'localhost'
265
- # These should only get set from the inventory if they have not yet
266
- # been instantiated
267
- set_vars_from_hash(target.name, data['vars']) unless @target_vars[target.name]
268
- set_facts(target.name, data['facts']) unless @target_facts[target.name]
269
- data['features']&.each { |feature| set_feature(target, feature) } unless @target_features[target.name]
270
- unless @target_plugin_hooks[target.name]
271
- set_plugin_hooks(target.name, (@plugins&.plugin_hooks || {}).merge(data['plugin_hooks'] || {}))
272
- end
273
-
274
- # Use Config object to ensure config section is treated consistently with config file
275
- conf = @config.deep_clone
276
- conf.update_from_inventory(data['config'])
277
- conf.validate
278
-
279
- target.update_conf(conf.transport_conf)
280
-
281
- unless target.transport.nil? || Bolt::TRANSPORTS.include?(target.transport.to_sym)
282
- raise Bolt::UnknownTransportError.new(target.transport, target.uri)
283
- end
284
-
285
- target
286
- end
287
- private :update_target
288
-
289
- # If target is a group name, expand it to the members of that group.
290
- # Else match against nodes in inventory by name or alias.
291
- # If a wildcard string, error if no matches are found.
292
- # Else fall back to [target] if no matches are found.
293
- def resolve_name(target)
294
- if (group = @group_lookup[target])
295
- group.node_names
296
- else
297
- # Try to wildcard match nodes in inventory
298
- # Ignore case because hostnames are generally case-insensitive
299
- regexp = Regexp.new("^#{Regexp.escape(target).gsub('\*', '.*?')}$", Regexp::IGNORECASE)
300
-
301
- nodes = @groups.node_names.select { |node| node =~ regexp }
302
- nodes += @groups.node_aliases.select { |target_alias, _node| target_alias =~ regexp }.values
303
-
304
- if nodes.empty?
305
- raise(WildcardError, target) if target.include?('*')
306
- [target]
307
- else
308
- nodes
309
- end
310
- end
311
- end
312
- private :resolve_name
313
-
314
- def create_target(data)
315
- Bolt::Target.new(data)
316
- end
317
- private :create_target
318
-
319
- def expand_targets(targets)
320
- if targets.is_a? Bolt::Target
321
- targets.inventory = self
322
- targets
323
- elsif targets.is_a? Array
324
- targets.map { |tish| expand_targets(tish) }
325
- elsif targets.is_a? String
326
- # Expand a comma-separated list
327
- targets.split(/[[:space:],]+/).reject(&:empty?).map do |name|
328
- ts = resolve_name(name)
329
- ts.map do |t|
330
- target = create_target(t)
331
- target.inventory = self
332
- target
333
- end
334
- end
335
- end
336
- end
337
- private :expand_targets
338
-
339
- def set_vars_from_hash(tname, data)
340
- if data
341
- # Instantiate empty vars hash in case no vars are defined
342
- @target_vars[tname] ||= {}
343
- # Assign target new merged vars hash
344
- # This is essentially a copy-on-write to maintain the immutability of @target_vars
345
- @target_vars[tname] = @target_vars[tname].merge(data).freeze
346
- end
347
- end
348
- private :set_vars_from_hash
349
-
350
- def set_facts(tname, hash)
351
- if hash
352
- @target_facts[tname] ||= {}
353
- @target_facts[tname] = Bolt::Util.deep_merge(@target_facts[tname], hash).freeze
354
- end
355
- end
356
- private :set_facts
357
-
358
- def set_plugin_hooks(tname, hash)
359
- if hash
360
- @target_plugin_hooks[tname] ||= {}
361
- @target_plugin_hooks[tname].merge!(hash)
362
- end
363
- end
364
- private :set_plugin_hooks
365
-
366
- def remove_node(current_group, target, desired_group, track = { 'all' => nil })
367
- # Remove the target from the group
368
- if current_group.name == desired_group
369
- current_group.nodes.delete(target.name)
370
- # If the target remains in the group, add the group data to the cache
371
- elsif current_group.node_names.include?(target.name)
372
- add_group_data_to_cache(current_group, target, track)
373
- end
374
- current_group.groups.each do |child_group|
375
- # If target was in current group, remove it from all child groups
376
- desired_group = child_group.name if current_group.name == desired_group
377
- track[child_group.name] = current_group
378
- remove_node(child_group, target, desired_group)
379
- end
380
- end
381
- private :remove_node
382
-
383
- def add_node(current_group, target, desired_group, track = { 'all' => nil })
384
- if current_group.name == desired_group
385
- # Group to add to is found
386
- t_name = target.name
387
- # Add target to nodes hash
388
- current_group.nodes[t_name] = { 'name' => t_name }.merge(target.options)
389
- add_group_data_to_cache(current_group, target, track)
390
- return true
391
- end
392
- # Recurse on children Groups if not desired_group
393
- current_group.groups.each do |child_group|
394
- track[child_group.name] = current_group
395
- add_node(child_group, target, desired_group, track)
396
- end
397
- end
398
- private :add_node
399
-
400
- def add_group_data_to_cache(current_group, target, track)
401
- t_name = target.name
402
- current_group_data = { facts: current_group.facts,
403
- vars: current_group.vars,
404
- features: current_group.features,
405
- plugin_hooks: current_group.plugin_hooks }
406
- data = inherit_data(track, current_group.name, current_group_data)
407
- set_facts(t_name, @target_facts[t_name] ? data[:facts].merge(@target_facts[t_name]) : data[:facts])
408
- set_vars_from_hash(t_name, @target_vars[t_name] ? data[:vars].merge(@target_vars[t_name]) : data[:vars])
409
- data[:features].each do |feature|
410
- set_feature(target, feature)
411
- end
412
- hook_data = @config.plugin_hooks.merge(data[:plugin_hooks])
413
- hash = if @target_plugin_hooks[t_name]
414
- hook_data.merge(@target_plugin_hooks[t_name])
415
- else
416
- hook_data
417
- end
418
- set_plugin_hooks(t_name, hash)
419
- end
420
- private :add_group_data_to_cache
421
-
422
- def inherit_data(track, name, data)
423
- unless track[name].nil?
424
- data[:facts] = track[name].facts.merge(data[:facts])
425
- data[:vars] = track[name].vars.merge(data[:vars])
426
- data[:features].concat(track[name].features)
427
- data[:plugin_hooks] = track[name].plugin_hooks.merge(data[:plugin_hooks])
428
- inherit_data(track, track[name].name, data)
429
- end
430
- data
431
- end
432
- private :inherit_data
433
-
434
- def invalidate_group_cache!(target)
435
- @target_facts.delete(target.name)
436
- @target_features.delete(target.name)
437
- @target_vars.delete(target.name)
438
- @target_plugin_hooks.delete(target.name)
78
+ def self.empty
79
+ config = Bolt::Config.default
80
+ plugins = Bolt::Plugin.setup(config, nil, nil, Bolt::Analytics::NoopClient)
81
+ create_version({}, config, plugins)
439
82
  end
440
- private :invalidate_group_cache!
441
83
  end
442
84
  end
@@ -1,258 +1,376 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'bolt/inventory/group'
4
+ require 'bolt/inventory/inventory'
5
+ require 'bolt/inventory/target'
6
+
3
7
  module Bolt
4
8
  class Inventory
5
- # Group is a specific implementation of Inventory based on nested
6
- # structured data.
7
9
  class Group
8
- attr_accessor :name, :nodes, :aliases, :name_or_alias, :groups,
9
- :config, :rest, :facts, :vars, :features, :plugin_hooks
10
+ attr_accessor :name, :groups
10
11
 
11
12
  # Regex used to validate group names and target aliases.
12
13
  NAME_REGEX = /\A[a-z0-9_][a-z0-9_-]*\Z/.freeze
13
14
 
14
- DATA_KEYS = %w[name config facts vars features plugin_hooks].freeze
15
- NODE_KEYS = DATA_KEYS + ['alias']
16
- GROUP_KEYS = DATA_KEYS + %w[groups nodes]
15
+ DATA_KEYS = %w[config facts vars features plugin_hooks].freeze
16
+ TARGET_KEYS = DATA_KEYS + %w[name alias uri]
17
+ GROUP_KEYS = DATA_KEYS + %w[name groups targets]
17
18
  CONFIG_KEYS = Bolt::TRANSPORTS.keys.map(&:to_s) + ['transport']
18
19
 
19
- def initialize(data)
20
+ def initialize(input, plugins)
20
21
  @logger = Logging.logger[self]
22
+ @plugins = plugins
23
+
24
+ input = @plugins.resolve_top_level_references(input) if @plugins.reference?(input)
21
25
 
22
- raise ValidationError.new("Expected group to be a Hash, not #{data.class}", nil) unless data.is_a?(Hash)
23
- raise ValidationError.new("Group does not have a name", nil) unless data.key?('name')
26
+ raise ValidationError.new("Group does not have a name", nil) unless input.key?('name')
27
+
28
+ @name = @plugins.resolve_references(input['name'])
24
29
 
25
- @name = data['name']
26
30
  raise ValidationError.new("Group name must be a String, not #{@name.inspect}", nil) unless @name.is_a?(String)
27
31
  raise ValidationError.new("Invalid group name #{@name}", @name) unless @name =~ NAME_REGEX
28
32
 
29
- unless (unexpected_keys = data.keys - GROUP_KEYS).empty?
30
- msg = "Found unexpected key(s) #{unexpected_keys.join(', ')} in group #{@name}"
31
- @logger.warn(msg)
32
- end
33
+ validate_group_input(input)
33
34
 
34
- @vars = fetch_value(data, 'vars', Hash)
35
- @facts = fetch_value(data, 'facts', Hash)
36
- @features = fetch_value(data, 'features', Array)
37
- @plugin_hooks = fetch_value(data, 'plugin_hooks', Hash)
38
- @config = fetch_value(data, 'config', Hash)
35
+ @input = input
39
36
 
40
- unless (unexpected_keys = @config.keys - CONFIG_KEYS).empty?
41
- msg = "Found unexpected key(s) #{unexpected_keys.join(', ')} in config for group #{@name}"
42
- @logger.warn(msg)
43
- end
37
+ validate_data_keys(@input)
38
+
39
+ targets = @plugins.resolve_top_level_references(input.fetch('targets', []))
44
40
 
45
- nodes = fetch_value(data, 'nodes', Array)
46
- groups = fetch_value(data, 'groups', Array)
41
+ @unresolved_targets = {}
42
+ @resolved_targets = {}
47
43
 
48
- @nodes = {}
49
44
  @aliases = {}
50
- nodes.reject { |node| node.is_a?(String) }.each do |node|
51
- unless node.is_a?(Hash)
52
- raise ValidationError.new("Node entry must be a String or Hash, not #{node.class}", @name)
45
+ @string_targets = []
46
+
47
+ Array(targets).each do |target|
48
+ # If target is a string, it can either be trivially defining a target
49
+ # or it could be a name/alias of a target defined in another group.
50
+ # We can't tell the difference until all groups have been resolved,
51
+ # so we store the string on its own here and process it later.
52
+ if target.is_a?(String)
53
+ @string_targets << target
54
+ # Handle plugins at this level so that lookups cannot trigger recursive lookups
55
+ elsif target.is_a?(Hash)
56
+ add_target_definition(target)
57
+ else
58
+ raise ValidationError.new("Target entry must be a String or Hash, not #{target.class}", @name)
53
59
  end
60
+ end
54
61
 
55
- if @nodes.include?(node['name'])
56
- @logger.warn("Ignoring duplicate node in #{@name}: #{node}")
57
- next
58
- end
62
+ groups = input.fetch('groups', [])
63
+ # 'groups' can be a _plugin reference, in which case we want to resolve
64
+ # it. That can itself return a reference, so we want to keep resolving
65
+ # them until we have a value. We don't just use resolve_references
66
+ # though, since that will resolve any nested references and we want to
67
+ # leave it to the group to do that lazily.
68
+ groups = @plugins.resolve_top_level_references(groups)
59
69
 
60
- raise ValidationError.new("Node #{node} does not have a name", @name) unless node['name']
61
- @nodes[node['name']] = node
70
+ @groups = Array(groups).map { |g| Group.new(g, plugins) }
71
+ end
62
72
 
63
- unless (unexpected_keys = node.keys - NODE_KEYS).empty?
64
- msg = "Found unexpected key(s) #{unexpected_keys.join(', ')} in node #{node['name']}"
65
- @logger.warn(msg)
66
- end
73
+ def target_data(target_name)
74
+ if @unresolved_targets.key?(target_name)
75
+ target = @unresolved_targets.delete(target_name)
76
+ resolved_data = resolve_data_keys(target, target_name).merge(
77
+ 'name' => target['name'],
78
+ 'uri' => target['uri'],
79
+ 'alias' => target['alias'],
80
+ # groups come from group_data
81
+ 'groups' => []
82
+ )
83
+ @resolved_targets[target_name] = resolved_data
84
+ else
85
+ @resolved_targets[target_name]
86
+ end
87
+ end
67
88
 
68
- unless node['config'].nil? || node['config'].is_a?(Hash)
69
- raise ValidationError.new("Invalid configuration for node: #{node['name']}", @name)
70
- end
89
+ def all_target_names
90
+ @unresolved_targets.keys + @resolved_targets.keys
91
+ end
71
92
 
72
- config_keys = node['config']&.keys || []
73
- unless (unexpected_keys = config_keys - CONFIG_KEYS).empty?
74
- msg = "Found unexpected key(s) #{unexpected_keys.join(', ')} in config for node #{node['name']}"
75
- @logger.warn(msg)
76
- end
93
+ def add_target_definition(target)
94
+ # This check ensures target lookup plugins do not returns bare strings.
95
+ # Remove it if we decide to allows task plugins to return string Target
96
+ # names.
97
+ unless target.is_a?(Hash)
98
+ raise ValidationError.new("Target entry must be a Hash, not #{target.class}", @name)
99
+ end
100
+
101
+ target['name'] = @plugins.resolve_references(target['name']) if target.key?('name')
102
+ target['uri'] = @plugins.resolve_references(target['uri']) if target.key?('uri')
103
+ target['alias'] = @plugins.resolve_references(target['alias']) if target.key?('alias')
104
+
105
+ t_name = target['name'] || target['uri']
106
+
107
+ if t_name.nil? || t_name.empty?
108
+ raise ValidationError.new("No name or uri for target: #{target}", @name)
109
+ end
110
+
111
+ unless t_name.is_a? String
112
+ raise ValidationError.new("Target name must be a String, not #{t_name.class}", @name)
113
+ end
114
+
115
+ unless t_name.ascii_only?
116
+ raise ValidationError.new("Target name must be ASCII characters: #{target}", @name)
117
+ end
118
+
119
+ if local_targets.include?(t_name)
120
+ @logger.warn("Ignoring duplicate target in #{@name}: #{target}")
121
+ return
122
+ end
123
+
124
+ unless (unexpected_keys = target.keys - TARGET_KEYS).empty?
125
+ msg = "Found unexpected key(s) #{unexpected_keys.join(', ')} in target #{t_name}"
126
+ @logger.warn(msg)
127
+ end
77
128
 
78
- next unless node.include?('alias')
129
+ validate_data_keys(target, t_name)
79
130
 
80
- aliases = node['alias']
131
+ if target.include?('alias')
132
+ aliases = target['alias']
81
133
  aliases = [aliases] if aliases.is_a?(String)
82
134
  unless aliases.is_a?(Array)
83
- msg = "Alias entry on #{node['name']} must be a String or Array, not #{aliases.class}"
135
+ msg = "Alias entry on #{t_name} must be a String or Array, not #{aliases.class}"
84
136
  raise ValidationError.new(msg, @name)
85
137
  end
86
138
 
87
- aliases.each do |alia|
88
- raise ValidationError.new("Invalid alias #{alia}", @name) unless alia =~ NAME_REGEX
89
-
90
- if (found = @aliases[alia])
91
- raise ValidationError.new(alias_conflict(alia, found, node['name']), @name)
92
- end
93
- @aliases[alia] = node['name']
94
- end
139
+ insert_alia(t_name, aliases)
95
140
  end
96
141
 
97
- # If node is a string, it can refer to either a node name or alias. Which can't be determined
98
- # until all groups have been resolved, and requires a depth-first traversal to categorize them.
99
- @name_or_alias = nodes.select { |node| node.is_a?(String) }
142
+ @unresolved_targets[t_name] = target
143
+ end
144
+
145
+ def remove_target(target)
146
+ @resolved_targets.delete(target.name)
147
+ @unresolved_targets.delete(target.name)
148
+ end
100
149
 
101
- @groups = groups.map { |g| Group.new(g) }
150
+ def add_target(target)
151
+ @resolved_targets[target.name] = { 'name' => target.name }
102
152
  end
103
153
 
104
- private def fetch_value(data, key, type)
105
- value = data.fetch(key, type.new)
106
- unless value.is_a?(type)
107
- raise ValidationError.new("Expected #{key} to be of type #{type}, not #{value.class}", @name)
154
+ def insert_alia(target_name, aliases)
155
+ aliases.each do |alia|
156
+ raise ValidationError.new("Invalid alias #{alia}", @name) unless alia =~ NAME_REGEX
157
+
158
+ if (found = @aliases[alia])
159
+ raise ValidationError.new(alias_conflict(alia, found, target_name), @name)
160
+ end
161
+ @aliases[alia] = target_name
108
162
  end
109
- value
110
163
  end
111
164
 
112
- def resolve_aliases(aliases)
113
- @name_or_alias.each do |name_or_alias|
114
- # If an alias is found, insert the name into this group. Otherwise use the name as a new node.
115
- node_name = aliases[name_or_alias] || name_or_alias
165
+ def clear_alia(target_name)
166
+ @aliases.reject! { |_alias, name| name == target_name }
167
+ end
168
+
169
+ def data_merge(data1, data2)
170
+ if data2.nil? || data1.nil?
171
+ return data2 || data1
172
+ end
173
+
174
+ {
175
+ 'config' => Bolt::Util.deep_merge(data1['config'], data2['config']),
176
+ 'name' => data1['name'] || data2['name'],
177
+ 'uri' => data1['uri'] || data2['uri'],
178
+ # Collect all aliases across all groups for each target uri
179
+ 'alias' => [*data1['alias'], *data2['alias']],
180
+ # Shallow merge instead of deep merge so that vars with a hash value
181
+ # are assigned a new hash, rather than merging the existing value
182
+ # with the value meant to replace it
183
+ 'vars' => data1['vars'].merge(data2['vars']),
184
+ 'facts' => Bolt::Util.deep_merge(data1['facts'], data2['facts']),
185
+ 'features' => data1['features'] | data2['features'],
186
+ 'plugin_hooks' => data1['plugin_hooks'].merge(data2['plugin_hooks']),
187
+ 'groups' => data2['groups'] + data1['groups']
188
+ }
189
+ end
116
190
 
117
- if @nodes.include?(node_name)
118
- @logger.warn("Ignoring duplicate node in #{@name}: #{node_name}")
191
+ def resolve_string_targets(aliases, known_targets)
192
+ @string_targets.each do |string_target|
193
+ # If this is the name of a target defined elsewhere, then insert the
194
+ # target into this group as just a name. Otherwise, add a new target
195
+ # with the string as the URI.
196
+ if known_targets.include?(string_target)
197
+ @unresolved_targets[string_target] = { 'name' => string_target }
198
+ # If this is an alias for an existing target, then add it to this group
199
+ elsif (canonical_name = aliases[string_target])
200
+ if local_targets.include?(canonical_name)
201
+ @logger.warn("Ignoring duplicate target in #{@name}: #{canonical_name}")
202
+ else
203
+ @unresolved_targets[canonical_name] = { 'name' => canonical_name }
204
+ end
205
+ # If it's not the name or alias of an existing target, then make a
206
+ # new target using the string as the URI
207
+ elsif local_targets.include?(string_target)
208
+ @logger.warn("Ignoring duplicate target in #{@name}: #{string_target}")
119
209
  else
120
- @nodes[node_name] = { 'name' => node_name }
210
+ @unresolved_targets[string_target] = { 'uri' => string_target }
121
211
  end
122
212
  end
123
213
 
124
- @groups.each { |g| g.resolve_aliases(aliases) }
214
+ @groups.each { |g| g.resolve_string_targets(aliases, known_targets) }
125
215
  end
126
216
 
127
- private def alias_conflict(name, node1, node2)
128
- "Alias #{name} refers to multiple targets: #{node1} and #{node2}"
217
+ private def alias_conflict(name, target1, target2)
218
+ "Alias #{name} refers to multiple targets: #{target1} and #{target2}"
129
219
  end
130
220
 
131
221
  private def group_alias_conflict(name)
132
222
  "Group #{name} conflicts with alias of the same name"
133
223
  end
134
224
 
135
- private def group_node_conflict(name)
136
- "Group #{name} conflicts with node of the same name"
225
+ private def group_target_conflict(name)
226
+ "Group #{name} conflicts with target of the same name"
227
+ end
228
+
229
+ private def alias_target_conflict(name)
230
+ "Target name #{name} conflicts with alias of the same name"
137
231
  end
138
232
 
139
- private def alias_node_conflict(name)
140
- "Node name #{name} conflicts with alias of the same name"
233
+ def validate_group_input(input)
234
+ raise ValidationError.new("Expected group to be a Hash, not #{input.class}", nil) unless input.is_a?(Hash)
235
+
236
+ # DEPRECATION : remove this before finalization
237
+ if input.key?('target-lookups')
238
+ msg = "'target-lookups' are no longer a separate key. Merge 'target-lookups' and 'targets' lists and replace 'plugin' with '_plugin'" # rubocop:disable Layout/LineLength
239
+ raise ValidationError.new(msg, @name)
240
+ end
241
+
242
+ if input.key?('nodes')
243
+ msg = <<~MSG.chomp
244
+ Found 'nodes' key in group #{@name}. This looks like a v1 inventory file, which is
245
+ no longer supported by Bolt. Migrate to a v2 inventory file automatically using
246
+ 'bolt project migrate'.
247
+ MSG
248
+ raise ValidationError.new(msg, nil)
249
+ end
250
+
251
+ unless (unexpected_keys = input.keys - GROUP_KEYS).empty?
252
+ msg = "Found unexpected key(s) #{unexpected_keys.join(', ')} in group #{@name}"
253
+ @logger.warn(msg)
254
+ end
255
+
256
+ Bolt::Util.walk_keys(input) do |key|
257
+ if @plugins.reference?(key)
258
+ raise ValidationError.new("Group keys cannot be specified as _plugin references", @name)
259
+ else
260
+ key
261
+ end
262
+ end
141
263
  end
142
264
 
143
- def validate(used_names = Set.new, node_names = Set.new, aliased = {}, depth = 0)
265
+ def validate(used_group_names = Set.new, used_target_names = Set.new, used_aliases = {})
144
266
  # Test if this group name conflicts with anything used before.
145
- raise ValidationError.new("Tried to redefine group #{@name}", @name) if used_names.include?(@name)
146
- raise ValidationError.new(group_node_conflict(@name), @name) if node_names.include?(@name)
147
- raise ValidationError.new(group_alias_conflict(@name), @name) if aliased.include?(@name)
267
+ raise ValidationError.new("Tried to redefine group #{@name}", @name) if used_group_names.include?(@name)
268
+ raise ValidationError.new(group_target_conflict(@name), @name) if used_target_names.include?(@name)
269
+ raise ValidationError.new(group_alias_conflict(@name), @name) if used_aliases.include?(@name)
148
270
 
149
- used_names << @name
271
+ used_group_names << @name
150
272
 
151
- # Collect node names and aliases into a list used to validate that subgroups don't conflict.
152
- # Used names validate that previously used group names don't conflict with new node names/aliases.
153
- @nodes.each_key do |n|
154
- # Require nodes to be parseable as a Target.
273
+ # Collect target names and aliases into a list used to validate that subgroups don't conflict.
274
+ # Used names validate that previously used group names don't conflict with new target names/aliases.
275
+ @unresolved_targets.merge(@resolved_targets).each do |t_name, t_data|
276
+ # Require targets to be parseable as a Target.
155
277
  begin
156
- Bolt::Target.new(n)
278
+ # Catch malformed URI here
279
+ Bolt::Inventory::Target.parse_uri(t_data['uri'])
157
280
  rescue Bolt::ParseError => e
158
281
  @logger.debug(e)
159
- raise ValidationError.new("Invalid node name #{n}", @name)
282
+ raise ValidationError.new("Invalid target uri #{t_data['uri']}", @name)
160
283
  end
161
284
 
162
- raise ValidationError.new(group_node_conflict(n), @name) if used_names.include?(n)
163
- raise ValidationError.new(alias_node_conflict(n), @name) if aliased.include?(n)
285
+ raise ValidationError.new(group_target_conflict(t_name), @name) if used_group_names.include?(t_name)
286
+ if used_aliases.include?(t_name)
287
+ raise ValidationError.new(alias_target_conflict(t_name), @name)
288
+ end
164
289
 
165
- node_names << n
290
+ used_target_names << t_name
166
291
  end
167
292
 
168
293
  @aliases.each do |n, target|
169
- raise ValidationError.new(group_alias_conflict(n), @name) if used_names.include?(n)
170
- raise ValidationError.new(alias_node_conflict(n), @name) if node_names.include?(n)
294
+ raise ValidationError.new(group_alias_conflict(n), @name) if used_group_names.include?(n)
295
+ if used_target_names.include?(n)
296
+ raise ValidationError.new(alias_target_conflict(n), @name)
297
+ end
171
298
 
172
- if aliased.include?(n) && aliased[n] != target
173
- raise ValidationError.new(alias_conflict(n, target, aliased[n]), @name)
299
+ if used_aliases.include?(n)
300
+ raise ValidationError.new(alias_conflict(n, target, used_aliases[n]), @name)
174
301
  end
175
302
 
176
- aliased[n] = target
303
+ used_aliases[n] = target
177
304
  end
178
305
 
179
306
  @groups.each do |g|
180
- begin
181
- g.validate(used_names, node_names, aliased, depth + 1)
182
- rescue ValidationError => e
183
- e.add_parent(@name)
184
- raise e
185
- end
307
+ g.validate(used_group_names, used_target_names, used_aliases)
308
+ rescue ValidationError => e
309
+ e.add_parent(@name)
310
+ raise e
186
311
  end
187
312
 
188
313
  nil
189
314
  end
190
315
 
191
- # The data functions below expect and return nil or a hash of the schema
192
- # { 'config' => Hash , 'vars' => Hash, 'facts' => Hash, 'features' => Array, groups => Array }
193
- def data_for(node_name)
194
- data_merge(group_collect(node_name), node_collect(node_name))
316
+ def resolve_data_keys(data, target = nil)
317
+ result = {
318
+ 'config' => @plugins.resolve_references(data.fetch('config', {})),
319
+ 'vars' => @plugins.resolve_references(data.fetch('vars', {})),
320
+ 'facts' => @plugins.resolve_references(data.fetch('facts', {})),
321
+ 'features' => @plugins.resolve_references(data.fetch('features', [])),
322
+ 'plugin_hooks' => @plugins.resolve_references(data.fetch('plugin_hooks', {}))
323
+ }
324
+ validate_data_keys(result, target)
325
+ result['features'] = Set.new(result['features'].flatten)
326
+ result
195
327
  end
196
328
 
197
- def node_data(node_name)
198
- if (data = @nodes[node_name])
199
- { 'config' => data['config'] || {},
200
- 'vars' => data['vars'] || {},
201
- 'facts' => data['facts'] || {},
202
- 'features' => data['features'] || [],
203
- 'plugin_hooks' => data['plugin_hooks'] || {},
204
- # groups come from group_data
205
- 'groups' => [] }
329
+ def validate_data_keys(data, target = nil)
330
+ {
331
+ 'config' => Hash,
332
+ 'vars' => Hash,
333
+ 'facts' => Hash,
334
+ 'features' => Array,
335
+ 'plugin_hooks' => Hash
336
+ }.each do |key, expected_type|
337
+ next if !data.key?(key) || data[key].is_a?(expected_type) || @plugins.reference?(data[key])
338
+
339
+ msg = +"Expected #{key} to be of type #{expected_type}, not #{data[key].class}"
340
+ msg << " for target #{target}" if target
341
+ raise ValidationError.new(msg, @name)
342
+ end
343
+ unless @plugins.reference?(data['config'])
344
+ unexpected_keys = data.fetch('config', {}).keys - CONFIG_KEYS
345
+ if unexpected_keys.any?
346
+ msg = +"Found unexpected key(s) #{unexpected_keys.join(', ')} in config for"
347
+ msg << " target #{target} in" if target
348
+ msg << " group #{@name}"
349
+ @logger.warn(msg)
350
+ end
206
351
  end
207
352
  end
208
353
 
209
354
  def group_data
210
- { 'config' => @config,
211
- 'vars' => @vars,
212
- 'facts' => @facts,
213
- 'features' => @features,
214
- 'plugin_hooks' => @plugin_hooks,
215
- 'groups' => [@name] }
355
+ @group_data ||= resolve_data_keys(@input).merge('groups' => [@name])
216
356
  end
217
357
 
218
- def empty_data
219
- { 'config' => {},
220
- 'vars' => {},
221
- 'facts' => {},
222
- 'features' => [],
223
- 'plugin_hooks' => {},
224
- 'groups' => [] }
225
- end
226
-
227
- def data_merge(data1, data2)
228
- if data2.nil? || data1.nil?
229
- return data2 || data1
230
- end
231
-
232
- {
233
- 'config' => Bolt::Util.deep_merge(data1['config'], data2['config']),
234
- # Shallow merge instead of deep merge so that vars with a hash value
235
- # are assigned a new hash, rather than merging the existing value
236
- # with the value meant to replace it
237
- 'vars' => data1['vars'].merge(data2['vars']),
238
- 'facts' => Bolt::Util.deep_merge(data1['facts'], data2['facts']),
239
- 'features' => data1['features'] | data2['features'],
240
- 'plugin_hooks' => data1['plugin_hooks'].merge(data2['plugin_hooks']),
241
- 'groups' => data2['groups'] + data1['groups']
242
- }
358
+ # Returns targets contained directly within the group, ignoring subgroups
359
+ def local_targets
360
+ Set.new(@unresolved_targets.keys) + Set.new(@resolved_targets.keys)
243
361
  end
244
362
 
245
- # Returns all nodes contained within the group, which includes nodes from subgroups.
246
- def node_names
247
- @groups.inject(local_node_names) do |acc, g|
248
- acc.merge(g.node_names)
363
+ # Returns all targets contained within the group, which includes targets from subgroups.
364
+ def all_targets
365
+ @groups.inject(local_targets) do |acc, g|
366
+ acc.merge(g.all_targets)
249
367
  end
250
368
  end
251
369
 
252
- # Returns a mapping of aliases to nodes contained within the group, which includes subgroups.
253
- def node_aliases
370
+ # Returns a mapping of aliases to targets contained within the group, which includes subgroups.
371
+ def target_aliases
254
372
  @groups.inject(@aliases) do |acc, g|
255
- acc.merge(g.node_aliases)
373
+ acc.merge(g.target_aliases)
256
374
  end
257
375
  end
258
376
 
@@ -263,35 +381,28 @@ module Bolt
263
381
  end
264
382
  end
265
383
 
266
- def local_node_names
267
- Set.new(@nodes.keys)
268
- end
269
- private :local_node_names
270
-
271
- def node_collect(node_name)
272
- data = @groups.inject(nil) do |acc, g|
273
- if (d = g.node_collect(node_name))
274
- data_merge(d, acc)
275
- else
276
- acc
277
- end
384
+ def target_collect(target_name)
385
+ child_data = @groups.map { |group| group.target_collect(target_name) }
386
+ # Data from earlier groups wins
387
+ child_result = child_data.inject do |acc, group_data|
388
+ data_merge(group_data, acc)
278
389
  end
279
- data_merge(node_data(node_name), data)
390
+ # Children override the parent
391
+ data_merge(target_data(target_name), child_result)
280
392
  end
281
393
 
282
- def group_collect(node_name)
283
- data = @groups.inject(nil) do |acc, g|
284
- if (d = g.data_for(node_name))
285
- data_merge(d, acc)
286
- else
287
- acc
288
- end
394
+ def group_collect(target_name)
395
+ child_data = @groups.map { |group| group.group_collect(target_name) }
396
+ # Data from earlier groups wins
397
+ child_result = child_data.inject do |acc, group_data|
398
+ data_merge(group_data, acc)
289
399
  end
290
400
 
291
- if data
292
- data_merge(group_data, data)
293
- elsif @nodes.include?(node_name)
294
- group_data
401
+ # If this group has the target or one of the child groups has the
402
+ # target, return the data, otherwise return nil
403
+ if child_result || local_targets.include?(target_name)
404
+ # Children override the parent
405
+ data_merge(group_data, child_result)
295
406
  end
296
407
  end
297
408
  end