cem_acpt 0.7.3 → 0.8.0

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