cem_acpt 0.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.
@@ -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