bolt 2.23.0 → 2.27.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/Puppetfile +1 -1
  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/exe/bolt +1 -0
  7. data/guides/inventory.txt +19 -0
  8. data/guides/project.txt +22 -0
  9. data/lib/bolt/analytics.rb +11 -7
  10. data/lib/bolt/applicator.rb +11 -10
  11. data/lib/bolt/bolt_option_parser.rb +75 -13
  12. data/lib/bolt/catalog.rb +4 -2
  13. data/lib/bolt/cli.rb +156 -176
  14. data/lib/bolt/config.rb +55 -25
  15. data/lib/bolt/config/options.rb +28 -6
  16. data/lib/bolt/executor.rb +5 -3
  17. data/lib/bolt/inventory.rb +8 -1
  18. data/lib/bolt/inventory/group.rb +4 -4
  19. data/lib/bolt/inventory/inventory.rb +1 -1
  20. data/lib/bolt/inventory/target.rb +1 -1
  21. data/lib/bolt/logger.rb +12 -6
  22. data/lib/bolt/outputter/human.rb +10 -0
  23. data/lib/bolt/outputter/json.rb +11 -0
  24. data/lib/bolt/outputter/logger.rb +3 -3
  25. data/lib/bolt/outputter/rainbow.rb +15 -0
  26. data/lib/bolt/pal.rb +23 -12
  27. data/lib/bolt/pal/yaml_plan/evaluator.rb +1 -1
  28. data/lib/bolt/pal/yaml_plan/transpiler.rb +11 -3
  29. data/lib/bolt/plugin/puppetdb.rb +1 -1
  30. data/lib/bolt/project.rb +63 -17
  31. data/lib/bolt/project_migrate.rb +138 -0
  32. data/lib/bolt/puppetdb/client.rb +1 -1
  33. data/lib/bolt/puppetdb/config.rb +1 -1
  34. data/lib/bolt/puppetfile.rb +160 -0
  35. data/lib/bolt/puppetfile/installer.rb +43 -0
  36. data/lib/bolt/puppetfile/module.rb +66 -0
  37. data/lib/bolt/r10k_log_proxy.rb +1 -1
  38. data/lib/bolt/rerun.rb +2 -2
  39. data/lib/bolt/result.rb +23 -0
  40. data/lib/bolt/shell.rb +1 -1
  41. data/lib/bolt/shell/bash.rb +7 -7
  42. data/lib/bolt/task.rb +1 -1
  43. data/lib/bolt/transport/base.rb +1 -1
  44. data/lib/bolt/transport/docker/connection.rb +10 -10
  45. data/lib/bolt/transport/local/connection.rb +3 -3
  46. data/lib/bolt/transport/orch.rb +3 -3
  47. data/lib/bolt/transport/ssh.rb +1 -1
  48. data/lib/bolt/transport/ssh/connection.rb +6 -6
  49. data/lib/bolt/transport/ssh/exec_connection.rb +5 -5
  50. data/lib/bolt/transport/winrm.rb +1 -1
  51. data/lib/bolt/transport/winrm/connection.rb +9 -9
  52. data/lib/bolt/util.rb +2 -2
  53. data/lib/bolt/util/puppet_log_level.rb +4 -3
  54. data/lib/bolt/version.rb +1 -1
  55. data/lib/bolt_server/base_config.rb +2 -2
  56. data/lib/bolt_server/config.rb +1 -1
  57. data/lib/bolt_server/file_cache.rb +1 -1
  58. data/lib/bolt_server/transport_app.rb +189 -14
  59. data/lib/bolt_spec/plans.rb +1 -1
  60. data/lib/bolt_spec/run.rb +3 -0
  61. metadata +12 -12
@@ -83,6 +83,17 @@ module Bolt
83
83
  @stream.puts result.to_json
84
84
  end
85
85
 
86
+ def print_topics(topics)
87
+ print_table('topics' => topics)
88
+ end
89
+
90
+ def print_guide(guide, topic)
91
+ @stream.puts({
92
+ 'topic' => topic,
93
+ 'guide' => guide
94
+ }.to_json)
95
+ end
96
+
86
97
  def print_puppetfile_result(success, puppetfile, moduledir)
87
98
  @stream.puts({ "success": success,
88
99
  "puppetfile": puppetfile,
@@ -7,7 +7,7 @@ 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)
@@ -40,13 +40,13 @@ module Bolt
40
40
 
41
41
  def log_plan_start(event)
42
42
  plan = event[:plan]
43
- @logger.notice("Starting: plan #{plan}")
43
+ @logger.info("Starting: plan #{plan}")
44
44
  end
45
45
 
46
46
  def log_plan_finish(event)
47
47
  plan = event[:plan]
48
48
  duration = event[:duration]
49
- @logger.notice("Finished: plan #{plan} in #{duration.round(2)} sec")
49
+ @logger.info("Finished: plan #{plan} in #{duration.round(2)} sec")
50
50
  end
51
51
  end
52
52
  end
@@ -86,6 +86,21 @@ module Bolt
86
86
  total_msg << " in #{duration_to_string(elapsed_time)}" unless elapsed_time.nil?
87
87
  @stream.puts colorize(:rainbow, total_msg)
88
88
  end
89
+
90
+ def print_guide(guide, _topic)
91
+ @stream.puts colorize(:rainbow, guide)
92
+ end
93
+
94
+ def print_topics(topics)
95
+ content = String.new("Available topics are:\n")
96
+ content += topics.join("\n")
97
+ content += "\n\nUse `bolt guide <topic>` to view a specific guide."
98
+ @stream.puts colorize(:rainbow, content)
99
+ end
100
+
101
+ def print_message(message)
102
+ @stream.puts colorize(:rainbow, message)
103
+ end
89
104
  end
90
105
  end
91
106
  end
@@ -65,9 +65,9 @@ module Bolt
65
65
  @resource_types = resource_types
66
66
  @project = project
67
67
 
68
- @logger = Logging.logger[self]
68
+ @logger = Bolt::Logger.logger(self)
69
69
  if modulepath && !modulepath.empty?
70
- @logger.info("Loading modules from #{@modulepath.join(File::PATH_SEPARATOR)}")
70
+ @logger.debug("Loading modules from #{@modulepath.join(File::PATH_SEPARATOR)}")
71
71
  end
72
72
 
73
73
  @loaded = false
@@ -76,7 +76,7 @@ module Bolt
76
76
  # Puppet logging is global so this is class method to avoid confusion
77
77
  def self.configure_logging
78
78
  Puppet::Util::Log.destinations.clear
79
- Puppet::Util::Log.newdestination(Logging.logger['Puppet'])
79
+ Puppet::Util::Log.newdestination(Bolt::Logger.logger('Puppet'))
80
80
  # Defer all log level decisions to the Logging library by telling Puppet
81
81
  # to log everything
82
82
  Puppet.settings[:log_level] = 'debug'
@@ -141,6 +141,19 @@ module Bolt
141
141
  end
142
142
  end
143
143
 
144
+ def detect_project_conflict(project, environment)
145
+ return unless project && project.load_as_module?
146
+ # The environment modulepath has stripped out non-existent directories,
147
+ # so we don't need to check for them
148
+ modules = environment.modulepath.flat_map do |path|
149
+ Dir.children(path).select { |name| Puppet::Module.is_module_directory?(name, path) }
150
+ end
151
+ if modules.include?(project.name)
152
+ Bolt::Logger.warn_once("project shadows module",
153
+ "The project '#{project.name}' shadows an existing module of the same name")
154
+ end
155
+ end
156
+
144
157
  # Runs a block in a PAL script compiler configured for Bolt. Catches
145
158
  # exceptions thrown by the block and re-raises them ensuring they are
146
159
  # Bolt::Errors since the script compiler block will squash all exceptions.
@@ -149,15 +162,13 @@ module Bolt
149
162
  setup
150
163
  r = Puppet::Pal.in_tmp_environment('bolt', modulepath: @modulepath, facts: {}) do |pal|
151
164
  # 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,
165
+ Puppet.override(bolt_project: @project,
159
166
  yaml_plan_instantiator: Bolt::PAL::YamlPlan::Loader) do
160
- pal.with_script_compiler do |compiler|
167
+ # Because this has the side effect of loading and caching the list
168
+ # of modules, it must happen *after* we have overridden
169
+ # bolt_project or the project will be ignored
170
+ detect_project_conflict(@project, Puppet.lookup(:environments).get('bolt'))
171
+ pal.with_script_compiler(set_local_facts: false) do |compiler|
161
172
  alias_types(compiler)
162
173
  register_resource_types(Puppet.lookup(:loaders)) if @resource_types
163
174
  begin
@@ -409,7 +420,7 @@ module Bolt
409
420
  end
410
421
  params[name] = { 'type' => type_str }
411
422
  params[name]['sensitive'] = param.type_expr.instance_of?(Puppet::Pops::Types::PSensitiveType)
412
- params[name]['default_value'] = param.value
423
+ params[name]['default_value'] = param.value unless param.value.nil?
413
424
  params[name]['description'] = param.description if param.description
414
425
  end
415
426
  {
@@ -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
@@ -21,10 +21,18 @@ module Bolt
21
21
  validate_path
22
22
 
23
23
  plan_object = parse_plan
24
+ param_descriptions = plan_object.parameters.map do |param|
25
+ str = String.new("# @param #{param.name}")
26
+ str << " #{param.description}" if param.description
27
+ str
28
+ end.join("\n")
24
29
 
25
- plan_string = String.new("# WARNING: This is an autogenerated plan. " \
26
- "It may not behave as expected.\n" \
27
- "plan #{plan_object.name}(")
30
+ plan_string = String.new('')
31
+ plan_string << "# #{plan_object.description}\n" if plan_object.description
32
+ plan_string << "# WARNING: This is an autogenerated plan. It may not behave as expected.\n"
33
+ plan_string << "#{param_descriptions}\n" unless param_descriptions.empty?
34
+
35
+ plan_string << "plan #{plan_object.name}("
28
36
  # Parameters are Bolt::PAL::YamlPlan::Parameter
29
37
  plan_object.parameters&.each_with_index do |param, i|
30
38
  plan_string << param.transpile
@@ -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
@@ -17,37 +17,53 @@ module Bolt
17
17
  }.freeze
18
18
 
19
19
  attr_reader :path, :data, :config_file, :inventory_file, :modulepath, :hiera_config,
20
- :puppetfile, :rerunfile, :type, :resource_types, :warnings, :project_file,
20
+ :puppetfile, :rerunfile, :type, :resource_types, :logs, :project_file,
21
21
  :deprecations, :downloads, :plans_path
22
22
 
23
- def self.default_project
24
- create_project(File.expand_path(File.join('~', '.puppetlabs', 'bolt')), 'user')
23
+ def self.default_project(logs = [])
24
+ create_project(File.expand_path(File.join('~', '.puppetlabs', 'bolt')), 'user', logs)
25
25
  # If homedir isn't defined use the system config path
26
26
  rescue ArgumentError
27
- create_project(Bolt::Config.system_path, 'system')
27
+ create_project(Bolt::Config.system_path, 'system', logs)
28
28
  end
29
29
 
30
30
  # Search recursively up the directory hierarchy for the Project. Look for a
31
31
  # directory called Boltdir or a file called bolt.yaml (for a control repo
32
32
  # type Project). Otherwise, repeat the check on each directory up the
33
33
  # hierarchy, falling back to the default if we reach the root.
34
- def self.find_boltdir(dir)
34
+ def self.find_boltdir(dir, logs = [])
35
35
  dir = Pathname.new(dir)
36
36
 
37
37
  if (dir + BOLTDIR_NAME).directory?
38
- create_project(dir + BOLTDIR_NAME, 'embedded')
38
+ create_project(dir + BOLTDIR_NAME, 'embedded', logs)
39
39
  elsif (dir + 'bolt.yaml').file? || (dir + 'bolt-project.yaml').file?
40
- create_project(dir, 'local')
40
+ create_project(dir, 'local', logs)
41
41
  elsif dir.root?
42
- default_project
42
+ default_project(logs)
43
43
  else
44
- find_boltdir(dir.parent)
44
+ logs << { debug: "Did not detect Boltdir, bolt.yaml, or bolt-project.yaml at '#{dir}'. "\
45
+ "This directory won't be loaded as a project." }
46
+ find_boltdir(dir.parent, logs)
45
47
  end
46
48
  end
47
49
 
48
- def self.create_project(path, type = 'option')
50
+ def self.create_project(path, type = 'option', logs = [])
49
51
  fullpath = Pathname.new(path).expand_path
50
52
 
53
+ if type == 'user'
54
+ begin
55
+ # This is already expanded if the type is user
56
+ FileUtils.mkdir_p(path)
57
+ rescue StandardError
58
+ logs << { warn: "Could not create default project at #{path}. Continuing without a writeable project. "\
59
+ "Log and rerun files will not be written." }
60
+ end
61
+ end
62
+
63
+ if type == 'option' && !File.directory?(path)
64
+ raise Bolt::Error.new("Could not find project at #{path}", "bolt/project-error")
65
+ end
66
+
51
67
  if !Bolt::Util.windows? && type != 'environment' && fullpath.world_writable?
52
68
  raise Bolt::Error.new(
53
69
  "Project directory '#{fullpath}' is world-writable which poses a security risk. Set "\
@@ -58,15 +74,18 @@ module Bolt
58
74
 
59
75
  project_file = File.join(fullpath, 'bolt-project.yaml')
60
76
  data = Bolt::Util.read_optional_yaml_hash(File.expand_path(project_file), 'project')
61
- new(data, path, type)
77
+ default = type =~ /user|system/ ? 'default ' : ''
78
+ exist = File.exist?(File.expand_path(project_file))
79
+ logs << { info: "Loaded #{default}project from '#{fullpath}'" } if exist
80
+ new(data, path, type, logs)
62
81
  end
63
82
 
64
- def initialize(raw_data, path, type = 'option')
83
+ def initialize(raw_data, path, type = 'option', logs = [])
65
84
  @path = Pathname.new(path).expand_path
66
85
 
67
86
  @project_file = @path + 'bolt-project.yaml'
68
87
 
69
- @warnings = []
88
+ @logs = logs
70
89
  @deprecations = []
71
90
  if (@path + 'bolt.yaml').file? && project_file?
72
91
  msg = "Project-level configuration in bolt.yaml is deprecated if using bolt-project.yaml. "\
@@ -88,7 +107,7 @@ module Bolt
88
107
  tc = Bolt::Config::INVENTORY_OPTIONS.keys & raw_data.keys
89
108
  if tc.any?
90
109
  msg = "Transport configuration isn't supported in bolt-project.yaml. Ignoring keys #{tc}"
91
- @warnings << { msg: msg }
110
+ @logs << { warn: msg }
92
111
  end
93
112
 
94
113
  @data = raw_data.reject { |k, _| Bolt::Config::INVENTORY_OPTIONS.include?(k) }
@@ -98,7 +117,7 @@ module Bolt
98
117
  @config_file = if (Bolt::Config::BOLT_OPTIONS & @data.keys).any?
99
118
  if (@path + 'bolt.yaml').file?
100
119
  msg = "bolt-project.yaml contains valid config keys, bolt.yaml will be ignored"
101
- @warnings << { msg: msg }
120
+ @logs << { warn: msg }
102
121
  end
103
122
  @project_file
104
123
  else
@@ -114,7 +133,9 @@ module Bolt
114
133
  # This API is used to prepend the project as a module to Puppet's internal
115
134
  # module_references list. CHANGE AT YOUR OWN RISK
116
135
  def to_h
117
- { path: @path.to_s, name: name }
136
+ { path: @path.to_s,
137
+ name: name,
138
+ load_as_module?: load_as_module? }
118
139
  end
119
140
 
120
141
  def eql?(other)
@@ -126,6 +147,10 @@ module Bolt
126
147
  @project_file.file?
127
148
  end
128
149
 
150
+ def load_as_module?
151
+ !name.nil?
152
+ end
153
+
129
154
  def name
130
155
  @data['name']
131
156
  end
@@ -138,6 +163,10 @@ module Bolt
138
163
  @data['plans']
139
164
  end
140
165
 
166
+ def modules
167
+ @data['modules']
168
+ end
169
+
141
170
  def validate
142
171
  if name
143
172
  if name !~ Bolt::Module::MODULE_NAME_REGEX
@@ -151,7 +180,7 @@ module Bolt
151
180
  end
152
181
  else
153
182
  message = "No project name is specified in bolt-project.yaml. Project-level content will not be available."
154
- @warnings << { msg: message }
183
+ @logs << { warn: message }
155
184
  end
156
185
 
157
186
  %w[tasks plans].each do |conf|
@@ -159,6 +188,23 @@ module Bolt
159
188
  raise Bolt::ValidationError, "'#{conf}' in bolt-project.yaml must be an array"
160
189
  end
161
190
  end
191
+
192
+ if @data['modules']
193
+ unless @data['modules'].is_a?(Array)
194
+ raise Bolt::ValidationError, "'modules' in bolt-project.yaml must be an array"
195
+ end
196
+
197
+ @data['modules'].each do |mod|
198
+ next if mod.is_a?(Hash)
199
+ raise Bolt::ValidationError, "Module declaration #{mod.inspect} must be a hash"
200
+ end
201
+
202
+ unknown_keys = data['modules'].flat_map(&:keys).uniq - ['name']
203
+ if unknown_keys.any?
204
+ @logs << { warn: "Module declarations in bolt-project.yaml only support a name key. Ignoring "\
205
+ "unsupported keys: #{unknown_keys.join(', ')}." }
206
+ end
207
+ end
162
208
  end
163
209
 
164
210
  def check_deprecated_file
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bolt
4
+ class ProjectMigrate
5
+ attr_reader :path, :project_file, :backup_dir, :outputter, :inventory_file, :config_file
6
+
7
+ # This init mostly makes testing easier
8
+ def initialize(path, outputter, configured_inventory = nil)
9
+ @path = Pathname.new(path).expand_path
10
+ @project_file = @path + 'bolt-project.yaml'
11
+ @config_file = @path + 'bolt.yaml'
12
+ @backup_dir = @path + '.bolt-bak'
13
+ @inventory_file = configured_inventory || @path + 'inventory.yaml'
14
+ @outputter = outputter
15
+ end
16
+
17
+ def migrate_project
18
+ inv_ok = inventory_1_to_2(inventory_file, outputter) if inventory_file.file?
19
+ config_ok = bolt_yaml_to_bolt_project(inventory_file, outputter)
20
+ inv_ok && config_ok ? 0 : 1
21
+ end
22
+
23
+ # This could be made public and used elsewhere if the need arises
24
+ private def backup_file(origin_path)
25
+ unless File.exist?(origin_path)
26
+ outputter.print_message "Could not find file #{origin_path}, skipping backup."
27
+ return
28
+ end
29
+
30
+ date = Time.new.strftime("%Y%m%d_%H%M%S%L")
31
+ FileUtils.mkdir_p(backup_dir)
32
+
33
+ filename = File.basename(origin_path)
34
+ backup_path = File.join(backup_dir, "#{filename}.#{date}.bak")
35
+
36
+ outputter.print_message "Backing up #{filename} from #{origin_path} to #{backup_path}"
37
+
38
+ begin
39
+ FileUtils.cp(origin_path, backup_path)
40
+ rescue StandardError => e
41
+ raise Bolt::FileError.new("#{e.message}; unable to create backup of #{filename}.", origin_path)
42
+ end
43
+ end
44
+
45
+ private def bolt_yaml_to_bolt_project(inventory_file, outputter)
46
+ # If bolt-project.yaml already exists
47
+ if project_file.file?
48
+ outputter.print_message "bolt-project.yaml already exists in Bolt "\
49
+ "project at #{path}. Skipping project file update."
50
+
51
+ # If bolt.yaml doesn't exist
52
+ elsif !config_file.file?
53
+ outputter.print_message "Could not find bolt.yaml in project at "\
54
+ "#{path}. Skipping project file update."
55
+
56
+ else
57
+ config_data = Bolt::Util.read_optional_yaml_hash(config_file, 'config')
58
+ transport_data, project_data = config_data.partition do |k, _|
59
+ Bolt::Config::INVENTORY_OPTIONS.keys.include?(k)
60
+ end.map(&:to_h)
61
+
62
+ if transport_data.any?
63
+ if File.exist?(inventory_file)
64
+ inventory_data = Bolt::Util.read_yaml_hash(inventory_file, 'inventory')
65
+ merged = Bolt::Util.deep_merge(transport_data, inventory_data['config'] || {})
66
+ inventory_data['config'] = merged
67
+ backup_file(inventory_file)
68
+ else
69
+ FileUtils.touch(inventory_file)
70
+ inventory_data = { 'config' => transport_data }
71
+ end
72
+
73
+ backup_file(config_file)
74
+
75
+ begin
76
+ outputter.print_message "Moving transportation configuration options "\
77
+ "'#{transport_data.keys.join(', ')}' from bolt.yaml to inventory.yaml"
78
+ File.write(inventory_file, inventory_data.to_yaml)
79
+ File.write(config_file, project_data.to_yaml)
80
+ rescue StandardError => e
81
+ raise Bolt::FileError.new("#{e.message}; unable to write inventory.", inventory_file)
82
+ end
83
+ end
84
+
85
+ outputter.print_message "Renaming bolt.yaml to bolt-project.yaml"
86
+ FileUtils.mv(config_file, project_file)
87
+ outputter.print_message "Successfully updated project. Please add a "\
88
+ "'name' key to bolt-project.yaml to use project-level tasks and plans. "\
89
+ "Learn more about projects by running 'bolt guide project'."
90
+ # If nothing errored, this succeeded
91
+ true
92
+ end
93
+ end
94
+
95
+ private def inventory_1_to_2(inventory_file, outputter)
96
+ data = Bolt::Util.read_yaml_hash(inventory_file, 'inventory')
97
+ data.delete('version') if data['version'] != 2
98
+ migrated = migrate_group(data)
99
+
100
+ ok = if migrated
101
+ backup_file(inventory_file)
102
+ File.write(inventory_file, data.to_yaml)
103
+ end
104
+
105
+ result = if migrated && ok
106
+ "Successfully migrated Bolt inventory to the latest version."
107
+ elsif !migrated
108
+ "Bolt inventory is already on the latest version. Skipping inventory update."
109
+ else
110
+ "Could not migrate Bolt inventory to the latest version. See "\
111
+ "https://puppet.com/docs/bolt/latest/inventory_file_v2.html to manually update."
112
+ end
113
+ outputter.print_message(result)
114
+ ok
115
+ end
116
+
117
+ # Walks an inventory hash and replaces all 'nodes' keys with 'targets' keys
118
+ # and all 'name' keys nested in a 'targets' hash with 'uri' keys. Data is
119
+ # modified in place.
120
+ private def migrate_group(group)
121
+ migrated = false
122
+ if group.key?('nodes')
123
+ migrated = true
124
+ targets = group['nodes'].map do |target|
125
+ target['uri'] = target.delete('name') if target.is_a?(Hash)
126
+ target
127
+ end
128
+ group.delete('nodes')
129
+ group['targets'] = targets
130
+ end
131
+ (group['groups'] || []).each do |subgroup|
132
+ migrated_group = migrate_group(subgroup)
133
+ migrated ||= migrated_group
134
+ end
135
+ migrated
136
+ end
137
+ end
138
+ end