cem_acpt 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,304 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent-ruby'
4
+ require 'json'
5
+ require 'open3'
6
+
7
+ module CemAcpt
8
+ require_relative 'bootstrap'
9
+ require_relative 'context'
10
+ require_relative 'logging'
11
+ require_relative 'puppet_helpers'
12
+ require_relative 'test_data'
13
+ require_relative 'utils'
14
+
15
+ # RunHandler orchestrates the acceptance test suites, including
16
+ # creating Runner objects, handling input and output, and exception
17
+ # handling.
18
+ class RunHandler
19
+ include CemAcpt::Logging
20
+
21
+ attr_accessor :params, :config_file
22
+
23
+ # @param params [Hash] the parameters passed from the command line
24
+ def initialize(params)
25
+ @params = params
26
+ @config_file = params[:config_file] || File.expand_path('./cem_acpt_config.yaml')
27
+ @module_pkg_path = Concurrent::IVar.new
28
+ @start_time = nil
29
+ @runners = Concurrent::Array.new
30
+ @results = Concurrent::Map.new
31
+ end
32
+
33
+ # Runs the acceptance test suites.
34
+ def run
35
+ @start_time = Time.now
36
+ logger.info("Running acceptance test suite at #{@start_time}")
37
+ logger.debug("Params: #{@params}")
38
+ logger.debug("Config file: #{@config_file}")
39
+ logger.info("Using module directory: #{@params[:module_dir]}")
40
+ context_opts = {
41
+ config_opts: @params,
42
+ config_file: @config_file,
43
+ }
44
+ build_module_package
45
+ begin
46
+ keep_terminal_alive if params[:CI] || ENV['CI'] || ENV['GITHUB_ACTION']
47
+ create_and_start_runners(context_opts)
48
+ rescue StandardError, SignalException, SystemExit => e
49
+ handle_fatal_error(e)
50
+ ensure
51
+ @keep_terminal_alive&.exit
52
+ end
53
+ handle_test_results
54
+ exit_code = @runners.map(&:spec_exit_code).any? { |rc| rc != 0 } ? 1 : 0
55
+ exit exit_code
56
+ end
57
+
58
+ private
59
+
60
+ # Prints periods to the terminal in a single line to keep the terminal
61
+ # alive. This is used when running in CI mode.
62
+ def keep_terminal_alive
63
+ @keep_terminal_alive = Thread.new do
64
+ loop do
65
+ $stdout.print("|\r")
66
+ sleep(1)
67
+ $stdout.print("/\r")
68
+ sleep(1)
69
+ $stdout.print("-\r")
70
+ sleep(1)
71
+ $stdout.print("\\\r")
72
+ sleep(1)
73
+ end
74
+ end
75
+ end
76
+
77
+ # @return [String] the module directory that contains the acceptance tests
78
+ def module_dir
79
+ if @params.key?(:working_dir)
80
+ @params[:working_dir]
81
+ else
82
+ Dir.pwd
83
+ end
84
+ end
85
+
86
+ # Builds the module package in a separate thread.
87
+ # This thread is set to abort_on_exception so that any
88
+ # exceptions raised in the thread will be caught and
89
+ # handled by the main thread.
90
+ def build_module_package
91
+ pkg_thread = Thread.new do
92
+ pkg_path = CemAcpt::PuppetHelpers::Module.build_module_package(@params[:module_dir])
93
+ @module_pkg_path.set(pkg_path)
94
+ end
95
+ pkg_thread.abort_on_exception = true
96
+ end
97
+
98
+ # Creates and starts Runner objects for each node in the acceptance test suites.
99
+ # @param context_opts [Hash] the options to pass to the Context object
100
+ # @param timeout [Integer] the timeout to use for the Runner object threads in seconds
101
+ def create_and_start_runners(context_opts, timeout = 600)
102
+ CemAcpt::Context.with(**context_opts) do |nodes, conf, tdata, node_inv|
103
+ nodes.each do |node|
104
+ runner = CemAcpt::Runner.new(node, conf, tdata, node_inv, @module_pkg_path, @results)
105
+ runner.start
106
+ @runners << runner
107
+ end
108
+ @runners.each { |r| r.join(timeout) }
109
+ end
110
+ end
111
+
112
+ # Handles how test results are logged.
113
+ def handle_test_results
114
+ @results.each_pair do |node, result|
115
+ logger.info("SUMMARY: #{result['summary_line']} on node #{node}")
116
+ next unless test_failures?(result)
117
+
118
+ failed = result['examples'].reject { |e| e['status'] == 'passed' }
119
+ failed.each do |e|
120
+ logger.error(test_error_msg(node, e))
121
+ end
122
+ end
123
+ debug_test_results if logger.debug?
124
+ end
125
+
126
+ # Formats a test result for tests that have failed. Is used for logging.
127
+ # @param node [String] the name of the node the test ran on
128
+ # @param result [Hash] the test result to format
129
+ # @return [String] the formatted test result
130
+ def test_error_msg(node, result)
131
+ [
132
+ "TEST FAILED: #{result['id']}",
133
+ "DESCRIPTION: #{result['full_description']}",
134
+ "STATUS: #{result['status']}",
135
+ "LOCATION: #{result['file_path']}:#{result['line_number']}",
136
+ "NODE: #{node}",
137
+ result['exception']['message'],
138
+ "\n",
139
+ ].join("\n")
140
+ end
141
+
142
+ # Logs performance data for the acceptance test suites.
143
+ # This is only logged if the log level is set to debug.
144
+ def debug_test_results
145
+ examples_by_time = []
146
+ @results.each_pair do |node, result|
147
+ result['examples'].each do |e|
148
+ examples_by_time << [e['run_time'], e['id'], e['status'], e['line_number'], node]
149
+ end
150
+ end
151
+ logger.debug('Showing test results in order of execution time...')
152
+ examples_by_time.sort_by(&:first).reverse.each do |e|
153
+ logger.debug("RUNTIME: #{e[0]}; ID: #{e[1]}; STATUS: #{e[2]}; LINE: #{e[3]}; HOST: #{e[4]};")
154
+ end
155
+ end
156
+
157
+ # Checks for failures in the test results.
158
+ # @param result [Hash] the test result to check
159
+ # @return [Boolean] whether or not there are test failures in result
160
+ def test_failures?(result)
161
+ result['summary']['failure_count'].positive? || result['summary']['errors_outside_of_examples_count'].positive?
162
+ end
163
+
164
+ # Gracefully handles a fatal error and exits the program.
165
+ # @param err [StandardError, Exception] the error that caused the fatal error
166
+ def handle_fatal_error(err)
167
+ @keep_terminal_alive&.exit
168
+ logger.fatal("Fatal error: #{err.message}")
169
+ logger.debug(err.backtrace.join('; '))
170
+ kill_runners
171
+ logger.fatal("Exiting with status 1 after #{Time.now - @start_time} seconds")
172
+ exit(1)
173
+ end
174
+
175
+ # Kills all running Runner objects.
176
+ def kill_runners
177
+ @runners.each(&:kill) unless @runners.empty?
178
+ end
179
+ end
180
+
181
+ # Runner is a class that runs a single acceptance test suite on a single node.
182
+ # It is responsible for managing the lifecycle of the test suite and
183
+ # reporting the results back to the main thread. Runner objects are created
184
+ # by the RunHandler and then, when started, execute their logic in a thread.
185
+ class Runner
186
+ include CemAcpt::Logging
187
+
188
+ attr_reader :spec_exit_code
189
+
190
+ # @param node [String] the name of the node to run the acceptance test suite on
191
+ # @param conf [CemAcpt::Config] the acceptance test suite configuration
192
+ # @param tdata [Hash] the test data to use for the acceptance test suite
193
+ # @param node_inv [CemAcpt::NodeInventory] the node inventory to use for the acceptance test suite
194
+ # @param module_pkg_path [Concurrent::IVar] the path to the module package
195
+ # @param results [Concurrent::Map] the results map to use for reporting test results
196
+ def initialize(node, conf, tdata, node_inv, module_pkg_path, results)
197
+ @node = node
198
+ @conf = conf
199
+ @tdata = tdata
200
+ @node_inv = node_inv
201
+ @module_pkg_path = module_pkg_path
202
+ @results = results
203
+ @spec_exit_code = 0
204
+ validate!
205
+ end
206
+
207
+ # Starts the runner thread and runs the lifecycle stages of the
208
+ # acceptance test suite.
209
+ def start
210
+ logger.info("Starting test suite for #{@node.node_name}")
211
+ @thread = Thread.new do
212
+ provision
213
+ bootstrap
214
+ run_tests
215
+ destroy
216
+ end
217
+ end
218
+
219
+ # Joins the runner thread.
220
+ # @param timeout [Integer] thread timeout in seconds
221
+ def join(timeout = 600)
222
+ @thread.join(timeout)
223
+ end
224
+
225
+ # Runner thread exits and runner node is destroyed, if it exists.
226
+ def exit
227
+ @thread.exit
228
+ @node&.destroy
229
+ end
230
+
231
+ # Runner thread is killed and runner node is destroyed, if it exists.
232
+ def kill
233
+ @thread.kill
234
+ @node&.destroy
235
+ end
236
+
237
+ private
238
+
239
+ # Provisions the node for the acceptance test suite.
240
+ def provision
241
+ logger.info("Provisioning #{@node.node_name}...")
242
+ start_time = Time.now
243
+ @node.provision
244
+ sleep(1) until @node.ready?
245
+ logger.info("Node #{@node.node_name} is ready...")
246
+ node_desc = {
247
+ test_data: @node.test_data,
248
+ platform: @conf.get('platform'),
249
+ local_port: @node.local_port,
250
+ }.merge(@node.node)
251
+ @node_inv.add(@node.node_name, node_desc)
252
+ logger.info("Node #{@node.node_name} provisioned in #{Time.now - start_time} seconds")
253
+ @node_inv.save(File.join(@conf.get('module_dir'), 'spec', 'fixtures', 'node_inventory.yaml'))
254
+ end
255
+
256
+ # Bootstraps the node for the acceptance test suite. Currently, this
257
+ # just uploads and installs the module package.
258
+ def bootstrap
259
+ logger.info("Bootstrapping #{@node.node_name}...")
260
+ until File.exist?(@module_pkg_path.value)
261
+ logger.debug("Waiting for module package #{@module_pkg_path.value} to exist...")
262
+ sleep(1)
263
+ end
264
+ logger.info("Installing module package #{@module_pkg_path.value}...")
265
+ @node.install_puppet_module_package(@module_pkg_path.value)
266
+ end
267
+
268
+ # Runs the acceptance test suite via rspec.
269
+ def run_tests
270
+ require 'json'
271
+
272
+ logger.info("Running tests for #{@node.node_name}...")
273
+ stdout = nil
274
+ stderr = nil
275
+ # ENV['RSPEC_DEBUG'] = 'true' if @conf.get('log_level') == 'debug'
276
+ test_command = "cd #{@conf.get('module_dir')} && bundle exec rspec #{@node.test_data[:test_file]} --format json"
277
+ @node.run_tests do
278
+ stdout, stderr, status = Open3.capture3(test_command)
279
+ @spec_exit_code = status.exitstatus
280
+ end
281
+ logger.info("Tests completed with exit code: #{@spec_exit_code}")
282
+ @results.put_if_absent(@node.node_name, JSON.parse(stdout))
283
+ end
284
+
285
+ # Destroys the node for the acceptance test suite.
286
+ def destroy
287
+ if @conf.get('no_destroy_nodes')
288
+ logger.info("Not destroying node #{@node.node_name} because 'no_destroy_nodes' is set to true")
289
+ return
290
+ end
291
+ logger.info("Destroying #{@node.node_name}...")
292
+ @node.destroy
293
+ logger.info("Node #{@node.node_name} destroyed successfully")
294
+ end
295
+
296
+ # Validates the runner configuration.
297
+ def validate!
298
+ raise 'No node provided' unless @node
299
+ raise 'No config provided' unless @conf
300
+ raise 'No test data provided' unless @tdata
301
+ raise 'No node inventory provided' unless @node_inv
302
+ end
303
+ end
304
+ end