bolt 3.4.0 → 3.7.1

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of bolt might be problematic. Click here for more details.

Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/Puppetfile +2 -2
  3. data/bolt-modules/boltlib/lib/puppet/datatypes/applyresult.rb +26 -0
  4. data/bolt-modules/boltlib/lib/puppet/datatypes/containerresult.rb +51 -0
  5. data/bolt-modules/boltlib/lib/puppet/datatypes/resourceinstance.rb +43 -0
  6. data/bolt-modules/boltlib/lib/puppet/datatypes/result.rb +29 -0
  7. data/bolt-modules/boltlib/lib/puppet/datatypes/resultset.rb +34 -0
  8. data/bolt-modules/boltlib/lib/puppet/datatypes/target.rb +55 -0
  9. data/bolt-modules/boltlib/lib/puppet/functions/add_to_group.rb +1 -0
  10. data/bolt-modules/boltlib/lib/puppet/functions/apply_prep.rb +1 -0
  11. data/bolt-modules/boltlib/lib/puppet/functions/parallelize.rb +1 -0
  12. data/bolt-modules/boltlib/lib/puppet/functions/puppetdb_command.rb +66 -0
  13. data/bolt-modules/boltlib/lib/puppet/functions/remove_from_group.rb +1 -0
  14. data/bolt-modules/boltlib/lib/puppet/functions/run_container.rb +162 -0
  15. data/bolt-modules/boltlib/lib/puppet/functions/write_file.rb +1 -0
  16. data/bolt-modules/boltlib/types/planresult.pp +1 -0
  17. data/bolt-modules/ctrl/lib/puppet/functions/ctrl/do_until.rb +2 -0
  18. data/guides/guide.txt +17 -0
  19. data/guides/links.txt +13 -0
  20. data/guides/targets.txt +29 -0
  21. data/guides/transports.txt +23 -0
  22. data/lib/bolt/analytics.rb +4 -8
  23. data/lib/bolt/bolt_option_parser.rb +329 -225
  24. data/lib/bolt/cli.rb +58 -29
  25. data/lib/bolt/config.rb +11 -7
  26. data/lib/bolt/config/options.rb +41 -9
  27. data/lib/bolt/config/transport/podman.rb +33 -0
  28. data/lib/bolt/container_result.rb +105 -0
  29. data/lib/bolt/error.rb +15 -0
  30. data/lib/bolt/executor.rb +17 -13
  31. data/lib/bolt/inventory.rb +5 -4
  32. data/lib/bolt/inventory/inventory.rb +3 -2
  33. data/lib/bolt/inventory/options.rb +9 -0
  34. data/lib/bolt/inventory/target.rb +16 -0
  35. data/lib/bolt/module_installer/specs/git_spec.rb +10 -6
  36. data/lib/bolt/outputter/human.rb +242 -76
  37. data/lib/bolt/outputter/json.rb +6 -4
  38. data/lib/bolt/outputter/logger.rb +17 -0
  39. data/lib/bolt/pal/yaml_plan/step.rb +4 -2
  40. data/lib/bolt/plan_creator.rb +2 -2
  41. data/lib/bolt/plugin.rb +13 -11
  42. data/lib/bolt/puppetdb/client.rb +54 -0
  43. data/lib/bolt/result.rb +1 -4
  44. data/lib/bolt/shell/bash.rb +23 -10
  45. data/lib/bolt/transport/docker.rb +1 -1
  46. data/lib/bolt/transport/docker/connection.rb +23 -34
  47. data/lib/bolt/transport/podman.rb +19 -0
  48. data/lib/bolt/transport/podman/connection.rb +98 -0
  49. data/lib/bolt/transport/ssh/connection.rb +3 -6
  50. data/lib/bolt/util.rb +34 -0
  51. data/lib/bolt/version.rb +1 -1
  52. data/lib/bolt_server/transport_app.rb +3 -0
  53. data/lib/bolt_spec/plans/mock_executor.rb +91 -11
  54. data/modules/puppet_connect/plans/test_input_data.pp +22 -0
  55. metadata +13 -2
@@ -100,12 +100,12 @@ module Bolt
100
100
  moduledir: moduledir.to_s }.to_json)
101
101
  end
102
102
 
103
- def print_targets(target_list, inventoryfile)
103
+ def print_targets(target_list, inventory_source, default_inventory, _target_flag)
104
104
  @stream.puts ::JSON.pretty_generate(
105
105
  inventory: {
106
106
  targets: target_list[:inventory].map(&:name),
107
107
  count: target_list[:inventory].count,
108
- file: inventoryfile.to_s
108
+ file: (inventory_source || default_inventory).to_s
109
109
  },
110
110
  adhoc: {
111
111
  targets: target_list[:adhoc].map(&:name),
@@ -116,14 +116,16 @@ module Bolt
116
116
  )
117
117
  end
118
118
 
119
- def print_target_info(targets)
119
+ def print_target_info(target_list, _inventory_source, _default_inventory, _target_flag)
120
+ targets = target_list.values.flatten
121
+
120
122
  @stream.puts ::JSON.pretty_generate(
121
123
  targets: targets.map(&:detail),
122
124
  count: targets.count
123
125
  )
124
126
  end
125
127
 
126
- def print_groups(groups)
128
+ def print_groups(groups, _inventory_source, _default_inventory)
127
129
  count = groups.count
128
130
  @stream.puts({ groups: groups,
129
131
  count: count }.to_json)
@@ -20,6 +20,10 @@ module Bolt
20
20
  log_plan_start(event)
21
21
  when :plan_finish
22
22
  log_plan_finish(event)
23
+ when :container_start
24
+ log_container_start(event)
25
+ when :container_finish
26
+ log_container_finish(event)
23
27
  end
24
28
  end
25
29
 
@@ -48,6 +52,19 @@ module Bolt
48
52
  duration = event[:duration]
49
53
  @logger.info("Finished: plan #{plan} in #{duration.round(2)} sec")
50
54
  end
55
+
56
+ def log_container_start(event)
57
+ @logger.info("Starting: run container '#{event[:image]}'")
58
+ end
59
+
60
+ def log_container_finish(event)
61
+ result = event[:result]
62
+ if result.success?
63
+ @logger.info("Finished: run container '#{result.object}' succeeded.")
64
+ else
65
+ @logger.info("Finished: run container '#{result.object}' failed.")
66
+ end
67
+ end
51
68
  end
52
69
  end
53
70
  end
@@ -122,9 +122,11 @@ module Bolt
122
122
  raise StepError.new("Parameters key must be a hash", body['name'], step_number)
123
123
  end
124
124
 
125
- metaparams = option_keys.map { |key| "_#{key}" }
125
+ metaparams = body['parameters'].keys
126
+ .select { |key| key.start_with?('_') }
127
+ .map { |key| key.sub(/^_/, '') }
126
128
 
127
- if (dups = body['parameters'].keys & metaparams).any?
129
+ if (dups = body.keys & metaparams).any?
128
130
  raise StepError.new(
129
131
  "Cannot specify metaparameters when using top-level keys with same name: #{dups.join(', ')}",
130
132
  body['name'],
@@ -36,8 +36,8 @@ module Bolt
36
36
  prefix, _, basename = segment_plan_name(plan_name)
37
37
 
38
38
  unless prefix == project.name
39
- message = "First segment of plan name '#{plan_name}' must match project name '#{project.name}'. "\
40
- "Did you mean '#{project.name}::#{plan_name}'?"
39
+ message = "Incomplete plan name: A plan name must be prefixed with the name of the "\
40
+ "project or module. Did you mean '#{project.name}::#{plan_name}'?"
41
41
 
42
42
  raise Bolt::ValidationError, message
43
43
  end
data/lib/bolt/plugin.rb CHANGED
@@ -178,8 +178,6 @@ module Bolt
178
178
  end
179
179
 
180
180
  def add_ruby_plugin(plugin_name)
181
- raise PluginError::LoadingDisabled, plugin_name unless @load_plugins
182
-
183
181
  cls_name = Bolt::Util.snake_name_to_class_name(plugin_name)
184
182
  filename = "bolt/plugin/#{plugin_name}"
185
183
  require filename
@@ -203,8 +201,6 @@ module Bolt
203
201
  }
204
202
 
205
203
  mod = modules[plugin_name]
206
- raise PluginError::Unknown, plugin_name unless mod&.plugin?
207
- raise PluginError::LoadingDisabled, plugin_name unless @load_plugins
208
204
 
209
205
  plugin = Bolt::Plugin::Module.load(mod, opts)
210
206
  add_plugin(plugin)
@@ -224,6 +220,12 @@ module Bolt
224
220
  end
225
221
  end
226
222
 
223
+ def known_plugin?(plugin_name)
224
+ @plugins.include?(plugin_name) ||
225
+ RUBY_PLUGINS.include?(plugin_name) ||
226
+ (modules.include?(plugin_name) && modules[plugin_name].plugin?)
227
+ end
228
+
227
229
  def get_hook(plugin_name, hook)
228
230
  plugin = by_name(plugin_name)
229
231
  raise PluginError::Unknown, plugin_name unless plugin
@@ -235,16 +237,16 @@ module Bolt
235
237
 
236
238
  # Calling by_name or get_hook will load any module based plugin automatically
237
239
  def by_name(plugin_name)
238
- return @plugins[plugin_name] if @plugins.include?(plugin_name)
239
- begin
240
- if RUBY_PLUGINS.include?(plugin_name)
240
+ if known_plugin?(plugin_name)
241
+ if @plugins.include?(plugin_name)
242
+ @plugins[plugin_name]
243
+ elsif !@load_plugins
244
+ raise PluginError::LoadingDisabled, plugin_name
245
+ elsif RUBY_PLUGINS.include?(plugin_name)
241
246
  add_ruby_plugin(plugin_name)
242
- elsif !@unknown.include?(plugin_name)
247
+ else
243
248
  add_module_plugin(plugin_name)
244
249
  end
245
- rescue PluginError::Unknown
246
- @unknown << plugin_name
247
- nil
248
250
  end
249
251
  end
250
252
 
@@ -95,6 +95,60 @@ module Bolt
95
95
  make_query(query, path)
96
96
  end
97
97
 
98
+ # Sends a command to PuppetDB using version 1 of the commands API.
99
+ # https://puppet.com/docs/puppetdb/latest/api/command/v1/commands.html
100
+ #
101
+ # @param command [String] The command to invoke.
102
+ # @param version [Integer] The version of the command to invoke.
103
+ # @param payload [Hash] The payload to send with the command.
104
+ # @return A UUID identifying the submitted command.
105
+ #
106
+ def send_command(command, version, payload)
107
+ command = command.dup.force_encoding('utf-8')
108
+ body = JSON.generate(payload)
109
+
110
+ # PDB requires the following query parameters to the POST request.
111
+ # Error early if there's no certname, as PDB does not return a
112
+ # message indicating it's required.
113
+ unless payload['certname']
114
+ raise Bolt::Error.new(
115
+ "Payload must include 'certname', unable to invoke command.",
116
+ 'bolt/pdb-command'
117
+ )
118
+ end
119
+
120
+ url = uri.tap do |u|
121
+ u.path = 'pdb/cmd/v1'
122
+ u.query_values = { 'command' => command,
123
+ 'version' => version,
124
+ 'certname' => payload['certname'] }
125
+ end
126
+
127
+ # Send the command to PDB
128
+ begin
129
+ @logger.debug("Sending PuppetDB command '#{command}' to #{url}")
130
+ response = http_client.post(url.to_s, body: body, header: headers)
131
+ rescue StandardError => e
132
+ raise Bolt::PuppetDBFailoverError, "Failed to invoke PuppetDB command: #{e}"
133
+ end
134
+
135
+ @logger.debug("Got response code #{response.code} from PuppetDB")
136
+ if response.code != 200
137
+ raise Bolt::PuppetDBError, "Failed to invoke PuppetDB command: #{response.body}"
138
+ end
139
+
140
+ # Return the UUID string from the response body
141
+ begin
142
+ JSON.parse(response.body).fetch('uuid', nil)
143
+ rescue JSON::ParserError
144
+ raise Bolt::PuppetDBError, "Unable to parse response as JSON: #{response.body}"
145
+ end
146
+ rescue Bolt::PuppetDBFailoverError => e
147
+ @logger.error("Request to puppetdb at #{@current_url} failed with #{e}.")
148
+ reject_url
149
+ send_command(command, version, payload)
150
+ end
151
+
98
152
  def http_client
99
153
  return @http if @http
100
154
  # lazy-load expensive gem code
data/lib/bolt/result.rb CHANGED
@@ -164,15 +164,12 @@ module Bolt
164
164
  target == other.target &&
165
165
  value == other.value
166
166
  end
167
+ alias == eql?
167
168
 
168
169
  def [](key)
169
170
  value[key]
170
171
  end
171
172
 
172
- def ==(other)
173
- eql?(other)
174
- end
175
-
176
173
  def to_json(opts = nil)
177
174
  to_data.to_json(opts)
178
175
  end
@@ -396,7 +396,12 @@ module Bolt
396
396
  # See if there's a sudo prompt
397
397
  if use_sudo
398
398
  ready_read = select([err], nil, nil, timeout * 5)
399
- read_streams[err] << check_sudo(err, inp, options[:stdin]) if ready_read
399
+ to_print = check_sudo(err, inp, options[:stdin]) if ready_read
400
+ unless to_print.nil?
401
+ log_stream(to_print, 'err')
402
+ read_streams[err] << to_print
403
+ result_output.merged_output << to_print
404
+ end
400
405
  end
401
406
 
402
407
  # True while the process is running or waiting for IO input
@@ -412,14 +417,7 @@ module Bolt
412
417
  else
413
418
  stream.readpartial(CHUNK_SIZE)
414
419
  end
415
-
416
- if !to_print.chomp.empty? && @stream_logger
417
- formatted = to_print.lines.map do |msg|
418
- "[#{@target.safe_name}] #{stream_name}: #{msg.chomp}"
419
- end.join("\n")
420
- @stream_logger.warn(formatted)
421
- end
422
-
420
+ log_stream(to_print, stream_name)
423
421
  read_streams[stream] << to_print
424
422
  result_output.merged_output << to_print
425
423
  rescue EOFError
@@ -458,7 +456,13 @@ module Bolt
458
456
  # Read any remaining data in the pipe. Do not wait for
459
457
  # EOF in case the pipe is inherited by a child process.
460
458
  read_streams.each do |stream, _|
461
- loop { read_streams[stream] << stream.read_nonblock(CHUNK_SIZE) }
459
+ stream_name = stream == out ? 'out' : 'err'
460
+ loop {
461
+ to_print = stream.read_nonblock(CHUNK_SIZE)
462
+ log_stream(to_print, stream_name)
463
+ read_streams[stream] << to_print
464
+ result_output.merged_output << to_print
465
+ }
462
466
  rescue Errno::EAGAIN, EOFError
463
467
  end
464
468
  result_output.stdout << read_streams[out]
@@ -489,6 +493,15 @@ module Bolt
489
493
  def sudo_prompt
490
494
  '[sudo] Bolt needs to run as another user, password: '
491
495
  end
496
+
497
+ private def log_stream(to_print, stream_name)
498
+ if !to_print.chomp.empty? && @stream_logger
499
+ formatted = to_print.lines.map do |msg|
500
+ "[#{@target.safe_name}] #{stream_name}: #{msg.chomp}"
501
+ end.join("\n")
502
+ @stream_logger.warn(formatted)
503
+ end
504
+ end
492
505
  end
493
506
  end
494
507
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
  require 'shellwords'
5
- require 'bolt/transport/base'
5
+ require 'bolt/transport/simple'
6
6
 
7
7
  module Bolt
8
8
  module Transport
@@ -34,11 +34,20 @@ module Bolt
34
34
  @container_info["Id"]
35
35
  end
36
36
 
37
+ def run_cmd(cmd, env_vars)
38
+ Bolt::Util.exec_docker(cmd, env_vars)
39
+ end
40
+
41
+ private def env_hash
42
+ # Set the DOCKER_HOST if we are using a non-default service-url
43
+ @docker_host.nil? ? {} : { 'DOCKER_HOST' => @docker_host }
44
+ end
45
+
37
46
  def connect
38
47
  # We don't actually have a connection, but we do need to
39
48
  # check that the container exists and is running.
40
- output = execute_local_json_command('ps')
41
- index = output.find_index { |item| item["ID"] == target.host || item["Names"] == target.host }
49
+ output = execute_local_json_command('ps', ['--no-trunc'])
50
+ index = output.find_index { |item| item["ID"].start_with?(target.host) || item["Names"] == target.host }
42
51
  raise "Could not find a container with name or ID matching '#{target.host}'" if index.nil?
43
52
  # Now find the indepth container information
44
53
  output = execute_local_json_command('inspect', [output[index]["ID"]])
@@ -54,10 +63,7 @@ module Bolt
54
63
  end
55
64
 
56
65
  def add_env_vars(env_vars)
57
- @env_vars = env_vars.each_with_object([]) do |env_var, acc|
58
- acc << "--env"
59
- acc << "#{env_var[0]}=#{env_var[1]}"
60
- end
66
+ @env_vars = Bolt::Util.format_env_vars_for_cli(env_vars)
61
67
  end
62
68
 
63
69
  # Executes a command inside the target container. This is called from the shell class.
@@ -88,9 +94,9 @@ module Bolt
88
94
 
89
95
  def upload_file(source, destination)
90
96
  @logger.trace { "Uploading #{source} to #{destination}" }
91
- _stdout, stderr, status = execute_local_command('cp', [source, "#{container_id}:#{destination}"])
92
- unless status.exitstatus.zero?
93
- raise "Error writing to container #{container_id}: #{stderr}"
97
+ _out, err, stat = run_cmd(['cp', source, "#{container_id}:#{destination}"], env_hash)
98
+ unless stat.exitstatus.zero?
99
+ raise "Error writing to container #{container_id}: #{err}"
94
100
  end
95
101
  rescue StandardError => e
96
102
  raise Bolt::Node::FileError.new(e.message, 'WRITE_ERROR')
@@ -102,31 +108,14 @@ module Bolt
102
108
  # copy the *contents* of the directory.
103
109
  # https://docs.docker.com/engine/reference/commandline/cp/
104
110
  FileUtils.mkdir_p(destination)
105
- _stdout, stderr, status = execute_local_command('cp', ["#{container_id}:#{source}", destination])
106
- unless status.exitstatus.zero?
107
- raise "Error downloading content from container #{container_id}: #{stderr}"
111
+ _out, err, stat = run_cmd(['cp', "#{container_id}:#{source}", destination], env_hash)
112
+ unless stat.exitstatus.zero?
113
+ raise "Error downloading content from container #{container_id}: #{err}"
108
114
  end
109
115
  rescue StandardError => e
110
116
  raise Bolt::Node::FileError.new(e.message, 'WRITE_ERROR')
111
117
  end
112
118
 
113
- # Executes a Docker CLI command. This is useful for running commands as
114
- # part of this class without having to go through the `execute`
115
- # function and manage pipes.
116
- #
117
- # @param subcommand [String] The docker subcommand to run
118
- # e.g. 'inspect' for `docker inspect`
119
- # @param arguments [Array] Arguments to pass to the docker command
120
- # e.g. 'src' and 'dest' for `docker cp <src> <dest>
121
- # @return [String, String, Process::Status] The output of the command: STDOUT, STDERR, Process Status
122
- private def execute_local_command(subcommand, arguments = [])
123
- # Set the DOCKER_HOST if we are using a non-default service-url
124
- env_hash = @docker_host.nil? ? {} : { 'DOCKER_HOST' => @docker_host }
125
- docker_command = [subcommand].concat(arguments)
126
-
127
- Open3.capture3(env_hash, 'docker', *docker_command, { binmode: true })
128
- end
129
-
130
119
  # Executes a Docker CLI command and parses the output in JSON format
131
120
  #
132
121
  # @param subcommand [String] The docker subcommand to run
@@ -134,15 +123,15 @@ module Bolt
134
123
  # @param arguments [Array] Arguments to pass to the docker command
135
124
  # e.g. 'src' and 'dest' for `docker cp <src> <dest>
136
125
  # @return [Object] Ruby object representation of the JSON string
137
- private def execute_local_json_command(subcommand, arguments = [])
138
- command_options = ['--format', '{{json .}}'].concat(arguments)
139
- stdout, _stderr, _status = execute_local_command(subcommand, command_options)
140
- extract_json(stdout)
126
+ def execute_local_json_command(subcommand, arguments = [])
127
+ cmd = [subcommand, '--format', '{{json .}}'].concat(arguments)
128
+ out, _err, _stat = run_cmd(cmd, env_hash)
129
+ extract_json(out)
141
130
  end
142
131
 
143
132
  # Converts the JSON encoded STDOUT string from the docker cli into ruby objects
144
133
  #
145
- # @param stdout_string [String] The string to convert
134
+ # @param stdout [String] The string to convert
146
135
  # @return [Object] Ruby object representation of the JSON string
147
136
  private def extract_json(stdout)
148
137
  # The output from the docker format command is a JSON string per line.
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'shellwords'
5
+ require 'bolt/transport/base'
6
+
7
+ module Bolt
8
+ module Transport
9
+ class Podman < Docker
10
+ def with_connection(target)
11
+ conn = Connection.new(target)
12
+ conn.connect
13
+ yield conn
14
+ end
15
+ end
16
+ end
17
+ end
18
+
19
+ require 'bolt/transport/podman/connection'
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logging'
4
+ require 'bolt/node/errors'
5
+
6
+ module Bolt
7
+ module Transport
8
+ class Podman < Docker
9
+ class Connection < Connection
10
+ attr_reader :user, :target
11
+
12
+ def initialize(target)
13
+ raise Bolt::ValidationError, "Target #{target.safe_name} does not have a host" unless target.host
14
+ @target = target
15
+ @user = ENV['USER'] || Etc.getlogin
16
+ @logger = Bolt::Logger.logger(target.safe_name)
17
+ @container_info = {}
18
+ @logger.trace("Initializing podman connection to #{target.safe_name}")
19
+ end
20
+
21
+ def run_cmd(cmd, env_vars)
22
+ Bolt::Util.exec_podman(cmd, env_vars)
23
+ end
24
+
25
+ def shell
26
+ @shell ||= if Bolt::Util.windows?
27
+ Bolt::Shell::Powershell.new(target, self)
28
+ else
29
+ Bolt::Shell::Bash.new(target, self)
30
+ end
31
+ end
32
+
33
+ def connect
34
+ # We don't actually have a connection, but we do need to
35
+ # check that the container exists and is running.
36
+ ps = execute_local_json_command('ps')
37
+ container = Array(ps).find { |item|
38
+ item["ID"].to_s.eql?(@target.host) ||
39
+ item["Id"].to_s.start_with?(@target.host) ||
40
+ Array(item["Names"]).include?(@target.host)
41
+ }
42
+ raise "Could not find a container with name or ID matching '#{@target.host}'" if container.nil?
43
+ # Now find the indepth container information
44
+ id = container["ID"] || container["Id"]
45
+ output = execute_local_json_command('inspect', [id])
46
+ # Store the container information for later
47
+ @container_info = output.first
48
+ @logger.trace { "Opened session" }
49
+ true
50
+ rescue StandardError => e
51
+ raise Bolt::Node::ConnectError.new(
52
+ "Failed to connect to #{target.safe_name}: #{e.message}",
53
+ 'CONNECT_ERROR'
54
+ )
55
+ end
56
+
57
+ # Executes a command inside the target container. This is called from the shell class.
58
+ #
59
+ # @param command [string] The command to run
60
+ def execute(command)
61
+ args = []
62
+ args += %w[--interactive]
63
+ args += %w[--tty] if target.options['tty']
64
+ args += @env_vars if @env_vars
65
+
66
+ if target.options['shell-command'] && !target.options['shell-command'].empty?
67
+ # escape any double quotes in command
68
+ command = command.gsub('"', '\"')
69
+ command = "#{target.options['shell-command']} \"#{command}\""
70
+ end
71
+
72
+ podman_command = %w[podman exec] + args + [container_id] + Shellwords.split(command)
73
+ @logger.trace { "Executing: #{podman_command.join(' ')}" }
74
+
75
+ Open3.popen3(*podman_command)
76
+ rescue StandardError
77
+ @logger.trace { "Command aborted" }
78
+ raise
79
+ end
80
+
81
+ # Converts the JSON encoded STDOUT string from the podman cli into ruby objects
82
+ #
83
+ # @param stdout [String] The string to convert
84
+ # @return [Object] Ruby object representation of the JSON string
85
+ private def extract_json(stdout)
86
+ # Podman renders the output in pretty JSON, which results in a newline
87
+ # appearing in the output before the closing bracket.
88
+ # should we only get a single line with no newline at all, we also
89
+ # assume it is a single minified JSON object
90
+ stdout.strip!
91
+ newline = stdout.index("\n") || -1
92
+ bracket = stdout.index('}') || -1
93
+ JSON.parse(stdout) if bracket > newline
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end