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.
- checksums.yaml +4 -4
- data/Gemfile +6 -4
- data/lib/kitchen/cli.rb +33 -8
- data/lib/kitchen/command/list.rb +49 -9
- data/lib/kitchen/command/logs.rb +185 -0
- data/lib/kitchen/command/test.rb +2 -1
- data/lib/kitchen/command.rb +22 -9
- data/lib/kitchen/config.rb +8 -0
- data/lib/kitchen/data_munger.rb +47 -12
- data/lib/kitchen/driver/base.rb +19 -0
- data/lib/kitchen/driver/dummy.rb +27 -0
- data/lib/kitchen/errors.rb +1 -0
- data/lib/kitchen/instance.rb +251 -88
- data/lib/kitchen/lifecycle_hook/remote.rb +5 -2
- data/lib/kitchen/logger.rb +407 -74
- data/lib/kitchen/logging.rb +2 -2
- data/lib/kitchen/provisioner/base.rb +27 -17
- data/lib/kitchen/provisioner/external.rb +420 -0
- data/lib/kitchen/shell_out.rb +1 -1
- data/lib/kitchen/state_file.rb +12 -2
- data/lib/kitchen/transport/base.rb +1 -1
- data/lib/kitchen/transport/ssh.rb +5 -5
- data/lib/kitchen/verifier/base.rb +17 -5
- data/lib/kitchen/version.rb +1 -1
- data/lib/kitchen.rb +21 -2
- data/test-kitchen.gemspec +1 -1
- metadata +12 -4
|
@@ -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
|
data/lib/kitchen/shell_out.rb
CHANGED
|
@@ -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
|
|
71
|
+
rescue ::StandardError => error
|
|
72
72
|
error.extend(Kitchen::Error)
|
|
73
73
|
raise
|
|
74
74
|
end
|
data/lib/kitchen/state_file.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
220
|
-
"SCP download failed for file or directory '#{file}'
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
data/lib/kitchen/version.rb
CHANGED
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(
|
|
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", "
|
|
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.
|
|
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-
|
|
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
|