bolt 2.20.0 → 2.24.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/Puppetfile +3 -1
  3. data/bolt-modules/boltlib/lib/puppet/functions/run_plan.rb +6 -0
  4. data/bolt-modules/ctrl/lib/puppet/functions/ctrl/do_until.rb +12 -6
  5. data/bolt-modules/dir/lib/puppet/functions/dir/children.rb +35 -0
  6. data/bolt-modules/out/lib/puppet/functions/out/message.rb +1 -1
  7. data/exe/bolt +1 -0
  8. data/guides/inventory.txt +19 -0
  9. data/guides/project.txt +22 -0
  10. data/lib/bolt/analytics.rb +5 -5
  11. data/lib/bolt/applicator.rb +4 -3
  12. data/lib/bolt/bolt_option_parser.rb +75 -25
  13. data/lib/bolt/catalog.rb +9 -1
  14. data/lib/bolt/cli.rb +226 -73
  15. data/lib/bolt/config.rb +7 -0
  16. data/lib/bolt/config/options.rb +4 -4
  17. data/lib/bolt/executor.rb +16 -8
  18. data/lib/bolt/inventory/group.rb +3 -3
  19. data/lib/bolt/logger.rb +3 -4
  20. data/lib/bolt/module.rb +2 -1
  21. data/lib/bolt/outputter.rb +56 -0
  22. data/lib/bolt/outputter/human.rb +10 -9
  23. data/lib/bolt/outputter/json.rb +11 -4
  24. data/lib/bolt/outputter/logger.rb +2 -2
  25. data/lib/bolt/outputter/rainbow.rb +15 -0
  26. data/lib/bolt/pal.rb +5 -9
  27. data/lib/bolt/pal/yaml_plan/evaluator.rb +4 -0
  28. data/lib/bolt/pal/yaml_plan/step.rb +14 -1
  29. data/lib/bolt/pal/yaml_plan/step/message.rb +30 -0
  30. data/lib/bolt/pal/yaml_plan/transpiler.rb +11 -3
  31. data/lib/bolt/plugin/prompt.rb +3 -3
  32. data/lib/bolt/project.rb +6 -4
  33. data/lib/bolt/project_migrate.rb +138 -0
  34. data/lib/bolt/shell/bash.rb +7 -7
  35. data/lib/bolt/transport/docker/connection.rb +9 -9
  36. data/lib/bolt/transport/local/connection.rb +2 -2
  37. data/lib/bolt/transport/orch.rb +3 -3
  38. data/lib/bolt/transport/ssh/connection.rb +5 -5
  39. data/lib/bolt/transport/ssh/exec_connection.rb +4 -4
  40. data/lib/bolt/transport/winrm/connection.rb +17 -8
  41. data/lib/bolt/util.rb +1 -1
  42. data/lib/bolt/util/puppet_log_level.rb +4 -3
  43. data/lib/bolt/version.rb +1 -1
  44. data/lib/bolt_server/base_config.rb +1 -1
  45. data/lib/bolt_server/pe/pal.rb +1 -1
  46. data/lib/bolt_server/transport_app.rb +76 -0
  47. data/lib/bolt_spec/plans.rb +1 -1
  48. data/lib/bolt_spec/plans/action_stubs.rb +1 -1
  49. data/libexec/apply_catalog.rb +2 -2
  50. data/libexec/bolt_catalog +1 -1
  51. data/libexec/custom_facts.rb +1 -1
  52. data/libexec/query_resources.rb +1 -1
  53. data/modules/secure_env_vars/plans/init.pp +20 -0
  54. metadata +8 -2
@@ -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
@@ -18,9 +18,9 @@ module Bolt
18
18
  end
19
19
 
20
20
  def resolve_reference(opts)
21
- STDERR.print("#{opts['message']}: ")
22
- value = STDIN.noecho(&:gets).to_s.chomp
23
- STDERR.puts
21
+ $stderr.print("#{opts['message']}: ")
22
+ value = $stdin.noecho(&:gets).to_s.chomp
23
+ $stderr.puts
24
24
 
25
25
  value
26
26
  end
@@ -3,6 +3,7 @@
3
3
  require 'pathname'
4
4
  require 'bolt/config'
5
5
  require 'bolt/pal'
6
+ require 'bolt/module'
6
7
 
7
8
  module Bolt
8
9
  class Project
@@ -17,7 +18,7 @@ module Bolt
17
18
 
18
19
  attr_reader :path, :data, :config_file, :inventory_file, :modulepath, :hiera_config,
19
20
  :puppetfile, :rerunfile, :type, :resource_types, :warnings, :project_file,
20
- :deprecations, :downloads
21
+ :deprecations, :downloads, :plans_path
21
22
 
22
23
  def self.default_project
23
24
  create_project(File.expand_path(File.join('~', '.puppetlabs', 'bolt')), 'user')
@@ -82,6 +83,7 @@ module Bolt
82
83
  @resource_types = @path + '.resource_types'
83
84
  @type = type
84
85
  @downloads = @path + 'downloads'
86
+ @plans_path = @path + 'plans'
85
87
 
86
88
  tc = Bolt::Config::INVENTORY_OPTIONS.keys & raw_data.keys
87
89
  if tc.any?
@@ -138,10 +140,10 @@ module Bolt
138
140
 
139
141
  def validate
140
142
  if name
141
- name_regex = /^[a-z][a-z0-9_]*$/
142
- if name !~ name_regex
143
+ if name !~ Bolt::Module::MODULE_NAME_REGEX
143
144
  raise Bolt::ValidationError, <<~ERROR_STRING
144
- Invalid project name '#{name}' in bolt-project.yaml; project name must match #{name_regex.inspect}
145
+ Invalid project name '#{name}' in bolt-project.yaml; project name must begin with a lowercase letter
146
+ and can include lowercase letters, numbers, and underscores.
145
147
  ERROR_STRING
146
148
  elsif Dir.children(Bolt::PAL::BOLTLIB_PATH).include?(name)
147
149
  raise Bolt::ValidationError, "The project '#{name}' will not be loaded. The project name conflicts "\
@@ -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
@@ -103,7 +103,7 @@ module Bolt
103
103
  " using '#{execute_options[:interpreter]}' interpreter"
104
104
  end
105
105
  # log the arguments with sensitive data redacted, do NOT log unwrapped_arguments
106
- logger.debug("Running '#{executable}' with #{arguments.to_json}#{interpreter_debug}")
106
+ logger.trace("Running '#{executable}' with #{arguments.to_json}#{interpreter_debug}")
107
107
  # unpack any Sensitive data
108
108
  arguments = unwrap_sensitive_args(arguments)
109
109
 
@@ -203,13 +203,13 @@ module Bolt
203
203
 
204
204
  def handle_sudo_errors(err)
205
205
  if err =~ /^#{conn.user} is not in the sudoers file\./
206
- @logger.debug { err }
206
+ @logger.trace { err }
207
207
  raise Bolt::Node::EscalateError.new(
208
208
  "User #{conn.user} does not have sudo permission on #{target}",
209
209
  'SUDO_DENIED'
210
210
  )
211
211
  elsif err =~ /^Sorry, try again\./
212
- @logger.debug { err }
212
+ @logger.trace { err }
213
213
  raise Bolt::Node::EscalateError.new(
214
214
  "Sudo password for user #{conn.user} not recognized on #{target}",
215
215
  'BAD_PASSWORD'
@@ -351,7 +351,7 @@ module Bolt
351
351
 
352
352
  command_str = [sudo_str, env_decl, command_str].compact.join(' ')
353
353
 
354
- @logger.debug { "Executing: #{command_str}" }
354
+ @logger.trace { "Executing `#{command_str}`" }
355
355
 
356
356
  in_buffer = if !use_sudo && options[:stdin]
357
357
  String.new(options[:stdin], encoding: 'binary')
@@ -431,16 +431,16 @@ module Bolt
431
431
  result_output.exit_code = t.value.respond_to?(:exitstatus) ? t.value.exitstatus : t.value
432
432
 
433
433
  if result_output.exit_code == 0
434
- @logger.debug { "Command returned successfully" }
434
+ @logger.trace { "Command `#{command_str}` returned successfully" }
435
435
  else
436
- @logger.info { "Command failed with exit code #{result_output.exit_code}" }
436
+ @logger.trace { "Command #{command_str} failed with exit code #{result_output.exit_code}" }
437
437
  end
438
438
  result_output
439
439
  rescue StandardError
440
440
  # Ensure we close stdin and kill the child process
441
441
  inp&.close
442
442
  t&.terminate if t&.alive?
443
- @logger.debug { "Command aborted" }
443
+ @logger.trace { "Command aborted" }
444
444
  raise
445
445
  end
446
446
 
@@ -12,7 +12,7 @@ module Bolt
12
12
  @target = target
13
13
  @logger = Logging.logger[target.safe_name]
14
14
  @docker_host = @target.options['service-url']
15
- @logger.debug("Initializing docker connection to #{@target.safe_name}")
15
+ @logger.trace("Initializing docker connection to #{@target.safe_name}")
16
16
  end
17
17
 
18
18
  def connect
@@ -25,7 +25,7 @@ module Bolt
25
25
  output = execute_local_docker_json_command('inspect', [output[index]["ID"]])
26
26
  # Store the container information for later
27
27
  @container_info = output[0]
28
- @logger.debug { "Opened session" }
28
+ @logger.trace { "Opened session" }
29
29
  true
30
30
  rescue StandardError => e
31
31
  raise Bolt::Node::ConnectError.new(
@@ -57,16 +57,16 @@ module Bolt
57
57
  command_options << container_id
58
58
  command_options.concat(command)
59
59
 
60
- @logger.debug { "Executing: exec #{command_options}" }
60
+ @logger.trace { "Executing: exec #{command_options}" }
61
61
 
62
62
  stdout_str, stderr_str, status = execute_local_docker_command('exec', command_options, options[:stdin])
63
63
 
64
64
  # The actual result is the exitstatus not the process object
65
65
  status = status.nil? ? -32768 : status.exitstatus
66
66
  if status == 0
67
- @logger.debug { "Command returned successfully" }
67
+ @logger.trace { "Command returned successfully" }
68
68
  else
69
- @logger.info { "Command failed with exit code #{status}" }
69
+ @logger.trace { "Command failed with exit code #{status}" }
70
70
  end
71
71
  stdout_str.force_encoding(Encoding::UTF_8)
72
72
  stderr_str.force_encoding(Encoding::UTF_8)
@@ -75,12 +75,12 @@ module Bolt
75
75
  stderr_str.gsub!("\r\n", "\n")
76
76
  [stdout_str, stderr_str, status]
77
77
  rescue StandardError
78
- @logger.debug { "Command aborted" }
78
+ @logger.trace { "Command aborted" }
79
79
  raise
80
80
  end
81
81
 
82
82
  def write_remote_file(source, destination)
83
- @logger.debug { "Uploading #{source}, to #{destination}" }
83
+ @logger.trace { "Uploading #{source} to #{destination}" }
84
84
  _, stdout_str, status = execute_local_docker_command('cp', [source, "#{container_id}:#{destination}"])
85
85
  unless status.exitstatus.zero?
86
86
  raise "Error writing file to container #{@container_id}: #{stdout_str}"
@@ -90,7 +90,7 @@ module Bolt
90
90
  end
91
91
 
92
92
  def write_remote_directory(source, destination)
93
- @logger.debug { "Uploading #{source}, to #{destination}" }
93
+ @logger.trace { "Uploading #{source} to #{destination}" }
94
94
  _, stdout_str, status = execute_local_docker_command('cp', [source, "#{container_id}:#{destination}"])
95
95
  unless status.exitstatus.zero?
96
96
  raise "Error writing directory to container #{@container_id}: #{stdout_str}"
@@ -100,7 +100,7 @@ module Bolt
100
100
  end
101
101
 
102
102
  def download_remote_content(source, destination)
103
- @logger.debug { "Downloading #{source} to #{destination}" }
103
+ @logger.trace { "Downloading #{source} to #{destination}" }
104
104
  # Create the destination directory, otherwise copying a source directory with Docker will
105
105
  # copy the *contents* of the directory.
106
106
  # https://docs.docker.com/engine/reference/commandline/cp/
@@ -28,7 +28,7 @@ module Bolt
28
28
  end
29
29
 
30
30
  def upload_file(source, dest)
31
- @logger.debug { "Uploading #{source}, to #{dest}" }
31
+ @logger.trace { "Uploading #{source} to #{dest}" }
32
32
  if source.is_a?(StringIO)
33
33
  Tempfile.create(File.basename(dest)) do |f|
34
34
  f.write(source.read)
@@ -46,7 +46,7 @@ module Bolt
46
46
  end
47
47
 
48
48
  def download_file(source, dest, _download)
49
- @logger.debug { "Downloading #{source} to #{dest}" }
49
+ @logger.trace { "Downloading #{source} to #{dest}" }
50
50
  # Create the destination directory for the target, or the
51
51
  # copied file will have the target's name
52
52
  FileUtils.mkdir_p(dest)
@@ -38,7 +38,7 @@ module Bolt
38
38
  @connections.each_value do |conn|
39
39
  conn.finish_plan(result)
40
40
  rescue StandardError => e
41
- @logger.debug("Failed to finish plan on #{conn.key}: #{e.message}")
41
+ @logger.trace("Failed to finish plan on #{conn.key}: #{e.message}")
42
42
  end
43
43
  end
44
44
  end
@@ -133,7 +133,7 @@ module Bolt
133
133
  next unless File.file?(file)
134
134
 
135
135
  tar_path = Pathname.new(file).relative_path_from(Pathname.new(directory))
136
- @logger.debug("Packing #{file} to #{tar_path}")
136
+ @logger.trace("Packing #{file} to #{tar_path}")
137
137
  stat = File.stat(file)
138
138
  content = File.binread(file)
139
139
  output.tar.add_file_simple(
@@ -146,7 +146,7 @@ module Bolt
146
146
  end
147
147
 
148
148
  duration = Time.now - start_time
149
- @logger.debug("Packed upload in #{duration * 1000} ms")
149
+ @logger.trace("Packed upload in #{duration * 1000} ms")
150
150
 
151
151
  output.close
152
152
  io.string
@@ -28,7 +28,7 @@ module Bolt
28
28
 
29
29
  @logger = Logging.logger[@target.safe_name]
30
30
  @transport_logger = transport_logger
31
- @logger.debug("Initializing ssh connection to #{@target.safe_name}")
31
+ @logger.trace("Initializing ssh connection to #{@target.safe_name}")
32
32
 
33
33
  if target.options['private-key']&.instance_of?(String)
34
34
  begin
@@ -131,7 +131,7 @@ module Bolt
131
131
 
132
132
  @session = Net::SSH.start(target.host, @user, options)
133
133
  validate_ssh_version
134
- @logger.debug { "Opened session" }
134
+ @logger.trace { "Opened session" }
135
135
  rescue Net::SSH::AuthenticationFailed => e
136
136
  raise Bolt::Node::ConnectError.new(
137
137
  e.message,
@@ -161,7 +161,7 @@ module Bolt
161
161
  rescue Timeout::Error
162
162
  @session.shutdown!
163
163
  end
164
- @logger.debug { "Closed session" }
164
+ @logger.trace { "Closed session" }
165
165
  end
166
166
  end
167
167
 
@@ -237,7 +237,7 @@ module Bolt
237
237
 
238
238
  def upload_file(source, destination)
239
239
  # Do not log wrapper script content
240
- @logger.debug { "Uploading #{source}, to #{destination}" } unless source.is_a?(StringIO)
240
+ @logger.trace { "Uploading #{source} to #{destination}" } unless source.is_a?(StringIO)
241
241
  @session.scp.upload!(source, destination, recursive: true)
242
242
  rescue StandardError => e
243
243
  raise Bolt::Node::FileError.new(e.message, 'WRITE_ERROR')
@@ -245,7 +245,7 @@ module Bolt
245
245
 
246
246
  def download_file(source, destination, _download)
247
247
  # Do not log wrapper script content
248
- @logger.debug { "Downloading #{source} to #{destination}" }
248
+ @logger.trace { "Downloading #{source} to #{destination}" }
249
249
  @session.scp.download!(source, destination, recursive: true)
250
250
  rescue StandardError => e
251
251
  raise Bolt::Node::FileError.new(e.message, 'WRITE_ERROR')
@@ -66,7 +66,7 @@ module Bolt
66
66
  end
67
67
 
68
68
  def upload_file(source, dest)
69
- @logger.debug { "Uploading #{source}, to #{userhost}:#{dest}" } unless source.is_a?(StringIO)
69
+ @logger.trace { "Uploading #{source} to #{dest}" } unless source.is_a?(StringIO)
70
70
 
71
71
  cp_conf = @target.transport_config['copy-command'] || ["scp", "-r"]
72
72
  cp_cmd = Array(cp_conf)
@@ -87,7 +87,7 @@ module Bolt
87
87
  end
88
88
 
89
89
  if stat.success?
90
- @logger.debug "Successfully uploaded #{source} to #{dest}"
90
+ @logger.trace "Successfully uploaded #{source} to #{dest}"
91
91
  else
92
92
  message = "Could not copy file to #{dest}: #{err}"
93
93
  raise Bolt::Node::FileError.new(message, 'COPY_ERROR')
@@ -95,7 +95,7 @@ module Bolt
95
95
  end
96
96
 
97
97
  def download_file(source, dest, _download)
98
- @logger.debug { "Downloading #{userhost}:#{source} to #{dest}" }
98
+ @logger.trace { "Downloading #{userhost}:#{source} to #{dest}" }
99
99
 
100
100
  FileUtils.mkdir_p(dest)
101
101
 
@@ -108,7 +108,7 @@ module Bolt
108
108
  _, err, stat = Open3.capture3(*cp_cmd)
109
109
 
110
110
  if stat.success?
111
- @logger.debug "Successfully downloaded #{userhost}:#{source} to #{dest}"
111
+ @logger.trace "Successfully downloaded #{userhost}:#{source} to #{dest}"
112
112
  else
113
113
  message = "Could not copy file to #{dest}: #{err}"
114
114
  raise Bolt::Node::FileError.new(message, 'COPY_ERROR')