bolt 1.15.0 → 1.16.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/bolt-modules/boltlib/lib/puppet/datatypes/target.rb +1 -1
  3. data/bolt-modules/boltlib/lib/puppet/functions/run_plan.rb +4 -4
  4. data/lib/bolt.rb +3 -0
  5. data/lib/bolt/analytics.rb +7 -2
  6. data/lib/bolt/applicator.rb +6 -2
  7. data/lib/bolt/bolt_option_parser.rb +4 -4
  8. data/lib/bolt/cli.rb +8 -4
  9. data/lib/bolt/config.rb +6 -6
  10. data/lib/bolt/executor.rb +2 -7
  11. data/lib/bolt/inventory.rb +37 -6
  12. data/lib/bolt/inventory/group2.rb +314 -0
  13. data/lib/bolt/inventory/inventory2.rb +261 -0
  14. data/lib/bolt/outputter/human.rb +3 -1
  15. data/lib/bolt/pal.rb +8 -7
  16. data/lib/bolt/puppetdb/client.rb +6 -5
  17. data/lib/bolt/target.rb +34 -14
  18. data/lib/bolt/task.rb +2 -2
  19. data/lib/bolt/transport/base.rb +2 -2
  20. data/lib/bolt/transport/docker.rb +1 -1
  21. data/lib/bolt/transport/docker/connection.rb +2 -0
  22. data/lib/bolt/transport/local.rb +9 -181
  23. data/lib/bolt/transport/local/shell.rb +202 -12
  24. data/lib/bolt/transport/local_windows.rb +203 -0
  25. data/lib/bolt/transport/orch.rb +6 -4
  26. data/lib/bolt/transport/orch/connection.rb +6 -2
  27. data/lib/bolt/transport/ssh.rb +10 -150
  28. data/lib/bolt/transport/ssh/connection.rb +15 -116
  29. data/lib/bolt/transport/sudoable.rb +163 -0
  30. data/lib/bolt/transport/sudoable/connection.rb +76 -0
  31. data/lib/bolt/transport/sudoable/tmpdir.rb +59 -0
  32. data/lib/bolt/transport/winrm.rb +4 -4
  33. data/lib/bolt/transport/winrm/connection.rb +1 -0
  34. data/lib/bolt/util.rb +2 -0
  35. data/lib/bolt/version.rb +1 -1
  36. data/lib/bolt_ext/puppetdb_inventory.rb +0 -1
  37. data/lib/bolt_server/transport_app.rb +3 -1
  38. data/lib/logging_extensions/logging.rb +13 -0
  39. data/lib/plan_executor/orch_client.rb +4 -0
  40. metadata +23 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 271a7fea2ecaf024054a9ae2266bc352b19a9969300c882f9622341582585d48
4
- data.tar.gz: d10c047f108f62efdfe1e6ac1a35bc7b3303332f0ca0bd50c55635857d41648e
3
+ metadata.gz: b815f57b892f3750786159c9251e03f34cae09c721a6c18a2ace76d7319324b3
4
+ data.tar.gz: 13d51985236eee75d10bd527a8fe4c0328b362175dc9313a84f519967c690d9a
5
5
  SHA512:
6
- metadata.gz: 7d6355d7246bfa1305c02e13edf5fd7873584b70306e505f48ee1470babf6aa282b9b5f141ee5af6703b60a589f937c7997110ad21a2d6705926835a8b42b415
7
- data.tar.gz: 4a1e5f3bf454ce08b084c0310b98c71119305f3c34fbeca552dffe3f6029cc16e7c410e2490605a985552e9603d7a9e653509b8a71679f3b2bb5e0a56e13951d
6
+ metadata.gz: f904cf6c228379b8f790ed6d102da43244cdc5b0fa8630f04758957e35c083ee9955d09b0d212c22ea5a9e07f989e72f73e88b07f99d984c9427a813386713f3
7
+ data.tar.gz: a755bc82082397a067d8a8bdbfc81f7687071bbb2aa993c3a95e0db24feeda4e28c7b34644cbb1a2195f3211918c9de7fafd8c232ce38acd7ce5c42cfd5359da
@@ -7,8 +7,8 @@ Puppet::DataTypes.create_type('Target') do
7
7
  options => { type => Hash[String[1], Data], value => {} }
8
8
  },
9
9
  functions => {
10
- host => Callable[[], String[1]],
11
10
  name => Callable[[], String[1]],
11
+ host => Callable[[], Optional[String]],
12
12
  password => Callable[[], Optional[String[1]]],
13
13
  port => Callable[[], Optional[Integer]],
14
14
  protocol => Callable[[], Optional[String[1]]],
@@ -82,11 +82,11 @@ Puppet::Functions.create_function(:run_plan, Puppet::Functions::InternalFunction
82
82
  end
83
83
 
84
84
  result
85
- rescue Puppet::PreformattedError => err
86
- if named_args['_catch_errors'] && err.cause.is_a?(Bolt::Error)
87
- result = err.cause.to_puppet_error
85
+ rescue Puppet::PreformattedError => e
86
+ if named_args['_catch_errors'] && e.cause.is_a?(Bolt::Error)
87
+ result = e.cause.to_puppet_error
88
88
  else
89
- raise err
89
+ raise e
90
90
  end
91
91
  ensure
92
92
  if run_as
data/lib/bolt.rb CHANGED
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # loads Logging gem, patching it for perf reasons to disable plugins
4
+ require 'logging_extensions/logging'
5
+
3
6
  module Bolt
4
7
  require 'bolt/executor'
5
8
  end
@@ -2,9 +2,7 @@
2
2
 
3
3
  require 'bolt/util'
4
4
  require 'bolt/version'
5
- require 'httpclient'
6
5
  require 'json'
7
- require 'locale'
8
6
  require 'logging'
9
7
  require 'securerandom'
10
8
 
@@ -62,17 +60,21 @@ module Bolt
62
60
 
63
61
  class Client
64
62
  attr_reader :user_id
63
+ attr_accessor :bundled_content
65
64
 
66
65
  def initialize(user_id)
67
66
  # lazy-load expensive gem code
68
67
  require 'concurrent/configuration'
69
68
  require 'concurrent/future'
69
+ require 'httpclient'
70
+ require 'locale'
70
71
 
71
72
  @logger = Logging.logger[self]
72
73
  @http = HTTPClient.new
73
74
  @user_id = user_id
74
75
  @executor = Concurrent.global_io_executor
75
76
  @os = compute_os
77
+ @bundled_content = []
76
78
  end
77
79
 
78
80
  def screen_view(screen, **kwargs)
@@ -164,8 +166,11 @@ module Bolt
164
166
  end
165
167
 
166
168
  class NoopClient
169
+ attr_accessor :bundled_content
170
+
167
171
  def initialize
168
172
  @logger = Logging.logger[self]
173
+ @bundled_content = []
169
174
  end
170
175
 
171
176
  def screen_view(screen, **_kwargs)
@@ -4,7 +4,6 @@ require 'base64'
4
4
  require 'find'
5
5
  require 'json'
6
6
  require 'logging'
7
- require 'minitar'
8
7
  require 'open3'
9
8
  require 'bolt/error'
10
9
  require 'bolt/task'
@@ -83,6 +82,7 @@ module Bolt
83
82
 
84
83
  def compile(target, ast, plan_vars)
85
84
  trusted = Puppet::Context::TrustedInformation.new('local', target.host, {})
85
+ facts = @inventory.facts(target).merge('bolt' => true)
86
86
 
87
87
  catalog_input = {
88
88
  code_ast: ast,
@@ -91,7 +91,7 @@ module Bolt
91
91
  hiera_config: @hiera_config,
92
92
  target: {
93
93
  name: target.host,
94
- facts: @inventory.facts(target),
94
+ facts: facts,
95
95
  variables: @inventory.vars(target).merge(plan_vars),
96
96
  trusted: trusted.to_h
97
97
  },
@@ -225,6 +225,10 @@ module Bolt
225
225
  end
226
226
 
227
227
  def build_plugin_tarball
228
+ # lazy-load expensive gem code
229
+ require 'minitar'
230
+ require 'zlib'
231
+
228
232
  start_time = Time.now
229
233
  sio = StringIO.new
230
234
  output = Minitar::Output.new(Zlib::GzipWriter.new(sio))
@@ -314,8 +314,8 @@ Usage: bolt apply <manifest.pp> [options]
314
314
  def parse_params(params)
315
315
  json = get_arg_input(params)
316
316
  JSON.parse(json)
317
- rescue JSON::ParserError => err
318
- raise Bolt::CLIError, "Unable to parse --params value as JSON: #{err}"
317
+ rescue JSON::ParserError => e
318
+ raise Bolt::CLIError, "Unable to parse --params value as JSON: #{e}"
319
319
  end
320
320
 
321
321
  def get_arg_input(value)
@@ -331,8 +331,8 @@ Usage: bolt apply <manifest.pp> [options]
331
331
 
332
332
  def read_arg_file(file)
333
333
  File.read(File.expand_path(file))
334
- rescue StandardError => err
335
- raise Bolt::FileError.new("Error attempting to read #{file}: #{err}", file)
334
+ rescue StandardError => e
335
+ raise Bolt::FileError.new("Error attempting to read #{file}: #{e}", file)
336
336
  end
337
337
  end
338
338
  end
data/lib/bolt/cli.rb CHANGED
@@ -231,6 +231,7 @@ module Bolt
231
231
  end
232
232
 
233
233
  @analytics = Bolt::Analytics.build_client
234
+ @analytics.bundled_content = bundled_content
234
235
 
235
236
  screen = "#{options[:subcommand]}_#{options[:action]}"
236
237
  # submit a different screen for `bolt task show` and `bolt task show foo`
@@ -282,7 +283,7 @@ module Bolt
282
283
  end
283
284
  code = apply_manifest(options[:code], options[:targets], options[:object], options[:noop])
284
285
  else
285
- executor = Bolt::Executor.new(config.concurrency, @analytics, options[:noop], bundled_content: bundled_content)
286
+ executor = Bolt::Executor.new(config.concurrency, @analytics, options[:noop])
286
287
  targets = options[:targets]
287
288
 
288
289
  results = nil
@@ -373,7 +374,7 @@ module Bolt
373
374
  params: params }
374
375
  plan_context[:description] = options[:description] if options[:description]
375
376
 
376
- executor = Bolt::Executor.new(config.concurrency, @analytics, options[:noop], bundled_content: bundled_content)
377
+ executor = Bolt::Executor.new(config.concurrency, @analytics, options[:noop])
377
378
  executor.start_plan(plan_context)
378
379
  result = pal.run_plan(plan_name, plan_arguments, executor, inventory, puppetdb_client)
379
380
 
@@ -386,7 +387,7 @@ module Bolt
386
387
  def apply_manifest(code, targets, filename = nil, noop = false)
387
388
  ast = pal.parse_manifest(code, filename)
388
389
 
389
- executor = Bolt::Executor.new(config.concurrency, @analytics, noop, bundled_content: bundled_content)
390
+ executor = Bolt::Executor.new(config.concurrency, @analytics, noop)
390
391
  # Call start_plan just to enable plan_logging
391
392
  executor.start_plan(nil)
392
393
 
@@ -469,7 +470,8 @@ module Bolt
469
470
  end
470
471
 
471
472
  def bundled_content
472
- if %w[plan task].include?(options[:subcommand])
473
+ # We only need to enumerate bundled content when running a task or plan
474
+ if %w[plan task].include?(options[:subcommand]) && options[:action] == 'run'
473
475
  default_content = Bolt::PAL.new([], nil)
474
476
  plans = default_content.list_plans.each_with_object([]) do |iter, col|
475
477
  col << iter&.first
@@ -478,6 +480,8 @@ module Bolt
478
480
  col << iter&.first
479
481
  end
480
482
  plans.concat tasks
483
+ else
484
+ []
481
485
  end
482
486
  end
483
487
  end
data/lib/bolt/config.rb CHANGED
@@ -1,24 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'yaml'
3
+ require 'etc'
4
4
  require 'logging'
5
- # limit the loaded portion of concurrent as its expensive
6
- require 'concurrent/utility/processor_counter'
7
5
  require 'pathname'
8
6
  require 'bolt/boltdir'
9
7
  require 'bolt/transport/ssh'
10
8
  require 'bolt/transport/winrm'
11
9
  require 'bolt/transport/orch'
12
10
  require 'bolt/transport/local'
11
+ require 'bolt/transport/local_windows'
13
12
  require 'bolt/transport/docker'
14
13
  require 'bolt/transport/remote'
14
+ require 'bolt/util'
15
15
 
16
16
  module Bolt
17
17
  TRANSPORTS = {
18
18
  ssh: Bolt::Transport::SSH,
19
19
  winrm: Bolt::Transport::WinRM,
20
20
  pcp: Bolt::Transport::Orch,
21
- local: Bolt::Transport::Local,
21
+ local: Bolt::Util.windows? ? Bolt::Transport::LocalWindows : Bolt::Transport::Local,
22
22
  docker: Bolt::Transport::Docker,
23
23
  remote: Bolt::Transport::Remote
24
24
  }.freeze
@@ -60,7 +60,7 @@ module Bolt
60
60
 
61
61
  @boltdir = boltdir
62
62
  @concurrency = 100
63
- @compile_concurrency = Concurrent.processor_count
63
+ @compile_concurrency = Etc.nprocessors
64
64
  @transport = 'ssh'
65
65
  @format = 'human'
66
66
  @puppetdb = {}
@@ -246,7 +246,7 @@ module Bolt
246
246
  raise Bolt::ValidationError, 'Compile concurrency must be a positive integer'
247
247
  end
248
248
 
249
- compile_limit = 2 * Concurrent.processor_count
249
+ compile_limit = 2 * Etc.nprocessors
250
250
  unless @compile_concurrency < compile_limit
251
251
  raise Bolt::ValidationError, "Compilation is CPU-intensive, set concurrency less than #{compile_limit}"
252
252
  end
data/lib/bolt/executor.rb CHANGED
@@ -21,17 +21,13 @@ module Bolt
21
21
  # https://makandracards.com/makandra/36011-ruby-do-not-mix-optional-and-keyword-arguments
22
22
  def initialize(concurrency = 1,
23
23
  analytics = Bolt::Analytics::NoopClient.new,
24
- noop = nil,
25
- bundled_content: nil,
26
- load_config: true)
24
+ noop = false)
27
25
 
28
26
  # lazy-load expensive gem code
29
27
  require 'concurrent'
30
28
 
31
29
  @analytics = analytics
32
- @bundled_content = bundled_content
33
30
  @logger = Logging.logger[self]
34
- @load_config = load_config
35
31
 
36
32
  @transports = Bolt::TRANSPORTS.each_with_object({}) do |(key, val), coll|
37
33
  coll[key.to_s] = if key == :remote
@@ -184,7 +180,7 @@ module Bolt
184
180
  end
185
181
 
186
182
  def report_bundled_content(mode, name)
187
- if @bundled_content&.include?(name)
183
+ if @analytics.bundled_content&.include?(name)
188
184
  @analytics&.event('Bundled Content', mode, label: name)
189
185
  end
190
186
  end
@@ -263,7 +259,6 @@ module Bolt
263
259
  log_action(description, targets) do
264
260
  notify = proc { |event| @notifier.notify(callback, event) if callback }
265
261
  options = { '_run_as' => run_as }.merge(options) if run_as
266
- options = options.merge('_load_config' => @load_config)
267
262
  arguments['_task'] = task.name
268
263
 
269
264
  results = batch_execute(targets) do |transport, batch|
@@ -3,8 +3,10 @@
3
3
  require 'set'
4
4
  require 'bolt/config'
5
5
  require 'bolt/inventory/group'
6
+ require 'bolt/inventory/inventory2'
6
7
  require 'bolt/target'
7
8
  require 'bolt/util'
9
+ require 'yaml'
8
10
 
9
11
  module Bolt
10
12
  class Inventory
@@ -52,11 +54,23 @@ module Bolt
52
54
  data = Bolt::Util.read_config_file(config.inventoryfile, config.default_inventoryfile, 'inventory')
53
55
  end
54
56
 
55
- inventory = new(data, config)
57
+ inventory = create_version(data, config)
56
58
  inventory.validate
57
59
  inventory
58
60
  end
59
61
 
62
+ def self.create_version(data, config)
63
+ version = (data || {}).delete('version') { 1 }
64
+ case version
65
+ when 1
66
+ new(data, config)
67
+ when 2
68
+ Bolt::Inventory::Inventory2.new(data, config)
69
+ else
70
+ raise ValidationError, "Unsupported version #{version} specified in inventory"
71
+ end
72
+ end
73
+
60
74
  def initialize(data, config = nil, target_vars: {}, target_facts: {}, target_features: {})
61
75
  @logger = Logging.logger[self]
62
76
  # Config is saved to add config options to targets
@@ -163,6 +177,21 @@ module Bolt
163
177
  end
164
178
  private :groups_in
165
179
 
180
+ # TODO: Possibly refactor this once inventory v2 is more stable
181
+ def self.localhost_defaults(data)
182
+ defaults = {
183
+ 'config' => {
184
+ 'transport' => 'local',
185
+ 'local' => { 'interpreters' => { '.rb' => RbConfig.ruby } }
186
+ },
187
+ 'features' => ['puppet-agent']
188
+ }
189
+ data = Bolt::Util.deep_merge(defaults, data)
190
+ # If features is an empty array deep_merge won't add the puppet-agent
191
+ data['features'] << 'puppet-agent' if data['features'].empty?
192
+ data
193
+ end
194
+
166
195
  # Pass a target to get_targets for a public version of this
167
196
  # Should this reconfigure configured targets?
168
197
  def update_target(target)
@@ -174,10 +203,7 @@ module Bolt
174
203
  data['config'] = {}
175
204
  end
176
205
 
177
- unless data['config']['transport']
178
- data['config']['transport'] = 'local' if target.name == 'localhost'
179
- end
180
-
206
+ data = self.class.localhost_defaults(data) if target.name == 'localhost'
181
207
  # These should only get set from the inventory if they have not yet
182
208
  # been instantiated
183
209
  set_vars_from_hash(target.name, data['vars']) unless @target_vars[target.name]
@@ -224,6 +250,11 @@ module Bolt
224
250
  end
225
251
  private :resolve_name
226
252
 
253
+ def create_target(data)
254
+ Target.new(data)
255
+ end
256
+ private :create_target
257
+
227
258
  def expand_targets(targets)
228
259
  if targets.is_a? Bolt::Target
229
260
  targets.inventory = self
@@ -235,7 +266,7 @@ module Bolt
235
266
  targets.split(/[[:space:],]+/).reject(&:empty?).map do |name|
236
267
  ts = resolve_name(name)
237
268
  ts.map do |t|
238
- target = Target.new(t)
269
+ target = create_target(t)
239
270
  target.inventory = self
240
271
  target
241
272
  end
@@ -0,0 +1,314 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bolt/inventory/group'
4
+
5
+ module Bolt
6
+ class Inventory
7
+ class Group2
8
+ attr_accessor :name, :nodes, :aliases, :name_or_alias, :groups, :config, :rest, :facts, :vars, :features
9
+
10
+ # THESE are duplicates with the old groups for now.
11
+ # Regex used to validate group names and target aliases.
12
+ NAME_REGEX = /\A[a-z0-9_][a-z0-9_-]*\Z/.freeze
13
+
14
+ DATA_KEYS = %w[name config facts vars features].freeze
15
+ NODE_KEYS = DATA_KEYS + %w[alias uri]
16
+ GROUP_KEYS = DATA_KEYS + %w[groups nodes]
17
+ CONFIG_KEYS = Bolt::TRANSPORTS.keys.map(&:to_s) + ['transport']
18
+
19
+ def initialize(data)
20
+ @logger = Logging.logger[self]
21
+
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')
24
+
25
+ @name = data['name']
26
+ raise ValidationError.new("Group name must be a String, not #{@name.inspect}", nil) unless @name.is_a?(String)
27
+ raise ValidationError.new("Invalid group name #{@name}", @name) unless @name =~ NAME_REGEX
28
+
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
+
34
+ @vars = fetch_value(data, 'vars', Hash)
35
+ @facts = fetch_value(data, 'facts', Hash)
36
+ @features = fetch_value(data, 'features', Array)
37
+ @config = fetch_value(data, 'config', Hash)
38
+
39
+ unless (unexpected_keys = @config.keys - CONFIG_KEYS).empty?
40
+ msg = "Found unexpected key(s) #{unexpected_keys.join(', ')} in config for group #{@name}"
41
+ @logger.warn(msg)
42
+ end
43
+
44
+ nodes = fetch_value(data, 'nodes', Array)
45
+ groups = fetch_value(data, 'groups', Array)
46
+
47
+ @nodes = {}
48
+ @aliases = {}
49
+ nodes.reject { |node| node.is_a?(String) }.each do |node|
50
+ unless node.is_a?(Hash)
51
+ raise ValidationError.new("Node entry must be a String or Hash, not #{node.class}", @name)
52
+ end
53
+
54
+ node['name'] ||= node['uri']
55
+
56
+ if node['name'].nil? || node['name'].empty?
57
+ raise ValidationError.new("No name or uri for node: #{node}", @name)
58
+ end
59
+
60
+ if @nodes.include?(node['name'])
61
+ @logger.warn("Ignoring duplicate node in #{@name}: #{node}")
62
+ next
63
+ end
64
+
65
+ raise ValidationError.new("Node #{node} does not have a name", @name) unless node['name']
66
+ @nodes[node['name']] = node
67
+
68
+ unless (unexpected_keys = node.keys - NODE_KEYS).empty?
69
+ msg = "Found unexpected key(s) #{unexpected_keys.join(', ')} in node #{node['name']}"
70
+ @logger.warn(msg)
71
+ end
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
77
+
78
+ next unless node.include?('alias')
79
+
80
+ aliases = node['alias']
81
+ aliases = [aliases] if aliases.is_a?(String)
82
+ unless aliases.is_a?(Array)
83
+ msg = "Alias entry on #{node['name']} must be a String or Array, not #{aliases.class}"
84
+ raise ValidationError.new(msg, @name)
85
+ end
86
+
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
95
+ end
96
+
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) }
100
+
101
+ @groups = groups.map { |g| Group2.new(g) }
102
+ end
103
+
104
+ def node_data(node_name)
105
+ if (data = @nodes[node_name])
106
+ { 'config' => data['config'] || {},
107
+ 'vars' => data['vars'] || {},
108
+ 'facts' => data['facts'] || {},
109
+ 'features' => data['features'] || [],
110
+ # This allows us to determine if a node was found?
111
+ 'name' => data['name'] || nil,
112
+ 'uri' => data['uri'] || nil,
113
+ # groups come from group_data
114
+ 'groups' => [] }
115
+ end
116
+ end
117
+
118
+ def data_merge(data1, data2)
119
+ if data2.nil? || data1.nil?
120
+ return data2 || data1
121
+ end
122
+
123
+ {
124
+ 'config' => Bolt::Util.deep_merge(data1['config'], data2['config']),
125
+ 'name' => data1['name'] || data2['name'],
126
+ 'uri' => data1['uri'] || data2['uri'],
127
+ # Shallow merge instead of deep merge so that vars with a hash value
128
+ # are assigned a new hash, rather than merging the existing value
129
+ # with the value meant to replace it
130
+ 'vars' => data1['vars'].merge(data2['vars']),
131
+ 'facts' => Bolt::Util.deep_merge(data1['facts'], data2['facts']),
132
+ 'features' => data1['features'] | data2['features'],
133
+ 'groups' => data2['groups'] + data1['groups']
134
+ }
135
+ end
136
+
137
+ private def fetch_value(data, key, type)
138
+ value = data.fetch(key, type.new)
139
+ unless value.is_a?(type)
140
+ raise ValidationError.new("Expected #{key} to be of type #{type}, not #{value.class}", @name)
141
+ end
142
+ value
143
+ end
144
+
145
+ def resolve_aliases(aliases, node_names)
146
+ @name_or_alias.each do |name_or_alias|
147
+ # If an alias is found, insert the name into this group. Otherwise use the name as a new node's uri.
148
+ if node_names.include?(name_or_alias)
149
+ @nodes[name_or_alias] = { 'name' => name_or_alias }
150
+ elsif (node_name = aliases[name_or_alias])
151
+ if @nodes.include?(node_name)
152
+ @logger.warn("Ignoring duplicate node in #{@name}: #{node_name}")
153
+ else
154
+ @nodes[node_name] = { 'name' => node_name }
155
+ end
156
+ else
157
+ node_name = name_or_alias
158
+
159
+ if @nodes.include?(node_name)
160
+ @logger.warn("Ignoring duplicate node in #{@name}: #{node_name}")
161
+ else
162
+ @nodes[node_name] = { 'uri' => node_name }
163
+ end
164
+ end
165
+ end
166
+
167
+ @groups.each { |g| g.resolve_aliases(aliases, node_names) }
168
+ end
169
+
170
+ private def alias_conflict(name, node1, node2)
171
+ "Alias #{name} refers to multiple targets: #{node1} and #{node2}"
172
+ end
173
+
174
+ private def group_alias_conflict(name)
175
+ "Group #{name} conflicts with alias of the same name"
176
+ end
177
+
178
+ private def group_node_conflict(name)
179
+ "Group #{name} conflicts with node of the same name"
180
+ end
181
+
182
+ private def alias_node_conflict(name)
183
+ "Node name #{name} conflicts with alias of the same name"
184
+ end
185
+
186
+ def validate(used_names = Set.new, node_names = Set.new, aliased = {}, depth = 0)
187
+ # Test if this group name conflicts with anything used before.
188
+ raise ValidationError.new("Tried to redefine group #{@name}", @name) if used_names.include?(@name)
189
+ raise ValidationError.new(group_node_conflict(@name), @name) if node_names.include?(@name)
190
+ raise ValidationError.new(group_alias_conflict(@name), @name) if aliased.include?(@name)
191
+
192
+ used_names << @name
193
+
194
+ # Collect node names and aliases into a list used to validate that subgroups don't conflict.
195
+ # Used names validate that previously used group names don't conflict with new node names/aliases.
196
+ @nodes.each_key do |n|
197
+ # Require nodes to be parseable as a Target.
198
+ begin
199
+ Target.new(n)
200
+ rescue Bolt::ParseError => e
201
+ @logger.debug(e)
202
+ raise ValidationError.new("Invalid node name #{n}", @name)
203
+ end
204
+
205
+ raise ValidationError.new(group_node_conflict(n), @name) if used_names.include?(n)
206
+ if aliased.include?(n)
207
+ raise ValidationError.new(alias_node_conflict(n), @name)
208
+ end
209
+
210
+ node_names << n
211
+ end
212
+
213
+ @aliases.each do |n, target|
214
+ raise ValidationError.new(group_alias_conflict(n), @name) if used_names.include?(n)
215
+ if node_names.include?(n)
216
+ raise ValidationError.new(alias_node_conflict(n), @name)
217
+ end
218
+
219
+ if aliased.include?(n)
220
+ raise ValidationError.new(alias_conflict(n, target, aliased[n]), @name)
221
+ end
222
+
223
+ aliased[n] = target
224
+ end
225
+
226
+ @groups.each do |g|
227
+ begin
228
+ g.validate(used_names, node_names, aliased, depth + 1)
229
+ rescue ValidationError => e
230
+ e.add_parent(@name)
231
+ raise e
232
+ end
233
+ end
234
+
235
+ nil
236
+ end
237
+
238
+ # The data functions below expect and return nil or a hash of the schema
239
+ # { 'config' => Hash , 'vars' => Hash, 'facts' => Hash, 'features' => Array, groups => Array }
240
+ def data_for(node_name)
241
+ data_merge(group_collect(node_name), node_collect(node_name))
242
+ end
243
+
244
+ def group_data
245
+ { 'config' => @config,
246
+ 'vars' => @vars,
247
+ 'facts' => @facts,
248
+ 'features' => @features,
249
+ 'groups' => [@name] }
250
+ end
251
+
252
+ def empty_data
253
+ { 'config' => {},
254
+ 'vars' => {},
255
+ 'facts' => {},
256
+ 'features' => [],
257
+ 'groups' => [] }
258
+ end
259
+
260
+ # Returns all nodes contained within the group, which includes nodes from subgroups.
261
+ def node_names
262
+ @groups.inject(local_node_names) do |acc, g|
263
+ acc.merge(g.node_names)
264
+ end
265
+ end
266
+
267
+ # Returns a mapping of aliases to nodes contained within the group, which includes subgroups.
268
+ def node_aliases
269
+ @groups.inject(@aliases) do |acc, g|
270
+ acc.merge(g.node_aliases)
271
+ end
272
+ end
273
+
274
+ # Return a mapping of group names to group.
275
+ def collect_groups
276
+ @groups.inject(name => self) do |acc, g|
277
+ acc.merge(g.collect_groups)
278
+ end
279
+ end
280
+
281
+ def local_node_names
282
+ Set.new(@nodes.keys)
283
+ end
284
+ private :local_node_names
285
+
286
+ def node_collect(node_name)
287
+ data = @groups.inject(nil) do |acc, g|
288
+ if (d = g.node_collect(node_name))
289
+ data_merge(d, acc)
290
+ else
291
+ acc
292
+ end
293
+ end
294
+ data_merge(node_data(node_name), data)
295
+ end
296
+
297
+ def group_collect(node_name)
298
+ data = @groups.inject(nil) do |acc, g|
299
+ if (d = g.data_for(node_name))
300
+ data_merge(d, acc)
301
+ else
302
+ acc
303
+ end
304
+ end
305
+
306
+ if data
307
+ data_merge(group_data, data)
308
+ elsif @nodes.include?(node_name)
309
+ group_data
310
+ end
311
+ end
312
+ end
313
+ end
314
+ end