bolt 2.40.2 → 3.1.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/Puppetfile +19 -17
  3. data/bolt-modules/boltlib/lib/puppet/functions/apply_prep.rb +25 -0
  4. data/bolt-modules/boltlib/lib/puppet/functions/parallelize.rb +6 -8
  5. data/bolt-modules/boltlib/lib/puppet/functions/wait_until_available.rb +7 -3
  6. data/lib/bolt/analytics.rb +3 -2
  7. data/lib/bolt/applicator.rb +11 -1
  8. data/lib/bolt/bolt_option_parser.rb +3 -113
  9. data/lib/bolt/catalog.rb +10 -29
  10. data/lib/bolt/cli.rb +54 -155
  11. data/lib/bolt/config.rb +62 -239
  12. data/lib/bolt/config/options.rb +58 -97
  13. data/lib/bolt/config/transport/local.rb +1 -0
  14. data/lib/bolt/config/transport/options.rb +8 -1
  15. data/lib/bolt/config/transport/orch.rb +1 -0
  16. data/lib/bolt/executor.rb +15 -5
  17. data/lib/bolt/inventory.rb +3 -2
  18. data/lib/bolt/inventory/group.rb +35 -4
  19. data/lib/bolt/inventory/inventory.rb +1 -1
  20. data/lib/bolt/logger.rb +115 -11
  21. data/lib/bolt/module.rb +10 -2
  22. data/lib/bolt/module_installer.rb +4 -2
  23. data/lib/bolt/module_installer/resolver.rb +65 -12
  24. data/lib/bolt/module_installer/specs/forge_spec.rb +8 -2
  25. data/lib/bolt/module_installer/specs/git_spec.rb +17 -2
  26. data/lib/bolt/outputter/human.rb +9 -5
  27. data/lib/bolt/outputter/json.rb +16 -16
  28. data/lib/bolt/outputter/rainbow.rb +3 -3
  29. data/lib/bolt/pal.rb +94 -14
  30. data/lib/bolt/pal/yaml_plan.rb +8 -2
  31. data/lib/bolt/pal/yaml_plan/evaluator.rb +7 -19
  32. data/lib/bolt/pal/yaml_plan/step.rb +3 -24
  33. data/lib/bolt/pal/yaml_plan/step/upload.rb +2 -2
  34. data/lib/bolt/pal/yaml_plan/transpiler.rb +6 -1
  35. data/lib/bolt/plugin.rb +3 -3
  36. data/lib/bolt/plugin/cache.rb +7 -7
  37. data/lib/bolt/plugin/module.rb +0 -23
  38. data/lib/bolt/plugin/puppet_connect_data.rb +77 -0
  39. data/lib/bolt/plugin/puppetdb.rb +1 -1
  40. data/lib/bolt/project.rb +54 -81
  41. data/lib/bolt/project_manager.rb +4 -3
  42. data/lib/bolt/project_manager/module_migrator.rb +6 -5
  43. data/lib/bolt/rerun.rb +1 -1
  44. data/lib/bolt/result.rb +6 -1
  45. data/lib/bolt/shell/bash.rb +9 -4
  46. data/lib/bolt/shell/bash/tmpdir.rb +4 -1
  47. data/lib/bolt/shell/powershell.rb +9 -5
  48. data/lib/bolt/shell/powershell/snippets.rb +37 -150
  49. data/lib/bolt/task.rb +1 -1
  50. data/lib/bolt/transport/base.rb +0 -9
  51. data/lib/bolt/transport/docker.rb +1 -125
  52. data/lib/bolt/transport/docker/connection.rb +86 -161
  53. data/lib/bolt/transport/local.rb +1 -9
  54. data/lib/bolt/transport/orch/connection.rb +1 -1
  55. data/lib/bolt/transport/ssh.rb +1 -2
  56. data/lib/bolt/transport/ssh/connection.rb +1 -1
  57. data/lib/bolt/validator.rb +2 -2
  58. data/lib/bolt/version.rb +1 -1
  59. data/lib/bolt_server/config.rb +1 -1
  60. data/lib/bolt_server/transport_app.rb +48 -31
  61. data/lib/bolt_spec/bolt_context.rb +9 -4
  62. data/lib/bolt_spec/plans.rb +1 -109
  63. data/libexec/bolt_catalog +1 -1
  64. data/modules/aggregate/plans/count.pp +21 -0
  65. data/modules/aggregate/plans/targets.pp +21 -0
  66. data/modules/puppet_connect/plans/test_input_data.pp +67 -0
  67. data/modules/puppetdb_fact/plans/init.pp +10 -0
  68. metadata +28 -19
  69. data/modules/aggregate/plans/nodes.pp +0 -36
@@ -48,7 +48,10 @@ module Bolt
48
48
  def delete
49
49
  result = @shell.execute(['rm', '-rf', @path], sudoable: true, run_as: @owner)
50
50
  if result.exit_code != 0
51
- @logger.warn("Failed to clean up tmpdir '#{@path}': #{result.stderr.string}")
51
+ Bolt::Logger.warn(
52
+ "fail_cleanup",
53
+ "Failed to clean up tmpdir '#{@path}': #{result.stderr.string}"
54
+ )
52
55
  end
53
56
  # For testing
54
57
  result.stderr.string
@@ -23,11 +23,10 @@ module Bolt
23
23
  # This lets us know how many targets have Powershell 2, and lets the
24
24
  # user know how many targets they have with PS2
25
25
  msg = "Detected PowerShell 2 on one or more targets.\nPowerShell 2 "\
26
- "is deprecated, and support will be removed in Bolt 3.0. See "\
27
- "bolt-debug.log or run with '--log-level debug' to see the full "\
26
+ "is unsupported. See bolt-debug.log or run with '--log-level debug' to see the full "\
28
27
  "list of targets with PowerShell 2."
29
28
 
30
- Bolt::Logger.deprecation_warning("PowerShell 2", msg)
29
+ Bolt::Logger.deprecate_once("powershell_2", msg)
31
30
  @logger.debug("Detected PowerShell 2 on #{target}.")
32
31
  end
33
32
  end
@@ -163,7 +162,7 @@ module Bolt
163
162
  if target.options['cleanup']
164
163
  rmdir(@tmpdir)
165
164
  else
166
- @logger.warn("Skipping cleanup of tmpdir '#{@tmpdir}'")
165
+ Bolt::Logger.warn("Skipping cleanup of tmpdir '#{@tmpdir}'", "skip_cleanup")
167
166
  end
168
167
  end
169
168
  end
@@ -275,7 +274,12 @@ module Bolt
275
274
  []
276
275
  end
277
276
 
278
- output = execute([Snippets.shell_init, *env_assignments, command].join("\n"))
277
+ output = execute([
278
+ Snippets.shell_init,
279
+ Snippets.append_ps_module_path(dir),
280
+ *env_assignments,
281
+ command
282
+ ].join("\n"))
279
283
 
280
284
  Bolt::Result.for_task(target, output.stdout.string,
281
285
  output.stderr.string,
@@ -55,27 +55,59 @@ module Bolt
55
55
  }
56
56
  #{build_arg_list}
57
57
 
58
+ switch -regex ( Get-ExecutionPolicy )
59
+ {
60
+ '^AllSigned'
61
+ {
62
+ if ((Get-AuthenticodeSignature -File "#{script_path}").Status -ne 'Valid') {
63
+ $Host.UI.WriteErrorLine("Error: Target host Powershell ExecutionPolicy is set to ${_} and script '#{script_path}' does not contain a valid signature.")
64
+ exit 1;
65
+ }
66
+ }
67
+ '^Restricted'
68
+ {
69
+ $Host.UI.WriteErrorLine("Error: Target host Powershell ExecutionPolicy is set to ${_} which denies running any scripts on the target.")
70
+ exit 1;
71
+ }
72
+ }
73
+
74
+ if([string]::IsNullOrEmpty($invokeArgs.ScriptBlock)){
75
+ $Host.UI.WriteErrorLine("Error: Failed to obtain scriptblock from '#{script_path}'. Running scripts might be disabled on this system. For more information, see about_Execution_Policies at https:/go.microsoft.com/fwlink/?LinkID=135170");
76
+ exit 1;
77
+ }
78
+
58
79
  try
59
80
  {
60
81
  Invoke-Command @invokeArgs
61
82
  }
62
83
  catch
63
84
  {
64
- Write-Error $_.Exception
65
- exit 1
85
+ $Host.UI.WriteErrorLine("[$($_.FullyQualifiedErrorId)] Exception $($_.InvocationInfo.PositionMessage).`n$($_.Exception.Message)");
86
+ exit 1;
66
87
  }
67
88
  PS
68
89
  end
69
90
 
91
+ def append_ps_module_path(directory)
92
+ <<~PS
93
+ $env:PSModulePath += ";#{directory}"
94
+ PS
95
+ end
96
+
70
97
  def ps_task(path, arguments)
71
98
  <<~PS
72
99
  $private:tempArgs = Get-ContentAsJson (
73
- $utf8.GetString([System.Convert]::FromBase64String('#{Base64.encode64(JSON.dump(arguments))}'))
100
+ [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('#{Base64.encode64(JSON.dump(arguments))}'))
74
101
  )
75
102
  $allowedArgs = (Get-Command "#{path}").Parameters.Keys
76
103
  $private:taskArgs = @{}
77
104
  $private:tempArgs.Keys | ? { $allowedArgs -contains $_ } | % { $private:taskArgs[$_] = $private:tempArgs[$_] }
78
- try { & "#{path}" @taskArgs } catch { Write-Error $_.Exception; exit 1 }
105
+ try {
106
+ & "#{path}" @taskArgs
107
+ } catch {
108
+ $Host.UI.WriteErrorLine("[$($_.FullyQualifiedErrorId)] Exception $($_.InvocationInfo.PositionMessage).`n$($_.Exception.Message)");
109
+ exit 1;
110
+ }
79
111
  PS
80
112
  end
81
113
 
@@ -102,151 +134,11 @@ module Bolt
102
134
  "${boltBaseDir}\\hiera\\lib;" +
103
135
  $ENV:RUBYLIB
104
136
 
105
- Add-Type -AssemblyName System.ServiceModel.Web, System.Runtime.Serialization
106
- $utf8 = [System.Text.Encoding]::UTF8
107
-
108
- function Write-Stream {
109
- PARAM(
110
- [Parameter(Position=0)] $stream,
111
- [Parameter(ValueFromPipeline=$true)] $string
112
- )
113
- PROCESS {
114
- $bytes = $utf8.GetBytes($string)
115
- $stream.Write( $bytes, 0, $bytes.Length )
116
- }
117
- }
118
-
119
- function Convert-JsonToXml {
120
- PARAM([Parameter(ValueFromPipeline=$true)] [string[]] $json)
121
- BEGIN {
122
- $mStream = New-Object System.IO.MemoryStream
123
- }
124
- PROCESS {
125
- $json | Write-Stream -Stream $mStream
126
- }
127
- END {
128
- $mStream.Position = 0
129
- try {
130
- $jsonReader = [System.Runtime.Serialization.Json.JsonReaderWriterFactory]::CreateJsonReader($mStream,[System.Xml.XmlDictionaryReaderQuotas]::Max)
131
- $xml = New-Object Xml.XmlDocument
132
- $xml.Load($jsonReader)
133
- $xml
134
- } finally {
135
- $jsonReader.Close()
136
- $mStream.Dispose()
137
- }
138
- }
139
- }
140
-
141
- Function ConvertFrom-Xml {
142
- [CmdletBinding(DefaultParameterSetName="AutoType")]
143
- PARAM(
144
- [Parameter(ValueFromPipeline=$true,Mandatory=$true,Position=1)] [Xml.XmlNode] $xml,
145
- [Parameter(Mandatory=$true,ParameterSetName="ManualType")] [Type] $Type,
146
- [Switch] $ForceType
147
- )
148
- PROCESS{
149
- if (Get-Member -InputObject $xml -Name root) {
150
- return $xml.root.Objects | ConvertFrom-Xml
151
- } elseif (Get-Member -InputObject $xml -Name Objects) {
152
- return $xml.Objects | ConvertFrom-Xml
153
- }
154
- $propbag = @{}
155
- foreach ($name in Get-Member -InputObject $xml -MemberType Properties | Where-Object{$_.Name -notmatch "^(__.*|type)$"} | Select-Object -ExpandProperty name) {
156
- Write-Debug "$Name Type: $($xml.$Name.type)" -Debug:$false
157
- $propbag."$Name" = Convert-Properties $xml."$name"
158
- }
159
- if (!$Type -and $xml.HasAttribute("__type")) { $Type = $xml.__Type }
160
- if ($ForceType -and $Type) {
161
- try {
162
- $output = New-Object $Type -Property $propbag
163
- } catch {
164
- $output = New-Object PSObject -Property $propbag
165
- $output.PsTypeNames.Insert(0, $xml.__type)
166
- }
167
- } elseif ($propbag.Count -ne 0) {
168
- $output = New-Object PSObject -Property $propbag
169
- if ($Type) {
170
- $output.PsTypeNames.Insert(0, $Type)
171
- }
172
- }
173
- return $output
174
- }
175
- }
176
-
177
- Function Convert-Properties {
178
- PARAM($InputObject)
179
- switch ($InputObject.type) {
180
- "object" {
181
- return (ConvertFrom-Xml -Xml $InputObject)
182
- }
183
- "string" {
184
- $MightBeADate = $InputObject.get_InnerText() -as [DateTime]
185
- ## Strings that are actually dates (*grumble* JSON is crap)
186
- if ($MightBeADate -and $propbag."$Name" -eq $MightBeADate.ToString("G")) {
187
- return $MightBeADate
188
- } else {
189
- return $InputObject.get_InnerText()
190
- }
191
- }
192
- "number" {
193
- $number = $InputObject.get_InnerText()
194
- if ($number -eq ($number -as [int])) {
195
- return $number -as [int]
196
- } elseif ($number -eq ($number -as [double])) {
197
- return $number -as [double]
198
- } else {
199
- return $number -as [decimal]
200
- }
201
- }
202
- "boolean" {
203
- return [bool]::parse($InputObject.get_InnerText())
204
- }
205
- "null" {
206
- return $null
207
- }
208
- "array" {
209
- [object[]]$Items = $(foreach( $item in $InputObject.GetEnumerator() ) {
210
- Convert-Properties $item
211
- })
212
- return $Items
213
- }
214
- default {
215
- return $InputObject
216
- }
217
- }
218
- }
219
-
220
- Function ConvertFrom-Json2 {
221
- [CmdletBinding()]
222
- PARAM(
223
- [Parameter(ValueFromPipeline=$true,Mandatory=$true,Position=1)] [string] $InputObject,
224
- [Parameter(Mandatory=$true)] [Type] $Type,
225
- [Switch] $ForceType
226
- )
227
- PROCESS {
228
- $null = $PSBoundParameters.Remove("InputObject")
229
- [Xml.XmlElement]$xml = (Convert-JsonToXml $InputObject).Root
230
- if ($xml) {
231
- if ($xml.Objects) {
232
- $xml.Objects.Item.GetEnumerator() | ConvertFrom-Xml @PSBoundParameters
233
- } elseif ($xml.Item -and $xml.Item -isnot [System.Management.Automation.PSParameterizedProperty]) {
234
- $xml.Item | ConvertFrom-Xml @PSBoundParameters
235
- } else {
236
- $xml | ConvertFrom-Xml @PSBoundParameters
237
- }
238
- } else {
239
- Write-Error "Failed to parse JSON with JsonReader" -Debug:$false
240
- }
241
- }
242
- }
243
-
244
137
  function ConvertFrom-PSCustomObject
245
138
  {
246
139
  PARAM([Parameter(ValueFromPipeline = $true)] $InputObject)
247
140
  PROCESS {
248
141
  if ($null -eq $InputObject) { return $null }
249
-
250
142
  if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) {
251
143
  $collection = @(
252
144
  foreach ($object in $InputObject) { ConvertFrom-PSCustomObject $object }
@@ -274,12 +166,7 @@ module Bolt
274
166
  [Parameter(Mandatory = $false)] [Text.Encoding] $Encoding = [Text.Encoding]::UTF8
275
167
  )
276
168
 
277
- # using polyfill cmdlet on PS2, so pass type info
278
- if ($PSVersionTable.PSVersion -lt [Version]'3.0') {
279
- $Text | ConvertFrom-Json2 -Type PSObject | ConvertFrom-PSCustomObject
280
- } else {
281
- $Text | ConvertFrom-Json | ConvertFrom-PSCustomObject
282
- }
169
+ $Text | ConvertFrom-Json | ConvertFrom-PSCustomObject
283
170
  }
284
171
  PS
285
172
  end
data/lib/bolt/task.rb CHANGED
@@ -149,7 +149,7 @@ module Bolt
149
149
  if unknown_keys.any?
150
150
  msg = "Metadata for task '#{@name}' contains unknown keys: #{unknown_keys.join(', ')}."
151
151
  msg += " This could be a typo in the task metadata or may result in incorrect behavior."
152
- @logger.warn(msg)
152
+ Bolt::Logger.warn("unknown_task_metadata_keys", msg)
153
153
  end
154
154
  end
155
155
  end
@@ -74,15 +74,6 @@ module Bolt
74
74
  interpreters[Pathname(executable).extname] if interpreters
75
75
  end
76
76
 
77
- # Transform a parameter map to an environment variable map, with parameter names prefixed
78
- # with 'PT_' and values transformed to JSON unless they're strings.
79
- def envify_params(params)
80
- params.each_with_object({}) do |(k, v), h|
81
- v = v.to_json unless v.is_a?(String)
82
- h["PT_#{k}"] = v
83
- end
84
- end
85
-
86
77
  # Raises an error if more than one target was given in the batch.
87
78
  #
88
79
  # The default implementations of batch_* strictly assume the transport is
@@ -6,7 +6,7 @@ require 'bolt/transport/base'
6
6
 
7
7
  module Bolt
8
8
  module Transport
9
- class Docker < Base
9
+ class Docker < Simple
10
10
  def provided_features
11
11
  ['shell']
12
12
  end
@@ -16,130 +16,6 @@ module Bolt
16
16
  conn.connect
17
17
  yield conn
18
18
  end
19
-
20
- def upload(target, source, destination, _options = {})
21
- with_connection(target) do |conn|
22
- conn.with_remote_tmpdir do |dir|
23
- basename = File.basename(source)
24
- tmpfile = "#{dir}/#{basename}"
25
- if File.directory?(source)
26
- conn.write_remote_directory(source, tmpfile)
27
- else
28
- conn.write_remote_file(source, tmpfile)
29
- end
30
-
31
- _, stderr, exitcode = conn.execute('mv', tmpfile, destination, {})
32
- if exitcode != 0
33
- message = "Could not move temporary file '#{tmpfile}' to #{destination}: #{stderr}"
34
- raise Bolt::Node::FileError.new(message, 'MV_ERROR')
35
- end
36
- end
37
- Bolt::Result.for_upload(target, source, destination)
38
- end
39
- end
40
-
41
- def download(target, source, destination, _options = {})
42
- with_connection(target) do |conn|
43
- download = File.join(destination, Bolt::Util.unix_basename(source))
44
- conn.download_remote_content(source, destination)
45
- Bolt::Result.for_download(target, source, destination, download)
46
- end
47
- end
48
-
49
- def run_command(target, command, options = {}, position = [])
50
- execute_options = {}
51
- execute_options[:tty] = target.options['tty']
52
- execute_options[:environment] = options[:env_vars]
53
-
54
- if target.options['shell-command'] && !target.options['shell-command'].empty?
55
- # escape any double quotes in command
56
- command = command.gsub('"', '\"')
57
- command = "#{target.options['shell-command']} \" #{command}\""
58
- end
59
- with_connection(target) do |conn|
60
- stdout, stderr, exitcode = conn.execute(*Shellwords.split(command), execute_options)
61
- Bolt::Result.for_command(target,
62
- stdout,
63
- stderr,
64
- exitcode,
65
- 'command',
66
- command,
67
- position)
68
- end
69
- end
70
-
71
- def run_script(target, script, arguments, options = {}, position = [])
72
- # unpack any Sensitive data
73
- arguments = unwrap_sensitive_args(arguments)
74
- execute_options = {}
75
- execute_options[:environment] = options[:env_vars]
76
-
77
- with_connection(target) do |conn|
78
- conn.with_remote_tmpdir do |dir|
79
- remote_path = conn.write_remote_executable(dir, script)
80
- stdout, stderr, exitcode = conn.execute(remote_path, *arguments, execute_options)
81
- Bolt::Result.for_command(target,
82
- stdout,
83
- stderr,
84
- exitcode,
85
- 'script',
86
- script,
87
- position)
88
- end
89
- end
90
- end
91
-
92
- def run_task(target, task, arguments, _options = {}, position = [])
93
- implementation = task.select_implementation(target, provided_features)
94
- executable = implementation['path']
95
- input_method = implementation['input_method']
96
- extra_files = implementation['files']
97
- input_method ||= 'both'
98
-
99
- # unpack any Sensitive data
100
- arguments = unwrap_sensitive_args(arguments)
101
- with_connection(target) do |conn|
102
- execute_options = {}
103
- execute_options[:interpreter] = select_interpreter(executable, target.options['interpreters'])
104
- conn.with_remote_tmpdir do |dir|
105
- if extra_files.empty?
106
- task_dir = dir
107
- else
108
- # TODO: optimize upload of directories
109
- arguments['_installdir'] = dir
110
- task_dir = File.join(dir, task.tasks_dir)
111
- conn.mkdirs([task_dir] + extra_files.map { |file| File.join(dir, File.dirname(file['name'])) })
112
- extra_files.each do |file|
113
- conn.write_remote_file(file['path'], File.join(dir, file['name']))
114
- end
115
- end
116
-
117
- remote_task_path = conn.write_remote_executable(task_dir, executable)
118
-
119
- if Bolt::Task::STDIN_METHODS.include?(input_method)
120
- execute_options[:stdin] = StringIO.new(JSON.dump(arguments))
121
- end
122
-
123
- if Bolt::Task::ENVIRONMENT_METHODS.include?(input_method)
124
- execute_options[:environment] = envify_params(arguments)
125
- end
126
-
127
- stdout, stderr, exitcode = conn.execute(remote_task_path, execute_options)
128
- Bolt::Result.for_task(target,
129
- stdout,
130
- stderr,
131
- exitcode,
132
- task.name,
133
- position)
134
- end
135
- end
136
- end
137
-
138
- def connected?(target)
139
- with_connection(target) { true }
140
- rescue Bolt::Node::ConnectError
141
- false
142
- end
143
19
  end
144
20
  end
145
21
  end
@@ -5,227 +5,152 @@ require 'bolt/node/errors'
5
5
 
6
6
  module Bolt
7
7
  module Transport
8
- class Docker < Base
8
+ class Docker < Simple
9
9
  class Connection
10
+ attr_reader :user, :target
11
+
10
12
  def initialize(target)
11
13
  raise Bolt::ValidationError, "Target #{target.safe_name} does not have a host" unless target.host
12
14
  @target = target
15
+ @user = ENV['USER'] || Etc.getlogin
13
16
  @logger = Bolt::Logger.logger(target.safe_name)
14
- @docker_host = @target.options['service-url']
15
- @logger.trace("Initializing docker connection to #{@target.safe_name}")
17
+ @container_info = {}
18
+ @docker_host = target.options['service-url']
19
+ @logger.trace("Initializing docker connection to #{target.safe_name}")
20
+ end
21
+
22
+ def shell
23
+ @shell ||= if Bolt::Util.windows?
24
+ Bolt::Shell::Powershell.new(target, self)
25
+ else
26
+ Bolt::Shell::Bash.new(target, self)
27
+ end
28
+ end
29
+
30
+ # The full ID of the target container
31
+ #
32
+ # @return [String] The full ID of the target container
33
+ def container_id
34
+ @container_info["Id"]
16
35
  end
17
36
 
18
37
  def connect
19
38
  # We don't actually have a connection, but we do need to
20
39
  # check that the container exists and is running.
21
- output = execute_local_docker_json_command('ps')
22
- index = output.find_index { |item| item["ID"] == @target.host || item["Names"] == @target.host }
23
- raise "Could not find a container with name or ID matching '#{@target.host}'" if index.nil?
40
+ output = execute_local_json_command('ps')
41
+ index = output.find_index { |item| item["ID"] == target.host || item["Names"] == target.host }
42
+ raise "Could not find a container with name or ID matching '#{target.host}'" if index.nil?
24
43
  # Now find the indepth container information
25
- output = execute_local_docker_json_command('inspect', [output[index]["ID"]])
44
+ output = execute_local_json_command('inspect', [output[index]["ID"]])
26
45
  # Store the container information for later
27
46
  @container_info = output[0]
28
47
  @logger.trace { "Opened session" }
29
48
  true
30
49
  rescue StandardError => e
31
50
  raise Bolt::Node::ConnectError.new(
32
- "Failed to connect to #{@target.safe_name}: #{e.message}",
51
+ "Failed to connect to #{target.safe_name}: #{e.message}",
33
52
  'CONNECT_ERROR'
34
53
  )
35
54
  end
36
55
 
37
- # Executes a command inside the target container
38
- #
39
- # @param command [Array] The command to run, expressed as an array of strings
40
- # @param options [Hash] command specific options
41
- # @option opts [String] :interpreter statements that are prefixed to the command e.g `/bin/bash` or `cmd.exe /c`
42
- # @option opts [Hash] :environment A hash of environment variables that will be injected into the command
43
- # @option opts [IO] :stdin An IO object that will be used to redirect STDIN for the docker command
44
- def execute(*command, options)
45
- command.unshift(options[:interpreter]) if options[:interpreter]
46
- # Build the `--env` parameters
47
- envs = []
48
- if options[:environment]
49
- options[:environment].each { |env, val| envs.concat(['--env', "#{env}=#{val}"]) }
56
+ 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]}"
50
60
  end
61
+ end
51
62
 
52
- command_options = []
53
- # Need to be interactive if redirecting STDIN
54
- command_options << '--interactive' unless options[:stdin].nil?
55
- command_options << '--tty' if options[:tty]
56
- command_options.concat(envs) unless envs.empty?
57
- command_options << container_id
58
- command_options.concat(command)
59
-
60
- @logger.trace { "Executing: exec #{command_options}" }
63
+ # Executes a command inside the target container. This is called from the shell class.
64
+ #
65
+ # @param command [string] The command to run
66
+ def execute(command)
67
+ args = []
68
+ # CODEREVIEW: Is it always safe to pass --interactive?
69
+ args += %w[--interactive]
70
+ args += %w[--tty] if target.options['tty']
71
+ args += %W[--env DOCKER_HOST=#{@docker_host}] if @docker_host
72
+ args += @env_vars if @env_vars
73
+
74
+ if target.options['shell-command'] && !target.options['shell-command'].empty?
75
+ # escape any double quotes in command
76
+ command = command.gsub('"', '\"')
77
+ command = "#{target.options['shell-command']} \"#{command}\""
78
+ end
61
79
 
62
- stdout_str, stderr_str, status = execute_local_docker_command('exec', command_options, options[:stdin])
80
+ docker_command = %w[docker exec] + args + [container_id] + Shellwords.split(command)
81
+ @logger.trace { "Executing: #{docker_command.join(' ')}" }
63
82
 
64
- # The actual result is the exitstatus not the process object
65
- status = status.nil? ? -32768 : status.exitstatus
66
- if status == 0
67
- @logger.trace { "Command returned successfully" }
68
- else
69
- @logger.trace { "Command failed with exit code #{status}" }
70
- end
71
- stdout_str.force_encoding(Encoding::UTF_8)
72
- stderr_str.force_encoding(Encoding::UTF_8)
73
- # Normalise line endings
74
- stdout_str.gsub!("\r\n", "\n")
75
- stderr_str.gsub!("\r\n", "\n")
76
- [stdout_str, stderr_str, status]
83
+ Open3.popen3(*docker_command)
77
84
  rescue StandardError
78
85
  @logger.trace { "Command aborted" }
79
86
  raise
80
87
  end
81
88
 
82
- def write_remote_file(source, destination)
83
- @logger.trace { "Uploading #{source} to #{destination}" }
84
- _, stdout_str, status = execute_local_docker_command('cp', [source, "#{container_id}:#{destination}"])
85
- unless status.exitstatus.zero?
86
- raise "Error writing file to container #{@container_id}: #{stdout_str}"
87
- end
88
- rescue StandardError => e
89
- raise Bolt::Node::FileError.new(e.message, 'WRITE_ERROR')
90
- end
91
-
92
- def write_remote_directory(source, destination)
89
+ def upload_file(source, destination)
93
90
  @logger.trace { "Uploading #{source} to #{destination}" }
94
- _, stdout_str, status = execute_local_docker_command('cp', [source, "#{container_id}:#{destination}"])
91
+ _stdout, stderr, status = execute_local_command('cp', [source, "#{container_id}:#{destination}"])
95
92
  unless status.exitstatus.zero?
96
- raise "Error writing directory to container #{@container_id}: #{stdout_str}"
93
+ raise "Error writing to container #{container_id}: #{stderr}"
97
94
  end
98
95
  rescue StandardError => e
99
96
  raise Bolt::Node::FileError.new(e.message, 'WRITE_ERROR')
100
97
  end
101
98
 
102
- def download_remote_content(source, destination)
99
+ def download_file(source, destination, _download)
103
100
  @logger.trace { "Downloading #{source} to #{destination}" }
104
101
  # Create the destination directory, otherwise copying a source directory with Docker will
105
102
  # copy the *contents* of the directory.
106
103
  # https://docs.docker.com/engine/reference/commandline/cp/
107
104
  FileUtils.mkdir_p(destination)
108
- _, stdout_str, status = execute_local_docker_command('cp', ["#{container_id}:#{source}", destination])
105
+ _stdout, stderr, status = execute_local_command('cp', ["#{container_id}:#{source}", destination])
109
106
  unless status.exitstatus.zero?
110
- raise "Error downloading content from container #{@container_id}: #{stdout_str}"
107
+ raise "Error downloading content from container #{container_id}: #{stderr}"
111
108
  end
112
109
  rescue StandardError => e
113
110
  raise Bolt::Node::FileError.new(e.message, 'WRITE_ERROR')
114
111
  end
115
112
 
116
- def mkdirs(dirs)
117
- _, stderr, exitcode = execute('mkdir', '-p', *dirs, {})
118
- if exitcode != 0
119
- message = "Could not create directories: #{stderr}"
120
- raise Bolt::Node::FileError.new(message, 'MKDIR_ERROR')
121
- end
122
- end
123
-
124
- def make_tmpdir
125
- tmpdir = @target.options.fetch('tmpdir', container_tmpdir)
126
- tmppath = "#{tmpdir}/#{SecureRandom.uuid}"
127
-
128
- stdout, stderr, exitcode = execute('mkdir', '-m', '700', tmppath, {})
129
- if exitcode != 0
130
- raise Bolt::Node::FileError.new("Could not make tmpdir: #{stderr}", 'TMPDIR_ERROR')
131
- end
132
- tmppath || stdout.first
133
- end
134
-
135
- def with_remote_tmpdir
136
- dir = make_tmpdir
137
- yield dir
138
- ensure
139
- if dir
140
- if @target.options['cleanup']
141
- _, stderr, exitcode = execute('rm', '-rf', dir, {})
142
- if exitcode != 0
143
- @logger.warn("Failed to clean up tmpdir '#{dir}': #{stderr}")
144
- end
145
- else
146
- @logger.warn("Skipping cleanup of tmpdir '#{dir}'")
147
- end
148
- end
149
- end
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)
150
126
 
151
- def write_remote_executable(dir, file, filename = nil)
152
- filename ||= File.basename(file)
153
- remote_path = File.join(dir.to_s, filename)
154
- write_remote_file(file, remote_path)
155
- make_executable(remote_path)
156
- remote_path
127
+ Open3.capture3(env_hash, 'docker', *docker_command, { binmode: true })
157
128
  end
158
129
 
159
- def make_executable(path)
160
- _, stderr, exitcode = execute('chmod', 'u+x', path, {})
161
- if exitcode != 0
162
- message = "Could not make file '#{path}' executable: #{stderr}"
163
- raise Bolt::Node::FileError.new(message, 'CHMOD_ERROR')
164
- end
130
+ # Executes a Docker CLI command and parses the output in JSON format
131
+ #
132
+ # @param subcommand [String] The docker subcommand to run
133
+ # e.g. 'inspect' for `docker inspect`
134
+ # @param arguments [Array] Arguments to pass to the docker command
135
+ # e.g. 'src' and 'dest' for `docker cp <src> <dest>
136
+ # @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)
165
141
  end
166
142
 
167
- private
168
-
169
143
  # Converts the JSON encoded STDOUT string from the docker cli into ruby objects
170
144
  #
171
145
  # @param stdout_string [String] The string to convert
172
146
  # @return [Object] Ruby object representation of the JSON string
173
- def extract_json(stdout_string)
147
+ private def extract_json(stdout)
174
148
  # The output from the docker format command is a JSON string per line.
175
149
  # We can't do a direct convert but this helper method will convert it into
176
150
  # an array of Objects
177
- stdout_string.split("\n")
178
- .reject { |str| str.strip.empty? }
179
- .map { |str| JSON.parse(str) }
180
- end
181
-
182
- # rubocop:disable Layout/LineLength
183
- # Executes a Docker CLI command
184
- #
185
- # @param subcommand [String] The docker subcommand to run e.g. 'inspect' for `docker inspect`
186
- # @param command_options [Array] Additional command options e.g. ['--size'] for `docker inspect --size`
187
- # @param redir_stdin [IO] IO object which will be use to as STDIN in the docker command. Default is nil, which does not perform redirection
188
- # @return [String, String, Process::Status] The output of the command: STDOUT, STDERR, Process Status
189
- # rubocop:enable Layout/LineLength
190
- def execute_local_docker_command(subcommand, command_options = [], redir_stdin = nil)
191
- env_hash = {}
192
- # Set the DOCKER_HOST if we are using a non-default service-url
193
- env_hash['DOCKER_HOST'] = @docker_host unless @docker_host.nil?
194
-
195
- command_options = [] if command_options.nil?
196
- docker_command = [subcommand].concat(command_options)
197
-
198
- # Always use binary mode for any text data
199
- capture_options = { binmode: true }
200
- capture_options[:stdin_data] = redir_stdin unless redir_stdin.nil?
201
- stdout_str, stderr_str, status = Open3.capture3(env_hash, 'docker', *docker_command, capture_options)
202
- [stdout_str, stderr_str, status]
203
- end
204
-
205
- # Executes a Docker CLI command and parses the output in JSON format
206
- #
207
- # @param subcommand [String] The docker subcommand to run e.g. 'inspect' for `docker inspect`
208
- # @param command_options [Array] Additional command options e.g. ['--size'] for `docker inspect --size`
209
- # @return [Object] Ruby object representation of the JSON string
210
- def execute_local_docker_json_command(subcommand, command_options = [])
211
- command_options = [] if command_options.nil?
212
- command_options = ['--format', '{{json .}}'].concat(command_options)
213
- stdout_str, _stderr_str, _status = execute_local_docker_command(subcommand, command_options)
214
- extract_json(stdout_str)
215
- end
216
-
217
- # The full ID of the target container
218
- #
219
- # @return [String] The full ID of the target container
220
- def container_id
221
- @container_info["Id"]
222
- end
223
-
224
- # The temp path inside the target container
225
- #
226
- # @return [String] The absolute path to the temp directory
227
- def container_tmpdir
228
- '/tmp'
151
+ stdout.split("\n")
152
+ .reject { |str| str.strip.empty? }
153
+ .map { |str| JSON.parse(str) }
229
154
  end
230
155
  end
231
156
  end