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.
@@ -16,7 +16,9 @@
16
16
  # limitations under the License.
17
17
 
18
18
  require "fileutils" unless defined?(FileUtils)
19
- require "logger"
19
+ require "json" unless defined?(JSON)
20
+ require "logger" unless defined?(Logger)
21
+ require "time" unless defined?(Time)
20
22
 
21
23
  module Kitchen
22
24
  # Logging implementation for Kitchen. By default the console/stdout output
@@ -31,6 +33,15 @@ module Kitchen
31
33
  # @return [IO] the log device
32
34
  attr_reader :logdev
33
35
 
36
+ # @return [IO] the structured log device
37
+ attr_reader :structured_logdev
38
+
39
+ # @return [String,nil] the expanded text log path, if configured
40
+ attr_reader :logdev_path
41
+
42
+ # @return [String,nil] the expanded structured log path, if configured
43
+ attr_reader :structured_logdev_path
44
+
34
45
  # @return [Boolean] whether logger is configured for
35
46
  # overwriting
36
47
  attr_reader :log_overwrite
@@ -55,13 +66,23 @@ module Kitchen
55
66
  # when Test Kitchen runs.
56
67
  # (default: `$stdout.tty?`)
57
68
  def initialize(options = {})
69
+ @base_metadata = options[:metadata] || {}
58
70
  @log_overwrite = if options[:log_overwrite].nil?
59
71
  default_log_overwrite
60
72
  else
61
73
  options[:log_overwrite]
62
74
  end
63
75
 
64
- @logdev = logdev_logger(options[:logdev], log_overwrite) if options[:logdev]
76
+ @logdev = device_factory.logdev_logger(options[:logdev], log_overwrite) if options[:logdev]
77
+ if options[:structured_logdev]
78
+ @structured_logdev = device_factory.structured_logdev_logger(
79
+ options[:structured_logdev],
80
+ log_overwrite,
81
+ -> { metadata }
82
+ )
83
+ end
84
+ @logdev_path = expanded_log_path(options[:logdev])
85
+ @structured_logdev_path = expanded_log_path(options[:structured_logdev])
65
86
 
66
87
  populate_loggers(options)
67
88
 
@@ -77,10 +98,12 @@ module Kitchen
77
98
  def populate_loggers(options)
78
99
  @loggers = []
79
100
  @loggers << logdev unless logdev.nil?
80
- @loggers << stdout_logger(options[:stdout], options[:color], options[:colorize]) if
101
+ @loggers << structured_logdev unless structured_logdev.nil?
102
+ @loggers << device_factory.stdout_logger(options[:stdout], options[:color], options[:colorize]) if
81
103
  options[:stdout]
82
- @loggers << stdout_logger($stdout, options[:color], options[:colorize]) if
104
+ @loggers << device_factory.stdout_logger($stdout, options[:color], options[:colorize]) if
83
105
  @loggers.empty?
106
+ @sink_set = SinkSet.new(@loggers)
84
107
  end
85
108
  private :populate_loggers
86
109
 
@@ -91,18 +114,14 @@ module Kitchen
91
114
  # @!macro delegate_to_first_logger
92
115
  # @method $1()
93
116
  def delegate_to_first_logger(meth)
94
- define_method(meth) { |*args| @loggers.first.public_send(meth, *args) }
117
+ define_method(meth) { |*args, &block| @sink_set.first(meth, *args, &block) }
95
118
  end
96
119
 
97
120
  # @api private
98
121
  # @!macro delegate_to_all_loggers
99
122
  # @method $1()
100
123
  def delegate_to_all_loggers(meth)
101
- define_method(meth) do |*args|
102
- result = nil
103
- @loggers.each { |l| result = l.public_send(meth, *args) }
104
- result
105
- end
124
+ define_method(meth) { |*args, &block| @sink_set.all(meth, *args, &block) }
106
125
  end
107
126
  end
108
127
 
@@ -268,6 +287,23 @@ module Kitchen
268
287
  # @see http://is.gd/b13cVn
269
288
  delegate_to_all_loggers :close
270
289
 
290
+ # @return [Hash] metadata added to structured log events
291
+ def metadata
292
+ metadata_stack.inject(@base_metadata.dup) do |result, values|
293
+ result.merge(values)
294
+ end
295
+ end
296
+
297
+ # Temporarily adds metadata to structured log events.
298
+ #
299
+ # @param values [Hash] metadata fields to add for the block duration
300
+ def with_metadata(values)
301
+ metadata_stack.push(values.compact)
302
+ yield
303
+ ensure
304
+ metadata_stack.pop
305
+ end
306
+
271
307
  private
272
308
 
273
309
  # @return [Integer] the default logger level
@@ -282,54 +318,188 @@ module Kitchen
282
318
  Kitchen::DEFAULT_LOG_OVERWRITE
283
319
  end
284
320
 
285
- # Construct a new standard out logger.
286
- #
287
- # @param stdout [IO] the IO object that represents stdout (or similar)
288
- # @param color [Symbol] color to use when outputting messages
289
- # @param colorize [Boolean] whether to enable color
290
- # @return [StdoutLogger] a new logger
291
- # @api private
292
- def stdout_logger(stdout, color, colorize)
293
- logger = StdoutLogger.new(stdout)
294
- if colorize
295
- logger.formatter = proc do |_severity, _datetime, _progname, msg|
296
- Color.colorize(msg.dup.to_s, color).concat("\n")
321
+ def device_factory
322
+ @device_factory ||= DeviceFactory.new
323
+ end
324
+
325
+ def expanded_log_path(logdev)
326
+ File.expand_path(logdev) if logdev.is_a?(String)
327
+ end
328
+
329
+ def metadata_stack
330
+ Thread.current[metadata_stack_key] ||= []
331
+ end
332
+
333
+ def metadata_stack_key
334
+ @metadata_stack_key ||= :"kitchen_logger_metadata_#{object_id}"
335
+ end
336
+
337
+ # Internal composite for forwarding stdlib Logger-compatible calls to all
338
+ # configured logging sinks.
339
+ class SinkSet
340
+ def initialize(loggers)
341
+ @loggers = loggers
342
+ end
343
+
344
+ def first(meth, *args, &block)
345
+ @loggers.first.public_send(meth, *args, &block)
346
+ end
347
+
348
+ def all(meth, *args, &block)
349
+ result = nil
350
+ block = memoized_block(block) if block
351
+ @loggers.each { |logger| result = logger.public_send(meth, *args, &block) }
352
+ result
353
+ end
354
+
355
+ private
356
+
357
+ def memoized_block(block)
358
+ evaluated = false
359
+ value = nil
360
+ proc do
361
+ unless evaluated
362
+ value = block.call
363
+ evaluated = true
364
+ end
365
+ value
297
366
  end
298
- else
299
- logger.formatter = proc do |_severity, _datetime, _progname, msg|
300
- msg.dup.concat("\n")
367
+ end
368
+ end
369
+
370
+ # Internal factory for building the concrete logger devices.
371
+ class DeviceFactory
372
+ # Construct a new standard out logger.
373
+ #
374
+ # @param stdout [IO] the IO object that represents stdout (or similar)
375
+ # @param color [Symbol] color to use when outputting messages
376
+ # @param colorize [Boolean] whether to enable color
377
+ # @return [StdoutLogger] a new logger
378
+ # @api private
379
+ def stdout_logger(stdout, color, colorize)
380
+ logger = StdoutLogger.new(stdout)
381
+ logger.formatter = stdout_formatter(color, colorize)
382
+ logger
383
+ end
384
+
385
+ # Construct a new logdev logger.
386
+ #
387
+ # @param filepath_or_logdev [String,IO] a filepath String or IO object
388
+ # @param log_overwrite [Boolean] apply log overwriting
389
+ # if filepath_or_logdev is a file path
390
+ # @return [LogdevLogger] a new logger
391
+ # @api private
392
+ def logdev_logger(filepath_or_logdev, log_overwrite)
393
+ LogdevLogger.new(resolve_logdev(filepath_or_logdev, log_overwrite))
394
+ end
395
+
396
+ # Construct a new structured logdev logger.
397
+ #
398
+ # @param filepath_or_logdev [String,IO] a filepath String or IO object
399
+ # @param log_overwrite [Boolean] apply log overwriting
400
+ # if filepath_or_logdev is a file path
401
+ # @param metadata_provider [Proc] callable returning structured metadata
402
+ # @return [StructuredLogdevLogger] a new logger
403
+ # @api private
404
+ def structured_logdev_logger(filepath_or_logdev, log_overwrite, metadata_provider)
405
+ StructuredLogdevLogger.new(
406
+ resolve_logdev(filepath_or_logdev, log_overwrite),
407
+ metadata_provider
408
+ )
409
+ end
410
+
411
+ private
412
+
413
+ def stdout_formatter(color, colorize)
414
+ if colorize
415
+ proc do |_severity, _datetime, _progname, msg|
416
+ Color.colorize(msg.dup.to_s, color).concat("\n")
417
+ end
418
+ else
419
+ proc do |_severity, _datetime, _progname, msg|
420
+ msg.dup.concat("\n")
421
+ end
422
+ end
423
+ end
424
+
425
+ # Return an IO object from a filepath String or the IO object itself.
426
+ #
427
+ # @param filepath_or_logdev [String,IO] a filepath String or IO object
428
+ # @param log_overwrite [Boolean] apply log overwriting
429
+ # if filepath_or_logdev is a file path
430
+ # @return [IO] an IO object
431
+ # @api private
432
+ def resolve_logdev(filepath_or_logdev, log_overwrite)
433
+ if filepath_or_logdev.is_a? String
434
+ mode = log_overwrite ? "wb" : "ab"
435
+ FileUtils.mkdir_p(File.dirname(filepath_or_logdev))
436
+ file = File.open(File.expand_path(filepath_or_logdev), mode)
437
+ file.sync = true
438
+ file
439
+ else
440
+ filepath_or_logdev
301
441
  end
302
442
  end
303
- logger
304
443
  end
305
444
 
306
- # Construct a new logdev logger.
307
- #
308
- # @param filepath_or_logdev [String,IO] a filepath String or IO object
309
- # @param log_overwrite [Boolean] apply log overwriting
310
- # if filepath_or_logdev is a file path
311
- # @return [LogdevLogger] a new logger
312
- # @api private
313
- def logdev_logger(filepath_or_logdev, log_overwrite)
314
- LogdevLogger.new(resolve_logdev(filepath_or_logdev, log_overwrite))
445
+ # Buffers streamed output until a complete line is available for logging.
446
+ class LineBuffer
447
+ def initialize(&line_handler)
448
+ @buffer = +""
449
+ @line_handler = line_handler
450
+ end
451
+
452
+ def <<(msg)
453
+ @buffer += msg
454
+ flush_lines
455
+ end
456
+
457
+ private
458
+
459
+ def flush_lines
460
+ while (i = @buffer.index("\n"))
461
+ @line_handler.call(@buffer[0, i].chomp)
462
+ @buffer[0, i + 1] = ""
463
+ end
464
+ end
315
465
  end
316
466
 
317
- # Return an IO object from a filepath String or the IO object itself.
318
- #
319
- # @param filepath_or_logdev [String,IO] a filepath String or IO object
320
- # @param log_overwrite [Boolean] apply log overwriting
321
- # if filepath_or_logdev is a file path
322
- # @return [IO] an IO object
323
- # @api private
324
- def resolve_logdev(filepath_or_logdev, log_overwrite)
325
- if filepath_or_logdev.is_a? String
326
- mode = log_overwrite ? "wb" : "ab"
327
- FileUtils.mkdir_p(File.dirname(filepath_or_logdev))
328
- file = File.open(File.expand_path(filepath_or_logdev), mode)
329
- file.sync = true
330
- file
331
- else
332
- filepath_or_logdev
467
+ # Rewrites already-prefixed stream lines into the matching logger call.
468
+ class StreamLineFormatter
469
+ def initialize(logger)
470
+ @logger = logger
471
+ end
472
+
473
+ def format(line)
474
+ case line
475
+ when /^-----> / then log_line(:banner, line.gsub(/^[ >-]{6} /, ""))
476
+ when /^D / then structured_line(:debug, line.gsub(/^D {6}/, ""), line)
477
+ when /^\$\$\$\$\$\$ / then structured_line(:warn, line.gsub(/^\${6} /, ""), line)
478
+ when /^>>>>>> / then log_line(:error, line.gsub(/^[ >-]{6} /, ""))
479
+ when /^!!!!!! / then structured_line(:fatal, line.gsub(/^!{6} /, ""), line)
480
+ when /^ / then log_line(:info, line.gsub(/^[ >-]{6} /, ""))
481
+ else log_line(:info, line)
482
+ end
483
+ end
484
+
485
+ private
486
+
487
+ def log_line(severity, message)
488
+ if severity == :banner
489
+ @logger.banner(message)
490
+ elsif @logger.respond_to?(:stream)
491
+ @logger.stream(severity, message)
492
+ else
493
+ @logger.public_send(severity, message)
494
+ end
495
+ end
496
+
497
+ def structured_line(severity, message, original)
498
+ if @logger.respond_to?(:stream)
499
+ @logger.stream(severity, message)
500
+ else
501
+ @logger.info(original)
502
+ end
333
503
  end
334
504
  end
335
505
 
@@ -342,35 +512,174 @@ module Kitchen
342
512
  #
343
513
  # @param msg [String] a message
344
514
  def <<(msg)
345
- @buffer ||= ""
346
- @buffer += msg
347
- while (i = @buffer.index("\n"))
348
- format_line(@buffer[0, i].chomp)
349
- @buffer[0, i + 1] = ""
350
- end
515
+ line_buffer << msg
351
516
  end
352
517
 
353
518
  # Log a banner message.
354
519
  #
355
520
  # @param msg [String] a message
356
521
  def banner(msg = nil, &block)
357
- super_info("-----> #{msg}", &block)
522
+ if block
523
+ super_info(nil) { "-----> #{block.call}" }
524
+ else
525
+ super_info("-----> #{msg}")
526
+ end
358
527
  end
359
528
 
360
529
  private
361
530
 
362
- # Reformat a line if it already contains log formatting.
363
- #
364
- # @param line [String] a message line
365
- # @api private
366
- def format_line(line)
367
- case line
368
- when /^-----> / then banner(line.gsub(/^[ >-]{6} /, ""))
369
- when /^>>>>>> / then error(line.gsub(/^[ >-]{6} /, ""))
370
- when /^ / then info(line.gsub(/^[ >-]{6} /, ""))
371
- else info(line)
531
+ def line_buffer
532
+ formatter = StreamLineFormatter.new(self)
533
+ @line_buffer ||= LineBuffer.new do |line|
534
+ formatter.format(line)
535
+ end
536
+ end
537
+ end
538
+
539
+ # Internal class that emits one JSON object per log event.
540
+ class StructuredLogdevLogger
541
+ include ::Logger::Severity
542
+
543
+ SEVERITY_NAMES = {
544
+ DEBUG => "debug",
545
+ INFO => "info",
546
+ WARN => "warn",
547
+ ERROR => "error",
548
+ FATAL => "fatal",
549
+ UNKNOWN => "unknown",
550
+ }.freeze
551
+
552
+ attr_accessor :level
553
+ attr_accessor :progname
554
+ attr_accessor :datetime_format
555
+
556
+ def initialize(logdev, metadata_provider)
557
+ @logdev = logdev
558
+ @metadata_provider = metadata_provider
559
+ @level = INFO
560
+ @progname = "Kitchen"
561
+ @sequence = 0
562
+ @mutex = Mutex.new
563
+ end
564
+
565
+ def add(severity, message = nil, progname = nil)
566
+ severity ||= UNKNOWN
567
+ return true if severity < level
568
+
569
+ message = if message.nil?
570
+ block_given? ? yield : progname
571
+ else
572
+ message
573
+ end
574
+ write_event(severity, message, "log")
575
+ end
576
+
577
+ def <<(msg)
578
+ line_buffer << msg
579
+ end
580
+
581
+ def banner(msg = nil)
582
+ message = block_given? ? yield : msg
583
+ write_event(INFO, message, "banner") unless INFO < level
584
+ end
585
+
586
+ def stream(severity, msg)
587
+ severity = severity_const(severity)
588
+ write_event(severity, msg, "stream") unless severity < level
589
+ end
590
+
591
+ def debug(msg = nil, &block)
592
+ add(DEBUG, msg, nil, &block)
593
+ end
594
+
595
+ def info(msg = nil, &block)
596
+ add(INFO, msg, nil, &block)
597
+ end
598
+
599
+ def warn(msg = nil, &block)
600
+ add(WARN, msg, nil, &block)
601
+ end
602
+
603
+ def error(msg = nil, &block)
604
+ add(ERROR, msg, nil, &block)
605
+ end
606
+
607
+ def fatal(msg = nil, &block)
608
+ add(FATAL, msg, nil, &block)
609
+ end
610
+
611
+ def unknown(msg = nil, &block)
612
+ add(UNKNOWN, msg, nil, &block)
613
+ end
614
+
615
+ def debug?
616
+ level <= DEBUG
617
+ end
618
+
619
+ def info?
620
+ level <= INFO
621
+ end
622
+
623
+ def warn?
624
+ level <= WARN
625
+ end
626
+
627
+ def error?
628
+ level <= ERROR
629
+ end
630
+
631
+ def fatal?
632
+ level <= FATAL
633
+ end
634
+
635
+ def close
636
+ return unless @logdev.respond_to?(:close)
637
+
638
+ if @logdev.respond_to?(:closed?)
639
+ @logdev.close unless @logdev.closed?
640
+ else
641
+ @logdev.close
642
+ end
643
+ end
644
+
645
+ private
646
+
647
+ def line_buffer
648
+ formatter = StreamLineFormatter.new(self)
649
+ @line_buffer ||= LineBuffer.new do |line|
650
+ formatter.format(line)
372
651
  end
373
652
  end
653
+
654
+ def next_sequence
655
+ @sequence += 1
656
+ end
657
+
658
+ def severity_const(severity)
659
+ return severity if severity.is_a?(Integer)
660
+
661
+ ::Logger.const_get(severity.to_s.upcase)
662
+ end
663
+
664
+ def severity_name(severity)
665
+ SEVERITY_NAMES.fetch(severity, "unknown")
666
+ end
667
+
668
+ def write_event(severity, message, event_type)
669
+ @mutex.synchronize do
670
+ event = @metadata_provider.call.merge(
671
+ timestamp: Time.now.utc.iso8601(6),
672
+ level: severity_name(severity),
673
+ event_type:,
674
+ message: message.to_s,
675
+ progname:,
676
+ sequence: next_sequence
677
+ ).compact
678
+ @logdev.write(JSON.generate(event))
679
+ @logdev.write("\n")
680
+ end
681
+ true
682
+ end
374
683
  end
375
684
 
376
685
  # Internal class which reformats logging methods for display as console
@@ -380,42 +689,66 @@ module Kitchen
380
689
  #
381
690
  # @param msg [String] a message
382
691
  def debug(msg = nil, &block)
383
- super("D #{msg}", &block)
692
+ if block
693
+ super(nil) { "D #{block.call}" }
694
+ else
695
+ super("D #{msg}")
696
+ end
384
697
  end
385
698
 
386
699
  # Log an info message
387
700
  #
388
701
  # @param msg [String] a message
389
702
  def info(msg = nil, &block)
390
- super(" #{msg}", &block)
703
+ if block
704
+ super(nil) { " #{block.call}" }
705
+ else
706
+ super(" #{msg}")
707
+ end
391
708
  end
392
709
 
393
710
  # Log a warn message
394
711
  #
395
712
  # @param msg [String] a message
396
713
  def warn(msg = nil, &block)
397
- super("$$$$$$ #{msg}", &block)
714
+ if block
715
+ super(nil) { "$$$$$$ #{block.call}" }
716
+ else
717
+ super("$$$$$$ #{msg}")
718
+ end
398
719
  end
399
720
 
400
721
  # Log an error message
401
722
  #
402
723
  # @param msg [String] a message
403
724
  def error(msg = nil, &block)
404
- super(">>>>>> #{msg}", &block)
725
+ if block
726
+ super(nil) { ">>>>>> #{block.call}" }
727
+ else
728
+ super(">>>>>> #{msg}")
729
+ end
405
730
  end
406
731
 
407
732
  # Log a fatal message
408
733
  #
409
734
  # @param msg [String] a message
410
735
  def fatal(msg = nil, &block)
411
- super("!!!!!! #{msg}", &block)
736
+ if block
737
+ super(nil) { "!!!!!! #{block.call}" }
738
+ else
739
+ super("!!!!!! #{msg}")
740
+ end
412
741
  end
413
742
 
414
743
  # Log an unknown message
415
744
  #
416
745
  # @param msg [String] a message
417
746
  def unknown(msg = nil, &block)
418
- super("?????? #{msg}", &block)
747
+ if block
748
+ super(nil) { "?????? #{block.call}" }
749
+ else
750
+ super("?????? #{msg}")
751
+ end
419
752
  end
420
753
  end
421
754
  end
@@ -36,8 +36,8 @@ module Kitchen
36
36
  # @return [nil,true] when the given severity is not high enough (for
37
37
  # this particular logger), log no message, and return true
38
38
  def logger_method(meth)
39
- define_method(meth) do |*args|
40
- logger.public_send(meth, *args)
39
+ define_method(meth) do |*args, &block|
40
+ logger.public_send(meth, *args, &block)
41
41
  end
42
42
  end
43
43
  end
@@ -105,18 +105,33 @@ module Kitchen
105
105
  debug("Executing prepare command: #{prepare_command}")
106
106
  conn.execute(prepare_command)
107
107
  debug("Executing run command: #{run_command}")
108
- conn.execute_with_retry(
109
- encode_for_powershell(run_command),
110
- config[:retry_on_exit_code],
111
- config[:max_retries],
112
- config[:wait_for_retry]
113
- )
114
- info("Downloading files from #{instance.to_str}")
115
- config[:downloads].to_h.each do |remotes, local|
116
- debug("Downloading #{Array(remotes).join(", ")} to #{local}")
117
- conn.download(remotes, local)
108
+ begin
109
+ conn.execute_with_retry(
110
+ encode_for_powershell(run_command),
111
+ config[:retry_on_exit_code],
112
+ config[:max_retries],
113
+ config[:wait_for_retry]
114
+ )
115
+ ensure
116
+ # Retrieval is best-effort: a file we could not fetch must not
117
+ # fail an otherwise successful converge, and must never replace
118
+ # an error already in flight from the run command. A transport
119
+ # that cannot download at all is a bug, not a missing file, so
120
+ # it still raises when there is no converge error to protect.
121
+ run_error = $!
122
+ begin
123
+ info("Downloading files from #{instance.to_str}")
124
+ config[:downloads].to_h.each do |remotes, local|
125
+ debug("Downloading #{Array(remotes).join(", ")} to #{local}")
126
+ conn.download(remotes, local)
127
+ end
128
+ debug("Download complete")
129
+ rescue => ex
130
+ raise unless run_error || ex.is_a?(Kitchen::Transport::TransportFailed)
131
+
132
+ warn("Failed to download files from #{instance.to_str}: #{ex.message}")
133
+ end
118
134
  end
119
- debug("Download complete")
120
135
  end
121
136
  rescue Kitchen::Transport::TransportFailed => ex
122
137
  raise ActionFailed, ex.message
@@ -339,13 +354,8 @@ module Kitchen
339
354
 
340
355
  # Replace $env:TEMP with the actual Windows temp directory based on the transport username
341
356
  if resolved_path.include?("$env:TEMP")
342
- # Try to get username from transport configuration
343
357
  # For Windows systems, fallback to "Administrator" if not found
344
- username = begin
345
- instance.transport[:username]
346
- rescue
347
- nil
348
- end
358
+ username = instance.transport[:username] if instance.transport.respond_to?(:[])
349
359
  username ||= "Administrator"
350
360
 
351
361
  temp_path = "C:/Users/#{username}/AppData/Local/Temp"