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,304 +0,0 @@
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