tfwrapper 0.2.0.beta1

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