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.
- checksums.yaml +7 -0
- data/.gitignore +54 -0
- data/.rubocop.yml +22 -0
- data/ChangeLog.md +3 -0
- data/Gemfile +4 -0
- data/Guardfile +21 -0
- data/LICENSE +21 -0
- data/README.md +320 -0
- data/Rakefile +60 -0
- data/circle.yml +24 -0
- data/lib/tfwrapper/helpers.rb +81 -0
- data/lib/tfwrapper/raketasks.rb +357 -0
- data/lib/tfwrapper/version.rb +6 -0
- data/lib/tfwrapper.rb +8 -0
- data/spec/acceptance/acceptance_helpers.rb +99 -0
- data/spec/acceptance/acceptance_spec.rb +461 -0
- data/spec/acceptance/consulserver.rb +34 -0
- data/spec/fixtures/Rakefile +7 -0
- data/spec/fixtures/testOne.tf +22 -0
- data/spec/fixtures/testThree/Rakefile +32 -0
- data/spec/fixtures/testThree/bar/testThreeBar.tf +28 -0
- data/spec/fixtures/testThree/baz/testThreeBaz.tf +22 -0
- data/spec/fixtures/testThree/foo/testThreeFoo.tf +27 -0
- data/spec/fixtures/testTwo/Rakefile +9 -0
- data/spec/fixtures/testTwo/foo/bar/testTwo.tf +28 -0
- data/spec/spec_helper.rb +58 -0
- data/spec/unit/helpers_spec.rb +143 -0
- data/spec/unit/raketasks_spec.rb +851 -0
- data/tfwrapper.gemspec +61 -0
- metadata +419 -0
@@ -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
|
data/lib/tfwrapper.rb
ADDED
@@ -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
|