cem_acpt 0.2.5 → 0.6.1

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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/spec.yml +38 -0
  3. data/Gemfile +4 -3
  4. data/Gemfile.lock +85 -56
  5. data/README.md +144 -83
  6. data/cem_acpt.gemspec +8 -7
  7. data/exe/cem_acpt +41 -7
  8. data/lib/cem_acpt/config.rb +345 -0
  9. data/lib/cem_acpt/core_extensions.rb +17 -61
  10. data/lib/cem_acpt/goss/api/action_response.rb +175 -0
  11. data/lib/cem_acpt/goss/api.rb +83 -0
  12. data/lib/cem_acpt/goss.rb +8 -0
  13. data/lib/cem_acpt/image_name_builder.rb +0 -9
  14. data/lib/cem_acpt/logging/formatter.rb +97 -0
  15. data/lib/cem_acpt/logging.rb +168 -142
  16. data/lib/cem_acpt/platform/base.rb +26 -37
  17. data/lib/cem_acpt/platform/gcp.rb +48 -62
  18. data/lib/cem_acpt/platform.rb +30 -28
  19. data/lib/cem_acpt/provision/terraform/linux.rb +47 -0
  20. data/lib/cem_acpt/provision/terraform/os_data.rb +72 -0
  21. data/lib/cem_acpt/provision/terraform/windows.rb +22 -0
  22. data/lib/cem_acpt/provision/terraform.rb +193 -0
  23. data/lib/cem_acpt/provision.rb +20 -0
  24. data/lib/cem_acpt/puppet_helpers.rb +0 -1
  25. data/lib/cem_acpt/test_data.rb +23 -13
  26. data/lib/cem_acpt/test_runner/log_formatter/goss_action_response.rb +104 -0
  27. data/lib/cem_acpt/test_runner/log_formatter.rb +10 -0
  28. data/lib/cem_acpt/test_runner.rb +170 -3
  29. data/lib/cem_acpt/utils/puppet.rb +29 -0
  30. data/lib/cem_acpt/utils/ssh.rb +197 -0
  31. data/lib/cem_acpt/utils/terminal.rb +27 -0
  32. data/lib/cem_acpt/utils.rb +4 -138
  33. data/lib/cem_acpt/version.rb +1 -1
  34. data/lib/cem_acpt.rb +70 -20
  35. data/lib/terraform/gcp/linux/goss/puppet_idempotent.yaml +10 -0
  36. data/lib/terraform/gcp/linux/goss/puppet_noop.yaml +12 -0
  37. data/lib/terraform/gcp/linux/main.tf +191 -0
  38. data/lib/terraform/gcp/linux/systemd/goss-acpt.service +8 -0
  39. data/lib/terraform/gcp/linux/systemd/goss-idempotent.service +8 -0
  40. data/lib/terraform/gcp/linux/systemd/goss-noop.service +8 -0
  41. data/lib/terraform/gcp/windows/.keep +0 -0
  42. data/sample_config.yaml +22 -21
  43. metadata +88 -56
  44. data/lib/cem_acpt/bootstrap/bootstrapper.rb +0 -206
  45. data/lib/cem_acpt/bootstrap/operating_system/rhel_family.rb +0 -129
  46. data/lib/cem_acpt/bootstrap/operating_system.rb +0 -17
  47. data/lib/cem_acpt/bootstrap.rb +0 -12
  48. data/lib/cem_acpt/context.rb +0 -153
  49. data/lib/cem_acpt/platform/base/cmd.rb +0 -71
  50. data/lib/cem_acpt/platform/gcp/cmd.rb +0 -345
  51. data/lib/cem_acpt/platform/gcp/compute.rb +0 -332
  52. data/lib/cem_acpt/platform/vmpooler.rb +0 -24
  53. data/lib/cem_acpt/rspec_utils.rb +0 -242
  54. data/lib/cem_acpt/shared_objects.rb +0 -537
  55. data/lib/cem_acpt/spec_helper_acceptance.rb +0 -184
  56. data/lib/cem_acpt/test_runner/run_handler.rb +0 -187
  57. data/lib/cem_acpt/test_runner/runner.rb +0 -210
  58. data/lib/cem_acpt/test_runner/runner_result.rb +0 -103
@@ -0,0 +1,345 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'yaml'
5
+ require_relative 'core_extensions'
6
+
7
+ module CemAcpt
8
+ using CemAcpt::CoreExtensions::ExtendedHash
9
+
10
+ # Holds the configuration for cem_acpt
11
+ class Config
12
+ KEYS = %i[
13
+ actions
14
+ ci_mode
15
+ config_file
16
+ image_name_builder
17
+ log_level
18
+ log_file
19
+ log_format
20
+ module_dir
21
+ node_data
22
+ no_ephemeral_ssh_key
23
+ platform
24
+ provisioner
25
+ quiet
26
+ terraform
27
+ test_data
28
+ tests
29
+ user_config
30
+ verbose
31
+ ].freeze
32
+
33
+ attr_reader :config, :env_vars
34
+
35
+ def initialize(opts: {}, config_file: nil, load_user_config: true)
36
+ @load_user_config = load_user_config
37
+ load(opts: opts, config_file: config_file)
38
+ end
39
+
40
+ # The default configuration
41
+ def defaults
42
+ {
43
+ actions: {},
44
+ ci_mode: false,
45
+ config_file: nil,
46
+ image_name_builder: {
47
+ character_substitutions: ['_', '-'],
48
+ parts: ['cem-acpt', '$image_fam', '$collection', '$firewall'],
49
+ join_with: '-',
50
+ },
51
+ log_level: 'info',
52
+ log_file: nil,
53
+ log_format: 'text',
54
+ module_dir: Dir.pwd,
55
+ node_data: {},
56
+ no_ephemeral_ssh_key: false,
57
+ platform: {
58
+ name: 'gcp',
59
+ },
60
+ quiet: false,
61
+ test_data: {
62
+ for_each: {
63
+ collection: %w[puppet7],
64
+ },
65
+ vars: {},
66
+ name_pattern_vars: %r{^(?<framework>[a-z]+)_(?<image_fam>[a-z0-9-]+)_(?<firewall>[a-z]+)_(?<framework_vars>[-_a-z0-9]+)$},
67
+ vars_post_processing: {
68
+ new_vars: [
69
+ {
70
+ name: 'profile',
71
+ string_split: {
72
+ from: 'framework_vars',
73
+ using: '_',
74
+ part: 0,
75
+ },
76
+ },
77
+ {
78
+ name: 'level',
79
+ string_split: {
80
+ from: 'framework_vars',
81
+ using: '_',
82
+ part: 1,
83
+ },
84
+ },
85
+ ],
86
+ delete_vars: %w[framework_vars],
87
+ },
88
+ },
89
+ tests: [],
90
+ verbose: false,
91
+ }
92
+ end
93
+
94
+ # Load the configuration from the environment variables, config file, and opts
95
+ # The order of precedence is:
96
+ # 1. environment variables
97
+ # 2. user config file (config.yaml in user_config_dir)
98
+ # 3. specified config file (if it exists)
99
+ # 4. opts
100
+ # 5. static options (set in this class)
101
+ # @param opts [Hash] The options to load
102
+ # @param config_file [String] The config file to load
103
+ # @return [self] This object with the config loaded
104
+ def load(opts: {}, config_file: nil)
105
+ create_config_dirs!
106
+ init_config!(opts: opts, config_file: config_file)
107
+ add_env_vars!(@config)
108
+ @config.merge!(user_config) if user_config && @load_user_config
109
+ @config.merge!(config_from_file) if config_from_file
110
+ @config.merge!(@options) if @options
111
+ add_static_options!(@config)
112
+ @config.format! # Symbolize keys of all hashes
113
+ validate_config!
114
+ # Freeze the config so it can't be modified
115
+ # This helps with thread safety and deterministic behavior
116
+ @config.freeze
117
+ self
118
+ end
119
+ alias to_h config
120
+
121
+ def explain
122
+ explanation = {}
123
+ %i[defaults env_vars user_config config_from_file options].each do |source|
124
+ source_vals = send(source).dup
125
+ next if source_vals.nil? || source_vals.empty?
126
+
127
+ # The loop below will overwrite the value of explanation[key] if the same key is found in multiple sources
128
+ # This is intentional, as the last source to set the value is the one that should be used
129
+ source_vals.each do |key, value|
130
+ explanation[key] = source if @config.dget(key.to_s) == value
131
+ end
132
+ end
133
+ explained = explanation.each_with_object([]) do |(key, value), ary|
134
+ ary << "Key '#{key}' from source '#{value}'"
135
+ end
136
+ explained.join("\n")
137
+ end
138
+
139
+ def [](key)
140
+ if key.is_a?(Symbol)
141
+ @config[key].dup
142
+ elsif key.is_a?(String)
143
+ @config.dget(key).dup
144
+ else
145
+ raise ArgumentError, "Invalid key type '#{key.class}'"
146
+ end
147
+ end
148
+
149
+ def get(dot_key)
150
+ @config.dget(dot_key).dup
151
+ end
152
+ alias dget get
153
+
154
+ def has?(dot_key)
155
+ !!get(dot_key)
156
+ end
157
+
158
+ def empty?
159
+ @config.empty?
160
+ end
161
+
162
+ def ci_mode?
163
+ !!get('ci_mode') || !!(ENV['GITHUB_ACTIONS'] || ENV['CI'])
164
+ end
165
+ alias ci? ci_mode?
166
+
167
+ def debug_mode?
168
+ get('log_level') == 'debug'
169
+ end
170
+ alias debug? debug_mode?
171
+
172
+ def verbose_mode?
173
+ !!get('verbose')
174
+ end
175
+ alias verbose? verbose_mode?
176
+
177
+ def quiet_mode?
178
+ !!get('quiet')
179
+ end
180
+ alias quiet? quiet_mode?
181
+
182
+ def to_yaml
183
+ @config.to_yaml
184
+ end
185
+
186
+ def to_json(*args)
187
+ @config.to_json(*args)
188
+ end
189
+
190
+ private
191
+
192
+ attr_reader :options
193
+
194
+ def user_config_dir
195
+ @user_config_dir ||= File.join(Dir.home, '.cem_acpt')
196
+ end
197
+
198
+ def user_config_file
199
+ @user_config_file ||= File.join(user_config_dir, 'config.yaml')
200
+ end
201
+
202
+ def terraform_dir
203
+ @terraform_dir ||= File.join(user_config_dir, 'terraform')
204
+ end
205
+
206
+ def valid_env_var?(env_var)
207
+ env_var.start_with?('CEM_ACPT_') && ENV[env_var]
208
+ end
209
+
210
+ def env_var_to_dot_key(env_var)
211
+ env_var.sub('CEM_ACPT_', '').gsub(%r{__}, '.').downcase
212
+ end
213
+
214
+ def add_static_options!(config)
215
+ config.dset('user_config.dir', user_config_dir)
216
+ config.dset('user_config.file', user_config_file)
217
+ config.dset('provisioner', 'terraform')
218
+ config.dset('terraform.dir', terraform_dir)
219
+ set_third_party_env_vars!(config)
220
+ end
221
+
222
+ # Certain environment variables are used by other tools like GitHub Actions
223
+ # This method sets the relative config values for those environment variables.
224
+ # This is the last step in composing the config, so it will override any
225
+ # values set by the user.
226
+ def set_third_party_env_vars!(config)
227
+ if ENV['RUNNER_DEBUG'] == '1'
228
+ config.dset('log_level', 'debug')
229
+ config.dset('verbose', true)
230
+ end
231
+ if ENV['GITHUB_ACTIONS'] == 'true' || ENV['CI'] == 'true'
232
+ config.dset('ci_mode', true)
233
+ end
234
+ end
235
+
236
+ # Used to source the config during loading of config files.
237
+ # Because config may not be fully loaded yet, this method
238
+ # checks the options hash first, then the current config,
239
+ # then the environment variables for the given key
240
+ # @param key [String] The key to find in dot notation
241
+ # @return [Any] The value of the key
242
+ def find_option(key)
243
+ return @options.dget(key) if @options.dget(key)
244
+ return @config.dget(key) if @config.dget(key)
245
+ ENV.each do |k, v|
246
+ next unless valid_env_var?(k)
247
+
248
+ return v if env_var_to_dot_key(k) == key
249
+ end
250
+ nil
251
+ end
252
+
253
+ def init_config!(opts: {}, config_file: nil)
254
+ # Blank out the config
255
+ @user_config = {}
256
+ @config_from_file = {}
257
+ @options = {}
258
+ @config = defaults.dup
259
+ # Set the parameterized defaults
260
+ config_file = ENV['CEM_ACPT_CONFIG_FILE'] if config_file.nil?
261
+ @config.dset('config_file', config_file) if config_file
262
+ @options = opts || {}
263
+ end
264
+
265
+ def add_env_vars!(config)
266
+ @env_vars = {}
267
+ # First load known environment variables into their respective config keys
268
+ # Then load any environment variables that start with CEM_ACPT_<known key> into their respective config keys
269
+ ENV.each do |env_var, value|
270
+ next unless valid_env_var?(env_var)
271
+
272
+ key = env_var_to_dot_key(env_var) # Convert CEM_ACPT_<key> to <dotkey>
273
+ next unless KEYS.include?(key.split('.').first.to_sym) # Skip if the key is not a known config key
274
+ @env_vars[key] = value
275
+ config.dset(key, value)
276
+ end
277
+ end
278
+
279
+ def user_config
280
+ return @user_config unless @user_config.nil? || @user_config.empty?
281
+
282
+ @user_config = if user_config_file && File.exist?(user_config_file)
283
+ load_config_file(user_config_file)
284
+ else
285
+ {}
286
+ end
287
+
288
+ @user_config
289
+ end
290
+
291
+ def config_from_file
292
+ return @config_from_file unless @config_from_file.nil? || @config_from_file.empty?
293
+
294
+ conf_file = find_option('config_file')
295
+ return {} if conf_file.nil? || conf_file.empty?
296
+
297
+ unless conf_file
298
+ warn "Invalid config_file type '#{conf_file.class}'. Must be a String."
299
+ return {}
300
+ end
301
+
302
+ mod_dir = find_option('module_dir')
303
+ if mod_dir && File.exist?(File.join(mod_dir, conf_file))
304
+ @config_from_file = load_config_file(File.join(mod_dir, conf_file))
305
+ elsif File.exist?(File.expand_path(conf_file))
306
+ @config_from_file = load_config_file(File.expand_path(conf_file))
307
+ else
308
+ err_msg = [
309
+ "Config file '#{File.expand_path(conf_file)}' does not exist.",
310
+ ]
311
+ err_msg << "Config file '#{File.join(mod_dir, conf_file)}' does not exist." if mod_dir
312
+ raise err_msg.join("\n")
313
+ end
314
+
315
+ @config_from_file
316
+ end
317
+
318
+ def load_config_file(config_file)
319
+ return {} if config_file.nil? || config_file.empty? || !File.exist?(File.expand_path(config_file))
320
+
321
+ loaded = load_yaml(config_file)
322
+ loaded.format!
323
+ loaded
324
+ end
325
+
326
+ def load_yaml(config_file)
327
+ if YAML.respond_to?(:safe_load_file) # Ruby 3.0+
328
+ YAML.safe_load_file(File.expand_path(config_file), permitted_classes: [Regexp])
329
+ else
330
+ YAML.safe_load(File.read(File.expand_path(config_file)), permitted_classes: [Regexp])
331
+ end
332
+ end
333
+
334
+ def validate_config!
335
+ @config.each do |key, _value|
336
+ warn "Unknown config key: #{key}" unless KEYS.include?(key)
337
+ end
338
+ end
339
+
340
+ def create_config_dirs!
341
+ FileUtils.mkdir_p(user_config_dir) unless Dir.exist?(user_config_dir)
342
+ FileUtils.cp_r(File.expand_path(File.join(__dir__, '..', 'terraform')), user_config_dir)
343
+ end
344
+ end
345
+ end
@@ -1,66 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # This module holds extensions to Ruby and the Ruby stdlib
4
- # Extensions related to deep_freeze were pulled from: https://gist.github.com/steakknife/1a37057b3b8539f4aca3
3
+ # This module holds extensions and refinements to Ruby and the Ruby stdlib
5
4
  module CemAcpt::CoreExtensions
6
- # DeepFreeze recursively freezes all keys and values in a hash
7
- # Currently unused, but was used at one point and may be useful again
8
- module DeepFreeze
9
- # Holds deep_freeze extensions to Kernel
10
- module Kernel
11
- alias deep_freeze freeze
12
- alias deep_frozen? frozen?
13
- end
14
-
15
- # Holds deep_freeze extensions to Enumerable
16
- module Enumerable
17
- def deep_freeze
18
- unless @deep_frozen
19
- each(&:deep_freeze)
20
- @deep_frozen = true
21
- end
22
- freeze
23
- end
24
-
25
- def deep_frozen?
26
- !!@deep_frozen
27
- end
28
- end
29
-
30
- # Holds deep_freeze extensions to Hash
31
- module Hash
32
- def deep_freeze
33
- transform_values! do |value|
34
- value.respond_to?(:deep_freeze) ? value.deep_freeze : value.freeze
35
- end
36
- freeze
37
- @deep_frozen = true
38
- end
39
-
40
- def deep_frozen?
41
- !!@deep_frozen
42
- end
43
- end
44
-
45
- # Holds deep_freeze extensions to OpenStruct
46
- module OpenStruct
47
- def deep_freeze
48
- unless deep_frozen?
49
- @table.reduce({}) do |h, (key, value)|
50
- fkey = key.respond_to?(:deep_freeze) ? key.deep_freeze : key
51
- fval = value.respond_to?(:deep_freeze) ? value.deep_freeze : value
52
- h.merge(fkey => fval)
53
- end.freeze
54
- @deep_frozen = true
55
- end
56
- end
57
-
58
- def deep_frozen?
59
- !!@deep_frozen
60
- end
61
- end
62
- end
63
-
64
5
  # Refines the Hash class with some convenience methods.
65
6
  # Must call `using CemAcpt::CoreExtensions::HashExtensions`
66
7
  # before these methods will be available.
@@ -83,6 +24,7 @@ module CemAcpt::CoreExtensions
83
24
  def has?(path)
84
25
  !!dot_dig(path)
85
26
  end
27
+ alias dhas? has?
86
28
 
87
29
  # Digs into a Hash using a dot-separated path.
88
30
  # If the path is not found, returns nil.
@@ -91,7 +33,16 @@ module CemAcpt::CoreExtensions
91
33
  # hash.dot_dig('a.b.c') # => 1
92
34
  def dot_dig(path)
93
35
  dig(*path.split('.').map(&:to_sym)) || dig(*path.split('.'))
36
+ rescue TypeError
37
+ # TypeError is raised if parts of the path don't have the #dig method
38
+ # This can happen if you do something like:
39
+ # hash = {a: {b: {c: 1}}}
40
+ # hash.dot_dig('a.b.c.d')
41
+ # The integer 1 doesn't have the #dig method, so we get a TypeError
42
+ # Since this means the path is invalid, we return nil
43
+ nil
94
44
  end
45
+ alias dget dot_dig
95
46
 
96
47
  # Stores a value in a nested Hash using a dot-separated path
97
48
  # to dig through keys.
@@ -99,10 +50,15 @@ module CemAcpt::CoreExtensions
99
50
  # hash = {a: {b: {c: 1}}}
100
51
  # hash.dot_store('a.b.c', 2)
101
52
  # hash #=> {a: {b: {c: 2}}}
53
+ # hash.dot_store('a.b.d', 3)
54
+ # hash #=> {a: {b: {c: 2, d: 3}}}
102
55
  def dot_store(path, value)
103
56
  *key, last = path.split('.').map(&:to_sym)
104
- key.inject(self, :fetch)[last] = value
57
+ key.inject(self) do |memo, k|
58
+ memo[k] ||= {}
59
+ end[last] = value
105
60
  end
61
+ alias dset dot_store
106
62
  end
107
63
  end
108
64
  end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CemAcpt
4
+ module Goss
5
+ module Api
6
+ class ActionResponse
7
+ attr_reader :host, :action, :body
8
+
9
+ def initialize(host, action, status, body)
10
+ @host = host
11
+ @action = action
12
+ @status = status
13
+ @body = body
14
+ end
15
+
16
+ def to_s
17
+ "#<#{self.class.name}:0x#{object_id.to_s(16)}>"
18
+ end
19
+
20
+ def inspect
21
+ to_s
22
+ end
23
+
24
+ def to_h
25
+ {
26
+ host: host,
27
+ action: action,
28
+ status: @status,
29
+ body: @body,
30
+ }
31
+ end
32
+
33
+ def status
34
+ @status.to_i
35
+ end
36
+ alias http_status status
37
+
38
+ def success?
39
+ status == 200
40
+ end
41
+
42
+ def results
43
+ @results ||= @body['results'].map { |r| ActionResponseResult.new(r) }
44
+ end
45
+
46
+ def results?
47
+ !results.nil? && !results.empty?
48
+ end
49
+
50
+ def summary
51
+ @summary ||= ActionResponseSummary.new(@body['summary'])
52
+ end
53
+
54
+ def summary?
55
+ !summary.nil? && !summary.empty?
56
+ end
57
+ end
58
+
59
+ module DurationHandler
60
+ DURATION_UNITS = %i[nanoseconds milliseconds seconds].freeze
61
+
62
+ Duration = Struct.new(:duration, :unit, :round) do
63
+ def to_f
64
+ return @to_f if defined?(@to_f)
65
+ return 0.0 if duration.nil?
66
+
67
+ case unit
68
+ when :nanoseconds
69
+ @to_f = duration.to_f.round(round)
70
+ when :milliseconds
71
+ @to_f = (duration.to_f / 1_000_000).round(round)
72
+ when :seconds
73
+ @to_f = (duration.to_f / 1_000_000_000).round(round)
74
+ else
75
+ raise ArgumentError, "Invalid unit #{unit}, must be one of #{DURATION_UNITS}"
76
+ end
77
+ @to_f
78
+ end
79
+
80
+ def to_s
81
+ return @to_s if defined?(@to_s)
82
+
83
+ case unit
84
+ when :nanoseconds
85
+ @to_s = "#{to_f}ns"
86
+ when :milliseconds
87
+ @to_s = "#{to_f}ms"
88
+ when :seconds
89
+ @to_s = "#{to_f}s"
90
+ else
91
+ raise ArgumentError, "Invalid unit #{unit}, must be one of #{DURATION_UNITS}"
92
+ end
93
+ @to_s
94
+ end
95
+ end
96
+
97
+ # @param unit [Symbol] The unit to return the duration in
98
+ # @param round [Integer] The number of decimal places to round to
99
+ # @return [Duration] The Duration object
100
+ def duration(unit: :seconds, round: 3)
101
+ @all_durations ||= {}
102
+ @all_durations[unit] ||= {}
103
+ @all_durations[unit][round] ||= Duration.new(@duration, unit, round)
104
+ end
105
+ end
106
+
107
+ class ActionResponseSummary
108
+ include DurationHandler
109
+
110
+ attr_reader :failed_count, :summary_line, :test_count
111
+
112
+ def initialize(summary)
113
+ @summary = summary
114
+ @duration = @summary['total-duration']
115
+ @failed_count = @summary['failed-count']
116
+ @summary_line = @summary['summary-line']
117
+ @test_count = @summary['test-count']
118
+ end
119
+ alias to_s summary_line
120
+ alias total_duration duration
121
+
122
+ def to_h
123
+ @summary
124
+ end
125
+
126
+ def failed_percentage
127
+ @failed_percentage ||= (test_count.zero? ? 0.00 : (failed_count.to_f / test_count.to_f) * 100).round(2)
128
+ end
129
+
130
+ def passed_count
131
+ @passed_count ||= test_count - failed_count
132
+ end
133
+ end
134
+
135
+ class ActionResponseResult
136
+ include DurationHandler
137
+
138
+ attr_reader(:duration, :err, :expected, :found, :human, :meta, :property,
139
+ :resource_id, :resource_type, :result, :skipped, :successful,
140
+ :summary_line, :test_type, :title)
141
+
142
+ def initialize(raw_result)
143
+ @raw_result = raw_result
144
+ @duration = @raw_result['duration']
145
+ @err = @raw_result['err']
146
+ @expected = @raw_result['expected']
147
+ @found = @raw_result['found']
148
+ @human = @raw_result['human']
149
+ @meta = @raw_result['meta']
150
+ @property = @raw_result['property']
151
+ @resource_id = @raw_result['resource-id']
152
+ @resource_type = @raw_result['resource-type']
153
+ @result = @raw_result['result']
154
+ @skipped = @raw_result['skipped']
155
+ @successful = @raw_result['successful']
156
+ @summary_line = @raw_result['summary-line']
157
+ @test_type = @raw_result['test-type']
158
+ @title = @raw_result['title']
159
+ end
160
+ alias error err
161
+ alias to_s summary_line
162
+ alias skipped? skipped
163
+ alias success? successful
164
+
165
+ def to_h
166
+ @raw_result
167
+ end
168
+
169
+ def error?
170
+ !err.nil?
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'async'
4
+ require 'async/barrier'
5
+ require 'async/http/internet'
6
+ require 'json'
7
+ require_relative 'api/action_response'
8
+
9
+ module CemAcpt
10
+ module Goss
11
+ # Holds methods for interacting with the Goss API running on a test node.
12
+ module Api
13
+ class << self
14
+ # The actions that can be run against the Goss API. The key is the action
15
+ # name and the value is the port/endpoint of the action.
16
+ ACTIONS = {
17
+ acpt: '8080/acpt',
18
+ idempotent: '8081/idempotent',
19
+ noop: '8082/noop',
20
+ }.freeze
21
+
22
+ # Create a URI for the specified action against the specified host.
23
+ # @param host [String] The host to run the action against. This should be
24
+ # a public IP address or a DNS-resolvable name.
25
+ # @param action [Symbol] The action to run.
26
+ # @return [URI] The URI for the action.
27
+ def action_uri(host, action)
28
+ URI("http://#{host}:#{ACTIONS[action.to_sym]}")
29
+ end
30
+
31
+ # Run the specified actions against the specified hosts asynchronously.
32
+ # @param hosts [Array<String>] The hosts to run the actions against. Each
33
+ # host should be a public IP address or a DNS-resolvable name.
34
+ # @param results [Queue] The queue to push the results to.
35
+ # @param only [Array<Symbol>] The actions to run.
36
+ # @param except [Array<Symbol>] The actions to skip.
37
+ # @return [Queue] The queue of results.
38
+ def run_actions_async(hosts, results: Queue.new, only: [], except: [])
39
+ raise ArgumentError, 'hosts must be an Array' unless hosts.is_a?(Array)
40
+ raise ArgumentError, 'results must be a Queue-like object implementing #<<' unless results.respond_to?(:<<)
41
+ raise ArgumentError, 'only must be an Array' unless except.is_a?(Array)
42
+ raise ArgumentError, 'except must be an Array' unless except.is_a?(Array)
43
+ only.map!(&:to_sym)
44
+ except.map!(&:to_sym)
45
+ only_specified = !only.empty?
46
+ except_specified = !except.empty?
47
+ Async do
48
+ internet = Async::HTTP::Internet.new
49
+ barrier = Async::Barrier.new
50
+ barrier.async do
51
+ hosts.each do |host|
52
+ ACTIONS.keys.each do |action, _|
53
+ next if only_specified && !only.include?(action)
54
+ next if except_specified && except.include?(action)
55
+
56
+ results << run_action(internet, host, action)
57
+ end
58
+ end
59
+ end
60
+ barrier.wait
61
+ ensure
62
+ internet&.close
63
+ results.close if results.respond_to?(:close)
64
+ end
65
+ results
66
+ end
67
+
68
+ # Run the specified action against the specified host.
69
+ # @param internet [Async::HTTP::Internet] The Async::HTTP::Internet object to use for the request.
70
+ # @param host [String] The host to run the action against. This should be
71
+ # a public IP address or a DNS-resolvable name.
72
+ # @param action [Symbol] The action to run.
73
+ # @return [ActionResponse] The response from the action.
74
+ def run_action(internet, host, action)
75
+ uri = action_uri(host, action)
76
+ response = internet.get(uri.to_s)
77
+ body = JSON.parse(response.read)
78
+ ActionResponse.new(host, action, response.status, body)
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end