bolt 3.5.0 → 3.8.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/Puppetfile +3 -3
  3. data/bolt-modules/boltlib/lib/puppet/datatypes/applyresult.rb +26 -0
  4. data/bolt-modules/boltlib/lib/puppet/datatypes/containerresult.rb +27 -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_script.rb +5 -1
  15. data/bolt-modules/boltlib/lib/puppet/functions/upload_file.rb +5 -1
  16. data/bolt-modules/boltlib/lib/puppet/functions/write_file.rb +1 -0
  17. data/bolt-modules/ctrl/lib/puppet/functions/ctrl/do_until.rb +2 -0
  18. data/bolt-modules/file/lib/puppet/functions/file/exists.rb +9 -3
  19. data/bolt-modules/file/lib/puppet/functions/file/read.rb +6 -2
  20. data/bolt-modules/file/lib/puppet/functions/file/readable.rb +8 -3
  21. data/guides/guide.txt +17 -0
  22. data/guides/links.txt +13 -0
  23. data/guides/targets.txt +29 -0
  24. data/guides/transports.txt +23 -0
  25. data/lib/bolt/analytics.rb +4 -8
  26. data/lib/bolt/applicator.rb +1 -1
  27. data/lib/bolt/bolt_option_parser.rb +351 -225
  28. data/lib/bolt/catalog.rb +2 -1
  29. data/lib/bolt/cli.rb +122 -55
  30. data/lib/bolt/config.rb +11 -7
  31. data/lib/bolt/config/options.rb +41 -9
  32. data/lib/bolt/config/transport/podman.rb +33 -0
  33. data/lib/bolt/executor.rb +15 -11
  34. data/lib/bolt/inventory.rb +5 -4
  35. data/lib/bolt/inventory/inventory.rb +3 -2
  36. data/lib/bolt/module_installer/specs/git_spec.rb +10 -6
  37. data/lib/bolt/outputter/human.rb +194 -79
  38. data/lib/bolt/outputter/json.rb +10 -4
  39. data/lib/bolt/pal.rb +45 -0
  40. data/lib/bolt/pal/yaml_plan/step.rb +4 -2
  41. data/lib/bolt/plan_creator.rb +2 -2
  42. data/lib/bolt/plugin.rb +13 -11
  43. data/lib/bolt/puppetdb/client.rb +54 -0
  44. data/lib/bolt/result.rb +5 -0
  45. data/lib/bolt/shell/bash.rb +23 -10
  46. data/lib/bolt/transport/docker.rb +1 -1
  47. data/lib/bolt/transport/docker/connection.rb +10 -6
  48. data/lib/bolt/transport/podman.rb +19 -0
  49. data/lib/bolt/transport/podman/connection.rb +98 -0
  50. data/lib/bolt/transport/ssh/connection.rb +3 -6
  51. data/lib/bolt/util.rb +71 -0
  52. data/lib/bolt/version.rb +1 -1
  53. data/lib/bolt_server/transport_app.rb +3 -0
  54. data/lib/bolt_spec/plans/mock_executor.rb +2 -1
  55. metadata +10 -2
@@ -83,6 +83,10 @@ module Bolt
83
83
  @stream.puts result.to_json
84
84
  end
85
85
 
86
+ def print_result_set(result_set)
87
+ @stream.puts result_set.to_json
88
+ end
89
+
86
90
  def print_topics(topics)
87
91
  print_table('topics' => topics)
88
92
  end
@@ -100,12 +104,12 @@ module Bolt
100
104
  moduledir: moduledir.to_s }.to_json)
101
105
  end
102
106
 
103
- def print_targets(target_list, inventoryfile)
107
+ def print_targets(target_list, inventory_source, default_inventory, _target_flag)
104
108
  @stream.puts ::JSON.pretty_generate(
105
109
  inventory: {
106
110
  targets: target_list[:inventory].map(&:name),
107
111
  count: target_list[:inventory].count,
108
- file: inventoryfile.to_s
112
+ file: (inventory_source || default_inventory).to_s
109
113
  },
110
114
  adhoc: {
111
115
  targets: target_list[:adhoc].map(&:name),
@@ -116,14 +120,16 @@ module Bolt
116
120
  )
117
121
  end
118
122
 
119
- def print_target_info(targets)
123
+ def print_target_info(target_list, _inventory_source, _default_inventory, _target_flag)
124
+ targets = target_list.values.flatten
125
+
120
126
  @stream.puts ::JSON.pretty_generate(
121
127
  targets: targets.map(&:detail),
122
128
  count: targets.count
123
129
  )
124
130
  end
125
131
 
126
- def print_groups(groups)
132
+ def print_groups(groups, _inventory_source, _default_inventory)
127
133
  count = groups.count
128
134
  @stream.puts({ groups: groups,
129
135
  count: count }.to_json)
data/lib/bolt/pal.rb CHANGED
@@ -615,5 +615,50 @@ module Bolt
615
615
  rescue Bolt::Error => e
616
616
  Bolt::PlanResult.new(e, 'failure')
617
617
  end
618
+
619
+ def lookup(key, targets, inventory, executor, _concurrency)
620
+ # Install the puppet-agent package and collect facts. Facts are
621
+ # automatically added to the targets.
622
+ in_plan_compiler(executor, inventory, nil) do |compiler|
623
+ compiler.call_function('apply_prep', targets)
624
+ end
625
+
626
+ overrides = {
627
+ bolt_inventory: inventory,
628
+ bolt_project: @project
629
+ }
630
+
631
+ # Do a lookup with a catalog compiler, which uses the 'hierarchy' key in
632
+ # Hiera config.
633
+ results = targets.map do |target|
634
+ node = Puppet::Node.from_data_hash(
635
+ 'name' => target.name,
636
+ 'parameters' => { 'clientcert' => target.name }
637
+ )
638
+
639
+ trusted = Puppet::Context::TrustedInformation.local(node).to_h
640
+
641
+ env_conf = {
642
+ modulepath: @modulepath.full_modulepath,
643
+ facts: target.facts,
644
+ variables: target.vars
645
+ }
646
+
647
+ with_puppet_settings do
648
+ Puppet::Pal.in_tmp_environment(target.name, **env_conf) do |pal|
649
+ Puppet.override(overrides) do
650
+ Puppet.lookup(:pal_current_node).trusted_data = trusted
651
+ pal.with_catalog_compiler do |compiler|
652
+ Bolt::Result.for_lookup(target, key, compiler.call_function('lookup', key))
653
+ rescue StandardError => e
654
+ Bolt::Result.from_exception(target, e)
655
+ end
656
+ end
657
+ end
658
+ end
659
+ end
660
+
661
+ Bolt::ResultSet.new(results)
662
+ end
618
663
  end
619
664
  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
@@ -28,6 +28,11 @@ module Bolt
28
28
  %w[file line].zip(position).to_h.compact
29
29
  end
30
30
 
31
+ def self.for_lookup(target, key, value)
32
+ val = { 'value' => value }
33
+ new(target, value: val, action: 'lookup', object: key)
34
+ end
35
+
31
36
  def self.for_command(target, value, action, command, position)
32
37
  details = create_details(position)
33
38
  unless value['exit_code'] == 0
@@ -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,6 +34,10 @@ 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
+
37
41
  private def env_hash
38
42
  # Set the DOCKER_HOST if we are using a non-default service-url
39
43
  @docker_host.nil? ? {} : { 'DOCKER_HOST' => @docker_host }
@@ -42,8 +46,8 @@ module Bolt
42
46
  def connect
43
47
  # We don't actually have a connection, but we do need to
44
48
  # check that the container exists and is running.
45
- output = execute_local_json_command('ps')
46
- 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 }
47
51
  raise "Could not find a container with name or ID matching '#{target.host}'" if index.nil?
48
52
  # Now find the indepth container information
49
53
  output = execute_local_json_command('inspect', [output[index]["ID"]])
@@ -90,7 +94,7 @@ module Bolt
90
94
 
91
95
  def upload_file(source, destination)
92
96
  @logger.trace { "Uploading #{source} to #{destination}" }
93
- _out, err, stat = Bolt::Util.exec_docker(['cp', source, "#{container_id}:#{destination}"], env_hash)
97
+ _out, err, stat = run_cmd(['cp', source, "#{container_id}:#{destination}"], env_hash)
94
98
  unless stat.exitstatus.zero?
95
99
  raise "Error writing to container #{container_id}: #{err}"
96
100
  end
@@ -104,7 +108,7 @@ module Bolt
104
108
  # copy the *contents* of the directory.
105
109
  # https://docs.docker.com/engine/reference/commandline/cp/
106
110
  FileUtils.mkdir_p(destination)
107
- _out, err, stat = Bolt::Util.exec_docker(['cp', "#{container_id}:#{source}", destination], env_hash)
111
+ _out, err, stat = run_cmd(['cp', "#{container_id}:#{source}", destination], env_hash)
108
112
  unless stat.exitstatus.zero?
109
113
  raise "Error downloading content from container #{container_id}: #{err}"
110
114
  end
@@ -119,9 +123,9 @@ module Bolt
119
123
  # @param arguments [Array] Arguments to pass to the docker command
120
124
  # e.g. 'src' and 'dest' for `docker cp <src> <dest>
121
125
  # @return [Object] Ruby object representation of the JSON string
122
- private def execute_local_json_command(subcommand, arguments = [])
126
+ def execute_local_json_command(subcommand, arguments = [])
123
127
  cmd = [subcommand, '--format', '{{json .}}'].concat(arguments)
124
- out, _err, _stat = Bolt::Util.exec_docker(cmd, env_hash)
128
+ out, _err, _stat = run_cmd(cmd, env_hash)
125
129
  extract_json(out)
126
130
  end
127
131
 
@@ -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