cem_acpt 0.7.3 → 0.8.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'fileutils'
4
+ require 'securerandom'
5
+ require_relative 'action_result'
3
6
  require_relative 'goss'
4
7
  require_relative 'logging'
5
8
  require_relative 'platform'
@@ -17,14 +20,17 @@ module CemAcpt
17
20
  include CemAcpt::Logging
18
21
 
19
22
  attr_reader :duration, :exit_code
23
+ attr_accessor :run_data # This is opened up mainly for windows use.
20
24
 
21
25
  def initialize(config)
22
26
  @config = config
23
27
  @run_data = {}
24
28
  @duration = 0
25
29
  @exit_code = 0
26
- @results = nil
30
+ @results = []
27
31
  @http_statuses = []
32
+ @provisioned = false
33
+ @destroyed = false
28
34
  end
29
35
 
30
36
  def inspect
@@ -38,149 +44,222 @@ module CemAcpt
38
44
  def run
39
45
  @run_data = {}
40
46
  @start_time = Time.now
41
- logger.with_ci_group("CemAcpt v#{CemAcpt::VERSION} run started at #{@start_time}") do
42
- logger.info('CemAcpt') { "Using module directory: #{config.get('module_dir')}..." }
43
- Dir.chdir(config.get('module_dir')) do
44
- keep_terminal_alive
45
- @run_data[:private_key], @run_data[:public_key], @run_data[:known_hosts] = new_ephemeral_ssh_keys
46
- logger.info('CemAcpt') { 'Created ephemeral SSH key pair...' }
47
- @run_data[:module_package_path] = build_module_package
48
- logger.info('CemAcpt') { "Created module package: #{@run_data[:module_package_path]}..." }
49
- @run_data[:test_data] = new_test_data
50
- logger.info('CemAcpt') { 'Created test data...' }
51
- logger.verbose('CemAcpt') { "Test data: #{@run_data[:test_data]}" }
52
- @run_data[:nodes] = new_node_data
53
- logger.info('CemAcpt') { 'Created node data...' }
54
- logger.verbose('CemAcpt') { "Node data: #{@run_data[:nodes]}" }
55
- @instance_names_ips = provision_test_nodes
56
- logger.info('CemAcpt') { 'Provisioned test nodes...' }
57
- logger.debug('CemAcpt') { "Instance names and IPs: #{@instance_names_ips}" }
58
- @results = run_tests(@instance_names_ips.map { |_, v| v['ip'] },
59
- config.get('actions.only'),
60
- config.get('actions.except'))
47
+ module_dir = config.get('module_dir')
48
+ @old_dir = Dir.pwd
49
+ Dir.chdir(module_dir)
50
+ logger.start_ci_group("CemAcpt v#{CemAcpt::VERSION} run started at #{@start_time}")
51
+ logger.info('CemAcpt::TestRunner') { "Using module directory: #{module_dir}..." }
52
+ @run_data[:private_key], @run_data[:public_key], @run_data[:known_hosts] = new_ephemeral_ssh_keys
53
+ logger.info('CemAcpt::TestRunner') { 'Created ephemeral SSH key pair...' }
54
+ @run_data[:module_package_path] = build_module_package
55
+ logger.info('CemAcpt::TestRunner') { "Created module package: #{@run_data[:module_package_path]}..." }
56
+ @run_data[:test_data] = new_test_data
57
+ logger.info('CemAcpt::TestRunner') { 'Created test data...' }
58
+ logger.verbose('CemAcpt::TestRunner') { "Test data: #{@run_data[:test_data]}" }
59
+ @run_data[:nodes] = new_node_data
60
+ logger.info('CemAcpt::TestRunner') { 'Created node data...' }
61
+ logger.verbose('CemAcpt::TestRunner') { "Node data: #{@run_data[:nodes]}" }
62
+ @instance_names_ips = provision_test_nodes
63
+ logger.info('CemAcpt::TestRunner') { "Instance names and IPs class: #{@instance_names_ips.class}" }
64
+ @provisioned = true
65
+ logger.info('CemAcpt::TestRunner') { 'Provisioned test nodes...' }
66
+ logger.debug('CemAcpt::TestRunner') { "Instance names and IPs: #{@instance_names_ips}" }
67
+ # Verifying that we're running on windows nodes or not
68
+ if config.get('tests').first.include? 'windows'
69
+ logger.info('CemAcpt') { 'Running on windows nodes...' }
70
+ upload_module_to_bucket
71
+
72
+ @instance_names_ips.each do |k, v|
73
+ # Login_info here is basically a super charged version of a hash from
74
+ # instance_names_ips. It contains the username, password, and ip of the
75
+ # windows node, as well as the test name that will be run on that node.
76
+ login_info = CemAcpt::Utils.get_windows_login_info(k, v)
77
+ win_node = CemAcpt::Utils::WinRMRunner::WinNode.new(login_info, @run_data[:win_remote_module_name])
78
+ win_node.run
61
79
  end
62
80
  end
81
+ @results = run_tests(@instance_names_ips.map { |_, v| v['ip'] },
82
+ config.get('actions.only'),
83
+ config.get('actions.except'))
84
+ rescue StandardError => e
85
+ logger.error('CemAcpt::TestRunner') { 'Run failed due to error...' }
86
+ @results << ActionResult.new(e)
63
87
  ensure
88
+ logger.end_ci_group
64
89
  clean_up
65
90
  process_test_results
91
+ Dir.chdir(@old_dir) if @old_dir
92
+ @results
66
93
  end
67
94
 
68
- def clean_up(trap_context = false)
69
- kill_keep_terminal_alive unless trap_context
70
-
71
- return no_destroy if config.get('no_destroy_nodes')
95
+ def clean_up(_trap_context = false)
96
+ logger.start_ci_group("CemAcpt v#{CemAcpt::VERSION} run finished at #{Time.now}")
97
+ logger.debug('CemAcpt::TestRunner') { "Starting clean up, provisioned: #{@provisioned}, destroyed: #{@destroyed}" }
72
98
 
73
- clean_ephemeral_ssh_keys
74
- destroy_test_nodes
99
+ if config.get('no_destroy_nodes')
100
+ logger.warn('CemAcpt::TestRunner') { 'Not destroying test nodes because no-destroy-nodes is set...' }
101
+ @provisioner&.show
102
+ logger.info('CemAcpt') { "Test SSH Keys:\n Private Key: #{@run_data[:private_key]}\n Public Key:#{@run_data[:public_key]}" }
103
+ else
104
+ cleanup_bucket # Clean up bucket if we're testing the cem_windows module
105
+ clean_ephemeral_ssh_keys
106
+ destroy_test_nodes
107
+ end
108
+ rescue StandardError => e
109
+ logger.verbose('CemAcpt::TestRunner') { "Error cleaning up: #{e}" }
110
+ logger.verbose('CemAcpt::TestRunner') { e.backtrace.join("\n") }
111
+ ensure
112
+ logger.end_ci_group
75
113
  end
76
114
 
77
115
  private
78
116
 
79
117
  attr_reader :config
80
118
 
81
- # @return [Thread] The thread that keeps the terminal alive
82
- def keep_terminal_alive
83
- return unless config.ci?
84
-
85
- @keep_terminal_alive ||= CemAcpt::Utils::Terminal.keep_terminal_alive
86
- end
87
-
88
- def kill_keep_terminal_alive
89
- return if @trap_context
90
-
91
- keep_terminal_alive&.kill
92
- end
93
-
94
119
  # @return [String] The path to the module package
95
120
  def build_module_package
96
- CemAcpt::Utils::Puppet.build_module_package(config.get('module_dir'))
121
+ if config.get('tests').first.include? 'windows'
122
+ CemAcpt::Utils.package_win_module(config.get('module_dir'))
123
+ else
124
+ CemAcpt::Utils::Puppet.build_module_package(config.get('module_dir'))
125
+ end
97
126
  end
98
127
 
99
128
  # @return [Array<String>] The paths to the ssh private key, public key, and known hosts file
100
129
  def new_ephemeral_ssh_keys
101
130
  return [nil, nil, nil] if config.get('no_ephemeral_ssh_key')
102
131
 
132
+ logger.info('CemAcpt::TestRunner') { 'Creating ephemeral SSH keys...' }
103
133
  CemAcpt::Utils::SSH::Ephemeral.create
104
134
  end
105
135
 
106
136
  def clean_ephemeral_ssh_keys
107
137
  return if config.get('no_ephemeral_ssh_key') || config.get('no_destroy_nodes')
108
138
 
139
+ logger.info('CemAcpt::TestRunner') { 'Cleaning ephemeral SSH keys...' }
109
140
  CemAcpt::Utils::SSH::Ephemeral.clean
110
141
  end
111
142
 
112
143
  def new_test_data
144
+ logger.debug('CemAcpt::TestRunner') { 'Creating new test data...' }
113
145
  CemAcpt::TestData.acceptance_test_data(config)
114
146
  end
115
147
 
116
148
  def new_node_data
149
+ logger.debug('CemAcpt::TestRunner') { 'Creating new node data...' }
117
150
  CemAcpt::Platform.use(config.get('platform.name'), config, @run_data)
118
151
  end
119
152
 
120
153
  def provision_test_nodes
154
+ logger.info('CemAcpt::TestRunner') { 'Provisioning test nodes...' }
121
155
  @provisioner = CemAcpt::Provision.new_provisioner(config, @run_data)
122
156
  @provisioner.provision
123
157
  end
124
158
 
125
159
  def destroy_test_nodes
126
- return no_destroy if config.get('no_destroy_nodes')
127
-
128
- logger.with_ci_group("CemAcpt v#{CemAcpt::VERSION} run finished at #{Time.now}") { @provisioner&.destroy }
160
+ logger.info('CemAcpt::TestRunner') { 'Destroying test nodes if necessary...' }
161
+ if !@provisioned
162
+ logger.warn('CemAcpt::TestRunner') { 'Test nodes not provisioned, nothing to destroy...' }
163
+ elsif @destroyed
164
+ logger.warn('CemAcpt::TestRunner') { 'Test nodes already destroyed, not destroying...' }
165
+ else
166
+ logger.info('CemAcpt::TestRunner') { 'Test nodes are provisioned and not destroyed, destroying...' }
167
+ @provisioner&.destroy
168
+ @destroyed = true
169
+ end
129
170
  end
130
171
 
131
172
  def no_destroy
132
- logger.warn('CemAcpt') { 'Not destroying test nodes...' }
133
- logger.with_ci_group("CemAcpt v#{CemAcpt::VERSION} run finished at #{Time.now}") do
134
- @provisioner&.show
135
- logger.info('CemAcpt') { "Test SSH Keys:\n Private Key: #{@run_data[:private_key]}\n Public Key:#{@run_data[:public_key]}" }
136
- end
173
+ logger.warn('CemAcpt::TestRunner') { 'Not destroying test nodes because no-destroy-nodes is set...' }
174
+ @provisioner&.show
175
+ logger.info('CemAcpt') { "Test SSH Keys:\n Private Key: #{@run_data[:private_key]}\n Public Key:#{@run_data[:public_key]}" }
137
176
  end
138
177
 
139
178
  def run_tests(hosts, only_actions, except_actions)
140
- only_actions = [] if only_actions.nil?
141
- except_actions = [] if except_actions.nil?
142
- CemAcpt::Goss::Api.run_actions_async(hosts, only: only_actions, except: except_actions)
143
- end
144
-
145
- def result_log_formatter
146
- @result_log_formatter ||= LogFormatter::GossActionResponse.new(config, @instance_names_ips)
179
+ logger.info('CemAcpt::TestRunner') { 'Running tests...' }
180
+ logger.verbose('CemAcpt::TestRunner') { "Hosts: #{hosts}" }
181
+ logger.verbose('CemAcpt::TestRunner') { "Only actions: #{only_actions}" }
182
+ logger.verbose('CemAcpt::TestRunner') { "Except actions: #{except_actions}" }
183
+ api_results = CemAcpt::Goss::Api.run_actions_async(hosts,
184
+ only: only_actions || [],
185
+ except: except_actions || [])
186
+ res = []
187
+ api_results.close unless api_results.closed?
188
+ while (r = api_results.pop)
189
+ res << ActionResult.new(r)
190
+ end
191
+ res
147
192
  end
148
193
 
149
194
  def process_test_results
150
- if @results.nil?
151
- logger.error('CemAcpt') { 'No test results to process' }
195
+ if @results.nil? || @results.empty?
196
+ logger.error('CemAcpt::TestRunner') { 'No test results to process' }
152
197
  @exit_code = 1
153
198
  else
199
+ logger.info('CemAcpt::TestRunner') { "Processing #{@results.size} test result(s)..." }
154
200
  until @results.empty?
155
201
  result = @results.pop
156
202
  @http_statuses << result.http_status
157
203
  log_test_result(result)
158
204
  end
159
205
  if @http_statuses.empty?
160
- logger.error('CemAcpt') { 'No test results to process' }
206
+ logger.error('CemAcpt::TestRunner') { 'No test results to process' }
161
207
  @exit_code = 1
162
208
  else
163
- @exit_code = @http_statuses.any? { |s| s.to_i != 200 } ? 1 : 0
209
+ @exit_code = (@http_statuses.any? { |s| s.to_i != 200 }) ? 1 : 0
164
210
  end
165
211
  end
166
212
  @duration = Time.now - @start_time
167
- logger.info('CemAcpt') { "Test suite finished after ~#{duration.round} seconds." }
213
+ logger.info('CemAcpt::TestRunner') { "Test suite finished after ~#{duration.round} seconds." }
168
214
  end
169
215
 
170
216
  def log_test_result(result)
171
- logger.with_ci_group("Test results for #{result_log_formatter.test_name(result)}") do
172
- logger.info(result_log_formatter.summary(result))
173
- formatted_results = result_log_formatter.results(result)
174
- formatted_results.each do |r|
175
- if r.start_with?('Passed:')
176
- logger.verbose { r }
177
- elsif r.start_with?('Skipped:')
178
- logger.info { r }
179
- else
180
- logger.error { r }
181
- end
217
+ result_log_formatter = LogFormatter.new_formatter(result, config, @instance_names_ips)
218
+ logger.start_ci_group("Test results for #{result_log_formatter.test_name(result)}")
219
+ return log_error_test_result(result_log_formatter, result) if result.error?
220
+
221
+ logger.info(result_log_formatter.summary(result))
222
+ formatted_results = result_log_formatter.results(result)
223
+ formatted_results.each do |r|
224
+ if r.start_with?('Passed:')
225
+ logger.verbose { r }
226
+ elsif r.start_with?('Skipped:')
227
+ logger.info { r }
228
+ else
229
+ logger.error { r }
182
230
  end
183
231
  end
232
+ ensure
233
+ logger.end_ci_group
234
+ end
235
+
236
+ def log_error_test_result(formatter, result)
237
+ logger.fatal { formatter.results(result).join("\n") }
238
+ end
239
+
240
+ # Upload the cem_windows module to the bucket if we're testing the cem_windows module
241
+ # This should only be done once per cem_acpt run. It's important to update the module_package_path
242
+ # in the run_data to reflect the new module path if we do end up changing the module name
243
+ def upload_module_to_bucket
244
+ @run_data[:win_remote_module_name] = SecureRandom.uuid << File.split(@run_data[:module_package_path]).last
245
+ @run_data[:win_remote_module_path] = File.join('gs://win_cem_acpt', @run_data[:win_remote_module_name])
246
+ # Upload the module from the local host to the bucket
247
+ logger.info('CemAcpt') { "Uploading #{@run_data[:module_pakage_path]} to #{@run_data[:win_remote_module_path]}..." }
248
+ CemAcpt::Utils::Shell.run_cmd("gcloud storage cp #{@run_data[:module_package_path]} #{@run_data[:win_remote_module_path]}")
249
+ logger.debug('CemAcpt') { 'Successfully uploaded module' }
250
+ end
251
+
252
+ # We have to clean up our gcp bucket after we're done testing. This will limit the duplicated
253
+ # modules existing in the bucket.
254
+ def cleanup_bucket
255
+ if @run_data[:win_remote_module_path]
256
+ logger.info('CemAcpt::TestRunner') { "Cleaning up bucket #{@run_data[:win_remote_module_path]}..." }
257
+ # Cleanup the module from the bucket
258
+ cleanup_cmd = CemAcpt::Utils::Shell.run_cmd("gcloud storage rm #{@run_data[:win_remote_module_path]}", raise_on_error: false)
259
+ logger.debug('CemAcpt::TestRunner') { "Removed module from bucket: #{cleanup_cmd}" } unless cleanup_cmd.nil? || cleanup_cmd.empty?
260
+ else
261
+ logger.info('CemAcpt::TestRunner') { 'No module to clean up in bucket...' }
262
+ end
184
263
  end
185
264
  end
186
265
  end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'stringio'
5
+
6
+ module CemAcpt
7
+ # Error class for shell commands
8
+ class ShellCommandError < StandardError; end
9
+
10
+ module Utils
11
+ # Generic utilities for running local shell commands
12
+ module Shell
13
+ # Runs a command in a subshell and returns the Process::Status
14
+ # and the string output of the command.
15
+ # @param cmd [String] The command to run
16
+ # @param env [Hash] A hash of environment variables to set
17
+ # @param output [IO] An IO object that implements #:<< to write the output of the
18
+ # command to in real time. Typically this is a Logger object. Defaults to $stdout.
19
+ # If the object responds to #:debug, the command will be logged at the debug level.
20
+ # @param raise_on_fail [Boolean] Whether to raise an error if the command fails
21
+ # @return [String] The string output of the command
22
+ def self.run_cmd(cmd, env = {}, output: $stdout, raise_on_fail: true)
23
+ io_outerr = StringIO.new
24
+ if output.respond_to?(:debug)
25
+ output.debug('CemAcpt::Utils::Shell') { "Running command:\n\t#{cmd}\nWith environment:\n\t#{env}" }
26
+ else
27
+ output << "Running command:\n\t#{cmd}\nWith environment:\n\t#{env}\n"
28
+ end
29
+ val = Open3.popen2e(env, cmd) do |stdin, outerr, wait_thr|
30
+ stdin.close
31
+ outerr.sync = true
32
+ output_thread = Thread.new do
33
+ while (line = outerr.readline_nonblock)
34
+ output << line if output
35
+ io_outerr.write(line) unless line.chomp.empty?
36
+ end
37
+ rescue IO::WaitReadable
38
+ begin
39
+ IO.select([outerr])
40
+ retry
41
+ rescue IOError
42
+ # outerr closed, won't retry
43
+ end
44
+ rescue EOFError
45
+ # outerr closed, won't retry
46
+ end
47
+ wait_thr.join
48
+ output_thread.exit
49
+ wait_thr.value
50
+ end
51
+ io_string = io_outerr.string
52
+ raise CemAcpt::ShellCommandError, "Error running command: #{cmd}\n#{io_string}" if raise_on_fail && !val.success?
53
+
54
+ io_string
55
+ end
56
+
57
+ # Mimics the behavior of the `which` command.
58
+ # @param cmd [String] The command to find
59
+ # @return [String] The path to the command
60
+ # @return [nil] If the command is not found
61
+ def self.which(cmd)
62
+ return cmd if File.executable?(cmd) && !File.directory?(cmd)
63
+
64
+ exts = ENV['PATHEXT'] ? ENV['PATHEXT'].split(';') : ['']
65
+ ENV['PATH'].split(File::PATH_SEPARATOR).each do |path|
66
+ exts.each do |ext|
67
+ exe = File.join(path, "#{cmd}#{ext}")
68
+ return exe if File.executable?(exe) && !File.directory?(exe)
69
+ end
70
+ end
71
+ nil
72
+ end
73
+
74
+ # IO monkey patch for non-blocking readline
75
+ class ::IO
76
+ def readline_nonblock
77
+ rlnb = []
78
+ rnlb << read_nonblock(1) while rlnb[-1] != "\n"
79
+ rlnb.join
80
+ rescue IO::WaitReadable => blocking
81
+ raise blocking if rlnb.empty?
82
+
83
+ rlnb.join
84
+ rescue EOFError
85
+ rlnb.join
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'open3'
3
+ require_relative 'shell'
4
4
  require_relative '../logging'
5
5
 
6
6
  module CemAcpt
@@ -31,10 +31,8 @@ module CemAcpt
31
31
  def create(key_name, **options)
32
32
  delete(key_name) # Delete existing keys with same name
33
33
  cmd = new_keygen_cmd(key_name, **options)
34
- logger.debug("Creating SSH key with command: #{cmd}")
35
- _stdout, stderr, status = Open3.capture3(cmd)
36
- raise "Failed to create SSH key! #{stderr}" unless status.success?
37
-
34
+ logger.debug('CemAcpt::Utils::SSH::Keygen') { "Creating SSH key with command: #{cmd}" }
35
+ CemAcpt::Utils::Shell.run_cmd(cmd, output: logger)
38
36
  key_paths(key_name)
39
37
  end
40
38
 
@@ -42,7 +40,7 @@ module CemAcpt
42
40
  priv_key = key_path(key_name)
43
41
  pub_key = key_path(key_name, public_key: true)
44
42
  if ::File.file?(priv_key)
45
- logger.debug("Deleting private key: #{priv_key}")
43
+ logger.debug('CemAcpt::Utils::SSH::Keygen') { "Deleting private key: #{priv_key}" }
46
44
  ::File.delete(priv_key)
47
45
  end
48
46
  if ::File.file?(pub_key)
@@ -53,20 +51,11 @@ module CemAcpt
53
51
 
54
52
  private
55
53
 
56
- FIND_BIN_PATH_COMMANDS = [
57
- "#{ENV['SHELL']} -c 'command -v ssh-keygen'",
58
- "#{ENV['SHELL']} -c 'which ssh-keygen'",
59
- ].freeze
60
-
61
- def find_bin_path(find_cmd = FIND_BIN_PATH_COMMANDS.first)
62
- bin_path, stderr, status = Open3.capture3(find_cmd)
63
- raise "Cannot find ssh-keygen with command #{find_cmd}: #{stderr}" unless status.success?
64
-
65
- bin_path.chomp
66
- rescue StandardError => e
67
- return find_bin_path(FIND_BIN_PATH_COMMANDS.last) unless FIND_BIN_PATH_COMMANDS.last == find_cmd
54
+ def find_bin_path
55
+ bin_path = CemAcpt::Utils::Shell.which('ssh-keygen')
56
+ raise 'Cannot find command ssh-keygen in PATH, make sure it is installed' if bin_path.nil?
68
57
 
69
- raise e
58
+ bin_path
70
59
  end
71
60
 
72
61
  def new_keygen_cmd(key_name, **options)
@@ -100,12 +89,10 @@ module CemAcpt
100
89
  end
101
90
 
102
91
  def self.ssh_keygen
103
- bin_path = `#{ENV['SHELL']} -c 'command -v ssh-keygen'`.chomp
104
- raise 'Cannot find ssh-keygen! Install it and verify PATH' unless bin_path
92
+ bin_path = CemAcpt::Utils::Shell.which('ssh-keygen')
93
+ raise 'Cannot find command ssh-keygen in PATH, make sure it is installed' if bin_path.nil?
105
94
 
106
95
  bin_path
107
- rescue StandardError => e
108
- raise "Cannot find ssh-keygen! Install it and verify PATH. Orignal error: #{e}"
109
96
  end
110
97
 
111
98
  def self.default_keydir
@@ -1,14 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'concurrent-ruby'
4
-
5
3
  module CemAcpt
6
4
  module Utils
7
5
  # Terminal-related utilities
8
6
  module Terminal
9
7
  def self.keep_terminal_alive
10
- executor = Concurrent::SingleThreadExecutor.new
11
- executor.post do
8
+ Thread.new do
12
9
  loop do
13
10
  $stdout.print(".\r")
14
11
  sleep(1)
@@ -20,7 +17,6 @@ module CemAcpt
20
17
  sleep(1)
21
18
  end
22
19
  end
23
- executor
24
20
  end
25
21
  end
26
22
  end