test-kitchen 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,420 @@
1
+ #
2
+ # Copyright:: (C) 2026
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # https://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ require "json" unless defined?(JSON)
17
+ require "open3"
18
+ require "rbconfig" unless defined?(RbConfig)
19
+ require "shellwords" unless defined?(Shellwords)
20
+
21
+ require_relative "base"
22
+ require_relative "../version"
23
+
24
+ module Kitchen
25
+ module Provisioner
26
+ # Executes an external provisioner provider over the v1 stdio protocol.
27
+ class External < Base
28
+ PROTOCOL_VERSION = "1.0".freeze
29
+ RESULT_STATUSES = %w{passed failed skipped}.freeze
30
+ LOG_LEVELS = %w{debug info warn error}.freeze
31
+ INTERNAL_CONFIG_KEYS = %i{
32
+ command provider pass_env kitchen_root test_base_path
33
+ }.freeze
34
+ REDACTED_KEYS = %w{password ssh_http_proxy_password}.freeze
35
+
36
+ kitchen_provisioner_api_version 2
37
+
38
+ plugin_version Kitchen::VERSION
39
+
40
+ required_config :command
41
+
42
+ default_config :provider, &:provider_name_from_command
43
+ default_config :pass_env, []
44
+
45
+ # (see Base#call)
46
+ def call(state)
47
+ verify_capabilities
48
+ validate_provider(state)
49
+ result = run_provider(state)
50
+ persist_provider_state(state, result)
51
+ end
52
+
53
+ def provider_name_from_command
54
+ first_part = Shellwords.split(config[:command].to_s).first.to_s
55
+ File.basename(first_part).sub(/^kitchen-provider-/, "")
56
+ end
57
+
58
+ private
59
+
60
+ def verify_capabilities
61
+ response = invoke_json("capabilities", {})
62
+ protocol_version = response.fetch("protocol_version", nil)
63
+ unless protocol_version == PROTOCOL_VERSION
64
+ raise_protocol_error(
65
+ "Unsupported external provider protocol version #{protocol_version.inspect}"
66
+ )
67
+ end
68
+
69
+ commands = Array(response["commands"])
70
+ missing_commands = %w{validate run} - commands
71
+ unless missing_commands.empty?
72
+ raise_protocol_error(
73
+ "External provider does not support required command(s): #{missing_commands.join(", ")}"
74
+ )
75
+ end
76
+
77
+ transports = Array(response["supported_transports"])
78
+ transport_type = transport_component({}).fetch(:type)
79
+ return if transports.include?(transport_type)
80
+
81
+ raise_protocol_error(
82
+ "External provider does not support #{transport_type.inspect} transport"
83
+ )
84
+ end
85
+
86
+ def validate_provider(state)
87
+ response = invoke_json("validate", request_envelope("validate", state))
88
+ require_protocol_version(response, "validation result")
89
+
90
+ return if response["status"] == "ok"
91
+
92
+ messages = Array(response["errors"]).map { |error| error["message"] }.compact
93
+ message = messages.empty? ? "external provider validation failed" : messages.join("; ")
94
+ raise ActionFailed, redact(message)
95
+ end
96
+
97
+ def run_provider(state)
98
+ stdout, stderr, status = invoke("run", request_envelope("run", state))
99
+ result = nil
100
+
101
+ stdout.each_line.with_index(1) do |line, index|
102
+ next if line.strip.empty?
103
+
104
+ event = parse_event(line, index)
105
+ require_protocol_version(event, "event")
106
+
107
+ case event["type"]
108
+ when "log"
109
+ handle_log_event(event)
110
+ when "progress"
111
+ handle_progress_event(event)
112
+ when "result"
113
+ result = event
114
+ else
115
+ raise_protocol_error("unknown external provider event type #{event["type"].inspect}")
116
+ end
117
+ end
118
+
119
+ unless status.success?
120
+ raise_protocol_error("external provider exited with #{status.exitstatus}", stderr)
121
+ end
122
+ raise_protocol_error("external provider did not emit a final result") if result.nil?
123
+
124
+ handle_result_event(result)
125
+ result
126
+ end
127
+
128
+ def handle_log_event(event)
129
+ level = event["level"]
130
+ raise_protocol_error("external provider log event has invalid level #{level.inspect}") unless LOG_LEVELS.include?(level)
131
+
132
+ logger.public_send(level, redact(event.fetch("message", "")))
133
+ end
134
+
135
+ def handle_progress_event(event)
136
+ stage = event.fetch("stage", "provider")
137
+ message = event.fetch("message", "")
138
+ info(redact("[#{stage}] #{message}"))
139
+ end
140
+
141
+ def handle_result_event(event)
142
+ status = event["status"]
143
+ unless RESULT_STATUSES.include?(status)
144
+ raise_protocol_error("external provider result has invalid status #{status.inspect}")
145
+ end
146
+
147
+ return if %w{passed skipped}.include?(status)
148
+
149
+ message = event["message"] || "external provider reported #{status}"
150
+ raise ActionFailed, redact(message)
151
+ end
152
+
153
+ def persist_provider_state(state, result)
154
+ provider_state = result.dig("state", "provider")
155
+ return if provider_state.nil?
156
+
157
+ state[:providers] ||= {}
158
+ state[:providers][config[:provider]] = provider_state
159
+ end
160
+
161
+ def invoke_json(operation, request)
162
+ stdout, stderr, status = invoke(operation, request)
163
+ unless status.success?
164
+ raise_protocol_error("external provider #{operation} exited with #{status.exitstatus}", stderr)
165
+ end
166
+
167
+ JSON.parse(stdout)
168
+ rescue JSON::ParserError => error
169
+ raise_protocol_error("malformed external provider #{operation} response: #{error.message}")
170
+ end
171
+
172
+ def invoke(operation, request)
173
+ Open3.capture3(
174
+ provider_environment,
175
+ *provider_command_parts,
176
+ operation,
177
+ stdin_data: JSON.generate(request)
178
+ )
179
+ rescue Errno::ENOENT => error
180
+ raise ActionFailed, redact("external provider command failed: #{error.message}")
181
+ end
182
+
183
+ def provider_command_parts
184
+ parts = Shellwords.split(config[:command].to_s)
185
+ raise ActionFailed, "external provider command cannot be blank" if parts.empty?
186
+
187
+ parts
188
+ end
189
+
190
+ def provider_environment
191
+ pass_env.each_with_object({}) do |name, env|
192
+ env[name] = ENV[name] if ENV.key?(name)
193
+ end
194
+ end
195
+
196
+ def request_envelope(operation, state)
197
+ {
198
+ protocol_version: PROTOCOL_VERSION,
199
+ operation: operation,
200
+ kitchen: kitchen_context,
201
+ instance: instance_context(state),
202
+ platform: platform_context,
203
+ suite: suite_context,
204
+ components: components_context(state),
205
+ executor: executor_context,
206
+ environment: environment_context,
207
+ state: state_context(state),
208
+ }
209
+ end
210
+
211
+ def kitchen_context
212
+ {
213
+ version: Kitchen::VERSION,
214
+ root_path: File.expand_path(config[:kitchen_root] || Dir.pwd),
215
+ }
216
+ end
217
+
218
+ def instance_context(state)
219
+ {
220
+ name: instance.name,
221
+ last_action: state[:last_action],
222
+ last_attempted_action: state[:last_attempted_action],
223
+ }
224
+ end
225
+
226
+ def platform_context
227
+ {
228
+ name: component_name(instance.platform),
229
+ os_type: component_attr(instance.platform, :os_type),
230
+ shell_type: component_attr(instance.platform, :shell_type),
231
+ }
232
+ end
233
+
234
+ def suite_context
235
+ { name: instance.suite.name }
236
+ end
237
+
238
+ def components_context(state)
239
+ {
240
+ driver: resolved_component(instance.driver, {}),
241
+ transport: transport_component(state),
242
+ provisioner: {
243
+ name: "external",
244
+ provider: config[:provider],
245
+ command: config[:command],
246
+ config: provider_config,
247
+ },
248
+ verifier: resolved_component(instance.verifier, {}),
249
+ }
250
+ end
251
+
252
+ def resolved_component(component, state)
253
+ {
254
+ name: component_name(component),
255
+ config: {},
256
+ state: state,
257
+ }
258
+ end
259
+
260
+ def transport_component(state)
261
+ name = component_name(instance.transport)
262
+ type = transport_type(name)
263
+ base = {
264
+ name: name,
265
+ type: type,
266
+ config: {},
267
+ }
268
+
269
+ case type
270
+ when "exec"
271
+ base.merge(
272
+ target: { kind: "local", platform: component_attr(instance.platform, :name) },
273
+ config: {
274
+ kitchen_root: File.expand_path(config[:kitchen_root] || Dir.pwd),
275
+ host_os: RbConfig::CONFIG["host_os"],
276
+ }
277
+ )
278
+ when "ssh"
279
+ base.merge(
280
+ target: machine_target(state),
281
+ config: compact_hash(
282
+ username: component_config(:username, state, "root"),
283
+ port: component_config(:port, state, 22),
284
+ connection_timeout: component_config(:connection_timeout, state),
285
+ connection_retries: component_config(:connection_retries, state),
286
+ connection_retry_sleep: component_config(:connection_retry_sleep, state),
287
+ proxy_command: component_config(:ssh_proxy_command, state),
288
+ gateway: component_config(:ssh_gateway, state),
289
+ gateway_port: component_config(:ssh_gateway_port, state),
290
+ gateway_username: component_config(:ssh_gateway_username, state)
291
+ )
292
+ )
293
+ when "winrm"
294
+ base.merge(
295
+ target: machine_target(state),
296
+ config: compact_hash(
297
+ username: component_config(:username, state, "administrator"),
298
+ port: component_config(:port, state, 5985),
299
+ scheme: component_config(:scheme, state, "http"),
300
+ transport: component_config(:winrm_transport, state, "negotiate").to_s,
301
+ elevated: component_config(:elevated, state),
302
+ operation_timeout: component_config(:operation_timeout, state),
303
+ receive_timeout: component_config(:receive_timeout, state),
304
+ connection_retries: component_config(:connection_retries, state),
305
+ connection_retry_sleep: component_config(:connection_retry_sleep, state)
306
+ )
307
+ )
308
+ else
309
+ base.merge(target: { kind: "custom" })
310
+ end
311
+ end
312
+
313
+ def transport_type(name)
314
+ case name.to_s
315
+ when "ssh", "winrm", "exec", "docker_exec"
316
+ name.to_s
317
+ else
318
+ "custom"
319
+ end
320
+ end
321
+
322
+ def executor_context
323
+ {
324
+ model: "exec",
325
+ command: config[:command],
326
+ input: "stdin",
327
+ request_file_argument: nil,
328
+ event_stream: "ndjson",
329
+ }
330
+ end
331
+
332
+ def environment_context
333
+ passed = pass_env.select { |name| ENV.key?(name) }
334
+ {
335
+ passed: passed,
336
+ user_allowed: pass_env,
337
+ kitchen_reserved: [],
338
+ }
339
+ end
340
+
341
+ def state_context(state)
342
+ providers = state.fetch(:providers, {})
343
+ { provider: providers.fetch(config[:provider], {}) }
344
+ end
345
+
346
+ def machine_target(state)
347
+ {
348
+ kind: "machine",
349
+ hostname: state.fetch(:hostname, state.fetch("hostname", "unknown")),
350
+ platform: component_attr(instance.platform, :name),
351
+ }
352
+ end
353
+
354
+ def component_config(key, state, default = nil)
355
+ return state[key] if state.key?(key)
356
+ return state[key.to_s] if state.key?(key.to_s)
357
+ return instance.transport[key] if instance.transport.respond_to?(:[]) && !instance.transport[key].nil?
358
+
359
+ default
360
+ end
361
+
362
+ def compact_hash(hash)
363
+ hash.compact
364
+ end
365
+
366
+ def provider_config
367
+ config.each_with_object({}) do |(key, value), result|
368
+ next if INTERNAL_CONFIG_KEYS.include?(key)
369
+
370
+ result[key.to_s] = value
371
+ end
372
+ end
373
+
374
+ def pass_env
375
+ Array(config[:pass_env]).map(&:to_s)
376
+ end
377
+
378
+ def component_name(component)
379
+ return component.name if component.respond_to?(:name)
380
+
381
+ component.class.name.split("::").last.downcase
382
+ end
383
+
384
+ def component_attr(component, attr)
385
+ component.public_send(attr) if component.respond_to?(attr)
386
+ end
387
+
388
+ def parse_event(line, index)
389
+ JSON.parse(line)
390
+ rescue JSON::ParserError => error
391
+ raise_protocol_error(
392
+ "malformed external provider event at line #{index}: #{error.message}"
393
+ )
394
+ end
395
+
396
+ def require_protocol_version(payload, payload_type)
397
+ return if payload["protocol_version"] == PROTOCOL_VERSION
398
+
399
+ raise_protocol_error(
400
+ "external provider #{payload_type} used unsupported protocol version #{payload["protocol_version"].inspect}"
401
+ )
402
+ end
403
+
404
+ def raise_protocol_error(message, stderr = nil)
405
+ details = [message]
406
+ details << stderr.to_s.strip unless stderr.to_s.strip.empty?
407
+ raise ActionFailed, redact(details.join(": "))
408
+ end
409
+
410
+ def redact(message)
411
+ redacted = Util.mask_values(message.to_s.dup, REDACTED_KEYS)
412
+ pass_env.each do |name|
413
+ value = ENV[name].to_s
414
+ redacted.gsub!(value, "******") unless value.empty?
415
+ end
416
+ redacted
417
+ end
418
+ end
419
+ end
420
+ end
@@ -68,7 +68,7 @@ module Kitchen
68
68
  sh.stdout
69
69
  rescue Mixlib::ShellOut::ShellCommandFailed => ex
70
70
  raise ShellCommandFailed, ex.message
71
- rescue Exception => error # rubocop:disable Lint/RescueException
71
+ rescue ::StandardError => error
72
72
  error.extend(Kitchen::Error)
73
73
  raise
74
74
  end
@@ -76,6 +76,13 @@ module Kitchen
76
76
  result
77
77
  end
78
78
 
79
+ # Returns the absolute path to the state file.
80
+ #
81
+ # @return [String] absolute path to the state file on disk
82
+ def path
83
+ file_name
84
+ end
85
+
79
86
  private
80
87
 
81
88
  # @return [String] absolute path to the yaml state file on disk
@@ -95,8 +102,11 @@ module Kitchen
95
102
  # @raise [StateFileLoadError] if the string document cannot be parsed
96
103
  # @api private
97
104
  def deserialize_string(string)
98
- YAML.safe_load(string)
99
- rescue SyntaxError, Psych::SyntaxError, Psych::DisallowedClass => ex
105
+ result = YAML.safe_load(string)
106
+ return result if result.is_a?(Hash)
107
+
108
+ raise StateFileLoadError, "Error parsing #{file_name} (expected a Hash)"
109
+ rescue SyntaxError, Psych::Exception => ex
100
110
  raise StateFileLoadError, "Error parsing #{file_name} (#{ex.message})"
101
111
  end
102
112
 
@@ -128,7 +128,7 @@ module Kitchen
128
128
  tries += 1
129
129
  debug("Attempting to execute command - try #{tries} of #{max_retries}.")
130
130
  execute(command)
131
- rescue Exception => e
131
+ rescue ::StandardError => e
132
132
  if retry?(tries, max_retries, retryable_exit_codes, e)
133
133
  close
134
134
  sleep wait_time
@@ -211,14 +211,14 @@ module Kitchen
211
211
  Array(remotes).each do |file|
212
212
  logger.debug("Attempting to download '#{file}' as file")
213
213
  session.scp.download!(file, local)
214
- rescue Net::SCP::Error
214
+ rescue Net::SCP::Error => file_error
215
215
  begin
216
216
  logger.debug("Attempting to download '#{file}' as directory")
217
217
  session.scp.download!(file, local, recursive: true)
218
- rescue Net::SCP::Error
219
- logger.warn(
220
- "SCP download failed for file or directory '#{file}', perhaps it does not exist?"
221
- )
218
+ rescue Net::SCP::Error => directory_error
219
+ raise SshFailed,
220
+ "SCP download failed for file or directory '#{file}' " \
221
+ "(file: #{file_error.message}; directory: #{directory_error.message})"
222
222
  end
223
223
  end
224
224
  rescue Net::SSH::Exception => ex
@@ -77,12 +77,24 @@ module Kitchen
77
77
  begin
78
78
  conn.execute(run_command)
79
79
  ensure
80
- info("Downloading files from #{instance.to_str}")
81
- config[:downloads].to_h.each do |remotes, local|
82
- debug("Downloading #{Array(remotes).join(", ")} to #{local}")
83
- conn.download(remotes, local)
80
+ # Retrieval is best-effort: a file we could not fetch must not
81
+ # fail an otherwise successful verify, and must never replace
82
+ # an error already in flight from the run command. A transport
83
+ # that cannot download at all is a bug, not a missing file, so
84
+ # it still raises when there is no verify error to protect.
85
+ run_error = $!
86
+ begin
87
+ info("Downloading files from #{instance.to_str}")
88
+ config[:downloads].to_h.each do |remotes, local|
89
+ debug("Downloading #{Array(remotes).join(", ")} to #{local}")
90
+ conn.download(remotes, local)
91
+ end
92
+ debug("Download complete")
93
+ rescue => ex
94
+ raise unless run_error || ex.is_a?(Kitchen::Transport::TransportFailed)
95
+
96
+ warn("Failed to download files from #{instance.to_str}: #{ex.message}")
84
97
  end
85
- debug("Download complete")
86
98
  end
87
99
  end
88
100
  rescue Kitchen::Transport::TransportFailed => ex
@@ -16,5 +16,5 @@
16
16
  # limitations under the License.
17
17
 
18
18
  module Kitchen
19
- VERSION = "4.0.0".freeze
19
+ VERSION = "4.1.0".freeze
20
20
  end
data/lib/kitchen.rb CHANGED
@@ -16,6 +16,7 @@
16
16
  # limitations under the License.
17
17
 
18
18
  require "pathname" unless defined?(Pathname)
19
+ require "securerandom" unless defined?(SecureRandom)
19
20
  require_relative "kitchen/errors"
20
21
  require_relative "kitchen/logger"
21
22
  require_relative "kitchen/logging"
@@ -59,6 +60,9 @@ module Kitchen
59
60
  # @return [Mutex] a mutex used for Dir.chdir coordination
60
61
  attr_accessor :mutex_chdir
61
62
 
63
+ # @return [String] identifier for the current Kitchen process invocation
64
+ attr_writer :run_id
65
+
62
66
  # Returns the root path of the Kitchen gem source code.
63
67
  #
64
68
  # @return [Pathname] root path of gem
@@ -70,7 +74,11 @@ module Kitchen
70
74
  #
71
75
  # @return [Logger] a logger
72
76
  def default_logger
73
- Logger.new(stdout: $stdout, level: Util.to_logger_level(env_log))
77
+ Logger.new(
78
+ stdout: $stdout,
79
+ level: Util.to_logger_level(env_log),
80
+ metadata: { kitchen_run_id: run_id }
81
+ )
74
82
  end
75
83
 
76
84
  # Returns a default file logger which emits on standard output and to a
@@ -84,15 +92,26 @@ module Kitchen
84
92
  log_overwrite = log_overwrite.nil? ? env_log_overwrite : log_overwrite
85
93
  log_location = File.expand_path(File.join(DEFAULT_LOG_DIR, "kitchen.log"))
86
94
  log_location = log_location.to_s
95
+ structured_log_location = File.expand_path(File.join(DEFAULT_LOG_DIR, "kitchen.ndjson"))
96
+ structured_log_location = structured_log_location.to_s
87
97
 
88
98
  Logger.new(
89
99
  stdout: $stdout,
90
100
  logdev: log_location,
101
+ structured_logdev: structured_log_location,
91
102
  level: Util.to_logger_level(level),
92
- log_overwrite:
103
+ log_overwrite:,
104
+ metadata: { kitchen_run_id: run_id }
93
105
  )
94
106
  end
95
107
 
108
+ # Returns the current Kitchen run identifier.
109
+ #
110
+ # @return [String] identifier for the current Kitchen process invocation
111
+ def run_id
112
+ @run_id ||= SecureRandom.uuid
113
+ end
114
+
96
115
  # Returns whether or not standard output is associated with a terminal
97
116
  # device (tty).
98
117
  #
data/test-kitchen.gemspec CHANGED
@@ -27,7 +27,7 @@ Gem::Specification.new do |gem|
27
27
  gem.add_dependency "csv", "~> 3.3"
28
28
  gem.add_dependency "ed25519", "~> 1.3" # ed25519 ssh key support
29
29
  gem.add_dependency "irb", "~> 1.15"
30
- gem.add_dependency "mixlib-install", "~> 3.6"
30
+ gem.add_dependency "mixlib-install", ">= 3.6", "< 5.0" # allow Cinc's mixlib-install 4.x fork
31
31
  gem.add_dependency "mixlib-shellout", ">= 1.2", "< 4.0"
32
32
  gem.add_dependency "net-scp", ">= 1.1", "< 5.0" # pinning until we can confirm 4+ works
33
33
  gem.add_dependency "net-ssh", ">= 2.9", "< 8.0" # pinning until we can confirm 8+ works
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: test-kitchen
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.0.0
4
+ version: 4.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Fletcher Nichol
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-01-22 00:00:00.000000000 Z
11
+ date: 2026-07-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bcrypt_pbkdf
@@ -84,16 +84,22 @@ dependencies:
84
84
  name: mixlib-install
85
85
  requirement: !ruby/object:Gem::Requirement
86
86
  requirements:
87
- - - "~>"
87
+ - - ">="
88
88
  - !ruby/object:Gem::Version
89
89
  version: '3.6'
90
+ - - "<"
91
+ - !ruby/object:Gem::Version
92
+ version: '5.0'
90
93
  type: :runtime
91
94
  prerelease: false
92
95
  version_requirements: !ruby/object:Gem::Requirement
93
96
  requirements:
94
- - - "~>"
97
+ - - ">="
95
98
  - !ruby/object:Gem::Version
96
99
  version: '3.6'
100
+ - - "<"
101
+ - !ruby/object:Gem::Version
102
+ version: '5.0'
97
103
  - !ruby/object:Gem::Dependency
98
104
  name: mixlib-shellout
99
105
  requirement: !ruby/object:Gem::Requirement
@@ -309,6 +315,7 @@ files:
309
315
  - lib/kitchen/command/exec.rb
310
316
  - lib/kitchen/command/list.rb
311
317
  - lib/kitchen/command/login.rb
318
+ - lib/kitchen/command/logs.rb
312
319
  - lib/kitchen/command/package.rb
313
320
  - lib/kitchen/command/sink.rb
314
321
  - lib/kitchen/command/test.rb
@@ -341,6 +348,7 @@ files:
341
348
  - lib/kitchen/provisioner.rb
342
349
  - lib/kitchen/provisioner/base.rb
343
350
  - lib/kitchen/provisioner/dummy.rb
351
+ - lib/kitchen/provisioner/external.rb
344
352
  - lib/kitchen/provisioner/shell.rb
345
353
  - lib/kitchen/rake_tasks.rb
346
354
  - lib/kitchen/shell_out.rb