cem_acpt 0.1.0 → 0.2.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'core_extensions'
4
+ require_relative 'logging'
5
+
3
6
  module CemAcpt
4
7
  # This module provides a method and class for extracting and formatting
5
8
  # test data.
6
9
  module TestData
7
- require_relative 'core_extensions'
8
- require_relative 'logging'
9
-
10
10
  # Returns a hash of test data.
11
11
  # @param config [CemAcpt::Config] the config object
12
12
  # @return [Array<Hash>] an array of test data hashes
@@ -82,7 +82,6 @@ module CemAcpt
82
82
  def process_static_vars(test_data)
83
83
  return unless @config.has?('test_data.vars')
84
84
 
85
- logger.debug('Processing static variables...')
86
85
  vars = @config.get('test_data.vars').each_with_object({}) do |(k, v), h|
87
86
  logger.debug("Static variable: #{k}")
88
87
  h[k] = v
@@ -92,7 +91,6 @@ module CemAcpt
92
91
  end
93
92
 
94
93
  def process_name_pattern_vars(test_name, test_data)
95
- logger.debug('Processing test name pattern...')
96
94
  name_pattern_vars = name_pattern_matches(test_name) || {}
97
95
  test_data.merge!(name_pattern_vars)
98
96
  test_data.format!
@@ -102,20 +100,17 @@ module CemAcpt
102
100
  return test_name unless @config.has?('test_data.name_pattern_vars')
103
101
 
104
102
  pattern = @config.get('test_data.name_pattern_vars')
105
- logger.debug("Test name pattern: #{pattern.inspect}")
106
103
  pattern_matches = test_name.match(pattern)&.named_captures
107
104
  unless pattern_matches
108
105
  logger.error("Test name #{test_name} does not match pattern #{pattern.inspect}")
109
106
  return
110
107
  end
111
- logger.debug("Test name matches pattern: #{pattern_matches}")
112
108
  pattern_matches
113
109
  end
114
110
 
115
111
  def vars_post_processing!(test_data)
116
112
  return unless @config.has?('test_data.vars_post_processing')
117
113
 
118
- logger.debug("Running test name pattern post processing with test data: #{test_data}")
119
114
  post_processing_new_vars!(test_data)
120
115
  post_processing_delete_vars!(test_data)
121
116
  end
@@ -124,7 +119,6 @@ module CemAcpt
124
119
  new_vars = @config.get('test_data.vars_post_processing.new_vars')
125
120
  return unless new_vars
126
121
 
127
- logger.debug("Processing new variables: #{new_vars}")
128
122
  new_vars.each do |var|
129
123
  if var.has?('string_split')
130
124
  test_data[var.dot_dig('name')] = op_string_split(test_data, var)
@@ -135,13 +129,9 @@ module CemAcpt
135
129
  end
136
130
 
137
131
  def op_string_split(test_data, var)
138
- logger.debug("Processing string split for new var #{var}")
139
132
  from_var = var.dot_dig('string_split.from')
140
- logger.debug("String split from var: #{from_var}")
141
133
  from = test_data.dot_dig(from_var)
142
- logger.debug("String split from value: #{from}")
143
134
  parts = from.split(var.dot_dig('string_split.using'))
144
- logger.debug("String split parts: #{parts}")
145
135
  var.has?('string_split.part') ? parts[var.dot_dig('string_split.part')] : parts
146
136
  end
147
137
 
@@ -149,7 +139,6 @@ module CemAcpt
149
139
  delete_vars = @config.get('test_data.vars_post_processing.delete_vars')
150
140
  return unless delete_vars
151
141
 
152
- logger.debug('Processing delete variables...')
153
142
  test_data.reject! { |k, _| delete_vars.include?(k.to_s) }
154
143
  end
155
144
  end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent-ruby'
4
+ require 'open3'
5
+ require_relative '../context'
6
+ require_relative '../logging'
7
+ require_relative '../puppet_helpers'
8
+ require_relative 'runner'
9
+
10
+ module CemAcpt
11
+ module TestRunner
12
+ # Raised when the RunHandler needs to be operating within the module directory and isn't
13
+ class RunHandlerNotInModuleDirError < StandardError; end
14
+
15
+ # Raised when the RunHandler executes a system command that fails
16
+ class RunHandlerSystemCommandError < StandardError; end
17
+
18
+ # RunHandler orchestrates the acceptance test suites, including
19
+ # creating Runner objects, handling input and output, and exception
20
+ # handling.
21
+ class RunHandler
22
+ include CemAcpt::Logging
23
+
24
+ attr_accessor :run_context
25
+
26
+ # @param run_context [CemAcpt::Context::Ctx] The context object for this run
27
+ def initialize(run_context)
28
+ @run_context = run_context
29
+ @module_pkg_path = Concurrent::IVar.new
30
+ @runners = Concurrent::Array.new
31
+ @thread_pool = Concurrent::SimpleExecutorService.new
32
+ end
33
+
34
+ # Gets the overall exit code for all runners
35
+ def exit_code
36
+ @runners&.map { |r| r.run_result.exit_status }&.all?(&:zero?) ? 0 : 1
37
+ end
38
+
39
+ # Runs the acceptance test suites.
40
+ def run
41
+ logger.info('RUN HANDLER: Creating and starting test runners...')
42
+ create_runners
43
+ logger.info("RUN HANDLER: Created #{@runners.length} runners...")
44
+ @runners.map { |r| @thread_pool.post { r.start } }
45
+ @thread_pool.shutdown
46
+ @thread_pool.wait_for_termination
47
+ @thread_pool = Concurrent::SimpleExecutorService.new
48
+ logger.info('Test runners have all exited...')
49
+ rescue StandardError => e
50
+ handle_fatal_error(e)
51
+ ensure
52
+ handle_test_results
53
+ end
54
+
55
+ def destroy_test_nodes
56
+ return if @runners.empty? || @runners.none?(&:node_exists)
57
+
58
+ @runners.each do |r|
59
+ r.send(:destroy) if r.node_exists
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ # Creates and starts Runner objects for each node in the acceptance test suites.
66
+ def create_runners
67
+ @run_context.nodes.each do |platform, nodes|
68
+ @runners += nodes.map { |node| create_runner(node, platform) }.compact
69
+ end
70
+ end
71
+
72
+ def create_runner(node, platform)
73
+ logger.info("RUN HANDLER: Creating runner for #{node.test_data[:test_name]} on node #{node.node_name}...")
74
+ runner = CemAcpt::TestRunner::Runner.new(node, @run_context, platform)
75
+ runner || runner_creation_error(node)
76
+ end
77
+
78
+ def runner_creation_error(node)
79
+ msg = [
80
+ "Failed to create runner object for node #{node.node_name}.",
81
+ "Cannot run test #{node.test_data[:test_name]}",
82
+ ].join(' ')
83
+ logger.error(msg)
84
+ nil
85
+ end
86
+
87
+ # Handles how test results are logged.
88
+ def handle_test_results
89
+ @runners.each do |runner|
90
+ result = runner.run_result.to_h
91
+ logger << "::group::{Results for #{runner.node.test_data[:test_name]}}\n"
92
+ if result.key?('summary_line')
93
+ logger.info("SUMMARY: #{result['summary_line']} for test #{runner.node.test_data[:test_name]}")
94
+ else
95
+ handle_runner_error_results(runner)
96
+ logger << "::endgroup::\n"
97
+ next
98
+ end
99
+ unless runner.test_failures?
100
+ logger << "::endgroup::\n"
101
+ next
102
+ end
103
+
104
+ if result.key?('examples') # Log errors outside of examples
105
+ if result['examples'].empty? && !result['messages'].empty?
106
+ logger.error(result['messages'].join("\n"))
107
+ else
108
+ failed = result['examples'].reject { |e| e['status'] == 'passed' }
109
+ failed.each do |e|
110
+ logger.error(test_error_msg(runner.node.node_name, e))
111
+ end
112
+ end
113
+ else
114
+ handle_runner_error_results(runner)
115
+ end
116
+ #debug_test_results(runner) if logger.debug?
117
+ logger << "::endgroup::\n"
118
+ end
119
+ end
120
+
121
+ # Handles logging the results of the runners that errored.
122
+ def handle_runner_error_results(runner)
123
+ logger.error("SUMMARY: Encountered an error with test #{runner.node.test_data[:test_name]} on node #{runner.node.node_name}")
124
+ runner.run_result.to_h.each do |k, v|
125
+ logger.error("#{k.upcase}: #{v}")
126
+ end
127
+ end
128
+
129
+ # Formats the error message for a runner that failed.
130
+ # @param node [Object] the node that failed
131
+ # @param err [Object] the error that occured
132
+ # @return [String] the formatted error message
133
+ def runner_error_msg(node, err)
134
+ [
135
+ "Error while running acceptance tests on node #{node.node_name}",
136
+ "Error: #{err.message}",
137
+ 'Backtrace:',
138
+ err.backtrace.join("\n"),
139
+ ].join("\n")
140
+ end
141
+
142
+ # Formats a test result for tests that have failed. Is used for logging.
143
+ # @param node [String] the name of the node the test ran on
144
+ # @param result [Hash] the test result to format
145
+ # @return [String] the formatted test result
146
+ def test_error_msg(node, result)
147
+ [
148
+ "TEST FAILED: #{result['id']}",
149
+ "DESCRIPTION: #{result['full_description']}",
150
+ "STATUS: #{result['status']}",
151
+ "LOCATION: #{result['file_path']}:#{result['line_number']}",
152
+ "NODE: #{node}",
153
+ result['exception']['message'],
154
+ "\n",
155
+ ].join("\n")
156
+ end
157
+
158
+ # Logs performance data for the acceptance test suites.
159
+ # This is only logged if the log level is set to debug.
160
+ # def debug_test_results(runner)
161
+ # examples_by_time = []
162
+ # runner.run_result.to_h.each do |node, result|
163
+ # next unless result['examples']
164
+
165
+ # result['examples'].each do |e|
166
+ # examples_by_time << [e['run_time'], e['id'], e['status'], e['line_number'], node]
167
+ # end
168
+ # end
169
+ # if examples_by_time
170
+ # logger.debug('Showing test results in order of execution time...')
171
+ # examples_by_time.sort_by(&:first).reverse.each do |e|
172
+ # logger.debug("RUNTIME: #{e[0]}; ID: #{e[1]}; STATUS: #{e[2]}; LINE: #{e[3]}; HOST: #{e[4]};")
173
+ # end
174
+ # else
175
+ # logger.debug('No debug results to show')
176
+ # end
177
+ # end
178
+
179
+ # Gracefully handles a fatal error and exits the program.
180
+ # @param err [StandardError, Exception] the error that caused the fatal error
181
+ def handle_fatal_error(err)
182
+ logger.fatal("RUN HANDLER: Fatal error: #{err.message}")
183
+ logger.debug(err.backtrace.join("\n"))
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent-ruby'
4
+ require 'English'
5
+ require_relative '../logging'
6
+ require_relative '../rspec_utils'
7
+ require_relative 'runner_result'
8
+
9
+ module CemAcpt
10
+ module TestRunner
11
+ class RunnerError < StandardError; end
12
+ # Error used to wrap fatal errors raised in Runner steps
13
+ class RunnerStepError < StandardError
14
+ attr_reader :step
15
+
16
+ def initialize(step, err)
17
+ @step = step
18
+ super err
19
+ set_backtrace err.backtrace if err.respond_to?(:backtrace)
20
+ end
21
+ end
22
+ class RunnerProvisionError < RunnerStepError; end
23
+
24
+ # Runner is a class that runs a single acceptance test suite on a single node.
25
+ # It is responsible for managing the lifecycle of the test suite and
26
+ # reporting the results back to the main thread. Runner objects are created
27
+ # by the RunHandler and then, when started, execute their logic in a thread.
28
+ class Runner
29
+ include CemAcpt::LoggingAsync
30
+
31
+ attr_reader :node, :node_exists, :run_result
32
+
33
+ # @param node [String] the name of the node to run the acceptance test suite on
34
+ # @param ctx [CemAcpt::RunnerCtx] a cem_acpt Ctx (context) object
35
+ # @param module_pkg_path [Concurrent::IVar] the path to the module package
36
+ def initialize(node, context, platform)
37
+ @node = node
38
+ @context = context
39
+ @platform = platform
40
+ @debug_mode = @context.config.debug_mode?
41
+ @node_inventory = @context.node_inventory
42
+ @module_pkg_path = @context.module_package_path
43
+ @node_exists = false
44
+ @run_result = CemAcpt::TestRunner::RunnerResult.new(@node, debug: @debug_mode)
45
+ @completed_steps = []
46
+ validate!
47
+ end
48
+
49
+ def run_step(step_sym)
50
+ send(step_sym)
51
+ @completed_steps << step_sym
52
+ rescue StandardError => e
53
+ err = CemAcpt::TestRunner::RunnerStepError.new(step_sym, e)
54
+ step_error_logging(err)
55
+ @run_result.from_error(err)
56
+ destroy unless step_sym == :destroy
57
+ end
58
+
59
+ # Executes test suite steps
60
+ def start
61
+ async_info("Starting test suite for #{@node.node_name}", log_prefix('RUNNER'))
62
+ run_step(:provision)
63
+ run_step(:bootstrap)
64
+ run_step(:run_tests)
65
+ run_step(:destroy)
66
+ true
67
+ rescue StandardError => e
68
+ step_error_logging(e)
69
+ @run_result.from_error(e)
70
+ destroy
71
+ end
72
+
73
+ # Checks for failures in the test results.
74
+ # @param result [Hash] the test result to check
75
+ # @return [Boolean] whether or not there are test failures in result
76
+ def test_failures?
77
+ @run_result.result_errors? || @run_result.result_failures?
78
+ end
79
+
80
+ private
81
+
82
+ def step_error_logging(err)
83
+ prefix = err.respond_to?(:step) ? log_prefix(err.step.capitalize) : log_prefix('RUNNER')
84
+ fatal_msg = ["runner failed: #{err.message}"]
85
+ async_fatal(fatal_msg, prefix)
86
+ async_debug("Completed steps: #{@completed_steps}", prefix)
87
+ async_debug("Failed runner backtrace:\n#{err.backtrace.join("\n")}", prefix)
88
+ async_debug("Failed runner test data: #{@node.test_data}", prefix)
89
+ end
90
+
91
+ def log_prefix(prefix)
92
+ "#{prefix}: #{@node.test_data[:test_name]}:"
93
+ end
94
+
95
+ # Provisions the node for the acceptance test suite.
96
+ def provision
97
+ async_info("Provisioning #{@node.node_name}...", log_prefix('PROVISION'))
98
+ start_time = Time.now
99
+ @node.provision
100
+ @node_exists = true
101
+ max_retries = 60 # equals 300 seconds because we check every five seconds
102
+ until @node.ready?
103
+ if max_retries <= 0
104
+ async_fatal("Node #{@node.node_name} failed to provision", log_prefix('PROVISION'))
105
+ raise CemAcpt::TestRunner::RunnerProvisionError, "Provisioning timed out for node #{@node.node_name}"
106
+ end
107
+
108
+ async_info("Waiting for #{@node.node_name} to be ready for remote connections...", log_prefix('PROVISION'))
109
+ max_retries -= 1
110
+ sleep(5)
111
+ end
112
+ async_info("Node #{@node.node_name} is ready...", log_prefix('PROVISION'))
113
+ node_desc = {
114
+ test_data: @node.test_data,
115
+ platform: @platform,
116
+ local_port: @node.local_port,
117
+ }.merge(@node.node)
118
+ @node_inventory.add(@node.node_name, node_desc)
119
+ @node_inventory.save
120
+ async_info("Node #{@node.node_name} provisioned in #{Time.now - start_time} seconds", log_prefix('PROVISION'))
121
+ end
122
+
123
+ # Bootstraps the node for the acceptance test suite. Currently, this
124
+ # just uploads and installs the module package.
125
+ def bootstrap
126
+ async_info("Bootstrapping #{@node.node_name}...", log_prefix('BOOTSTRAP'))
127
+ until File.exist?(@module_pkg_path)
128
+ async_debug("Waiting for module package #{@module_pkg_path} to exist...", log_prefix('BOOTSTRAP'))
129
+ sleep(1)
130
+ end
131
+ async_info("Installing module package #{@module_pkg_path}...", log_prefix('BOOTSTRAP'))
132
+ @node.install_puppet_module_package(@module_pkg_path)
133
+ end
134
+
135
+ # Runs the acceptance test suite via rspec.
136
+ def run_tests
137
+ attempts = 0
138
+ until File.exist?(@node_inventory.save_file_path)
139
+ raise 'Node inventory file not found' if (attempts += 1) > 3
140
+
141
+ sleep(1)
142
+ end
143
+ async_info("Running test #{@node.test_data[:test_name]} on node #{@node.node_name}...", log_prefix('RSPEC'))
144
+ @node.run_tests do |cmd_env|
145
+ cmd_opts = rspec_opts
146
+ cmd_opts[:env].merge!(cmd_env) if cmd_env
147
+ # Documentation format gets logged in real time, JSON file is read after the fact
148
+ begin
149
+ @rspec_cmd = CemAcpt::RSpecUtils::Command.new(cmd_opts)
150
+ @rspec_cmd.execute(log_prefix: log_prefix('RSPEC'))
151
+ @run_result.from_json_file(cmd_opts[:format][:json])
152
+ rescue Errno::EIO => e
153
+ async_error("failed to run rspec: #{@node.test_data[:test_name]}: #{$ERROR_INFO}", log_prefix('RSPEC'))
154
+ @run_result.from_error(e)
155
+ rescue StandardError => e
156
+ async_error("failed to run rspec: #{@node.test_data[:test_name]}: #{e.message}", log_prefix('RSPEC'))
157
+ async_debug("Backtrace:\n#{e.backtrace}", log_prefix('RSPEC'))
158
+ @run_result.from_error(e)
159
+ end
160
+ end
161
+ async_info("Tests completed with exit code: #{@run_result.exit_status}", log_prefix('RSPEC'))
162
+ end
163
+
164
+ # Destroys the node for the acceptance test suite.
165
+ def destroy
166
+ kill_spec_pty_if_exists
167
+ if @context.config.get('no_destroy_nodes')
168
+ async_info("Not destroying node #{@node.node_name} because 'no_destroy_nodes' is set to true",
169
+ log_prefix('DESTROY'))
170
+ else
171
+ async_info("Destroying #{@node.node_name}...", log_prefix('DESTROY'))
172
+ @node.destroy
173
+ @node_exists = false
174
+ async_info("Node #{@node.node_name} destroyed successfully", log_prefix('DESTROY'))
175
+ end
176
+ end
177
+
178
+ def kill_spec_pty_if_exists
179
+ @rspec_cmd.kill_pty
180
+ end
181
+
182
+ # Validates the runner configuration.
183
+ def validate!
184
+ raise 'No node provided' unless @node
185
+ raise 'Node does not have config' if @node.config.nil? || @node.config.empty?
186
+ raise 'Node does not have test data' if @node.test_data.nil? || @node.test_data.empty?
187
+ raise 'No node inventory provided' unless @node_inventory
188
+ end
189
+
190
+ # Options used with RSpec
191
+ def rspec_opts
192
+ opts = {
193
+ test_path: @node.test_data[:test_file],
194
+ use_bundler: false,
195
+ bundle_install: false,
196
+ format: {
197
+ json: "results_#{@node.test_data[:test_name]}.json",
198
+ },
199
+ debug: (@debug_mode && @context.config.get('verbose')),
200
+ quiet: @context.config.get('quiet'),
201
+ env: {
202
+ 'TARGET_HOST' => @node.node_name,
203
+ }
204
+ }
205
+ opts[:format][:documentation] = nil unless @context.config.get('verbose')
206
+ opts
207
+ end
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CemAcpt
4
+ module TestRunner
5
+ require 'json'
6
+
7
+ # Error thrown when there are problems parsing the JSON output from Rspec
8
+ class RspecOutputJSONParserError < StandardError
9
+ def initialize(err = nil)
10
+ super err
11
+ set_backtrace err.backtrace unless err.nil?
12
+ end
13
+ end
14
+
15
+ # Class to process the results of a Runner
16
+ class RunnerResult
17
+ attr_reader :run_result
18
+
19
+ def initialize(node, debug: false)
20
+ @node = node
21
+ @run_result = {}
22
+ @debug = debug
23
+ end
24
+
25
+ def to_h
26
+ @run_result
27
+ end
28
+
29
+ def debug?
30
+ @debug
31
+ end
32
+
33
+ def exit_status
34
+ if run_result.nil?
35
+ 99
36
+ elsif run_result.empty?
37
+ 66
38
+ elsif result_errors? || result_failures?
39
+ 1
40
+ else
41
+ 0
42
+ end
43
+ end
44
+
45
+ def result_array
46
+ [run_result, exit_status]
47
+ end
48
+
49
+ def from_json_file(file_path)
50
+ res = JSON.parse(File.read(file_path))
51
+ new_run_result(res)
52
+ rescue StandardError => e
53
+ from_error(RspecOutputJSONParserError.new(e))
54
+ end
55
+
56
+ def from_error(err)
57
+ label = err.class.to_s.start_with?('Errno::') ? 'system_error' : 'standard_error'
58
+ res = {
59
+ label => {
60
+ 'message' => err.message,
61
+ 'error_class' => err.class.to_s,
62
+ 'backtrace' => err.backtrace,
63
+ 'cause' => err.cause,
64
+ 'full_message' => err.full_message,
65
+ }
66
+ }
67
+ res[label]['errno'] = err.errno if label == 'system_error'
68
+ new_run_result(res)
69
+ end
70
+
71
+ def result_errors?
72
+ run_result.keys.any? { |k| k.end_with?('error') }
73
+ end
74
+
75
+ def result_failures?
76
+ if run_result['summary']
77
+ run_result['summary']['failure_count'].positive? ||
78
+ run_result['summary']['errors_outside_of_examples_count'].positive?
79
+ else
80
+ true
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ def new_run_result(results)
87
+ @run_result = debug? ? with_debug_data(results) : results
88
+ end
89
+
90
+ def with_debug_data(results)
91
+ results.merge(
92
+ {
93
+ 'debug' => {
94
+ 'node' => @node,
95
+ 'config' => @config,
96
+ 'test_data' => @test_data,
97
+ }
98
+ }
99
+ )
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CemAcpt
4
+ # Namespace for all Runner-related classes and modules
5
+ module TestRunner
6
+ require_relative 'test_runner/run_handler'
7
+ require_relative 'test_runner/runner'
8
+ require_relative 'test_runner/runner_result'
9
+ end
10
+ end