tfwrapper 0.2.0.beta1

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,357 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tfwrapper/helpers'
4
+ require 'json'
5
+ require 'rake'
6
+ require 'rubygems'
7
+ require 'tfwrapper/version'
8
+
9
+ module TFWrapper
10
+ # Generates Rake tasks for working with Terraform at Manheim.
11
+ #
12
+ # Before using this, the ``CONSUL_HOST`` environment variable must be set.
13
+ #
14
+ # __NOTE:__ Be sure to document all tasks in README.md
15
+ class RakeTasks
16
+ include Rake::DSL if defined? Rake::DSL
17
+
18
+ class << self
19
+ # set when installed
20
+ attr_accessor :instance
21
+
22
+ # Install the Rake tasks for working with Terraform at Manheim.
23
+ #
24
+ # @param (see #initialize)
25
+ def install_tasks(tf_dir, opts = {})
26
+ new(tf_dir, opts).install
27
+ end
28
+ end
29
+
30
+ def min_tf_version
31
+ Gem::Version.new('0.9.0')
32
+ end
33
+
34
+ # Generate Rake tasks for working with Terraform at Manheim.
35
+ #
36
+ # @param tf_dir [String] Terraform config directory, relative to Rakefile.
37
+ # Set to '.' if the Rakefile is in the same directory as the ``.tf``
38
+ # configuration files.
39
+ # @param [Hash] options to use when adding tasks
40
+ # @option opts [Hash] :backend_config hash of Terraform remote state
41
+ # backend configuration options, to override or supplement those in
42
+ # the terraform configuration. See the
43
+ # [Remote State](https://www.terraform.io/docs/state/remote.html)
44
+ # documentation for further information.
45
+ # @option opts [String] :namespace_prefix if specified and not nil, this
46
+ # will put all tasks in a "#{namespace_prefix}_tf:" namespace instead
47
+ # of "tf:". This allows using manheim_helpers for multiple terraform
48
+ # configurations in the same Rakefile.
49
+ # @option opts [Hash] :tf_vars_from_env hash of Terraform variables to the
50
+ # (required) environment variables to populate their values from
51
+ # @option opts [Hash] :tf_extra_vars hash of Terraform variables to their
52
+ # values; overrides any same-named keys in ``tf_vars_from_env``
53
+ # @option opts [String] :consul_url URL to access Consul at, for the
54
+ # ``:consul_env_vars_prefix`` option.
55
+ # @option opts [String] :consul_env_vars_prefix if specified and not nil,
56
+ # write the environment variables used from ``tf_vars_from_env``
57
+ # and their values to JSON at this path in Consul. This should have
58
+ # the same naming constraints as ``consul_prefix``.
59
+ def initialize(tf_dir, opts = {})
60
+ # find the directory that contains the Rakefile
61
+ rakedir = File.realpath(Rake.application.rakefile)
62
+ rakedir = File.dirname(rakedir) if File.file?(rakedir)
63
+ @tf_dir = File.realpath(File.join(rakedir, tf_dir))
64
+ @ns_prefix = opts.fetch(:namespace_prefix, nil)
65
+ @consul_env_vars_prefix = opts.fetch(:consul_env_vars_prefix, nil)
66
+ @tf_vars_from_env = opts.fetch(:tf_vars_from_env, {})
67
+ @tf_extra_vars = opts.fetch(:tf_extra_vars, {})
68
+ @backend_config = opts.fetch(:backend_config, {})
69
+ @consul_url = opts.fetch(:consul_url, nil)
70
+ # rubocop:disable Style/GuardClause
71
+ if @consul_url.nil? && !@consul_env_vars_prefix.nil?
72
+ raise StandardError, 'Cannot set env vars in Consul when consul_url ' \
73
+ 'option is nil.'
74
+ end
75
+ # rubocop:enable Style/GuardClause
76
+ end
77
+
78
+ def nsprefix
79
+ if @ns_prefix.nil?
80
+ 'tf'.to_sym
81
+ else
82
+ "#{@ns_prefix}_tf".to_sym
83
+ end
84
+ end
85
+
86
+ # install all Rake tasks - calls other install_* methods
87
+ # rubocop:disable Metrics/CyclomaticComplexity
88
+ def install
89
+ install_init
90
+ install_plan
91
+ install_apply
92
+ install_refresh
93
+ install_destroy
94
+ install_write_tf_vars
95
+ end
96
+
97
+ # add the 'tf:init' Rake task. This checks environment variables,
98
+ # runs ``terraform -version``, and then runs ``terraform init`` with
99
+ # the ``backend_config`` options, if any.
100
+ def install_init
101
+ namespace nsprefix do
102
+ desc 'Run terraform init with appropriate arguments'
103
+ task :init do
104
+ TFWrapper::Helpers.check_env_vars(@tf_vars_from_env.values)
105
+ check_tf_version
106
+ cmd = [
107
+ 'terraform',
108
+ 'init',
109
+ '-input=false'
110
+ ].join(' ')
111
+ @backend_config.each do |k, v|
112
+ cmd = cmd + ' ' + "-backend-config='#{k}=#{v}'"
113
+ end
114
+ terraform_runner(cmd)
115
+ end
116
+ end
117
+ end
118
+
119
+ # add the 'tf:plan' Rake task
120
+ def install_plan
121
+ namespace nsprefix do
122
+ desc 'Output the set plan to be executed by apply; specify ' \
123
+ 'optional CSV targets'
124
+ task :plan, [:target] => [
125
+ :"#{nsprefix}:init",
126
+ :"#{nsprefix}:write_tf_vars"
127
+ ] do |_t, args|
128
+ cmd = cmd_with_targets(
129
+ ['terraform', 'plan', "-var-file #{var_file_path}"],
130
+ args[:target],
131
+ args.extras
132
+ )
133
+
134
+ terraform_runner(cmd)
135
+ end
136
+ end
137
+ end
138
+
139
+ # add the 'tf:apply' Rake task
140
+ def install_apply
141
+ namespace nsprefix do
142
+ desc 'Apply a terraform plan that will provision your resources; ' \
143
+ 'specify optional CSV targets'
144
+ task :apply, [:target] => [
145
+ :"#{nsprefix}:init",
146
+ :"#{nsprefix}:write_tf_vars",
147
+ :"#{nsprefix}:plan"
148
+ ] do |_t, args|
149
+ cmd = cmd_with_targets(
150
+ ['terraform', 'apply', "-var-file #{var_file_path}"],
151
+ args[:target],
152
+ args.extras
153
+ )
154
+ terraform_runner(cmd)
155
+
156
+ update_consul_stack_env_vars unless @consul_env_vars_prefix.nil?
157
+ end
158
+ end
159
+ end
160
+
161
+ # add the 'tf:refresh' Rake task
162
+ def install_refresh
163
+ namespace nsprefix do
164
+ task refresh: [
165
+ :"#{nsprefix}:init",
166
+ :"#{nsprefix}:write_tf_vars"
167
+ ] do
168
+ cmd = [
169
+ 'terraform',
170
+ 'refresh',
171
+ "-var-file #{var_file_path}"
172
+ ].join(' ')
173
+
174
+ terraform_runner(cmd)
175
+ end
176
+ end
177
+ end
178
+
179
+ # add the 'tf:destroy' Rake task
180
+ def install_destroy
181
+ namespace nsprefix do
182
+ desc 'Destroy any live resources that are tracked by your state ' \
183
+ 'files; specify optional CSV targets'
184
+ task :destroy, [:target] => [
185
+ :"#{nsprefix}:init",
186
+ :"#{nsprefix}:write_tf_vars"
187
+ ] do |_t, args|
188
+ cmd = cmd_with_targets(
189
+ ['terraform', 'destroy', '-force', "-var-file #{var_file_path}"],
190
+ args[:target],
191
+ args.extras
192
+ )
193
+
194
+ terraform_runner(cmd)
195
+ end
196
+ end
197
+ end
198
+
199
+ def var_file_path
200
+ if @ns_prefix.nil?
201
+ File.absolute_path('build.tfvars.json')
202
+ else
203
+ File.absolute_path("#{@ns_prefix}_build.tfvars.json")
204
+ end
205
+ end
206
+
207
+ # add the 'tf:write_tf_vars' Rake task
208
+ def install_write_tf_vars
209
+ namespace nsprefix do
210
+ desc "Write #{var_file_path}"
211
+ task :write_tf_vars do
212
+ tf_vars = terraform_vars
213
+ puts 'Terraform vars:'
214
+ tf_vars.sort.map do |k, v|
215
+ if k == 'aws_access_key' || k == 'aws_secret_key'
216
+ puts "#{k} => (redacted)"
217
+ else
218
+ puts "#{k} => #{v}"
219
+ end
220
+ end
221
+ File.open(var_file_path, 'w') do |f|
222
+ f.write(tf_vars.to_json)
223
+ end
224
+ STDERR.puts "Terraform vars written to: #{var_file_path}"
225
+ end
226
+ end
227
+ end
228
+
229
+ def terraform_vars
230
+ res = {}
231
+ @tf_vars_from_env.each { |tfname, envname| res[tfname] = ENV[envname] }
232
+ @tf_extra_vars.each { |name, val| res[name] = val }
233
+ res
234
+ end
235
+
236
+ # Run a Terraform command, providing some useful output and handling AWS
237
+ # API rate limiting gracefully. Raises StandardError on failure. The command
238
+ # is run in @tf_dir.
239
+ #
240
+ # @param cmd [String] Terraform command to run
241
+ # rubocop:disable Metrics/PerceivedComplexity
242
+ def terraform_runner(cmd)
243
+ require 'retries'
244
+ STDERR.puts "terraform_runner command: '#{cmd}' (in #{@tf_dir})"
245
+ out_err = nil
246
+ status = nil
247
+ # exponential backoff as long as we're getting 403s
248
+ handler = proc do |exception, attempt_number, total_delay|
249
+ STDERR.puts "terraform_runner failed with #{exception}; retry " \
250
+ "attempt #{attempt_number}; #{total_delay} seconds have passed."
251
+ end
252
+ status = -1
253
+ with_retries(
254
+ max_tries: 5,
255
+ handler: handler,
256
+ base_sleep_seconds: 1.0,
257
+ max_sleep_seconds: 10.0
258
+ ) do
259
+ # this streams STDOUT and STDERR as a combined stream,
260
+ # and also captures them as a combined string
261
+ out_err, status = TFWrapper::Helpers.run_cmd_stream_output(cmd, @tf_dir)
262
+ if status != 0 && out_err.include?('hrottling')
263
+ raise StandardError, 'Terraform hit AWS API rate limiting'
264
+ end
265
+ if status != 0 && out_err.include?('status code: 403')
266
+ raise StandardError, 'Terraform command got 403 error - access ' \
267
+ 'denied or credentials not propagated'
268
+ end
269
+ if status != 0 && out_err.include?('status code: 401')
270
+ raise StandardError, 'Terraform command got 401 error - access ' \
271
+ 'denied or credentials not propagated'
272
+ end
273
+ end
274
+ # end exponential backoff
275
+ unless status.zero?
276
+ raise StandardError, "Errors have occurred executing: '#{cmd}' " \
277
+ "(exited #{status})"
278
+ end
279
+ STDERR.puts "terraform_runner command '#{cmd}' finished and exited 0"
280
+ end
281
+ # rubocop:enable Metrics/PerceivedComplexity
282
+
283
+ # Check that the terraform version is compatible
284
+ def check_tf_version
285
+ # run: terraform -version
286
+ all_out_err, exit_status = TFWrapper::Helpers.run_cmd_stream_output(
287
+ 'terraform version', @tf_dir
288
+ )
289
+ unless exit_status.zero?
290
+ raise StandardError, "ERROR: 'terraform -version' exited " \
291
+ "#{exit_status}: #{all_out_err}"
292
+ end
293
+ all_out_err = all_out_err.strip
294
+ # Find the terraform version string
295
+ m = /Terraform v(\d+\.\d+\.\d+).*/.match(all_out_err)
296
+ unless m
297
+ raise StandardError, 'ERROR: could not determine terraform version ' \
298
+ "from 'terraform -version' output: #{all_out_err}"
299
+ end
300
+ # the version will be a string like:
301
+ # Terraform v0.9.2
302
+ # or:
303
+ # Terraform v0.9.3-dev (<GIT SHA><+CHANGES>)
304
+ tf_ver = Gem::Version.new(m[1])
305
+ unless tf_ver >= min_tf_version
306
+ raise StandardError, "ERROR: tfwrapper #{TFWrapper::VERSION} is only " \
307
+ "compatible with Terraform >= #{min_tf_version} but your terraform " \
308
+ "binary reports itself as #{m[1]} (#{all_out_err})"
309
+ end
310
+ puts "Running with: #{all_out_err}"
311
+ end
312
+
313
+ # update stack status in Consul
314
+ def update_consul_stack_env_vars
315
+ require 'diplomat'
316
+ require 'json'
317
+ data = {}
318
+ @tf_vars_from_env.values.each { |k| data[k] = ENV[k] }
319
+
320
+ Diplomat.configure do |config|
321
+ config.url = @consul_url
322
+ end
323
+
324
+ puts "Writing stack information to #{@consul_url} at: "\
325
+ "#{@consul_env_vars_prefix}"
326
+ puts JSON.pretty_generate(data)
327
+ raw = JSON.generate(data)
328
+ Diplomat::Kv.put(@consul_env_vars_prefix, raw)
329
+ end
330
+
331
+ # Create a terraform command line with optional targets specified; targets
332
+ # are inserted between cmd_array and suffix_array.
333
+ #
334
+ # This is intended to simplify parsing Rake task arguments and inserting
335
+ # them into the command as targets; to get a Rake task to take a variable
336
+ # number of arguments, we define a first argument (``:target``) which is
337
+ # either a String or nil. Any additional arguments specified end up in
338
+ # ``args.extras``, which is either nil or an Array of additional String
339
+ # arguments.
340
+ #
341
+ # @param cmd_array [Array] array of the beginning parts of the terraform
342
+ # command; usually something like:
343
+ # ['terraform', 'ACTION', '-var'file', 'VAR_FILE_PATH']
344
+ # @param target [String] the first target parameter given to the Rake
345
+ # task, or nil.
346
+ # @param extras [Array] array of additional target parameters given to the
347
+ # Rake task, or nil.
348
+ def cmd_with_targets(cmd_array, target, extras)
349
+ final_arr = cmd_array
350
+ final_arr.concat(['-target', target]) unless target.nil?
351
+ # rubocop:disable Style/SafeNavigation
352
+ extras.each { |e| final_arr.concat(['-target', e]) } unless extras.nil?
353
+ # rubocop:enable Style/SafeNavigation
354
+ final_arr.join(' ')
355
+ end
356
+ end
357
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TFWrapper
4
+ # version of the Gem/module; used in the gemspec and in messages
5
+ VERSION = '0.2.0.beta1'
6
+ end
data/lib/tfwrapper.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # TFWrapper module
4
+ module TFWrapper
5
+ end
6
+
7
+ gem_libs_dir = "#{File.dirname File.absolute_path(__FILE__)}/tfwrapper"
8
+ Dir.glob("#{gem_libs_dir}/*.rb") { |file| require file }
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ffi'
4
+
5
+ def cleanup_tf
6
+ fixture_dir = File.absolute_path(
7
+ File.join(File.dirname(__FILE__), '..', 'fixtures')
8
+ )
9
+ Dir.glob("#{fixture_dir}/**/.terraform").each do |d|
10
+ FileUtils.rmtree(d) if File.directory?(d)
11
+ end
12
+ end
13
+
14
+ class HashicorpFetcher
15
+ def initialize(program, version)
16
+ @program = program
17
+ @prog_ucase = @program.upcase
18
+ @prog_cap = @program.capitalize
19
+ @version = version
20
+ end
21
+
22
+ def bin_dir
23
+ "vendor/bin/#{@program}/#{@version}"
24
+ end
25
+
26
+ def bin_os
27
+ FFI::Platform::OS
28
+ end
29
+
30
+ def bin_arch
31
+ arch = FFI::Platform::ARCH
32
+ case arch
33
+ when /x86_64|amd64/
34
+ 'amd64'
35
+ when /i?86|x86/
36
+ '386'
37
+ else
38
+ arch
39
+ end
40
+ end
41
+
42
+ def bin_path
43
+ return ENV["#{@prog_ucase}_BIN"] if ENV.include?("#{@prog_ucase}_BIN")
44
+ "#{bin_dir}/#{@program}"
45
+ end
46
+
47
+ def package_name
48
+ "#{@program}_#{@version}_#{bin_os}_#{bin_arch}.zip"
49
+ end
50
+
51
+ def package_url
52
+ "https://releases.hashicorp.com/#{@program}/#{@version}/#{package_name}"
53
+ end
54
+
55
+ def vendored_required?
56
+ return false if File.file?(bin_path) && is_correct_version?
57
+ true
58
+ end
59
+
60
+ # rubocop:disable Metrics/AbcSize
61
+ def fetch
62
+ return File.realpath(bin_path) unless vendored_required?
63
+ require 'open-uri'
64
+
65
+ puts "Fetching #{package_url}..."
66
+
67
+ zippath = "vendor/#{@program}.zip"
68
+ Dir.mkdir('vendor') unless File.directory?('vendor')
69
+ begin
70
+ File.open(zippath, 'wb') do |saved_file|
71
+ open(package_url, 'rb') do |read_file|
72
+ saved_file.write(read_file.read)
73
+ end
74
+ end
75
+ rescue OpenURI::HTTPError
76
+ raise StandardError, "#{@prog_cap} version #{@version} not found " \
77
+ "(HTTPError for #{package_url})."
78
+ end
79
+
80
+ puts "Extracting binary #{bin_dir}..."
81
+ system 'mkdir', '-p', bin_dir
82
+ system 'unzip', zippath, '-d', bin_dir
83
+
84
+ puts 'Cleaning up...'
85
+ system 'rm', zippath
86
+ raise StandardErrro, 'Error: wrong version' unless is_correct_version?
87
+ File.realpath(bin_path)
88
+ end
89
+ # rubocop:enable Metrics/AbcSize
90
+
91
+ def is_correct_version?
92
+ ver = `#{bin_path} version`.strip
93
+ unless ver =~ /^#{Regexp.quote(@prog_cap)} v#{Regexp.quote(@version)}/
94
+ puts "ERROR: Tests need #{@prog_cap} version #{@version} but got: #{ver}"
95
+ return false
96
+ end
97
+ true
98
+ end
99
+ end