cem_acpt 0.1.0 → 0.2.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.
@@ -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,228 @@
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
+ <<<<<<< HEAD
46
+ validate!
47
+ end
48
+
49
+ =======
50
+ @completed_steps = []
51
+ validate!
52
+ end
53
+
54
+ def run_step(step_sym)
55
+ send(step_sym)
56
+ @completed_steps << step_sym
57
+ rescue StandardError => e
58
+ err = CemAcpt::TestRunner::RunnerStepError.new(step_sym, e)
59
+ step_error_logging(err)
60
+ @run_result.from_error(err)
61
+ destroy unless step_sym == :destroy
62
+ end
63
+
64
+ >>>>>>> 489757f (Fixes for race conditions)
65
+ # Executes test suite steps
66
+ def start
67
+ async_info("Starting test suite for #{@node.node_name}", log_prefix('RUNNER'))
68
+ run_step(:provision)
69
+ run_step(:bootstrap)
70
+ run_step(:run_tests)
71
+ run_step(:destroy)
72
+ true
73
+ rescue StandardError => e
74
+ step_error_logging(e)
75
+ @run_result.from_error(e)
76
+ destroy
77
+ end
78
+
79
+ # Checks for failures in the test results.
80
+ # @param result [Hash] the test result to check
81
+ # @return [Boolean] whether or not there are test failures in result
82
+ def test_failures?
83
+ @run_result.result_errors? || @run_result.result_failures?
84
+ end
85
+
86
+ private
87
+
88
+ <<<<<<< HEAD
89
+ def run_step(step_sym)
90
+ send(step_sym)
91
+ rescue StandardError => e
92
+ raise CemAcpt::TestRunner::RunnerStepError.new(step_sym, e)
93
+ end
94
+
95
+ =======
96
+ >>>>>>> 489757f (Fixes for race conditions)
97
+ def step_error_logging(err)
98
+ prefix = err.respond_to?(:step) ? log_prefix(err.step.capitalize) : log_prefix('RUNNER')
99
+ fatal_msg = ["runner failed: #{err.message}"]
100
+ async_fatal(fatal_msg, prefix)
101
+ <<<<<<< HEAD
102
+ =======
103
+ async_debug("Completed steps: #{@completed_steps}", prefix)
104
+ >>>>>>> 489757f (Fixes for race conditions)
105
+ async_debug("Failed runner backtrace:\n#{err.backtrace.join("\n")}", prefix)
106
+ async_debug("Failed runner test data: #{@node.test_data}", prefix)
107
+ end
108
+
109
+ def log_prefix(prefix)
110
+ "#{prefix}: #{@node.test_data[:test_name]}:"
111
+ end
112
+
113
+ # Provisions the node for the acceptance test suite.
114
+ def provision
115
+ async_info("Provisioning #{@node.node_name}...", log_prefix('PROVISION'))
116
+ start_time = Time.now
117
+ @node.provision
118
+ @node_exists = true
119
+ max_retries = 60 # equals 300 seconds because we check every five seconds
120
+ until @node.ready?
121
+ if max_retries <= 0
122
+ async_fatal("Node #{@node.node_name} failed to provision", log_prefix('PROVISION'))
123
+ raise CemAcpt::TestRunner::RunnerProvisionError, "Provisioning timed out for node #{@node.node_name}"
124
+ end
125
+
126
+ async_info("Waiting for #{@node.node_name} to be ready for remote connections...", log_prefix('PROVISION'))
127
+ max_retries -= 1
128
+ sleep(5)
129
+ end
130
+ async_info("Node #{@node.node_name} is ready...", log_prefix('PROVISION'))
131
+ node_desc = {
132
+ test_data: @node.test_data,
133
+ platform: @platform,
134
+ local_port: @node.local_port,
135
+ }.merge(@node.node)
136
+ @node_inventory.add(@node.node_name, node_desc)
137
+ @node_inventory.save
138
+ async_info("Node #{@node.node_name} provisioned in #{Time.now - start_time} seconds", log_prefix('PROVISION'))
139
+ end
140
+
141
+ # Bootstraps the node for the acceptance test suite. Currently, this
142
+ # just uploads and installs the module package.
143
+ def bootstrap
144
+ async_info("Bootstrapping #{@node.node_name}...", log_prefix('BOOTSTRAP'))
145
+ until File.exist?(@module_pkg_path)
146
+ async_debug("Waiting for module package #{@module_pkg_path} to exist...", log_prefix('BOOTSTRAP'))
147
+ sleep(1)
148
+ end
149
+ async_info("Installing module package #{@module_pkg_path}...", log_prefix('BOOTSTRAP'))
150
+ @node.install_puppet_module_package(@module_pkg_path)
151
+ end
152
+
153
+ # Runs the acceptance test suite via rspec.
154
+ def run_tests
155
+ attempts = 0
156
+ until File.exist?(@node_inventory.save_file_path)
157
+ raise 'Node inventory file not found' if (attempts += 1) > 3
158
+
159
+ sleep(1)
160
+ end
161
+ async_info("Running test #{@node.test_data[:test_name]} on node #{@node.node_name}...", log_prefix('RSPEC'))
162
+ @node.run_tests do |cmd_env|
163
+ cmd_opts = rspec_opts
164
+ cmd_opts[:env].merge!(cmd_env) if cmd_env
165
+ # Documentation format gets logged in real time, JSON file is read after the fact
166
+ begin
167
+ @rspec_cmd = CemAcpt::RSpecUtils::Command.new(cmd_opts)
168
+ @rspec_cmd.execute(log_prefix: log_prefix('RSPEC'))
169
+ @run_result.from_json_file(cmd_opts[:format][:json])
170
+ rescue Errno::EIO => e
171
+ async_error("failed to run rspec: #{@node.test_data[:test_name]}: #{$ERROR_INFO}", log_prefix('RSPEC'))
172
+ @run_result.from_error(e)
173
+ rescue StandardError => e
174
+ async_error("failed to run rspec: #{@node.test_data[:test_name]}: #{e.message}", log_prefix('RSPEC'))
175
+ async_debug("Backtrace:\n#{e.backtrace}", log_prefix('RSPEC'))
176
+ @run_result.from_error(e)
177
+ end
178
+ end
179
+ async_info("Tests completed with exit code: #{@run_result.exit_status}", log_prefix('RSPEC'))
180
+ end
181
+
182
+ # Destroys the node for the acceptance test suite.
183
+ def destroy
184
+ kill_spec_pty_if_exists
185
+ if @context.config.get('no_destroy_nodes')
186
+ async_info("Not destroying node #{@node.node_name} because 'no_destroy_nodes' is set to true",
187
+ log_prefix('DESTROY'))
188
+ else
189
+ async_info("Destroying #{@node.node_name}...", log_prefix('DESTROY'))
190
+ @node.destroy
191
+ @node_exists = false
192
+ async_info("Node #{@node.node_name} destroyed successfully", log_prefix('DESTROY'))
193
+ end
194
+ end
195
+
196
+ def kill_spec_pty_if_exists
197
+ @rspec_cmd.kill_pty
198
+ end
199
+
200
+ # Validates the runner configuration.
201
+ def validate!
202
+ raise 'No node provided' unless @node
203
+ raise 'Node does not have config' if @node.config.nil? || @node.config.empty?
204
+ raise 'Node does not have test data' if @node.test_data.nil? || @node.test_data.empty?
205
+ raise 'No node inventory provided' unless @node_inventory
206
+ end
207
+
208
+ # Options used with RSpec
209
+ def rspec_opts
210
+ opts = {
211
+ test_path: @node.test_data[:test_file],
212
+ use_bundler: false,
213
+ bundle_install: false,
214
+ format: {
215
+ json: "results_#{@node.test_data[:test_name]}.json",
216
+ },
217
+ debug: (@debug_mode && @context.config.get('verbose')),
218
+ quiet: @context.config.get('quiet'),
219
+ env: {
220
+ 'TARGET_HOST' => @node.node_name,
221
+ }
222
+ }
223
+ opts[:format][:documentation] = nil unless @context.config.get('verbose')
224
+ opts
225
+ end
226
+ end
227
+ end
228
+ 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