cem_acpt 0.2.5 → 0.6.1

Sign up to get free protection for your applications and to get access to all the features.
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