cem_acpt 0.2.6-universal-java-17

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +18 -0
  3. data/.rspec +3 -0
  4. data/CODEOWNERS +1 -0
  5. data/Gemfile +9 -0
  6. data/Gemfile.lock +93 -0
  7. data/README.md +150 -0
  8. data/Rakefile +6 -0
  9. data/bin/console +14 -0
  10. data/bin/setup +8 -0
  11. data/cem_acpt.gemspec +39 -0
  12. data/exe/cem_acpt +84 -0
  13. data/lib/cem_acpt/bootstrap/bootstrapper.rb +206 -0
  14. data/lib/cem_acpt/bootstrap/operating_system/rhel_family.rb +129 -0
  15. data/lib/cem_acpt/bootstrap/operating_system.rb +17 -0
  16. data/lib/cem_acpt/bootstrap.rb +12 -0
  17. data/lib/cem_acpt/context.rb +153 -0
  18. data/lib/cem_acpt/core_extensions.rb +108 -0
  19. data/lib/cem_acpt/image_name_builder.rb +104 -0
  20. data/lib/cem_acpt/logging.rb +351 -0
  21. data/lib/cem_acpt/platform/base/cmd.rb +71 -0
  22. data/lib/cem_acpt/platform/base.rb +78 -0
  23. data/lib/cem_acpt/platform/gcp/cmd.rb +345 -0
  24. data/lib/cem_acpt/platform/gcp/compute.rb +332 -0
  25. data/lib/cem_acpt/platform/gcp.rb +85 -0
  26. data/lib/cem_acpt/platform/vmpooler.rb +24 -0
  27. data/lib/cem_acpt/platform.rb +103 -0
  28. data/lib/cem_acpt/puppet_helpers.rb +39 -0
  29. data/lib/cem_acpt/rspec_utils.rb +242 -0
  30. data/lib/cem_acpt/shared_objects.rb +537 -0
  31. data/lib/cem_acpt/spec_helper_acceptance.rb +184 -0
  32. data/lib/cem_acpt/test_data.rb +146 -0
  33. data/lib/cem_acpt/test_runner/run_handler.rb +187 -0
  34. data/lib/cem_acpt/test_runner/runner.rb +210 -0
  35. data/lib/cem_acpt/test_runner/runner_result.rb +103 -0
  36. data/lib/cem_acpt/test_runner.rb +10 -0
  37. data/lib/cem_acpt/utils.rb +144 -0
  38. data/lib/cem_acpt/version.rb +5 -0
  39. data/lib/cem_acpt.rb +34 -0
  40. data/sample_config.yaml +58 -0
  41. metadata +218 -0
@@ -0,0 +1,537 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent-ruby'
4
+ require 'deep_merge'
5
+ require 'json'
6
+ require 'yaml'
7
+ require_relative 'core_extensions'
8
+ require_relative 'logging'
9
+
10
+ module CemAcpt
11
+ using CemAcpt::CoreExtensions::ExtendedHash
12
+
13
+ class ConfigImmutableError < RuntimeError; end
14
+
15
+ # Holds module methods for writing various config objects
16
+ # to files.
17
+ module ConfigUtils
18
+ class << self
19
+ include CemAcpt::Logging
20
+ end
21
+
22
+ def self.object_to_file(obj, file_path)
23
+ case File.extname(file_path)
24
+ when '.json'
25
+ raise "Object #{obj} is not serializable to JSON" unless obj.respond_to?(:to_json)
26
+
27
+ logger.debug("Writing object to #{file_path}")
28
+ File.write(file_path, obj.to_json)
29
+ when '.yaml', '.yml'
30
+ raise "Object #{obj} is not serializable to YAML" unless obj.respond_to?(:to_yaml)
31
+
32
+ logger.debug("Writing object to #{file_path}")
33
+ File.write(file_path, obj.to_yaml)
34
+ else
35
+ raise "File extension #{File.extname(file_path)} is not supported"
36
+ end
37
+ end
38
+ end
39
+
40
+ # Provides a thread-safe Config object.
41
+ # Once the config is loaded by calling the `load` method, the object is
42
+ # immutable and thread-safe as long as the internal hash is only accessed
43
+ # by the `#get` and `#has?` methods. If `#load` is called more than once,
44
+ # the object will raise an error.
45
+ class Config
46
+ include CemAcpt::Logging
47
+
48
+ attr_reader :config_file
49
+
50
+ def initialize
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
+ }
61
+ end
62
+
63
+ # Returns the value of the dot-separated key.
64
+ # If the key is not found, returns nil.
65
+ # @param dot_key [String] Dot-separated key
66
+ # @return [Object] Value of the key
67
+ def get(dot_key)
68
+ @opts.dot_dig(dot_key)
69
+ end
70
+
71
+ # Checks if the dot-separated key exists in the config.
72
+ # @param dot_key [String] Dot-separated key
73
+ # @return [Boolean] True if the key exists, false otherwise
74
+ def has?(dot_key)
75
+ !!get(dot_key)
76
+ end
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
+
85
+ # Loads the config from specified file path. Config files must be in YAML
86
+ # format. If 'opts' is specified, it will be merged with the config file.
87
+ # Values in 'opts' will override values in the config file.
88
+ # @param opts [Hash] Options to be merged with the config file
89
+ # @param config_file [String] Path to the config file
90
+ def load(opts: {}, config_file: './cem_acpt_config.yaml')
91
+ raise ConfigImmutableError, 'Config is immutable, cannot load more than once' if frozen?
92
+ raise ArgumentError, 'opts must be a Hash' unless opts.is_a?(Hash)
93
+ raise ArgumentError, 'config_file must be a String' unless config_file.is_a?(String)
94
+
95
+ @config_file = File.expand_path(config_file)
96
+ @opts.deep_merge!(load_opts_from_file(File.expand_path(config_file)).deep_merge!(opts))
97
+ @opts.format!
98
+ @opts.freeze
99
+ freeze
100
+ end
101
+
102
+ # Returns the config as a Hash.
103
+ # @return [Hash] Config as a Hash
104
+ def to_h
105
+ ::Marshal.load(::Marshal.dump(@opts))
106
+ end
107
+
108
+ # Returns the config as a YAML string.
109
+ # @return [String] Config as a YAML string
110
+ def to_yaml
111
+ to_h.to_yaml
112
+ end
113
+
114
+ # Returns the config as a JSON string.
115
+ # @return [String] Config as a JSON string
116
+ def to_json(*args)
117
+ to_h.to_json(*args)
118
+ end
119
+
120
+ private
121
+
122
+ # Loads the config from the specified file path.
123
+ # @param config_file [String] Path to the config file
124
+ # @return [Hash] Config hash
125
+ def load_opts_from_file(config_file)
126
+ return {} unless File.exist?(config_file)
127
+
128
+ logger.debug("Loading config from #{config_file}")
129
+ YAML.load_file(config_file)
130
+ end
131
+ end
132
+
133
+ # Provides an object to hold NodeData configs.
134
+ # Not thread-safe. Should not be mutated by itself,
135
+ # but can be mutated safely by the NodeInventory object.
136
+ # Currently unused.
137
+ class NodeData
138
+ include CemAcpt::Logging
139
+
140
+ attr_reader :name, :data
141
+
142
+ def initialize(name, data)
143
+ data.format!
144
+ @name = name
145
+ @opts = data
146
+ end
147
+
148
+ def deep_merge(other)
149
+ @opts.deep_merge(other.to_h)
150
+ end
151
+
152
+ def dot_dig(dot_key)
153
+ @opts.dot_dig(dot_key)
154
+ end
155
+
156
+ def dot_store(dot_key, value)
157
+ @opts.dot_store(dot_key, value)
158
+ end
159
+
160
+ def to_h
161
+ @opts
162
+ end
163
+
164
+ def to_yaml
165
+ to_h.to_yaml
166
+ end
167
+
168
+ def to_json(*args)
169
+ to_h.to_json(*args)
170
+ end
171
+ end
172
+
173
+ class NodeInventoryFileNotFoundError < StandardError; end
174
+ class NodeInventoryFileLoadError < StandardError; end
175
+ class NodeClaimedError < StandardError; end
176
+ class NodeDoesNotExistError < StandardError; end
177
+ class PropertyNotFoundError < StandardError; end
178
+ class LockWaitTimeoutError < StandardError; end
179
+ class LockNotRecognizedError < StandardError; end
180
+
181
+ # Provides a thread-safe inventory of test nodes.
182
+ class NodeInventory
183
+ include CemAcpt::Logging
184
+
185
+ attr_accessor :save_file_path
186
+
187
+ def initialize
188
+ @inventory = Concurrent::Map.new
189
+ @lock = Concurrent::ReadWriteLock.new
190
+ @claimed = Concurrent::Set.new
191
+ @save_on_claim = false
192
+ @save_file_path = 'spec/fixtures/node_inventory'
193
+ @loaded_node_inv = nil
194
+ end
195
+
196
+ # When called, enables saving the inventory to a file on claim.
197
+ def save_on_claim
198
+ @save_on_claim = true
199
+ end
200
+
201
+ # Checks if save_on_claim is enabled.
202
+ def save_on_claim?
203
+ @save_on_claim
204
+ end
205
+
206
+ # Adds a new node to the inventory.
207
+ # @param node_name [String] The name of the node to add.
208
+ # @param node_data [Hash] The node data to add.
209
+ def add(node_name, node_data)
210
+ @inventory.put_if_absent(node_name, node_data)
211
+ end
212
+
213
+ def update(node_name, node_data)
214
+ @inventory.replace_if_exists(node_name, node_data)
215
+ end
216
+
217
+ # Returns the node data for a given node.
218
+ # @param node_name [String] The name of the node to get data for.
219
+ def get(node_name)
220
+ @inventory[node_name]
221
+ end
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
+
258
+ # Sets a specific property on a node in the inventory.
259
+ # @param node_name [String] The name of the node to set the property on.
260
+ # @param property_path [String] The dot-separated property path to set.
261
+ # @param value [Object] The value to set.
262
+ def set(node_name, property_path, value)
263
+ @inventory[node_name].dot_store(property_path, value)
264
+ end
265
+
266
+ # Merges new node data in with the existing node data for the given node.
267
+ # @param node_name [String] The name of the node to merge data for.
268
+ # @param new_node_data [Hash] The new node data to merge.
269
+ def merge!(node_name, new_node_data)
270
+ @inventory.merge_pair(node_name, new_node_data) do |old_data|
271
+ old_data.deep_merge(new_node_data)
272
+ end
273
+ end
274
+
275
+ # Checks if a node is claimed.
276
+ # @param node_name [String] The name of the node to check.
277
+ # @return [Boolean] True if the node is claimed, false otherwise.
278
+ def claimed?(node_name)
279
+ raise NodeDoesNotExistError, "Node #{node_name} does not exist" unless @inventory.keys.include?(node_name)
280
+
281
+ @claimed.include?(node_name)
282
+ end
283
+
284
+ # Claims a node, which returns the node data and adds that node to the
285
+ # claimed set. Raises an error if the node is already claimed or does
286
+ # not exist. This is used during acceptance testing to ensure that
287
+ # nodes are not reused.
288
+ # @param node_name [String] The name of the node to claim.
289
+ # @return [String] The name of the node that was claimed.
290
+ # @raise [NodeDoesNotExistError] If the node does not exist in the inventory.
291
+ # @raise [NodeClaimedError] If the node is already claimed.
292
+ def claim(node_name)
293
+ with_lock_retry(:write) do
294
+ unless @inventory.keys.include?(node_name)
295
+ raise NodeDoesNotExistError, "Node #{node_name} does not exist in inventory"
296
+ end
297
+
298
+ if @claimed.include?(node_name)
299
+ raise NodeClaimedError, "Node #{node_name} is already tainted and cannot be claimed"
300
+ end
301
+
302
+ @claimed.add(node_name)
303
+ save_no_lock!(@save_file_path) if @save_on_claim
304
+ node_name
305
+ end
306
+ end
307
+
308
+ # Claims a node based on a property of the node data and a value that property should equal.
309
+ # If a node is found but is already claimed, the method will continue to search for an
310
+ # unclaimed node. Raises an error if no valid node is found. This is used during acceptance
311
+ # testing to ensure that nodes are not reused.
312
+ # @param property_path [String] The dot-separated property path to check.
313
+ # @param value [Object] The value to check for.
314
+ # @return [String] The name of the node that was claimed.
315
+ # @raise [NodeDoesNotExistError] If no valid node is found.
316
+ def claim_by_property(property_path, value)
317
+ attempts ||= 1
318
+ claim_name = nil
319
+ @inventory.each_pair do |node_name, node_data|
320
+ next if @claimed.include?(node_name)
321
+
322
+ claim_name = node_name if node_data.dot_dig(property_path) == value
323
+ end
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
335
+ end
336
+
337
+ # Deletes a node from the inventory.
338
+ # @param node_name [String] The name of the node to delete.
339
+ def delete(node_name)
340
+ @inventory.delete(node_name)
341
+ end
342
+
343
+ # Returns all node names in the current inventory.
344
+ def nodes
345
+ @inventory.keys
346
+ end
347
+
348
+ # Clears the inventory and removes claimed nodes. DOES NOT USE LOCK.
349
+ # If this is called outside of a lock, it will not be thread-safe.
350
+ def clear_no_lock!
351
+ nodes.each { |node| delete(node) }
352
+ @claimed.clear
353
+ end
354
+
355
+ # Clears the inventory and removes claimed nodes. Thread-safe.
356
+ def clear!
357
+ with_lock_retry(:write) do
358
+ clear_no_lock!
359
+ end
360
+ end
361
+
362
+ # Checks if the inventory contains a node with the given property.
363
+ # @param node_name [String] The name of the node to check.
364
+ # @param property_path [String] The dot-separated property path to check.
365
+ def property?(node_name, property_path)
366
+ !!@inventory[node_name].dot_dig(property_path)
367
+ end
368
+
369
+ # Returns a hash of the inventory.
370
+ def to_h
371
+ h = {}
372
+ @inventory.each_pair do |node_name, node_data|
373
+ h[node_name] = node_data.to_h
374
+ end
375
+ h[:claimed] = @claimed.to_a
376
+ h
377
+ end
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
+
389
+ # Returns a YAML string of the inventory.
390
+ def to_yaml
391
+ to_h.to_yaml
392
+ end
393
+
394
+ # Returns a JSON string of the inventory.
395
+ def to_json(*args)
396
+ to_h.to_json(*args)
397
+ end
398
+
399
+ # Saves the current node inventory to a file. DOES NOT USE LOCK.
400
+ # If this is called outside of a lock, it will not be thread-safe.
401
+ def save_no_lock!(file_path = @save_file_path)
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
410
+ end
411
+ end
412
+
413
+ # Saves the current node inventory to a yaml file. Thread-safe.
414
+ # @param file_path [String] The path to the file to save to.
415
+ def save(file_path = @save_file_path)
416
+ with_lock_retry(:write) do
417
+ save_no_lock!(file_path)
418
+ end
419
+ end
420
+
421
+ # Loads a node inventory from a file. DOES NOT USE LOCK.
422
+ # If this is called outside of a lock, it will not be thread-safe.
423
+ def load_no_lock!(file_path = @save_file_path)
424
+ require 'net/ssh/proxy/command' # If ProxyCommand is used in ssh options, this is required.
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)
430
+ end
431
+ rescue StandardError => e
432
+ raise e unless (attempts += 1) <= 3
433
+
434
+ sleep(1)
435
+ retry
436
+ end
437
+
438
+ def update_no_lock!; end
439
+
440
+ # Loads a node inventory from a yaml file. Thread-safe.
441
+ # @param file_path [String] The path to the file to load from.
442
+ def load(file_path = @save_file_path)
443
+ with_lock_retry(:write) do
444
+ load_no_lock!(file_path)
445
+ end
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
477
+ end
478
+
479
+ class LocalPortAllocationError < StandardError; end
480
+
481
+ # Handles local port allocation for things like local SSH tunnels.
482
+ # This class is thread-safe as the port range is a Concurrent::Array,
483
+ # which blocks when modified. When this class is initialized, it's
484
+ # total assignable ports are set to all ports in the ephemeral port range.
485
+ # When a port is allocated, it's removed from the port range.
486
+ class LocalPortAllocator
487
+ EPHEMERAL_PORT_RANGE = (49_152..65_535).to_a.freeze
488
+
489
+ def initialize
490
+ @port_range = Concurrent::Array.new(EPHEMERAL_PORT_RANGE.dup).shuffle!
491
+ end
492
+
493
+ # Returns an open port(s) in the ephemeral port range on the local machine.
494
+ # If 'number_of_ports' is specified and greater than one, returns an array of ports.
495
+ # If 'number_of_ports' is not specified or is one, returns a single port.
496
+ # @param number_of_ports [Integer] the number of ports to allocate
497
+ # @return [Integer, Array<Integer>] the port(s) allocated
498
+ # @raise [ArgumentError] if number_of_ports is not an Integer or is less than one
499
+ # @raise [LocalPortAllocationError] if there are no ports available
500
+ def allocate(number_of_ports = 1)
501
+ unless number_of_ports.is_a?(Integer) && number_of_ports.positive?
502
+ raise ArgumentError, 'number_of_ports must be a positive integer'
503
+ end
504
+ raise LocalPortAllocationError, 'No ports available' if @port_range.empty?
505
+
506
+ ports = []
507
+ while ports.length < number_of_ports
508
+ port = @port_range.pop
509
+ next unless local_port_open?(port)
510
+
511
+ ports << port
512
+ end
513
+ if ports.length == 1
514
+ ports[0]
515
+ else
516
+ ports
517
+ end
518
+ end
519
+
520
+ private
521
+
522
+ # Checks if the given port is open on the local machine. This is done by
523
+ # opening a socket on the port and checking if it raises an exception.
524
+ # @param port [Integer] the port to check
525
+ # @return [Boolean] true if the port is open, false otherwise
526
+ def local_port_open?(port)
527
+ require 'socket'
528
+ socket = Socket.new(Socket::Constants::AF_INET, Socket::Constants::SOCK_STREAM, 0)
529
+ socket.bind(Socket.pack_sockaddr_in(port, '0.0.0.0'))
530
+ true
531
+ rescue Errno::EADDRINUSE, Errno::CONNREFUSED
532
+ false
533
+ ensure
534
+ socket&.close
535
+ end
536
+ end
537
+ end