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.
@@ -240,6 +240,9 @@ module CemAcpt::Platform::Gcp
240
240
  end
241
241
 
242
242
  def create
243
+ # Add the test ssh key to os-login
244
+ logger.debug("Adding test SSH key to os-login for #{name}")
245
+ @cmd.local_exec("compute os-login ssh-keys add --key-file #{@cmd.ssh_key}.pub --project #{project.name} --ttl 4h")
243
246
  @cmd.local_exec(create_cmd)
244
247
  rescue StandardError => e
245
248
  raise "Failed to create VM #{name} with command #{create_cmd}: #{e}"
@@ -253,7 +256,7 @@ module CemAcpt::Platform::Gcp
253
256
 
254
257
  logger.debug("Checking instance #{name} SSH connectivity")
255
258
  @cmd.ssh_ready?(name)
256
- rescue StandardError
259
+ rescue StandardError, Exception
257
260
  false
258
261
  end
259
262
 
@@ -263,7 +266,9 @@ module CemAcpt::Platform::Gcp
263
266
 
264
267
  def install_puppet_module_package(module_pkg_path, remote_path, puppet_path)
265
268
  @cmd.scp_upload(@name, module_pkg_path, remote_path)
269
+ logger.info("Uploaded module package #{module_pkg_path} to #{remote_path} on #{@name}")
266
270
  @cmd.ssh(@name, "sudo #{puppet_path} module install #{remote_path}")
271
+ logger.info("Installed module package #{remote_path} on #{@name}")
267
272
  end
268
273
 
269
274
  private
@@ -39,7 +39,7 @@ module Platform
39
39
  # If necessary, can pass information into the block to be used in the test suite.
40
40
  def run_tests(&block)
41
41
  logger.debug("Running tests for #{node_name}...")
42
- block.call
42
+ block.call @instance.cmd.env
43
43
  end
44
44
 
45
45
  # Uploads and installs a Puppet module package on the GCP instance.
@@ -70,7 +70,7 @@ module Platform
70
70
  # @param opts [Hash] options to pass to the apply command
71
71
  # @return [String] the output of the apply command
72
72
  def apply_manifest(instance_name, manifest, opts = {})
73
- CemAcpt::Platform::Gcp::Cmd.new(out_format: 'json').apply_manifest(instance_name, manifest, opts)
73
+ command_provider.apply_manifest(instance_name, manifest, opts)
74
74
  end
75
75
 
76
76
  # Runs a shell command on the given instance
@@ -79,7 +79,7 @@ module Platform
79
79
  # @param opts [Hash] options to pass to the run_shell command
80
80
  # @return [String] the output of the run_shell command
81
81
  def run_shell(instance_name, cmd, opts = {})
82
- CemAcpt::Platform::Gcp::Cmd.new(out_format: 'json').run_shell(instance_name, cmd, opts)
82
+ command_provider.run_shell(instance_name, cmd, opts)
83
83
  end
84
84
  end
85
85
  end
@@ -21,6 +21,7 @@ module CemAcpt
21
21
  # @return [String] Path to the built package.
22
22
  def self.build_module_package(module_dir, target_dir = nil, should_log: false)
23
23
  require 'puppet/modulebuilder'
24
+ require 'fileutils'
24
25
 
25
26
  builder_logger = should_log ? logger : nil
26
27
  builder = Puppet::Modulebuilder::Builder.new(File.expand_path(module_dir), target_dir, builder_logger)
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'English'
4
+ require 'open3'
5
+ require 'pty'
6
+ require 'shellwords'
7
+ require_relative 'logging'
8
+
9
+ module CemAcpt
10
+ # Module that provides methods and objects for running and managing RSpec
11
+ module RSpecUtils
12
+ class BundlerNotFoundError < StandardError; end
13
+ class RSpecNotFoundError < StandardError; end
14
+
15
+ # Holds and formats a RSpec command
16
+ class Command
17
+ include CemAcpt::LoggingAsync
18
+ attr_reader :debug, :format, :test_path, :use_bundler, :pty_pid
19
+
20
+ # @param opts [Hash] options hash for the RSpec command
21
+ # @option opts [String] :test_path The path (or glob path) to the test file(s) to run. If blank, runs all.
22
+ # @option opts [Hash] :format Format options for rspec where the key is the format (documentation, json, etc)
23
+ # and the value is the out-file path. If you do not want to save the results of a format to a file, the
24
+ # value should be `nil`.
25
+ # @option opts [Boolean] :debug True if RSpec should run in debug mode, false if not. Mutually exclusive with
26
+ # `:quiet`. Default is `false`.
27
+ # @option opts [Boolean] :quiet True if no output should be logged from RSpec command, false if output should
28
+ # be logged. Mutually exclusive with `:debug`. Default is `false`.
29
+ # @option opts [Boolean] :use_bundler Whether or not `bundle exec` should be used to run the RSpec command.
30
+ # Default is `true`.
31
+ # @option opts [Boolean] :bundle_install Whether or not to run `bundle install` before the RSpec command
32
+ # if `use_bundler` is `true`.
33
+ # @option opts [Boolean] :use_shell Whether or not to add `$SHELL` as a prefix to the command
34
+ # @option opts [Hash] :env Environment variables to prepend to the command
35
+ def initialize(opts = {})
36
+ @test_path = opts[:test_path]&.shellescape
37
+ @format = opts.fetch(:format, {})
38
+ @debug = opts.fetch(:debug, false)
39
+ @quiet = @debug ? false : opts.fetch(:quiet, false)
40
+ @use_bundler = opts.fetch(:use_bundler, false)
41
+ @bundle_install = opts.fetch(:bundle_install, false)
42
+ @env = opts.fetch(:env, {})
43
+ @pty_pid = nil
44
+ validate_and_set_bin_paths(opts)
45
+ end
46
+
47
+ # Sets debug mode to `true`
48
+ def set_debug
49
+ @debug = true
50
+ if @quiet
51
+ async_debug('Setting :quiet to false because :debug is now true.')
52
+ @quiet = false
53
+ end
54
+ end
55
+
56
+ # Sets debug mode to `false`
57
+ def unset_debug
58
+ @debug = false
59
+ end
60
+
61
+ def quiet
62
+ @quiet && !debug
63
+ end
64
+
65
+ # Adds a new format to the RSpec command
66
+ # @param fmt [String] The name of the format (i.e. "documentation", "json", etc.)
67
+ # @param out [String] If specified, saves the specified format to a file at this path
68
+ def with_format(fmt, out: nil)
69
+ @format[fmt.to_sym] = out
70
+ end
71
+
72
+ # Environment variables that will be used for the RSpec command
73
+ # @return [Hash] A Hash of environment variables with each key pair being: <var name> => <var value>
74
+ def env
75
+ @debug ? @env.merge({ 'RSPEC_DEBUG' => 'true' }) : @env
76
+ end
77
+
78
+ # Returns an array representation of the RSpec command
79
+ def to_a
80
+ cmd = cmd_base.dup
81
+ cmd << test_path if test_path
82
+ format.each do |fmt, out|
83
+ cmd += ['--format', fmt.to_s.shellescape]
84
+ cmd += ['--out', out.to_s.shellescape] if out
85
+ end
86
+ cmd.compact
87
+ end
88
+
89
+ # Returns a string representation of the RSpec command
90
+ def to_s
91
+ to_a.join(' ')
92
+ end
93
+
94
+ # Executes the RSpec command on the current machine
95
+ # @param pty [Boolean] If true, execute command in a PTY. If false, execute command directly.
96
+ # @param log_prefix [String] A prefix to add to log messages generated while the command is running.
97
+ def execute(pty: true, log_prefix: 'RSPEC')
98
+ if pty
99
+ execute_pty(log_prefix: log_prefix)
100
+ else
101
+ execute_no_pty(log_prefix: log_prefix)
102
+ end
103
+ end
104
+
105
+ # Executes the RSpec command in a psuedo-terminal (PTY). First, it spawns a process
106
+ # for $SHELL, sets environment variables `export_envs`, then calls the current RSpec
107
+ # command in the shell and exits with the last exit code `$?`. Output is read from the
108
+ # RSpec command in near real-time in a blocking manner unless the `:quiet` option has
109
+ # been specified.
110
+ # @param log_prefix [String] A prefix to add to the log messages that are output from
111
+ # the RSpec command.
112
+ # @return [Integer] The exit code of the RSpec command
113
+ def execute_pty(log_prefix: 'RSPEC')
114
+ async_debug("Executing RSpec command '#{self}' in PTY...", log_prefix)
115
+ PTY.spawn(env, ENV['SHELL']) do |r, w, pid|
116
+ @pty_pid = pid
117
+ async_debug("Spawned RSpec PTY with PID #{@pty_pid}", log_prefix)
118
+ export_envs(w)
119
+ w.puts "#{self}; exit $?"
120
+ quiet ? wait_io(r) : read_io(r, log_prefix: log_prefix)
121
+ end
122
+ $CHILD_STATUS
123
+ end
124
+
125
+ # Executes the RSpec command using Open3.popen2e(). The output stream, which is both
126
+ # stderr and stdout, is read in real-time in a non-blocking manner.
127
+ # @param log_prefix [String] A prefix to add to the log messages that are output from
128
+ # the RSpec command.
129
+ # @return [Integer] The exit code of the RSpec command
130
+ def execute_no_pty(log_prefix: 'RSPEC')
131
+ async_info("Executing RSpec command '#{self}' with Open3.popen2e()...", log_prefix)
132
+ exit_status = nil
133
+ Open3.popen2e(env, to_s) do |stdin, std_out_err, wait_thr|
134
+ stdin.close
135
+ quiet ? wait_io(std_out_err) : read_io(std_out_err, log_prefix: log_prefix)
136
+ exit_status = wait_thr.value
137
+ end
138
+ exit_status
139
+ end
140
+
141
+ # Kills the PTY process with `SIGKILL` if the process exists
142
+ def kill_pty
143
+ Process.kill('KILL', @pty_pid) unless @pty_pid.nil?
144
+ rescue Errno::ESRCH
145
+ true
146
+ end
147
+
148
+ private
149
+
150
+ # Detects if the current Ruby context is JRuby
151
+ def jruby?
152
+ File.basename(RbConfig.ruby) == 'jruby'
153
+ end
154
+
155
+ # The base RSpec command
156
+ def cmd_base
157
+ use_bundler ? cmd_base_bundler : cmd_base_rspec
158
+ end
159
+
160
+ # The base RSpec command if `:use_bundler` is `true`.
161
+ def cmd_base_bundler
162
+ base = [@bundle, 'exec', 'rspec']
163
+ base.unshift("#{@bundle} install;") if @bundle_install
164
+ base
165
+ end
166
+
167
+ # The base RSpec command if `:use_bundler` is `false`
168
+ def cmd_base_rspec
169
+ [@rspec]
170
+ end
171
+
172
+ # Puts export statements for each key-value pair in `env` to the given writer.
173
+ # Writer is the write pipe of a PTY session, or a similar IO object that can
174
+ # pass the statements to a shell.
175
+ # @param writer [IO] An IO object that supprts `puts` and can send statements to a shell
176
+ def export_envs(writer)
177
+ env.each do |key, val|
178
+ writer.puts "export #{key}=#{val}"
179
+ end
180
+ end
181
+
182
+ # Finds and sets the paths to the `bundle` and `rspec` binaries. The paths can
183
+ # be either passed in as options in the `opts` Hash or interrogated from the
184
+ # system.
185
+ # @param opts [Hash] The options hash
186
+ # @option opts [String] :bundle An absolute path on the system to the `bundle` binary.
187
+ # @option opts [String] :rspec An absolute path on the system to the `rspec` binary.
188
+ # @raise [CemAcpt::RSpecUtils::BundlerNotFoundError] if `@use_bundler` is true and
189
+ # `bundle` binary is not found.
190
+ # @raise [CemAcpt::RSpecUtils::RSpecNotFoundError] if `rspec` binary is not found.
191
+ def validate_and_set_bin_paths(opts = {})
192
+ %i[bundle rspec].each do |bin|
193
+ bin_path = opts[bin] || `command -v #{bin}`.strip
194
+ bin_not_found(bin, bin_path) unless bin_path && File.exist?(bin_path)
195
+ instance_variable_set("@#{bin}", bin_path)
196
+ end
197
+ end
198
+
199
+ # Handles binary paths which are not found
200
+ # @param bin [Symbol] The binary that was not found, either :bundle or :rspec.
201
+ # @param bin_path [String] The path to the binary that was checked.
202
+ # @raise [CemAcpt::RSpecUtils::BundlerNotFoundError] if `@use_bundler` is true and
203
+ # `bundle` binary is not found.
204
+ # @raise [CemAcpt::RSpecUtils::RSpecNotFoundError] if `rspec` binary is not found.
205
+ # @raise [RuntimeError] if `bin` is not :bundle or :rspec.
206
+ def bin_not_found(bin, bin_path)
207
+ msg_base = "#{bin} not found."
208
+ msg = bin_path.nil? ? "#{msg_base} Path is nil." : "#{msg_base} Path: #{bin_path}"
209
+ case bin
210
+ when :bundle
211
+ raise BundlerNotFoundError, msg if @use_bundler
212
+ when :rspec
213
+ raise RSpecNotFoundError, msg
214
+ else
215
+ raise "bin #{bin} not recognized!"
216
+ end
217
+ end
218
+
219
+ # Blocking wait on an IO stream. Wait stops once the IO stream has reached
220
+ # end of file.
221
+ # @param stdout [IO] An IO stream with output that can be read from.
222
+ def wait_io(stdout)
223
+ sleep(0.01) until stdout.eof?
224
+ end
225
+
226
+ # Reads and logs data from `stdout` in a near real-time, blocking manner.
227
+ # @param stdout [IO] An IO stream with output that can be read from.
228
+ # @param log_prefix [String] A string prefix that is added to log messages.
229
+ def read_io(stdout, log_prefix: 'RSPEC')
230
+ loop do
231
+ chunk = stdout.read_nonblock(4096).strip
232
+ async_info(chunk, log_prefix) unless chunk.nil? || chunk.empty?
233
+ rescue IO::WaitReadable
234
+ IO.select([stdout])
235
+ retry
236
+ rescue EOFError
237
+ break
238
+ end
239
+ end
240
+ end
241
+ end
242
+ end
@@ -3,7 +3,6 @@
3
3
  require 'concurrent-ruby'
4
4
  require 'deep_merge'
5
5
  require 'json'
6
- require 'ostruct'
7
6
  require 'yaml'
8
7
  require_relative 'core_extensions'
9
8
  require_relative 'logging'
@@ -49,7 +48,16 @@ module CemAcpt
49
48
  attr_reader :config_file
50
49
 
51
50
  def initialize
52
- @opts = {}
51
+ @opts = {
52
+ thread_pool: {
53
+ min_threads: [2, Concurrent.processor_count - 1].max,
54
+ max_threads: [2, Concurrent.processor_count - 1].max,
55
+ max_queue: [2, Concurrent.processor_count - 1].max * 10,
56
+ fallback_policy: :caller_runs,
57
+ idletime: 1200,
58
+ auto_terminate: true,
59
+ },
60
+ }
53
61
  end
54
62
 
55
63
  # Returns the value of the dot-separated key.
@@ -67,18 +75,25 @@ module CemAcpt
67
75
  !!get(dot_key)
68
76
  end
69
77
 
78
+ # Checks to see if cem_acpt is set to run in debug mode (log level is set to debug)
79
+ # @return [TrueClass] If cem_acpt is running in debug mode
80
+ # @return [FalseClass] If cem_acpt is not runnint in debug mode
81
+ def debug_mode?
82
+ @opts.dot_dig('log_level') == 'debug'
83
+ end
84
+
70
85
  # Loads the config from specified file path. Config files must be in YAML
71
86
  # format. If 'opts' is specified, it will be merged with the config file.
72
87
  # Values in 'opts' will override values in the config file.
73
88
  # @param opts [Hash] Options to be merged with the config file
74
89
  # @param config_file [String] Path to the config file
75
- def load(opts: {}, config_file: '~/.cem_acpt.yaml')
90
+ def load(opts: {}, config_file: './cem_acpt_config.yaml')
76
91
  raise ConfigImmutableError, 'Config is immutable, cannot load more than once' if frozen?
77
92
  raise ArgumentError, 'opts must be a Hash' unless opts.is_a?(Hash)
78
93
  raise ArgumentError, 'config_file must be a String' unless config_file.is_a?(String)
79
94
 
80
95
  @config_file = File.expand_path(config_file)
81
- @opts = load_opts_from_file(File.expand_path(config_file)).deep_merge(opts)
96
+ @opts.deep_merge!(load_opts_from_file(File.expand_path(config_file)).deep_merge!(opts))
82
97
  @opts.format!
83
98
  @opts.freeze
84
99
  freeze
@@ -155,8 +170,13 @@ module CemAcpt
155
170
  end
156
171
  end
157
172
 
173
+ class NodeInventoryFileNotFoundError < StandardError; end
174
+ class NodeInventoryFileLoadError < StandardError; end
158
175
  class NodeClaimedError < StandardError; end
159
176
  class NodeDoesNotExistError < StandardError; end
177
+ class PropertyNotFoundError < StandardError; end
178
+ class LockWaitTimeoutError < StandardError; end
179
+ class LockNotRecognizedError < StandardError; end
160
180
 
161
181
  # Provides a thread-safe inventory of test nodes.
162
182
  class NodeInventory
@@ -169,7 +189,8 @@ module CemAcpt
169
189
  @lock = Concurrent::ReadWriteLock.new
170
190
  @claimed = Concurrent::Set.new
171
191
  @save_on_claim = false
172
- @save_file_path = 'spec/fixtures/node_inventory.yaml'
192
+ @save_file_path = 'spec/fixtures/node_inventory'
193
+ @loaded_node_inv = nil
173
194
  end
174
195
 
175
196
  # When called, enables saving the inventory to a file on claim.
@@ -189,12 +210,51 @@ module CemAcpt
189
210
  @inventory.put_if_absent(node_name, node_data)
190
211
  end
191
212
 
213
+ def update(node_name, node_data)
214
+ @inventory.replace_if_exists(node_name, node_data)
215
+ end
216
+
192
217
  # Returns the node data for a given node.
193
218
  # @param node_name [String] The name of the node to get data for.
194
219
  def get(node_name)
195
220
  @inventory[node_name]
196
221
  end
197
222
 
223
+ # Returns the node_name and node_data for a given node
224
+ # that has a matching value for the given property.
225
+ # @param property_path [String] The dot-separated property path to check.
226
+ # @param value [Object] The value to check for.
227
+ # @return [Array] Two-item frozen array of node name and node data.
228
+ # @raise [NodeDoesNotExistError] If no nodes are found with the property equal to the value.
229
+ def get_by_property(property_path, value)
230
+ found = []
231
+ @inventory.each_pair do |node_name, node_data|
232
+ next unless node_data.dot_dig(property_path) == value
233
+
234
+ found = [node_name, node_data].freeze
235
+ end
236
+ raise NodeDoesNotExistError, "No node found with property #{property_path} == #{value}" if found.empty?
237
+
238
+ found
239
+ end
240
+
241
+ # Returns all property values of all nodes for the given property path
242
+ # @param property_path [String] The dot-separated property path to check.
243
+ # @return [Array] A frozen array of found property values.
244
+ # @raise [PropertyNotFoundError] If no values are found on any nodes for the property path.
245
+ def get_all_properties(property_path)
246
+ found = []
247
+ @inventory.each_pair do |_, node_data|
248
+ prop = node_data.dot_dig(property_path)
249
+ next unless prop
250
+
251
+ found << prop
252
+ end
253
+ raise PropertyNotFoundError, "No property with path #{property_path} found in nodes" if found.empty?
254
+
255
+ found.freeze
256
+ end
257
+
198
258
  # Sets a specific property on a node in the inventory.
199
259
  # @param node_name [String] The name of the node to set the property on.
200
260
  # @param property_path [String] The dot-separated property path to set.
@@ -230,7 +290,7 @@ module CemAcpt
230
290
  # @raise [NodeDoesNotExistError] If the node does not exist in the inventory.
231
291
  # @raise [NodeClaimedError] If the node is already claimed.
232
292
  def claim(node_name)
233
- @lock.with_write_lock do
293
+ with_lock_retry(:write) do
234
294
  unless @inventory.keys.include?(node_name)
235
295
  raise NodeDoesNotExistError, "Node #{node_name} does not exist in inventory"
236
296
  end
@@ -254,16 +314,24 @@ module CemAcpt
254
314
  # @return [String] The name of the node that was claimed.
255
315
  # @raise [NodeDoesNotExistError] If no valid node is found.
256
316
  def claim_by_property(property_path, value)
257
- claimed_set = @claimed.dup
317
+ attempts ||= 1
258
318
  claim_name = nil
259
319
  @inventory.each_pair do |node_name, node_data|
260
- next if claimed_set.include?(node_name)
320
+ next if @claimed.include?(node_name)
261
321
 
262
322
  claim_name = node_name if node_data.dot_dig(property_path) == value
263
323
  end
264
- return claim(claim_name) unless claim_name.nil?
265
-
266
- raise NodeDoesNotExistError, "No node found with property #{property_path} == #{value}"
324
+ raise NodeDoesNotExistError, "No node found with property #{property_path} == #{value}" if claim_name.nil?
325
+
326
+ claim(claim_name)
327
+ rescue NodeDoesNotExistError => e
328
+ # We sleep than retry three times to help mitigate race conditions
329
+ if (attempts += 1) <= 3
330
+ sleep(1)
331
+ retry
332
+ else
333
+ raise e
334
+ end
267
335
  end
268
336
 
269
337
  # Deletes a node from the inventory.
@@ -286,7 +354,7 @@ module CemAcpt
286
354
 
287
355
  # Clears the inventory and removes claimed nodes. Thread-safe.
288
356
  def clear!
289
- @lock.with_write_lock do
357
+ with_lock_retry(:write) do
290
358
  clear_no_lock!
291
359
  end
292
360
  end
@@ -308,6 +376,16 @@ module CemAcpt
308
376
  h
309
377
  end
310
378
 
379
+ # Returns a YAML string of a specific node's node data
380
+ def node_to_yaml(node_name)
381
+ get(node_name).to_h.to_yaml
382
+ end
383
+
384
+ # Returns a JSON string of a specific node's node data
385
+ def node_to_json(node_name, *args)
386
+ get(node_name).to_h.to_json(*args)
387
+ end
388
+
311
389
  # Returns a YAML string of the inventory.
312
390
  def to_yaml
313
391
  to_h.to_yaml
@@ -321,15 +399,21 @@ module CemAcpt
321
399
  # Saves the current node inventory to a file. DOES NOT USE LOCK.
322
400
  # If this is called outside of a lock, it will not be thread-safe.
323
401
  def save_no_lock!(file_path = @save_file_path)
324
- File.open(file_path, 'w') do |file|
325
- file.write(to_yaml)
402
+ Dir.mkdir(file_path) unless Dir.exist?(file_path)
403
+ @inventory.each_pair do |node_name, node_data|
404
+ path = File.join(file_path, "#{node_name}.yaml")
405
+ next if File.file?(path)
406
+
407
+ File.open(path, 'w') do |f|
408
+ f.write(node_data.to_h.to_yaml)
409
+ end
326
410
  end
327
411
  end
328
412
 
329
413
  # Saves the current node inventory to a yaml file. Thread-safe.
330
414
  # @param file_path [String] The path to the file to save to.
331
415
  def save(file_path = @save_file_path)
332
- @lock.with_write_lock do
416
+ with_lock_retry(:write) do
333
417
  save_no_lock!(file_path)
334
418
  end
335
419
  end
@@ -338,23 +422,58 @@ module CemAcpt
338
422
  # If this is called outside of a lock, it will not be thread-safe.
339
423
  def load_no_lock!(file_path = @save_file_path)
340
424
  require 'net/ssh/proxy/command' # If ProxyCommand is used in ssh options, this is required.
341
- clear_no_lock!
342
- YAML.load_file(file_path).each_pair do |node_name, node_data|
343
- if node_name == :claimed
344
- @claimed = Set.new(node_data)
345
- else
346
- add(node_name, node_data)
347
- end
425
+ attempts ||= 1
426
+ Dir.glob('*.yaml', base: file_path) do |file_name|
427
+ node_name = File.basename(file_name, '.yaml')
428
+ node_data = YAML.load_file(File.join(file_path, file_name))
429
+ add(node_name, node_data) || update(node_name, node_data)
348
430
  end
431
+ rescue StandardError => e
432
+ raise e unless (attempts += 1) <= 3
433
+
434
+ sleep(1)
435
+ retry
349
436
  end
350
437
 
438
+ def update_no_lock!; end
439
+
351
440
  # Loads a node inventory from a yaml file. Thread-safe.
352
441
  # @param file_path [String] The path to the file to load from.
353
442
  def load(file_path = @save_file_path)
354
- @lock.with_write_lock do
443
+ with_lock_retry(:write) do
355
444
  load_no_lock!(file_path)
356
445
  end
357
446
  end
447
+
448
+ def clean_local_files(file_path = @save_file_path)
449
+ Dir.glob('*.yaml', base: file_path) do |file_name|
450
+ File.delete(File.expand_path(File.join(file_path, file_name)))
451
+ end
452
+ end
453
+
454
+ private
455
+
456
+ def with_lock_retry(lock_type, max_attempts: 3)
457
+ raise 'No block given' unless block_given?
458
+
459
+ attempts ||= 1
460
+ case lock_type
461
+ when :read
462
+ @lock.with_read_lock { yield }
463
+ when :write
464
+ @lock.with_write_lock { yield }
465
+ else
466
+ raise LockNotRecognizedError, "Lock type #{lock_type} not recognized! Must be :read or :write!"
467
+ end
468
+ rescue Concurrent::ResourceLimitError => e
469
+ if (attempts += 1) <= max_attempts
470
+ sleep(1)
471
+ logger.debug("Failed to acquire lock with error #{e.message}. Retrying...")
472
+ retry
473
+ else
474
+ raise LockWaitTimeoutError, 'Lock could not be aquired'
475
+ end
476
+ end
358
477
  end
359
478
 
360
479
  class LocalPortAllocationError < StandardError; end
@@ -391,9 +510,11 @@ module CemAcpt
391
510
 
392
511
  ports << port
393
512
  end
394
- return ports[0] if ports.length == 1
395
-
396
- ports
513
+ if ports.length == 1
514
+ ports[0]
515
+ else
516
+ ports
517
+ end
397
518
  end
398
519
 
399
520
  private
@@ -14,15 +14,12 @@ module CemAcpt
14
14
  config.extend CemAcpt::SpecHelperAcceptance
15
15
  config.add_setting :acpt_test_data, default: {}
16
16
  config.add_setting :acpt_node_inventory
17
+ config.threadsafe = true
18
+ config.color_mode = :off
19
+ config.fail_fast = false
17
20
  end
18
21
 
19
- return unless RSpec.configuration.acpt_node_inventory.nil?
20
-
21
- raise 'Node inventory file not found!' unless File.exist?('spec/fixtures/node_inventory.yaml')
22
-
23
22
  node_inventory = NodeInventory.new
24
- node_inventory.save_on_claim
25
- node_inventory.save_file_path = 'spec/fixtures/node_inventory.yaml'
26
23
  node_inventory.load
27
24
  RSpec.configuration.acpt_node_inventory = node_inventory
28
25
  end
@@ -35,8 +32,10 @@ module CemAcpt
35
32
  def initialize_test_environment!
36
33
  test_file = caller_locations.first.path
37
34
 
38
- node_name = RSpec.configuration.acpt_node_inventory.claim_by_property('test_data.test_file', test_file)
39
- node_data = RSpec.configuration.acpt_node_inventory.get(node_name)
35
+ node_name, node_data = RSpec.configuration.acpt_node_inventory.get_by_property('test_data.test_file', test_file)
36
+ raise "Failed to get node data for node #{node_name}" unless node_data
37
+ raise "Node data format is incorrect: #{node_data}" unless node_data.is_a?(Hash)
38
+
40
39
  backend = nil
41
40
  host = nil
42
41
  ssh_options = nil
@@ -81,13 +80,14 @@ module CemAcpt
81
80
  RSpec.configuration.acpt_test_data = acpt_test_data
82
81
  end
83
82
 
84
- # This method formats Puppet Apply options
83
+ # This method formats Puppet Apply options
85
84
  def puppet_apply_options(opts = {})
86
85
  if [opts[:catch_changes], opts[:expect_changes], opts[:catch_failures], opts[:expect_failures]].compact.length > 1
87
- raise ArgumentError, 'Please specify only one of "catch_changes", "expect_changes", "catch_failures", or "expect_failures"'
86
+ raise ArgumentError,
87
+ 'Please specify only one of "catch_changes", "expect_changes", "catch_failures", or "expect_failures"'
88
88
  end
89
89
 
90
- apply_opts = { trace: true }.merge(opts)
90
+ apply_opts = {}.merge(opts)
91
91
 
92
92
  if opts[:catch_changes]
93
93
  apply_opts[:detailed_exit_codes] = true
@@ -169,8 +169,16 @@ module CemAcpt
169
169
  # asserts that the second run has no changes.
170
170
  def idempotent_apply(manifest, opts = {})
171
171
  opts.reject! { |k, _| %i[catch_changes expect_changes catch_failures expect_failures].include?(k) }
172
- apply_manifest(manifest, opts.merge(catch_failures: true))
173
- apply_manifest(manifest, opts.merge(catch_changes: true))
172
+ begin
173
+ apply_manifest(manifest, opts.merge({ catch_failures: true }))
174
+ rescue StandardError => e
175
+ raise "Idempotent apply failed during first apply: #{e.message}\n#{e.backtrace.join("\n")}"
176
+ end
177
+ begin
178
+ apply_manifest(manifest, opts.merge({ catch_changes: true }))
179
+ rescue StandardError => e
180
+ raise "Idempotent apply failed during second apply: #{e.message}\n#{e.backtrace.join("\n")}"
181
+ end
174
182
  end
175
183
  end
176
184
  end