test-kitchen 4.1.3 → 4.1.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 35bb40be8e39ac171865c0bd23300293032f6558dbb62d788efcb1441dbbe66c
4
- data.tar.gz: d9d9be6360a468f2cd55958e37a21a1f0b1ffa4435a0eb53dc84dae07a219369
3
+ metadata.gz: c56fc990001b04aa4573652682c08b02ea00508480f76beb1db8ed72e8ade07f
4
+ data.tar.gz: 575f723e4cd3105fdd6e3ec4961f3bcfaa08ab8d2d38bc434dab095c3e9f193c
5
5
  SHA512:
6
- metadata.gz: 75dce025d9f85c60bb7b07cbc30f809d864eb2cdbf562557e9511376c050ae1b41f488ed3640dddf11c4664fa91ab5a048a7e5d434d0c1172195eaa055289276
7
- data.tar.gz: dda50545d44c4c3522f28df5dd89eeeefb1356d3e349abfc22f0899ced79ed58b7eaa300a764a239e182ecec384dc8a39e76ad27cbf5e86dba48c8efbf0c63ea
6
+ metadata.gz: f629caaf011634872054a8e6fae1643758a50a97c98d28fd4967181d4a5e6f68719965894cdb007d571e9a71ae45eb41cf1de8d9639d917b2b9cab99540d5c3e
7
+ data.tar.gz: e10c5f823385e29b6d3c08fe9f7059d160e815ef02ebca55e81e5dd526bcbb081727b6f3aacdc4f46bcb0b0928f562014fcc0475a4c3fe95c88e6d4e67d898ab
data/Gemfile CHANGED
@@ -12,19 +12,12 @@ group :test do
12
12
  gem "maruku", "~> 0.7"
13
13
  gem "minitest", "~> 6.0", "< 6.1"
14
14
  gem "mocha", "~> 3.0"
15
+ gem "simplecov", "~> 0.22", require: false
15
16
  gem "yard", "~> 0.9"
16
17
  end
17
18
 
18
- group :integration do
19
- gem "cinc-auditor-bin", source: "https://rubygems.cinc.sh"
20
- gem "kitchen-cinc"
21
- gem "kitchen-cinc-auditor",
22
- git: "https://github.com/test-kitchen/kitchen-cinc-auditor.git",
23
- ref: "3d0b89eaa13f12da08a8761970e39c0f564c24c6"
24
- gem "kitchen-dokken"
25
- gem "kitchen-vagrant"
26
- end
19
+ # Acceptance-suite gems live in Gemfile.integration.
27
20
 
28
21
  group :linting do
29
- gem "cookstyle", "~> 8.2"
22
+ gem "cookstyle", "~> 9.0"
30
23
  end
data/Rakefile CHANGED
@@ -7,6 +7,48 @@ Rake::TestTask.new(:unit) do |t|
7
7
  t.verbose = true
8
8
  end
9
9
 
10
+ namespace :unit do
11
+ desc "Run each spec file in its own process to catch cross-file coupling"
12
+ task :isolated do
13
+ require "open3"
14
+
15
+ specs = FileList["spec/**/*_spec.rb"].sort
16
+ # Spec files are independent, and each one spends most of its time in
17
+ # process startup, so run a few at a time.
18
+ workers = (ENV["ISOLATED_WORKERS"] || 4).to_i
19
+ failed = []
20
+
21
+ specs.each_slice(workers) do |batch|
22
+ batch.map do |spec|
23
+ Thread.new do
24
+ out, status = Open3.capture2e(
25
+ Gem.ruby, "-Ilib", "-Ispec", spec
26
+ )
27
+ [spec, status.success?, out]
28
+ end
29
+ end.map(&:value).each do |spec, ok, out|
30
+ puts(ok ? " ok #{spec}" : " FAIL #{spec}\n#{out}")
31
+ failed << spec unless ok
32
+ end
33
+ end
34
+
35
+ unless failed.empty?
36
+ abort "\n#{failed.length} of #{specs.length} spec files fail on their " \
37
+ "own:\n #{failed.join("\n ")}\n\n" \
38
+ "These pass in `rake unit` only because another spec file loads " \
39
+ "something they need. Add the missing require."
40
+ end
41
+
42
+ puts "\nAll #{specs.length} spec files pass in isolation."
43
+ end
44
+
45
+ desc "Run the unit tests with coverage reporting"
46
+ task :coverage do
47
+ ENV["COVERAGE"] = "1"
48
+ Rake::Task[:unit].invoke
49
+ end
50
+ end
51
+
10
52
  begin
11
53
  require "cucumber"
12
54
  require "cucumber/rake/task"
@@ -20,6 +62,9 @@ end
20
62
  desc "Run all test suites"
21
63
  task test: %i{unit features}
22
64
 
65
+ desc "Run the checks that must pass before a change is merged"
66
+ task verify: %i{unit unit:isolated style}
67
+
23
68
  desc "Display LOC stats"
24
69
  task :stats do
25
70
  puts "\n## Production Code Stats"
@@ -28,10 +28,12 @@ module Kitchen
28
28
  end
29
29
  # By default only doctor the first instance to avoid output spam.
30
30
  results = [results.first] unless options[:all]
31
- failed = results.any? do |instance|
31
+ # Doctor every instance before deciding the outcome. #any? would
32
+ # stop at the first instance reporting a problem and skip the rest.
33
+ failed = results.map do |instance|
32
34
  debug "Doctor on #{instance.name}."
33
35
  instance.doctor_action
34
- end
36
+ end.any?
35
37
  exit(1) if failed
36
38
  end
37
39
  end
@@ -195,18 +195,20 @@ module Kitchen
195
195
  end
196
196
 
197
197
  # Determines how many instances may be acted on at once, clamped to the
198
- # number of instances available.
198
+ # number of instances available and to at least one worker. Without the
199
+ # lower bound a zero or negative --concurrency would start no worker
200
+ # threads at all, so every instance would be skipped and the run would
201
+ # still report success.
199
202
  #
200
203
  # @param instances [Array<Instance>] the instances to be acted on
201
204
  # @return [Integer] the number of worker threads to start
202
205
  # @api private
203
206
  def concurrency_setting(instances)
204
- concurrency = 1
205
- if options[:concurrency]
206
- concurrency = options[:concurrency] || instances.size
207
- concurrency = instances.size if concurrency > instances.size
208
- end
209
- concurrency
207
+ return 1 unless options[:concurrency]
208
+
209
+ concurrency = options[:concurrency]
210
+ concurrency = instances.size if concurrency > instances.size
211
+ [concurrency, 1].max
210
212
  end
211
213
 
212
214
  # Performs an action on a single instance, recording any instance or
@@ -1037,9 +1037,7 @@ module Kitchen
1037
1037
  # Hash if not found
1038
1038
  # @api private
1039
1039
  def platform_data_for(name)
1040
- data.fetch(:platforms, {}).find(-> { {} }) do |platform|
1041
- platform.fetch(:name, nil) == name
1042
- end
1040
+ platform_index[name] || {}
1043
1041
  end
1044
1042
 
1045
1043
  # Destructively sets a base kitchen config key/value pair at the root of
@@ -1068,8 +1066,41 @@ module Kitchen
1068
1066
  # Hash if not found
1069
1067
  # @api private
1070
1068
  def suite_data_for(name)
1071
- data.fetch(:suites, {}).find(-> { {} }) do |suite|
1072
- suite.fetch(:name, nil) == name
1069
+ suite_index[name] || {}
1070
+ end
1071
+
1072
+ # Name-keyed index of the platform data, built once on first use.
1073
+ #
1074
+ # Every plugin lookup for every instance resolves a platform and a suite by
1075
+ # name, so the linear scans this replaces made building a project's
1076
+ # instances quadratic in its size.
1077
+ #
1078
+ # @return [Hash{String => Hash}] platform name to platform data
1079
+ # @api private
1080
+ def platform_index
1081
+ @platform_index ||= index_by_name(data.fetch(:platforms, []))
1082
+ end
1083
+
1084
+ # Name-keyed index of the suite data, built once on first use.
1085
+ #
1086
+ # @return [Hash{String => Hash}] suite name to suite data
1087
+ # @api private
1088
+ def suite_index
1089
+ @suite_index ||= index_by_name(data.fetch(:suites, []))
1090
+ end
1091
+
1092
+ # Indexes a collection of named data hashes by their :name value.
1093
+ #
1094
+ # An earlier entry wins over a later duplicate, matching the first match
1095
+ # that a linear search would have returned.
1096
+ #
1097
+ # @param collection [Enumerable<Hash>] platform or suite data
1098
+ # @return [Hash{String => Hash}] name to entry
1099
+ # @api private
1100
+ def index_by_name(collection)
1101
+ collection.each_with_object({}) do |entry, index|
1102
+ key = entry.fetch(:name, nil)
1103
+ index[key] = entry unless index.key?(key)
1073
1104
  end
1074
1105
  end
1075
1106
  end
@@ -52,7 +52,7 @@ module Kitchen
52
52
  else
53
53
  [
54
54
  formatted_exception(exception.original, "Nested Exception"),
55
- formatted_backtrace(exception),
55
+ formatted_backtrace(exception.original),
56
56
  ].flatten
57
57
  end
58
58
  end
@@ -15,7 +15,6 @@
15
15
  # See the License for the specific language governing permissions and
16
16
  # limitations under the License.
17
17
 
18
- require "rubygems/gem_runner"
19
18
  require "thor/group"
20
19
 
21
20
  module Kitchen
@@ -242,9 +242,12 @@ module Kitchen
242
242
  #
243
243
  def doctor_action
244
244
  banner "The doctor is in"
245
- [driver, provisioner, transport, verifier].any? do |obj|
245
+ # Each plugin reports its own problems as it finds them, so run all of
246
+ # them and reduce afterwards. #any? would stop at the first plugin with
247
+ # something to say and hide the rest.
248
+ [driver, provisioner, transport, verifier].map do |obj|
246
249
  obj.doctor(state_file.read)
247
- end
250
+ end.any?
248
251
  end
249
252
 
250
253
  # Returns a Hash of configuration and other useful diagnostic information.
@@ -771,12 +774,12 @@ module Kitchen
771
774
  # reported transitioned state into the desired transitioned state.
772
775
  #
773
776
  # @param last [String,Symbol,nil] the last known transitioned state of
774
- # the Instance, defaulting to `nil` (for unknown or no history)
777
+ # the Instance, or `nil` for unknown or no history
775
778
  # @param desired [String,Symbol] the desired transitioned state for the
776
779
  # Instance
777
780
  # @return [Array<Symbol>] an Array of transition actions to perform
778
781
  # @api private
779
- def self.actions(last = nil, desired)
782
+ def self.actions(last, desired)
780
783
  last_index = index(last)
781
784
  desired_index = index(desired)
782
785
 
@@ -95,6 +95,41 @@ module Kitchen
95
95
  end
96
96
  end
97
97
 
98
+ # Sets the raw value for the given key. Values are stored unrendered, so a
99
+ # callable assigned here is still evaluated lazily on read.
100
+ #
101
+ # SimpleDelegator would forward this through #method_missing; defining it
102
+ # here keeps the hot path off that dispatch, which every plugin walks once
103
+ # per default attribute while applying its defaults.
104
+ #
105
+ # @param key [Object] hash key
106
+ # @param value [Object] the value to store
107
+ # @return [Object] the stored value
108
+ def []=(key, value)
109
+ __getobj__[key] = value
110
+ end
111
+
112
+ # Returns whether the given key is present, without rendering its value.
113
+ #
114
+ # Defined for the same reason as {#[]=}: to bypass SimpleDelegator's
115
+ # #method_missing on a hot path.
116
+ #
117
+ # @param key [Object] hash key
118
+ # @return [Boolean] whether the key is present
119
+ def key?(key)
120
+ __getobj__.key?(key)
121
+ end
122
+
123
+ # Returns the keys of the underlying hash.
124
+ #
125
+ # Defined for the same reason as {#[]=}: to bypass SimpleDelegator's
126
+ # #method_missing on a hot path.
127
+ #
128
+ # @return [Array] the hash keys
129
+ def keys
130
+ __getobj__.keys
131
+ end
132
+
98
133
  # Returns a new Hash with all keys and rendered values of the LazyHash.
99
134
  #
100
135
  # @return [Hash] a new hash
@@ -312,8 +312,10 @@ module Kitchen
312
312
 
313
313
  # @return [Hash] metadata added to structured log events
314
314
  def metadata
315
- metadata_stack.inject(@base_metadata.dup) do |result, values|
316
- result.merge(values)
315
+ # Merge in place into the fresh dup rather than allocating a new hash per
316
+ # stack entry; this runs once per structured log event.
317
+ metadata_stack.each_with_object(@base_metadata.dup) do |values, result|
318
+ result.merge!(values)
317
319
  end
318
320
  end
319
321
 
@@ -449,15 +451,20 @@ module Kitchen
449
451
 
450
452
  private
451
453
 
454
+ # Builds the formatter used for console output.
455
+ #
456
+ # The colour is fixed for the life of the logger, so both escape
457
+ # sequences are resolved once here rather than on every line. An
458
+ # uncolourised logger, and one whose colour did not resolve, share the
459
+ # same uncoloured formatter.
452
460
  def stdout_formatter(color, colorize)
453
- if colorize
454
- proc do |_severity, _datetime, _progname, msg|
455
- Color.colorize(msg.dup.to_s, color).concat("\n")
456
- end
461
+ escape = colorize ? Color.escape(color) : ""
462
+
463
+ if escape.empty?
464
+ proc { |_severity, _datetime, _progname, msg| "#{msg}\n" }
457
465
  else
458
- proc do |_severity, _datetime, _progname, msg|
459
- msg.dup.concat("\n")
460
- end
466
+ reset = Color.escape(:reset)
467
+ proc { |_severity, _datetime, _progname, msg| "#{escape}#{msg}#{reset}\n" }
461
468
  end
462
469
  end
463
470
 
@@ -496,22 +503,50 @@ module Kitchen
496
503
  # @return [void]
497
504
  # @api private
498
505
  def <<(msg)
499
- @buffer += msg
506
+ @buffer << msg
500
507
  flush_lines
501
508
  end
502
509
 
503
510
  private
504
511
 
512
+ # Emits every complete line in the buffer, then drops the emitted bytes
513
+ # in a single pass. Scanning from a moving offset instead of deleting
514
+ # each line as it is found keeps a chunk of n lines linear rather than
515
+ # quadratic, which matters because one SSH or WinRM read can carry
516
+ # thousands of lines.
505
517
  def flush_lines
506
- while (i = @buffer.index("\n"))
507
- @line_handler.call(@buffer[0, i].chomp)
508
- @buffer[0, i + 1] = ""
518
+ start = 0
519
+ while (i = @buffer.index("\n", start))
520
+ @line_handler.call(@buffer[start, i - start].chomp)
521
+ start = i + 1
509
522
  end
523
+ @buffer.slice!(0, start) unless start == 0
510
524
  end
511
525
  end
512
526
 
513
527
  # Rewrites already-prefixed stream lines into the matching logger call.
514
528
  class StreamLineFormatter
529
+ # Width of every stream prefix in {PREFIXES}. All prefixes are the same
530
+ # width so that a line's severity is a fixed-offset lookup.
531
+ #
532
+ # @return [Integer] the prefix width in characters
533
+ # @api private
534
+ PREFIX_WIDTH = 7
535
+
536
+ # Maps each stream prefix to the severity it denotes and whether the line
537
+ # must be replayed verbatim on sinks that cannot accept stream events.
538
+ #
539
+ # @return [Hash{String => Array}] prefix to [severity, structured] pairs
540
+ # @api private
541
+ PREFIXES = {
542
+ "-----> " => [:banner, false],
543
+ "D " => [:debug, true],
544
+ "$$$$$$ " => [:warn, true],
545
+ ">>>>>> " => [:error, false],
546
+ "!!!!!! " => [:fatal, true],
547
+ " " => [:info, false],
548
+ }.freeze
549
+
515
550
  def initialize(logger)
516
551
  @logger = logger
517
552
  end
@@ -519,18 +554,22 @@ module Kitchen
519
554
  # Routes an already-prefixed stream line to the logger call matching its
520
555
  # prefix, stripping the prefix before logging.
521
556
  #
557
+ # A hash lookup on the fixed-width prefix replaces a chain of anchored
558
+ # regexp matches, so an unprefixed line costs one lookup rather than six
559
+ # failed matches.
560
+ #
522
561
  # @param line [String] a single line of prefixed stream output
523
562
  # @return [void]
524
563
  # @api private
525
564
  def format(line)
526
- case line
527
- when /^-----> / then log_line(:banner, line.gsub(/^[ >-]{6} /, ""))
528
- when /^D / then structured_line(:debug, line.gsub(/^D {6}/, ""), line)
529
- when /^\$\$\$\$\$\$ / then structured_line(:warn, line.gsub(/^\${6} /, ""), line)
530
- when /^>>>>>> / then log_line(:error, line.gsub(/^[ >-]{6} /, ""))
531
- when /^!!!!!! / then structured_line(:fatal, line.gsub(/^!{6} /, ""), line)
532
- when /^ / then log_line(:info, line.gsub(/^[ >-]{6} /, ""))
533
- else log_line(:info, line)
565
+ severity, structured = PREFIXES[line[0, PREFIX_WIDTH]]
566
+
567
+ if severity.nil?
568
+ log_line(:info, line)
569
+ elsif structured
570
+ structured_line(severity, line[PREFIX_WIDTH..], line)
571
+ else
572
+ log_line(severity, line[PREFIX_WIDTH..])
534
573
  end
535
574
  end
536
575
 
@@ -808,9 +847,11 @@ module Kitchen
808
847
  message: message.to_s,
809
848
  progname:,
810
849
  sequence: next_sequence
811
- ).compact
812
- @logdev.write(JSON.generate(event))
813
- @logdev.write("\n")
850
+ )
851
+ event.compact!
852
+ # One writev rather than two writes: the log device is opened with
853
+ # sync = true, so each call is a syscall.
854
+ @logdev.write(JSON.generate(event), "\n")
814
855
  end
815
856
  true
816
857
  end
@@ -32,9 +32,10 @@ module Kitchen
32
32
  # A string "looks-like" a regexp if it starts with / and ends with / + Regexp options i or x
33
33
  #
34
34
  # @return [Array] filters with regexp-like string converted to PlatformRegexpFilter
35
+ # @raise [ArgumentError] if a filter is neither a String nor a Regexp
35
36
  def self.convert(filters)
36
37
  ::Kernel.Array(filters).map do |filter|
37
- if (match = filter.match(REGEXP_LIKE_PATTERN))
38
+ if filter.is_a?(::String) && (match = filter.match(REGEXP_LIKE_PATTERN))
38
39
  options = match["options"].include?("i") ? ::Regexp::IGNORECASE : 0
39
40
  options |= ::Regexp::EXTENDED if match["options"].include?("x")
40
41
  filter = ::Regexp.new(match["pattern"], options)
@@ -51,7 +52,8 @@ module Kitchen
51
52
  # @param [Regexp,String] value of the filter
52
53
  def initialize(value)
53
54
  unless value.is_a?(::Regexp) || value.is_a?(::String)
54
- raise ::ArgumentError, "PlatformFilter#new requires value to be a String or a Regexp"
55
+ raise ::ArgumentError, "PlatformFilter#new requires value to be a String or a Regexp, " \
56
+ "got #{value.inspect} (#{value.class})"
55
57
  end
56
58
 
57
59
  @value = value
@@ -230,7 +230,11 @@ module Kitchen
230
230
  # will persist after the process terminates. In other words, cleanup is
231
231
  # explicit. This method is safe to call multiple times.
232
232
  def cleanup_sandbox
233
- return if sandbox_path.nil?
233
+ # Check the ivar, not #sandbox_path, which raises rather than
234
+ # returning nil when the sandbox was never created. This runs from
235
+ # an ensure block, so raising here would mask the error that kept
236
+ # #create_sandbox from finishing.
237
+ return if @sandbox_path.nil?
234
238
 
235
239
  debug("Cleaning up local sandbox in #{sandbox_path}")
236
240
  @install_script_path = nil
@@ -134,11 +134,11 @@ module Kitchen
134
134
  # @return [Hash] hash of connection options
135
135
  # @api private
136
136
  def connection_options(data)
137
- opts = {
137
+ {
138
138
  instance_name: instance.name,
139
- kitchen_root: Dir.pwd,
139
+ kitchen_root: data[:kitchen_root] || Dir.pwd,
140
+ logger:,
140
141
  }
141
- opts
142
142
  end
143
143
  end
144
144
  end
data/lib/kitchen/util.rb CHANGED
@@ -99,17 +99,35 @@ module Kitchen
99
99
  end
100
100
  end
101
101
 
102
+ # The text substituted for a masked value.
103
+ MASK = "******".freeze
104
+
105
+ # A double-quoted string value, allowing for backslash escapes so that a
106
+ # quote inside the value does not end the match early.
107
+ QUOTED_VALUE = /"(?:\\.|[^"\\])*"/.source
108
+
102
109
  # Returns a string with masked values for specified parameters.
103
110
  #
111
+ # Hashes are commonly interpolated into these strings via Hash#inspect,
112
+ # whose rendering has changed over time, so every spelling a key/value
113
+ # pair has had is matched:
114
+ #
115
+ # :key=>"value" symbol key, Ruby <= 3.3
116
+ # "key"=>"value" string key, Ruby <= 3.3
117
+ # key: "value" symbol key, Ruby >= 3.4
118
+ # "key" => "value" string key, Ruby >= 3.4
119
+ #
104
120
  # @param string_to_mask [String] the object whose string representation is parsed
105
121
  # @param keys [Array] the list of keys whose values should be masked
106
- # @return [String] the string representation of passed object with masked values
122
+ # @return [String] a new string with the values of the given keys masked
107
123
  def self.mask_values(string_to_mask, keys)
108
- masked_string = string_to_mask
109
- keys.each do |key|
110
- masked_string.gsub!(/:#{key}=>"([^"]*)"/, %{:#{key}=>"******"})
124
+ keys.reduce(string_to_mask.to_s) do |masked, key|
125
+ key = ::Regexp.escape(key.to_s)
126
+ masked.gsub(
127
+ /(?<assignment>(?::#{key}|"#{key}")\s*=>\s*|(?<!\w)#{key}:\s*)#{QUOTED_VALUE}/,
128
+ %{\\k<assignment>"#{MASK}"}
129
+ )
111
130
  end
112
- masked_string
113
131
  end
114
132
 
115
133
  # Returns a formatted string representing a duration in seconds.
@@ -115,7 +115,11 @@ module Kitchen
115
115
  # will persist after the process terminates. In other words, cleanup is
116
116
  # explicit. This method is safe to call multiple times.
117
117
  def cleanup_sandbox
118
- return if sandbox_path.nil?
118
+ # Check the ivar, not #sandbox_path, which raises rather than
119
+ # returning nil when the sandbox was never created. This runs from
120
+ # an ensure block, so raising here would mask the error that kept
121
+ # #create_sandbox from finishing.
122
+ return if @sandbox_path.nil?
119
123
 
120
124
  debug("Cleaning up local sandbox in #{sandbox_path}")
121
125
  FileUtils.rmtree(sandbox_path)
@@ -39,13 +39,12 @@ module Kitchen
39
39
  def call(state)
40
40
  info("[#{name}] Verify on instance #{instance.name} with state=#{state}")
41
41
  sleep_if_set
42
- merge_state_to_env(state)
43
42
  if config[:remote_exec]
44
43
  instance.transport.connection(state) do |conn|
45
44
  conn.execute(config[:command])
46
45
  end
47
46
  else
48
- shellout
47
+ shellout(state)
49
48
  end
50
49
  debug("[#{name}] Verify completed.")
51
50
  end
@@ -72,8 +71,8 @@ module Kitchen
72
71
  end
73
72
  end
74
73
 
75
- def shellout
76
- cmd = Mixlib::ShellOut.new(config[:command], config[:shellout_opts])
74
+ def shellout(state = {})
75
+ cmd = Mixlib::ShellOut.new(config[:command], shellout_opts(state))
77
76
  cmd.live_stream = config[:live_stream]
78
77
  cmd.run_command
79
78
  begin
@@ -83,16 +82,38 @@ module Kitchen
83
82
  end
84
83
  end
85
84
 
86
- def merge_state_to_env(state)
87
- env_state = { environment: {} }
88
- env_state[:environment]["KITCHEN_INSTANCE"] = instance.name
89
- env_state[:environment]["KITCHEN_PLATFORM"] = instance.platform.name
90
- env_state[:environment]["KITCHEN_SUITE"] = instance.suite.name
91
- env_state[:environment]["KITCHEN_USERNAME"] = instance.transport[:username] if instance.respond_to?(:transport)
85
+ # Returns the options to run the command with: whatever the user
86
+ # configured, with the KITCHEN_* variables merged into their environment
87
+ # rather than replacing it.
88
+ #
89
+ # This builds a new Hash instead of writing back to config. Nested config
90
+ # hashes are shared between every instance, so mutating :shellout_opts
91
+ # would leak one instance's environment into the others.
92
+ #
93
+ # @param state [Hash] mutable instance state
94
+ # @return [Hash] options for Mixlib::ShellOut
95
+ # @api private
96
+ def shellout_opts(state)
97
+ opts = config[:shellout_opts].to_h
98
+ opts.merge(
99
+ environment: opts.fetch(:environment, {}).to_h.merge(kitchen_environment(state))
100
+ )
101
+ end
102
+
103
+ # @param state [Hash] mutable instance state
104
+ # @return [Hash] the KITCHEN_* environment variables for this instance
105
+ # @api private
106
+ def kitchen_environment(state)
107
+ env = {
108
+ "KITCHEN_INSTANCE" => instance.name,
109
+ "KITCHEN_PLATFORM" => instance.platform.name,
110
+ "KITCHEN_SUITE" => instance.suite.name,
111
+ }
112
+ env["KITCHEN_USERNAME"] = instance.transport[:username] if instance.respond_to?(:transport)
92
113
  state.each_pair do |key, value|
93
- env_state[:environment]["KITCHEN_" + key.to_s.upcase] = value.to_s
114
+ env["KITCHEN_" + key.to_s.upcase] = value.to_s
94
115
  end
95
- config[:shellout_opts].merge!(env_state)
116
+ env
96
117
  end
97
118
  end
98
119
  end
@@ -19,5 +19,5 @@ module Kitchen
19
19
  # The currently released version of Test Kitchen.
20
20
  #
21
21
  # @return [String] a semantic version string
22
- VERSION = "4.1.3".freeze
22
+ VERSION = "4.1.4".freeze
23
23
  end
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.1.3
4
+ version: 4.1.4
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-08-24 00:00:00.000000000 Z
11
+ date: 2026-09-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bcrypt_pbkdf