cem_acpt 0.7.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+ require 'stringio'
6
+ require_relative '../../logging'
7
+
8
+ module CemAcpt
9
+ module Provision
10
+ # Stand-in for ruby-terraform because ruby-terraform doesn't work with Ruby 3
11
+ class TerraformCmd
12
+ include CemAcpt::Logging
13
+
14
+ attr_accessor :working_dir
15
+ attr_reader :bin_path
16
+
17
+ def initialize(working_dir = nil, environment = {})
18
+ @working_dir = working_dir
19
+ @environment = environment
20
+ @bin_path = which_terraform
21
+ end
22
+
23
+ def inspect
24
+ to_s
25
+ end
26
+
27
+ def to_s
28
+ "#<#{self.class.name}:0x#{object_id.to_s(16)}>"
29
+ end
30
+
31
+ def environment=(env)
32
+ raise ArgumentError, 'environment must be a Hash' unless env.is_a?(Hash)
33
+
34
+ @environment = env
35
+ end
36
+
37
+ def environment(env = {})
38
+ raise ArgumentError, 'additional environment must be a Hash' unless env.is_a?(Hash)
39
+
40
+ if env.key?(:environment) && env[:environment].is_a?(Hash)
41
+ env = env[:environment]
42
+ end
43
+ @environment.merge(env)
44
+ end
45
+
46
+ def init(opts = {}, env = {})
47
+ run_cmd('init', opts, env)
48
+ end
49
+
50
+ def plan(opts = {}, env = {})
51
+ plan = extract_arg!(opts, :plan, required: true)
52
+ opts[:out] = plan
53
+ run_cmd('plan', opts, env)
54
+ end
55
+
56
+ def apply(opts = {}, env = {})
57
+ plan = extract_arg!(opts, :plan, required: true)
58
+ run_cmd('apply', opts, env, suffix: plan)
59
+ end
60
+
61
+ def destroy(opts = {}, env = {})
62
+ run_cmd('destroy', opts, env)
63
+ end
64
+
65
+ def show(opts = {}, env = {})
66
+ run_cmd('show', opts, env)
67
+ end
68
+
69
+ def output(opts = {}, env = {})
70
+ name = extract_arg!(opts, :name, required: true)
71
+ run_cmd('output', opts, env, suffix: name)
72
+ end
73
+
74
+ private
75
+
76
+ def extract_arg!(opts, key, required: false)
77
+ key = key.to_sym
78
+ raise ArgumentError, "option #{key} is required" if required && !opts.key?(key) && (opts[key].nil? || opts[key].empty?)
79
+
80
+ opts.delete(key)
81
+ end
82
+
83
+ def run_cmd(cmd, opts = {}, env = {}, suffix: '')
84
+ logger.debug('CemAcpt::Provision::TerraformCmd') { "Running command with args: cmd = \"#{cmd}\", opts = \"#{opts}\", env = \"#{env}\", suffix = \"#{suffix}\"" }
85
+ val, outerr = execute(format_cmd(cmd, opts, suffix), environment(env))
86
+ raise "Error running command: #{cmd}\n#{outerr}" unless val.success?
87
+
88
+ outerr
89
+ end
90
+
91
+ def execute(cmd, env)
92
+ logger.debug('CemAcpt::Provision::TerraformCmd') { "Executing command: #{cmd}" }
93
+ io_outerr = StringIO.new
94
+ val = Open3.popen2e(env, cmd) do |stdin, outerr, wait_thr|
95
+ stdin.close
96
+ outerr.sync = true
97
+ output_thread = Thread.new do
98
+ while (line = outerr.readline_nonblock)
99
+ logger << line
100
+ io_outerr.write(line) unless line.chomp.empty?
101
+ end
102
+ rescue IO::WaitReadable
103
+ retry
104
+ rescue EOFError
105
+ # Do nothing
106
+ end
107
+ wait_thr.join
108
+ output_thread.exit
109
+ wait_thr.value
110
+ end
111
+ [val, io_outerr.string]
112
+ end
113
+
114
+ def chdir(opts = {})
115
+ [extract_arg!(opts, :chdir), working_dir, Dir.pwd].each do |d|
116
+ next if d.nil? || d.empty?
117
+
118
+ d = File.expand_path(d)
119
+ return "-chdir=#{d}" if File.directory?(d)
120
+
121
+ logger.warn('CemAcpt::Provision::TerraformCmd') { "Directory #{d} does not exist, using next..." }
122
+ end
123
+ end
124
+
125
+ def which_terraform
126
+ exts = ENV['PATHEXT'] ? ENV['PATHEXT'].split(';') : ['']
127
+ ENV['PATH'].split(File::PATH_SEPARATOR).each do |path|
128
+ exts.each do |ext|
129
+ exe = File.join(path, "terraform#{ext}")
130
+ return exe if File.executable?(exe) && !File.directory?(exe)
131
+ end
132
+ end
133
+ raise 'terraform not found in PATH, make sure it is installed'
134
+ end
135
+
136
+ def format_cmd(cmd, opts = {}, suffix = '')
137
+ formatted = [bin_path, chdir(opts), cmd]
138
+ opts_s = opts_string(opts)
139
+ formatted << opts_s unless opts_s.empty?
140
+ formatted << suffix unless suffix.empty?
141
+ formatted.join(' ')
142
+ end
143
+
144
+ def opts_string(opts = {})
145
+ formatted = opts.map do |k, v|
146
+ next if k.nil? || k.empty?
147
+
148
+ k = k.to_s
149
+ k.tr!('_', '-')
150
+ if k == 'vars'
151
+ v.map { |vk, vv| "-var '#{vk}=#{vv}'" }.join(' ')
152
+ elsif %w[input lock refresh].include?(k) # These are boolean flags with values
153
+ "-#{k}=#{v}"
154
+ elsif v.nil? || (v.respond_to?(:empty) && v.empty?) || v.is_a?(TrueClass)
155
+ "-#{k}"
156
+ else
157
+ case v
158
+ when Array, Hash
159
+ "-#{k}='#{v.to_json}'"
160
+ else
161
+ "-#{k}=#{v}"
162
+ end
163
+ end
164
+ end
165
+ formatted.join(' ')
166
+ end
167
+ end
168
+ end
169
+
170
+ # IO monkey patch for non-blocking readline
171
+ class ::IO
172
+ def readline_nonblock
173
+ rlnb = []
174
+ while (ch = read_nonblock(1))
175
+ rlnb << ch
176
+ break if ch == "\n"
177
+ end
178
+ rlnb.join
179
+ end
180
+ end
181
+ end
@@ -6,6 +6,7 @@ module CemAcpt
6
6
  module Provision
7
7
  # Class provides methods for gathering provision data for Windows nodes
8
8
  class Windows < OsData
9
+ # A name that will match with how image names are on GCP
9
10
  def self.valid_names
10
11
  %w[windows]
11
12
  end
@@ -17,6 +18,14 @@ module CemAcpt
17
18
  def puppet_bin_path
18
19
  'C:/Program Files/Puppet Labs/Puppet/bin/puppet.bat'
19
20
  end
21
+
22
+ def destination_provision_directory
23
+ 'C:/cem_acpt'
24
+ end
25
+
26
+ def provision_commands
27
+ ['placeholder']
28
+ end
20
29
  end
21
30
  end
22
31
  end
@@ -2,10 +2,10 @@
2
2
 
3
3
  require 'fileutils'
4
4
  require 'json'
5
- require 'ruby-terraform'
6
5
  require_relative '../logging'
7
6
  require_relative 'terraform/linux'
8
7
  require_relative 'terraform/windows'
8
+ require_relative 'terraform/terraform_cmd'
9
9
 
10
10
  module CemAcpt
11
11
  module Provision
@@ -27,13 +27,12 @@ module CemAcpt
27
27
  @public_key = nil
28
28
  end
29
29
 
30
+ # @return [Hash] A hash of instance names and IPs
30
31
  def provision(reuse_working_dir: false)
31
- logger.info('Terraform') { 'Provisioning nodes...' }
32
+ logger.info('CemAcpt::Provision::Terraform') { 'Provisioning nodes...' }
32
33
  @working_dir = new_working_dir unless reuse_working_dir
33
34
  validate_working_dir!
34
35
  save_vars_to_file!(formatted_vars) # Easier to reuse nodes this way
35
-
36
- terraform_configure_logging
37
36
  terraform_init
38
37
  terraform_plan(formatted_vars, DEFAULT_PLAN_NAME)
39
38
  terraform_apply(DEFAULT_PLAN_NAME)
@@ -42,7 +41,7 @@ module CemAcpt
42
41
 
43
42
  def destroy
44
43
  terraform_destroy(formatted_vars)
45
- logger.verbose('Terraform') { "Deleting old working directory #{working_dir}" }
44
+ logger.verbose('CemAcpt::Provision::Terraform') { "Deleting old working directory #{working_dir}" }
46
45
  FileUtils.rm_rf(working_dir)
47
46
  @working_dir = nil
48
47
  @module_package_path = nil
@@ -57,25 +56,17 @@ module CemAcpt
57
56
  private
58
57
 
59
58
  def terraform
60
- @terraform ||= RubyTerraform
61
- end
62
-
63
- def terraform_configure_logging
64
- terraform.configure do |c|
65
- c.logger = logger
66
- c.stdout = c.logger
67
- c.stderr = c.logger
68
- end
59
+ @terraform ||= CemAcpt::Provision::TerraformCmd.new(working_dir, environment)
69
60
  end
70
61
 
71
62
  def terraform_init
72
- logger.debug('Terraform') { 'Initializing Terraform' }
63
+ logger.info('CemAcpt::Provision::Terraform') { 'Initializing Terraform' }
73
64
  terraform.init({ chdir: working_dir, input: false, no_color: true }, { environment: environment })
74
65
  end
75
66
 
76
67
  def terraform_plan(vars, plan_name = DEFAULT_PLAN_NAME)
77
- logger.debug('Terraform') { "Creating Terraform plan '#{plan_name}'" }
78
- logger.verbose('Terraform') { "Using vars:\n#{JSON.pretty_generate(vars)}" }
68
+ logger.info('CemAcpt::Provision::Terraform') { "Creating Terraform plan '#{plan_name}'" }
69
+ logger.verbose('CemAcpt::Provision::Terraform') { "Using vars:\n#{JSON.pretty_generate(vars)}" }
79
70
  terraform.plan(
80
71
  {
81
72
  chdir: working_dir,
@@ -91,23 +82,24 @@ module CemAcpt
91
82
  end
92
83
 
93
84
  def terraform_apply(plan_name = DEFAULT_PLAN_NAME)
94
- logger.debug('Terraform') { "Running Terraform apply with the plan #{plan_name}" }
85
+ logger.info('CemAcpt::Provision::Terraform') { "Running Terraform apply with the plan #{plan_name}" }
95
86
  terraform.apply({ chdir: working_dir, input: false, no_color: true, plan: plan_name }, { environment: environment })
96
87
  end
97
88
 
98
89
  def terraform_output(name, json: true)
99
- logger.debug('Terraform') { "Getting Terraform output #{name}" }
90
+ logger.info('CemAcpt::Provision::Terraform') { "Getting Terraform output #{name}" }
100
91
  terraform.output({ chdir: working_dir, no_color: true, json: json, name: name }, { environment: environment })
101
92
  end
102
93
 
103
94
  def terraform_destroy(vars)
104
- logger.debug('Terraform') { 'Destroying Terraform resources' }
95
+ logger.info('CemAcpt::Provision::Terraform') { 'Destroying Terraform resources...' }
96
+ logger.verbose('CemAcpt::Provision::Terraform') { "Using vars: #{vars}" }
105
97
  terraform.destroy(
106
98
  {
107
99
  chdir: working_dir,
108
100
  auto_approve: true,
109
101
  input: false,
110
- no_color: true,
102
+ no_color: nil,
111
103
  vars: vars,
112
104
  },
113
105
  {
@@ -117,18 +109,18 @@ module CemAcpt
117
109
  end
118
110
 
119
111
  def terraform_show
120
- logger.debug('Terraform') { 'Showing Terraform state' }
112
+ logger.info('CemAcpt::Provision::Terraform') { 'Showing Terraform state' }
121
113
  terraform.show({ chdir: working_dir, no_color: true }, { environment: environment })
122
114
  end
123
115
 
124
116
  def new_backend(test_name)
125
117
  if CemAcpt::Provision::Linux.use_for?(test_name)
126
- logger.info('Terraform') { 'Using Linux backend' }
127
- logger.verbose('Terraform') { "Creating backend with provision_data:\n#{JSON.pretty_generate(@provision_data)}" }
118
+ logger.info('CemAcpt::Provision::Terraform') { 'Using Linux backend' }
119
+ logger.verbose('CemAcpt::Provision::Terraform') { "Creating backend with provision_data:\n#{JSON.pretty_generate(@provision_data)}" }
128
120
  CemAcpt::Provision::Linux.new(@config, @provision_data)
129
121
  elsif CemAcpt::Provision::Windows.use_for?(test_name)
130
- logger.info('Terraform') { 'Using Windows backend' }
131
- logger.verbose('Terraform') { "Creating backend with provision_data:\n#{JSON.pretty_generate(@provision_data)}" }
122
+ logger.info('CemAcpt::Provision::Terraform') { 'Using Windows backend' }
123
+ logger.verbose('CemAcpt::Provision::Terraform') { "Creating backend with provision_data:\n#{JSON.pretty_generate(@provision_data)}" }
132
124
  CemAcpt::Provision::Windows.new(@config, @provision_data)
133
125
  else
134
126
  err_msg = [
@@ -136,9 +128,8 @@ module CemAcpt
136
128
  "Known OSes are: #{CemAcpt::Provision::Linux.valid_names.join(', ')}",
137
129
  "and #{CemAcpt::Provision::Windows.valid_names.join(', ')}.",
138
130
  "Known versions are: #{CemAcpt::Provision::Linux.valid_versions.join(', ')}",
139
- ", and #{CemAcpt::Provision::Windows.valid_versions.join(', ')}."
131
+ ", and #{CemAcpt::Provision::Windows.valid_versions.join(', ')}.",
140
132
  ].join(' ')
141
- logger.error('Terraform') { err_msg }
142
133
  raise ArgumentError, err_msg
143
134
  end
144
135
  end
@@ -146,57 +137,55 @@ module CemAcpt
146
137
  def new_environment(config)
147
138
  env = (config.get('terraform.environment') || {})
148
139
  env['CLOUDSDK_PYTHON_SITEPACKAGES'] = '1' # This is needed for gcloud to use numpy
149
- logger.verbose('Terraform') { "Using environment:\n#{JSON.pretty_generate(env)}" }
140
+ logger.verbose('CemAcpt::Provision::Terraform') { "Using environment:\n#{JSON.pretty_generate(env)}" }
150
141
  env
151
142
  end
152
143
 
153
144
  def new_working_dir
154
- logger.debug('Terraform') { 'Creating new working directory' }
145
+ logger.debug('CemAcpt::Provision::Terraform') { 'Creating new working directory' }
155
146
  base_dir = File.join(@config.get('terraform.dir'), @config.get('platform.name'))
156
- logger.verbose('Terraform') { "Base directory defined as #{base_dir}" }
147
+ logger.verbose('CemAcpt::Provision::Terraform') { "Base directory defined as #{base_dir}" }
157
148
  @backend.base_provision_directory = base_dir
158
- logger.verbose('Terraform') { 'Base directory set in backend' }
149
+ logger.verbose('CemAcpt::Provision::Terraform') { 'Base directory set in backend' }
159
150
  work_dir = File.join(@config.get('terraform.dir'), "test_#{Time.now.to_i}")
160
- logger.verbose('Terraform') { "Working directory defined as #{work_dir}" }
161
- logger.verbose('Terraform') { "Copying backend provision directory #{@backend.provision_directory} to working directory" }
151
+ logger.verbose('CemAcpt::Provision::Terraform') { "Working directory defined as #{work_dir}" }
152
+ logger.verbose('CemAcpt::Provision::Terraform') { "Copying backend provision directory #{@backend.provision_directory} to working directory" }
162
153
  FileUtils.cp_r(@backend.provision_directory, work_dir)
163
- logger.verbose('Terraform') { "Copied provision directory #{@backend.provision_directory} to #{work_dir}" }
154
+ logger.verbose('CemAcpt::Provision::Terraform') { "Copied provision directory #{@backend.provision_directory} to #{work_dir}" }
164
155
  FileUtils.cp(@provision_data[:module_package_path], work_dir)
165
156
  @module_package_path = File.join(work_dir, File.basename(@provision_data[:module_package_path]))
166
- logger.verbose('Terraform') { "Copied module package #{@provision_data[:module_package_path]} to #{work_dir}" }
157
+ logger.verbose('CemAcpt::Provision::Terraform') { "Copied module package #{@provision_data[:module_package_path]} to #{work_dir}" }
167
158
  if File.exist?(@provision_data[:private_key])
168
159
  FileUtils.cp(@provision_data[:private_key], work_dir)
169
160
  @private_key = File.join(work_dir, File.basename(@provision_data[:private_key]))
170
- logger.verbose('Terraform') { "Copied private key #{@provision_data[:private_key]} to #{work_dir}" }
161
+ logger.verbose('CemAcpt::Provision::Terraform') { "Copied private key #{@provision_data[:private_key]} to #{work_dir}" }
171
162
  end
172
163
  if File.exist?(@provision_data[:public_key])
173
164
  FileUtils.cp(@provision_data[:public_key], work_dir)
174
165
  @public_key = File.join(work_dir, File.basename(@provision_data[:public_key]))
175
- logger.verbose('Terraform') { "Copied public key #{@provision_data[:public_key]} to #{work_dir}" }
166
+ logger.verbose('CemAcpt::Provision::Terraform') { "Copied public key #{@provision_data[:public_key]} to #{work_dir}" }
176
167
  end
177
168
  work_dir
178
169
  rescue StandardError => e
179
- logger.error('Terraform') { 'Error creating working directory' }
170
+ logger.error('CemAcpt::Provision::Terraform') { 'Error creating working directory' }
180
171
  raise e
181
172
  end
182
173
 
183
174
  def validate_working_dir!
184
- logger.debug('Terraform') { "Validating working directory #{working_dir}" }
185
- logger.verbose('Terraform') { "Content of #{working_dir}:\n#{Dir.glob(File.join(working_dir, '*')).join("\n")}" }
175
+ logger.debug('CemAcpt::Provision::Terraform') { "Validating working directory #{working_dir}" }
176
+ logger.verbose('CemAcpt::Provision::Terraform') { "Content of #{working_dir}:\n#{Dir.glob(File.join(working_dir, '*')).join("\n")}" }
186
177
  raise "Terraform working directory #{working_dir} does not exist" unless File.directory?(working_dir)
187
178
  raise "Terraform working directory #{working_dir} does not contain a Terraform file" unless Dir.glob(File.join(working_dir, '*.tf')).any?
188
179
 
189
- logger.info('Terraform') { "Using working directory: #{working_dir}" }
180
+ logger.info('CemAcpt::Provision::Terraform') { "Using working directory: #{working_dir}" }
190
181
  rescue StandardError => e
191
- logger.error('Terraform') { 'Error validating working directory' }
192
182
  raise e
193
183
  end
194
184
 
195
185
  def save_vars_to_file!(vars)
196
- logger.debug('Terraform') { "Saving vars to file #{File.join(working_dir, DEFAULT_VARS_FILE)}" }
186
+ logger.debug('CemAcpt::Provision::Terraform') { "Saving vars to file #{File.join(working_dir, DEFAULT_VARS_FILE)}" }
197
187
  File.write(File.join(working_dir, DEFAULT_VARS_FILE), vars.to_json)
198
188
  rescue StandardError => e
199
- logger.error('Terraform') { 'Error saving vars to file' }
200
189
  raise e
201
190
  end
202
191
 
@@ -214,7 +203,6 @@ module CemAcpt
214
203
  end
215
204
  node_data.to_json
216
205
  rescue StandardError => e
217
- logger.error('Terraform') { 'Error creating node data' }
218
206
  raise e
219
207
  end
220
208
 
@@ -228,7 +216,6 @@ module CemAcpt
228
216
  }
229
217
  )
230
218
  rescue StandardError => e
231
- logger.error('Terraform') { 'Error creating formatted vars' }
232
219
  raise e
233
220
  end
234
221
  end
@@ -10,7 +10,7 @@ module CemAcpt
10
10
  def self.new_provisioner(config, provision_data)
11
11
  case config.get('provisioner')
12
12
  when 'terraform'
13
- logger.debug('Provision') { 'Using Terraform provisioner' }
13
+ logger.debug('CemAcpt::Provision') { 'Using Terraform provisioner' }
14
14
  CemAcpt::Provision::Terraform.new(config, provision_data)
15
15
  else
16
16
  raise ArgumentError, "Unknown provisioner #{config.get('provisioner')}"
@@ -30,7 +30,7 @@ module CemAcpt
30
30
  _metadata = builder.metadata
31
31
 
32
32
  # Builds the module package
33
- logger.info("Building module package for #{builder.release_name}")
33
+ logger.info('CemAcpt::PuppetHelpers') { "Building module package for #{builder.release_name}" }
34
34
  builder.build
35
35
  end
36
36
  end
@@ -33,7 +33,7 @@ module CemAcpt
33
33
  # Extracts, formats, and returns a test data hash.
34
34
  # @return [Array<Hash>] an array of test data hashes
35
35
  def acceptance_test_data
36
- logger.info 'Gathering acceptance test data...'
36
+ logger.info('CemAcpt::TestData') { 'Gathering acceptance test data...' }
37
37
  raise "No 'tests' entry found in config" unless @config.has?('tests')
38
38
 
39
39
  @config.get('tests').each_with_object([]) do |test_name, a|
@@ -45,7 +45,7 @@ module CemAcpt
45
45
  raise "Goss file not found for test #{test_name}" unless File.exist?(goss_file)
46
46
  raise "Puppet manifest not found for test #{test_name}" unless File.exist?(puppet_manifest)
47
47
 
48
- logger.debug("Complete test directory found for test #{test_name}: #{test_dir}")
48
+ logger.debug('CemAcpt::TestData') { "Complete test directory found for test #{test_name}: #{test_dir}" }
49
49
  test_data = {
50
50
  test_name: test_name,
51
51
  test_dir: File.expand_path(test_dir),
@@ -71,7 +71,7 @@ module CemAcpt
71
71
  @acceptance_tests = acpt_test_dir.children.select { |f| f.directory? && File.exist?(File.join(f, 'goss.yaml')) }.map(&:to_s)
72
72
  raise 'No acceptance tests found' if @acceptance_tests.empty?
73
73
 
74
- logger.info "Found #{@acceptance_tests.size} acceptance tests"
74
+ logger.info('CemAcpt') { "Found #{@acceptance_tests.size} acceptance tests" }
75
75
  end
76
76
 
77
77
  # Processes a for_each statement in the test data config.
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CemAcpt
4
+ module TestRunner
5
+ module LogFormatter
6
+ class ErrorFormatter
7
+ def inspect
8
+ to_s
9
+ end
10
+
11
+ def to_s
12
+ "#<#{self.class.name}:0x#{object_id.to_s(16)}>"
13
+ end
14
+
15
+ def summary(response)
16
+ "Error: #{response.summary}"
17
+ end
18
+
19
+ def results(response)
20
+ [response.summary, response.results.join("\n")]
21
+ end
22
+
23
+ def host_name(response)
24
+ "Error: #{response.error.class.name}"
25
+ end
26
+
27
+ def test_name(response)
28
+ "Error: #{response.error.class.name}"
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -1,10 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'log_formatter/goss_action_response'
4
+ require_relative 'log_formatter/error_formatter'
4
5
 
5
6
  module CemAcpt
6
7
  module TestRunner
7
8
  # Holds classes for formatting test runner results
8
- module LogFormatter; end
9
+ module LogFormatter
10
+ def self.new_formatter(result, *args, **kwargs)
11
+ if result.error?
12
+ ErrorFormatter.new
13
+ else
14
+ GossActionResponse.new(*args)
15
+ end
16
+ end
17
+ end
9
18
  end
10
19
  end