cem_acpt 0.8.7 → 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/spec.yml +0 -3
  3. data/Gemfile.lock +9 -1
  4. data/README.md +95 -13
  5. data/cem_acpt.gemspec +2 -1
  6. data/lib/cem_acpt/action_result.rb +8 -2
  7. data/lib/cem_acpt/actions.rb +153 -0
  8. data/lib/cem_acpt/bolt/cmd/base.rb +174 -0
  9. data/lib/cem_acpt/bolt/cmd/output.rb +315 -0
  10. data/lib/cem_acpt/bolt/cmd/task.rb +59 -0
  11. data/lib/cem_acpt/bolt/cmd.rb +22 -0
  12. data/lib/cem_acpt/bolt/errors.rb +49 -0
  13. data/lib/cem_acpt/bolt/helpers.rb +52 -0
  14. data/lib/cem_acpt/bolt/inventory.rb +62 -0
  15. data/lib/cem_acpt/bolt/project.rb +38 -0
  16. data/lib/cem_acpt/bolt/summary_results.rb +96 -0
  17. data/lib/cem_acpt/bolt/tasks.rb +181 -0
  18. data/lib/cem_acpt/bolt/tests.rb +415 -0
  19. data/lib/cem_acpt/bolt/yaml_file.rb +74 -0
  20. data/lib/cem_acpt/bolt.rb +142 -0
  21. data/lib/cem_acpt/cli.rb +6 -0
  22. data/lib/cem_acpt/config/base.rb +4 -0
  23. data/lib/cem_acpt/config/cem_acpt.rb +7 -1
  24. data/lib/cem_acpt/core_ext.rb +25 -0
  25. data/lib/cem_acpt/goss/api/action_response.rb +4 -0
  26. data/lib/cem_acpt/goss/api.rb +23 -25
  27. data/lib/cem_acpt/logging/formatter.rb +3 -3
  28. data/lib/cem_acpt/logging.rb +17 -1
  29. data/lib/cem_acpt/provision/terraform/linux.rb +1 -1
  30. data/lib/cem_acpt/test_data.rb +2 -0
  31. data/lib/cem_acpt/test_runner/log_formatter/base.rb +73 -0
  32. data/lib/cem_acpt/test_runner/log_formatter/bolt_error_formatter.rb +65 -0
  33. data/lib/cem_acpt/test_runner/log_formatter/bolt_output_formatter.rb +54 -0
  34. data/lib/cem_acpt/test_runner/log_formatter/bolt_summary_results_formatter.rb +64 -0
  35. data/lib/cem_acpt/test_runner/log_formatter/goss_action_response.rb +17 -30
  36. data/lib/cem_acpt/test_runner/log_formatter/goss_error_formatter.rb +31 -0
  37. data/lib/cem_acpt/test_runner/log_formatter/standard_error_formatter.rb +35 -0
  38. data/lib/cem_acpt/test_runner/log_formatter.rb +17 -5
  39. data/lib/cem_acpt/test_runner/test_results.rb +150 -0
  40. data/lib/cem_acpt/test_runner.rb +153 -53
  41. data/lib/cem_acpt/utils/files.rb +189 -0
  42. data/lib/cem_acpt/utils/finalizer_queue.rb +73 -0
  43. data/lib/cem_acpt/utils/shell.rb +13 -4
  44. data/lib/cem_acpt/version.rb +1 -1
  45. data/sample_config.yaml +13 -0
  46. metadata +41 -5
  47. data/lib/cem_acpt/test_runner/log_formatter/error_formatter.rb +0 -33
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bd72076945b79db4583d665e691c86214c901331f37ccdfc25e0d07b999b50ca
4
- data.tar.gz: f32e25b3c220e20c73aa3bcd7bab9873c2ac753b5d41c61e2f069988eaf29822
3
+ metadata.gz: dcea95d44401d3a90c8724ac2dbb85753eda2f38564ce84192f5580e1d528eaf
4
+ data.tar.gz: 6b2b8f267e951c578b35d49a11180bc8640da1695352097dd6652fc3e401aea9
5
5
  SHA512:
6
- metadata.gz: a9e7b0d71cfd8efedd0faef1e8fb2cb23b0335a049b1b8d270a7325a3cf19b97d1d603042d7326478c6e4e23ae42de890de0b88bc38a21085ab44e19c47d605b
7
- data.tar.gz: ab1df006f61525ead0c8b64f38b52fe93a1152a337eea0d7a78535f3496fa3d4856b3fdc46e6f9dd29f8fea038e17af5acd76ced4df502707f3880201d6df511
6
+ metadata.gz: cbe2360892776beb52c7d83bda5813283fc8b26c43148a45a60c5d8be03d45104976d4a91e48dc4a0207092b65712fd87224df40844cc4000628869fd24f5824
7
+ data.tar.gz: a6cfb5bc229561cdd0905d15c969de745bd14e5a767a60099543ca579b72659f6abc09cfa4182a44634552e23ace099c38095cc9bdfd06d471a436adebbd76a3
@@ -8,8 +8,6 @@ on:
8
8
  - synchronize
9
9
  branches:
10
10
  - main
11
- tags:
12
- - v.*
13
11
 
14
12
  jobs:
15
13
  tests:
@@ -18,7 +16,6 @@ jobs:
18
16
  strategy:
19
17
  matrix:
20
18
  ruby:
21
- - 2.7
22
19
  - 3.2
23
20
  steps:
24
21
  - name: Checkout Source
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- cem_acpt (0.8.7)
4
+ cem_acpt (0.9.0)
5
5
  async-http (>= 0.60, < 0.70)
6
6
  bcrypt_pbkdf (>= 1.0, < 2.0)
7
7
  deep_merge (>= 1.2, < 2.0)
@@ -36,6 +36,7 @@ GEM
36
36
  fiber-local
37
37
  deep_merge (1.2.2)
38
38
  diff-lcs (1.5.0)
39
+ docile (1.4.0)
39
40
  ed25519 (1.3.0)
40
41
  erubi (1.12.0)
41
42
  ffi (1.15.5)
@@ -115,6 +116,12 @@ GEM
115
116
  rubocop-factory_bot (~> 2.22)
116
117
  ruby-progressbar (1.13.0)
117
118
  rubyntlm (0.6.3)
119
+ simplecov (0.21.2)
120
+ docile (~> 1.1)
121
+ simplecov-html (~> 0.11)
122
+ simplecov_json_formatter (~> 0.1)
123
+ simplecov-html (0.12.3)
124
+ simplecov_json_formatter (0.1.4)
118
125
  timers (4.3.5)
119
126
  traces (0.9.1)
120
127
  unicode-display_width (2.4.2)
@@ -141,6 +148,7 @@ DEPENDENCIES
141
148
  rubocop
142
149
  rubocop-performance
143
150
  rubocop-rspec
151
+ simplecov
144
152
 
145
153
  BUNDLED WITH
146
154
  2.4.19
data/README.md CHANGED
@@ -10,7 +10,7 @@ CemAcpt uses [Terraform](#terraform) for provisioning test nodes, and [Goss](#go
10
10
  gem install cem_acpt
11
11
  ```
12
12
 
13
- `cem_acpt` was developed using Ruby 3.2.1, but other Ruby versions may work.
13
+ `cem_acpt` was developed using Ruby 3.2.1, but other Ruby versions `>= 3.0.0` should work.
14
14
 
15
15
  ## Usage
16
16
 
@@ -101,6 +101,88 @@ To aide in development, you can enable tracing on CemAcpt's Ruby code execution
101
101
 
102
102
  CemAcpt uses [Terraform](https://www.terraform.io/) for managing the lifecycle of test nodes. Users don't interact with Terraform directly, but you will need it installed to use CemAcpt.
103
103
 
104
+ ## Bolt task testing
105
+
106
+ CemAcpt can execute Bolt tasks against the test nodes and perform basic validation against their status and outputs.
107
+
108
+ ### Configuring Bolt tests
109
+
110
+ Bolt tests expose the following configuration options that can be set via a config file:
111
+
112
+ * `bolt.inventory_path` - A relative or absolute path to an existing inventory file, or the where the inventory file will be created
113
+ * `bolt.project.name` - The name of the Bolt project to be created and used during the Bolt tests
114
+ * `bolt.project.analytics` - Whether or not to enable Bolt analytics. Should normally be `false`.
115
+ * `bolt.project.path` - A relative or absolute path to an existing project file, or where the project file will be created
116
+ * `bolt.tests.only` - An array of acceptance tests to only run Bolt tests for. When acceptance test names are specified here, Bolt tasks will only be ran against the nodes created for those acceptance tests.
117
+ * `bolt.tests.ignore` - An array of acceptance tests to not run Bolt tests for. When acceptance test names are specified here, Bolt tasks will not be ran against the nodes created for those acceptance tests.
118
+ * `bolt.tasks.only` - An array of Bolt tasks to only run against nodes. Bolt tasks not listed here will not be ran.
119
+ * `bolt.tasks.ignore` - An array of Bolt tasks to not run against nodes. Bolt tasks specified here will not be ran at all.
120
+ * `bolt.tasks.module_pattern` - If specified, will only run Bolt tasks whose module prefix matches the specified pattern will be ran. The module prefix is the first part of the full task name before the first `::`. This option is interpreted as a RegEx pattern. For example, if `bolt.tasks.module_prefix` is set to `^our_module`, and our we have two tasks, `our_module::the_task` and `other_module::another_task`, only `our_module::task` will be ran.
121
+ * `bolt.tasks.name_filter` - Similar to `module_pattern`, but works on the portion of the task name after the module prefix and is exclusionary. For example, if `bolt.tasks.name_filter` is set to `^another_`, and our we have two tasks, `our_module::the_task` and `other_module::another_task`, only `our_module::the_task` will be ran.
122
+
123
+ ### Creating Bolt tests
124
+
125
+ Bolt tests are not mandatory as all Bolt tasks that run will automatically be validated based on if they run successfully or not. However, sometimes we may want to test certain aspects of a Bolt task's output or pass a Bolt task parameters. To do this, we need to create a Bolt test file.
126
+
127
+ Bolt test files are YAML files named `bolt.yaml` that live inside the individual acceptance test directories. For each acceptance test, only one `bolt.yaml` file should exist. The `bolt.yaml` file consists of one or more YAML hashes that take the following form:
128
+
129
+ ```yaml
130
+ 'module_name::task_name':
131
+ params: # Optional, if you want to specify params to pass to the task at runtime
132
+ param_name: 'param_string_value'
133
+ status: <success | failure | skipped> # Optional, this is set to 'success' by default
134
+ other_bolt_json_output_keys: # Optional
135
+ match: '^a string RegEx pattern$' # Optional
136
+ not_match: '^a string RegEx pattern$' # Optional
137
+ ```
138
+
139
+ All Bolt tasks are ran with the `--format json` flag, which returns a JSON document of their output. The keys of this document can be validated to either a specified pattern, not match a specified pattern, or both. You can also specify a string value for a key, and then the key's value with be compared for simple string equality with the given value.
140
+
141
+ Example:
142
+
143
+ In this example, we will go over how a Bolt task's output would be evaluated by a Bolt test hash. This example assumes one Bolt task named `cem_linux::audit_sssd_certmap` that takes no parameters was ran successfully against two nodes.
144
+
145
+ Bolt task JSON output:
146
+
147
+ ```json
148
+ {
149
+ "items": [
150
+ {
151
+ "target":"35.212.146.14",
152
+ "action":"task",
153
+ "object":"cem_linux::audit_sssd_certmap",
154
+ "status":"success",
155
+ "value":{
156
+ "sssd_certmap_exists":false
157
+ }
158
+ },
159
+ {
160
+ "target":"35.212.197.53",
161
+ "action":"task",
162
+ "object":"cem_linux::audit_sssd_certmap",
163
+ "status":"success",
164
+ "value":{
165
+ "sssd_certmap_exists":false
166
+ }
167
+ }
168
+ ],
169
+ "target_count": 2,
170
+ "elapsed_time": 7
171
+ }
172
+ ```
173
+
174
+ Bolt test hash in `bolt.yaml`:
175
+
176
+ ```yaml
177
+ 'cem_linux::audit_sssd_certmap':
178
+ status: 'success'
179
+ value:
180
+ match: 'false'
181
+ ```
182
+
183
+ This should result in the following log message after a run:
184
+ `INFO: CemAcpt: SUMMARY: Bolt tests: status: passed, tests total: 1, tests succeeded: 1, tests failed: 0`
185
+
104
186
  ## Platforms
105
187
 
106
188
  Platforms are the underlying infrastructure that nodes are provisioned on. Currently, only GCP is supported. Each platform has two parts to it: the [platform](#platform) and the [node data](#node-data).
@@ -202,18 +284,18 @@ Much like `name_pattern_vars`, specifying the `image_name_builder` top-level key
202
284
 
203
285
  ## The acceptance test lifecycle
204
286
 
205
- - Load and merge the config
206
- - Create the local test directory under `$HOME/.cem_acpt`
207
- - Build the Puppet module. Uses current dir if no `module_dir` is specified in the config
208
- - Copy all relevant files into the local test directory
209
- - This includes the Terraform files provided by CemAcpt, as well as the files under the specified acceptance test directory, and the built Puppet module
210
- - Provision test nodes using Terraform
211
- - After the node is created, the contents of the local test directory are copied to the node
212
- - Additionally, the Puppet module is installed on the node and Puppet is ran once to apply `manifest.pp`
213
- - After, the Goss server endpoints are started and exposed on the node
214
- - Once node is provisioned, make HTTP get requests to the Goss server endpoints to run the tests
215
- - Destroy the test nodes
216
- - Report the results of the tests
287
+ * Load and merge the config
288
+ * Create the local test directory under `$HOME/.cem_acpt`
289
+ * Build the Puppet module. Uses current dir if no `module_dir` is specified in the config
290
+ * Copy all relevant files into the local test directory
291
+ * This includes the Terraform files provided by CemAcpt, as well as the files under the specified acceptance test directory, and the built Puppet module
292
+ * Provision test nodes using Terraform
293
+ * After the node is created, the contents of the local test directory are copied to the node
294
+ * Additionally, the Puppet module is installed on the node and Puppet is ran once to apply `manifest.pp`
295
+ * After, the Goss server endpoints are started and exposed on the node
296
+ * Once node is provisioned, make HTTP get requests to the Goss server endpoints to run the tests
297
+ * Destroy the test nodes
298
+ * Report the results of the tests
217
299
 
218
300
  ## Generating acceptance test node images
219
301
 
data/cem_acpt.gemspec CHANGED
@@ -12,7 +12,7 @@ Gem::Specification.new do |spec|
12
12
  spec.description = 'Litmus-like library focusing on CEM Acceptance Tests'
13
13
  spec.homepage = 'https://github.com/puppetlabs/cem_acpt'
14
14
  spec.license = 'proprietary'
15
- spec.required_ruby_version = Gem::Requirement.new('>= 2.6.0')
15
+ spec.required_ruby_version = Gem::Requirement.new('>= 3.0.0')
16
16
 
17
17
  spec.metadata['homepage_uri'] = spec.homepage
18
18
  spec.metadata['source_code_uri'] = 'https://github.com/puppetlabs/cem_acpt'
@@ -37,4 +37,5 @@ Gem::Specification.new do |spec|
37
37
  spec.add_development_dependency 'rubocop'
38
38
  spec.add_development_dependency 'rubocop-performance'
39
39
  spec.add_development_dependency 'rubocop-rspec'
40
+ spec.add_development_dependency 'simplecov'
40
41
  end
@@ -4,16 +4,22 @@ module CemAcpt
4
4
  # Wrapper class for the result of an action. Provides a common interface for
5
5
  # getting reportable data from the result.
6
6
  class ActionResult
7
+ attr_reader :result
8
+
7
9
  def initialize(result)
8
10
  @result = if result.instance_of?(CemAcpt::Goss::Api::ActionResponse)
9
11
  result
10
- else
12
+ elsif result.instance_of?(CemAcpt::Bolt::Cmd::Output)
13
+ result
14
+ elsif result.is_a?(StandardError)
11
15
  ErrorActionResult.new(result)
16
+ else
17
+ raise ArgumentError, "result must be a CemAcpt::Goss::Api::ActionResponse, CemAcpt::Bolt::Cmd::Output, or StandardError, got #{result.class}"
12
18
  end
13
19
  end
14
20
 
15
21
  def error?
16
- @result.is_a?(ErrorActionResult)
22
+ @result.is_a?(ErrorActionResult) || (@result.respond_to?(:error?) && @result.error?)
17
23
  end
18
24
 
19
25
  def method_missing(method_name, *args, **kwargs, &block)
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'async'
4
+ require 'async/barrier'
5
+ require 'async/http/internet'
6
+
7
+ module CemAcpt
8
+ # Provides a way to register actions to be executed if the registered
9
+ # actions are included in the list of actions to be executed.
10
+ module Actions
11
+ # Represents an action to be executed.
12
+ class Action
13
+ attr_reader :name, :order
14
+
15
+ def initialize(name, order = 0, &block)
16
+ @name = name.to_sym
17
+ @order = order
18
+ @block = block
19
+ end
20
+
21
+ def call(context)
22
+ @block&.call(context)
23
+ end
24
+ end
25
+
26
+ # Represents a group of actions.
27
+ class ActionGroup
28
+ attr_reader :name, :actions, :async, :order
29
+
30
+ def initialize(name = :main, **opts)
31
+ @name = name.to_sym
32
+ @actions = []
33
+ @async = opts[:async] || false
34
+ @order = opts[:order] || 0
35
+ end
36
+
37
+ # Registers a block to be executed if the registered actions are
38
+ # included in the list of actions to be executed.
39
+ # @param action [String, Symbol] the name of the action to be registered
40
+ # @param order [Integer] the order in which the action should be executed.
41
+ # Lower numbers are executed first, but this order is relative to the
42
+ # order of the associated action groups' execution order. Defaults to 0.
43
+ # @param block [Proc] the block to be executed
44
+ def register_action(name, order: 0, &block)
45
+ new_action = Action.new(name, order, &block)
46
+ @actions << new_action
47
+ sort!
48
+ self # return self to allow chaining
49
+ end
50
+
51
+ def filter_actions
52
+ filtered = @actions.dup
53
+ only = CemAcpt::Actions.config.only
54
+ except = CemAcpt::Actions.config.except
55
+ filtered.select! { |action| only.include?(action.name) } unless only.empty?
56
+ filtered.reject! { |action| except.include?(action.name) } unless except.empty?
57
+ filtered.sort_by!(&:order)
58
+ filtered
59
+ end
60
+
61
+ def sort!
62
+ @actions.sort_by!(&:order)
63
+ end
64
+ end
65
+
66
+ # Represents the configuration for the Actions module.
67
+ class ActionConfig
68
+ attr_reader :groups, :only, :except
69
+
70
+ def initialize
71
+ @groups = {
72
+ main: ActionGroup.new,
73
+ }
74
+ @only = []
75
+ @except = []
76
+ end
77
+
78
+ # Returns the action group with the specified name.
79
+ # @param key [String, Symbol] the name of the action group
80
+ def [](key)
81
+ groups[key.to_sym]
82
+ end
83
+
84
+ # Registers an action group. Actions groups are logical groups of
85
+ # actions that can be executed in parallel.
86
+ # @param name [String, Symbol] the name of the action group
87
+ # @param async [Boolean] whether the actions in the group should be
88
+ # executed in parallel. Defaults to false.
89
+ # @param order [Integer] the order in which the action group should
90
+ # be executed. Lower numbers are executed first.
91
+ def register_group(name, **opts)
92
+ return groups[name] if groups.key?(name)
93
+
94
+ groups[name] = ActionGroup.new(name, **opts)
95
+ groups[name]
96
+ end
97
+
98
+ # Returns a list of the names of all registered actions.
99
+ # @return [Array<String>] the list of all registered action names
100
+ def action_names(include_all: false)
101
+ return groups.values.map { |a| a.actions }.flatten.map(&:name).uniq if include_all
102
+
103
+ groups.values.map { |a| a.filter_actions }.flatten.map(&:name).uniq
104
+ end
105
+
106
+ def only=(actions)
107
+ raise ArgumentError, 'only must be an array' unless actions.is_a?(Array)
108
+
109
+ @only = actions.map(&:to_sym)
110
+ end
111
+
112
+ def except=(actions)
113
+ raise ArgumentError, 'except must be an array' unless actions.is_a?(Array)
114
+
115
+ @except = actions.map(&:to_sym)
116
+ end
117
+ end
118
+
119
+ class << self
120
+ attr_reader :config
121
+
122
+ # Configures the Actions module.
123
+ # @param world_config [CemAcpt::Config::Base] the current config for the "world"
124
+ # @yield [CemAcpt::Actions::ActionConfig] the config object for the Actions module
125
+ def configure(world_config)
126
+ @config = ActionConfig.new
127
+ @config.only = world_config.get('actions.only') || []
128
+ @config.except = world_config.get('actions.except') || []
129
+ yield @config if block_given?
130
+ end
131
+
132
+ # Executes the actions in the current action groups.
133
+ # @param opts [Hash] the options to be passed to the actions
134
+ # @option opts [Hash] :context the context to be passed to the actions
135
+ # @return [Array] the results of the actions
136
+ def execute(**opts)
137
+ context = opts[:context] || {}
138
+ ordered_groups = config.groups.values.sort_by(&:order)
139
+ ordered_groups.each_with_object([]) do |group, results|
140
+ actions = group.filter_actions
141
+ next if actions.empty?
142
+
143
+ context[:group] = group.name
144
+ context[:actions] = actions.map(&:name)
145
+ actions.each do |action|
146
+ action_opts = opts[action.name.to_sym] || {}
147
+ results << action.call(context.merge(action_opts))
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'output'
5
+ require_relative '../../logging'
6
+ require_relative '../../utils/shell'
7
+
8
+ module CemAcpt
9
+ module Bolt
10
+ module Cmd
11
+ # Base class for all Bolt command classes
12
+ class Base
13
+ include CemAcpt::Logging
14
+
15
+ # Class method that holds the names of all options for the command
16
+ def self.option_names
17
+ @option_names ||= []
18
+ end
19
+
20
+ # Class method that defines an option for the command
21
+ # @param name [String, Symbol] The name of the option
22
+ # @param flag [String] The flag to use for the option. Ex: '--modulepath'
23
+ # @param config_path [String] The path to the config value for the option. Ex: 'bolt.module_path'
24
+ # @param default [String] The default value for the option. Ex: '/home/user/.puppetlabs/bolt/modules'
25
+ # @param bool_flag [Boolean] Whether the option is a boolean flag or not. If this is true,
26
+ # only the flag will be used for the option. For example, if the flag is '--clear-cache'
27
+ # and bool_flag is set to true, and the value is 'true', then just '--clear-cache' will
28
+ # be added to the command options. This differs from the default behavior of adding
29
+ # '--clear-cache <value>' to the command options.
30
+ def self.option(name, flag, config_path: nil, default: nil, bool_flag: false)
31
+ name = name.to_sym
32
+ option_names << name
33
+
34
+ define_method(name) do
35
+ if bool_flag
36
+ find_val(name, flag: flag, config_path: config_path, default: default) ? flag : nil
37
+ else
38
+ val = find_val(name, flag: flag, config_path: config_path, default: default)
39
+ val ? "#{flag} #{val}" : nil
40
+ end
41
+ end
42
+ end
43
+
44
+ # Denotes that the command supports parameters. Calling this method will define a
45
+ # supports_params? method on the command class that returns true.
46
+ def self.supports_params
47
+ define_method(:supports_params?) { true }
48
+ end
49
+
50
+ attr_reader :cmd_env, :cmd_params
51
+
52
+ def initialize
53
+ @cmd_env = { 'BOLT_GEM' => 'TRUE' }
54
+ @cmd_params = {}
55
+ end
56
+
57
+ def inspect
58
+ "#{self.class}:\"#{cmd}\""
59
+ end
60
+
61
+ def to_s
62
+ cmd
63
+ end
64
+
65
+ # Returns the command to run as a string
66
+ # @return [String] The command to run as a string
67
+ def cmd
68
+ raise NotImplementedError, 'cmd method must be implemented in subclass'
69
+ end
70
+
71
+ # Adds an environment variable to the command
72
+ # @param name [String] The name of the environment variable
73
+ # @param value [String] The value of the environment variable
74
+ def add_cmd_env(name, value)
75
+ @cmd_env[name] = value
76
+ end
77
+
78
+ # Adds a parameter to the command. If passing a complex value, such as a hash, use
79
+ # the JSON representation of the value.
80
+ # @param name [String] The name of the parameter
81
+ # @param value [String] The value of the parameter
82
+ def add_cmd_param(name, value)
83
+ @cmd_params[name] = value
84
+ end
85
+
86
+ # Returns the family of the command. This is used to determine which command to run
87
+ # when multiple commands are available for the same action. For example, the 'task'
88
+ # command has multiple subcommands, such as 'task show' and 'task run'. The family of the
89
+ # 'task' command is 'task', and the family of the 'task show' and 'task run' subcommands is
90
+ # 'task'.
91
+ # @return [String] The family of the command
92
+ def command_family
93
+ raise NotImplementedError, 'command_family method must be implemented in subclass'
94
+ end
95
+
96
+ # Runs the command with the given options and environment variables
97
+ # @param strict [Boolean] Whether to raise an error if the command returns output that
98
+ # does not implement items. Used for commands where the raw JSON output does not
99
+ # include an 'items' key. Defaults to true.
100
+ # @param params [Hash] Parameters to pass to the Bolt command. Only used for some command types.
101
+ # @return [CemAcpt::Bolt::Cmd::Output] The output of the command
102
+ def run(strict: true, **params)
103
+ unless params.empty?
104
+ logger.debug('CemAcpt::Bolt') { "Setting command parameters: #{params}" }
105
+ params.each { |k, v| add_cmd_param(k, v) }
106
+ end
107
+ logger.debug('CemAcpt::Bolt') { "Running Bolt command: #{cmd}" }
108
+ logger.debug('CemAcpt::Bolt') { "Bolt command environment: #{cmd_env}" }
109
+ begin
110
+ out = CemAcpt::Utils::Shell.run_cmd(cmd, cmd_env, output: nil, raise_on_fail: false)
111
+ logger.debug('CemAcpt::Bolt') { "Bolt command raw output:\n#{out}" }
112
+ CemAcpt::Bolt::Cmd::Output.new(out, strict: strict, **(@item_defaults || {}))
113
+ rescue StandardError => e
114
+ logger.debug('CemAcpt::Bolt') { "Bolt command error:\n#{e}" }
115
+ CemAcpt::Bolt::Cmd::Output.new(e, **(@item_defaults || {}))
116
+ end
117
+ end
118
+
119
+ # Returns the options for the command as a string
120
+ # @return [String] The options for the command as a string
121
+ def options
122
+ opts = ['--format json']
123
+ recursive_option_names.each do |opt|
124
+ val = send(opt)
125
+ opts << val if val
126
+ end
127
+ if respond_to?(:supports_params?) && supports_params? && !cmd_params.empty?
128
+ opts << "--params '#{JSON.generate(cmd_params)}'"
129
+ end
130
+ join_array(opts)
131
+ end
132
+
133
+ def bolt_bin
134
+ @bolt_bin ||= CemAcpt::Utils::Shell.which('bolt', raise_if_not_found: true)
135
+ end
136
+
137
+ private
138
+
139
+ attr_reader :config
140
+
141
+ def join_array(ary)
142
+ ary.compact.join(' ')
143
+ end
144
+
145
+ def find_val(name, flag: nil, config_path: nil, default: nil)
146
+ ivar_safe_get("@#{name}") ||
147
+ ivar_safe_get("@#{flag.to_s.gsub(%r{[-]+}, '')}") ||
148
+ config&.get(config_path.to_s) ||
149
+ config&.get("bolt.tasks.cmd.defaults.#{flag.to_s.gsub(%r{[-]+}, '')}") ||
150
+ default
151
+ end
152
+
153
+ def ivar_safe_get(name)
154
+ name = "@#{name}" unless name.to_s.start_with?('@')
155
+ instance_variable_get(name)
156
+ rescue StandardError
157
+ nil
158
+ end
159
+
160
+ # Collects options defined in parent classes
161
+ def recursive_option_names(klass = self.class)
162
+ return [] if klass == Object
163
+ onames = klass.option_names.dup
164
+
165
+ if klass.superclass.respond_to?(:option_names)
166
+ onames.concat(recursive_option_names(klass.superclass))
167
+ else
168
+ onames
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end