terraspace 0.3.6 → 0.4.4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/README.md +57 -30
  4. data/lib/templates/base/project/README.md +1 -1
  5. data/lib/terraspace.rb +3 -1
  6. data/lib/terraspace/all/runner.rb +1 -0
  7. data/lib/terraspace/all/summary.rb +8 -1
  8. data/lib/terraspace/app.rb +9 -5
  9. data/lib/terraspace/builder.rb +10 -6
  10. data/lib/terraspace/cli.rb +12 -16
  11. data/lib/terraspace/cli/all.rb +6 -0
  12. data/lib/terraspace/cli/bundle.rb +2 -1
  13. data/lib/terraspace/cli/clean.rb +18 -6
  14. data/lib/terraspace/cli/clean/all.rb +18 -0
  15. data/lib/terraspace/cli/clean/base.rb +15 -0
  16. data/lib/terraspace/cli/clean/cache.rb +25 -0
  17. data/lib/terraspace/cli/{logs/tasks.rb → clean/logs.rb} +8 -9
  18. data/lib/terraspace/cli/cloud.rb +2 -0
  19. data/lib/terraspace/cli/help/all/init.md +33 -0
  20. data/lib/terraspace/cli/help/clean/all.md +10 -0
  21. data/lib/terraspace/cli/help/clean/cache.md +12 -0
  22. data/lib/terraspace/cli/help/clean/logs.md +17 -0
  23. data/lib/terraspace/cli/help/logs.md +48 -0
  24. data/lib/terraspace/cli/info.rb +12 -0
  25. data/lib/terraspace/cli/init.rb +3 -7
  26. data/lib/terraspace/cli/list.rb +2 -1
  27. data/lib/terraspace/cli/logs.rb +105 -10
  28. data/lib/terraspace/cli/{log → logs}/concern.rb +2 -1
  29. data/lib/terraspace/cli/new/helper.rb +9 -2
  30. data/lib/terraspace/dependency/helper/output.rb +1 -1
  31. data/lib/terraspace/hooks/builder.rb +52 -0
  32. data/lib/terraspace/hooks/concern.rb +9 -0
  33. data/lib/terraspace/{terraform/hooks → hooks}/dsl.rb +3 -2
  34. data/lib/terraspace/hooks/runner.rb +23 -0
  35. data/lib/terraspace/mod.rb +11 -2
  36. data/lib/terraspace/plugin/summary/interface.rb +3 -1
  37. data/lib/terraspace/shell.rb +5 -28
  38. data/lib/terraspace/shell/error.rb +46 -0
  39. data/lib/terraspace/terraform/args/custom.rb +1 -1
  40. data/lib/terraspace/terraform/args/default.rb +9 -19
  41. data/lib/terraspace/terraform/remote_state/output_proxy.rb +3 -3
  42. data/lib/terraspace/terraform/remote_state/{null_object.rb → unresolved.rb} +1 -1
  43. data/lib/terraspace/terraform/runner.rb +8 -21
  44. data/lib/terraspace/terraform/runner/retryer.rb +65 -0
  45. data/lib/terraspace/version.rb +1 -1
  46. data/spec/terraspace/{terraform/hooks → hooks}/builder_spec.rb +4 -5
  47. data/spec/terraspace/terraform/remote_state/output_proxy_spec.rb +3 -3
  48. data/terraspace.gemspec +1 -1
  49. metadata +23 -14
  50. data/lib/terraspace/cli/help/clean.md +0 -5
  51. data/lib/terraspace/cli/help/log.md +0 -48
  52. data/lib/terraspace/cli/log.rb +0 -112
  53. data/lib/terraspace/terraform/hooks/builder.rb +0 -40
@@ -0,0 +1,52 @@
1
+ module Terraspace::Hooks
2
+ class Builder
3
+ extend Memoist
4
+ include Dsl
5
+ include DslEvaluator
6
+ include Terraspace::Util
7
+
8
+ # IE: dsl_file: config/hooks/terraform.rb
9
+ attr_accessor :name
10
+ def initialize(mod, dsl_file, name)
11
+ @mod, @dsl_file, @name = mod, dsl_file, name
12
+ @hooks = {before: {}, after: {}}
13
+ end
14
+
15
+ def build
16
+ return @hooks unless File.exist?(@dsl_file)
17
+ evaluate_file(@dsl_file)
18
+ @hooks.deep_stringify_keys!
19
+ end
20
+ memoize :build
21
+
22
+ def run_hooks
23
+ build
24
+ run_each_hook("before")
25
+ out = yield if block_given?
26
+ run_each_hook("after")
27
+ out
28
+ end
29
+
30
+ def run_each_hook(type)
31
+ hooks = @hooks.dig(type, @name) || []
32
+ hooks.each do |hook|
33
+ run_hook(type, hook)
34
+ end
35
+ end
36
+
37
+ def run_hook(type, hook)
38
+ return unless run?(hook)
39
+
40
+ command = File.basename(@dsl_file).sub('.rb','') # IE: kubes, kubectl, docker
41
+ id = "#{command} #{type} #{@name}"
42
+ label = " label: #{hook["label"]}" if hook["label"]
43
+ logger.info "Running #{id} hook.#{label}"
44
+ logger.debug "Hook options: #{hook}"
45
+ Runner.new(@mod, hook).run
46
+ end
47
+
48
+ def run?(hook)
49
+ !!hook["execute"]
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,9 @@
1
+ module Terraspace::Hooks
2
+ module Concern
3
+ def run_hooks(dsl_file, name, &block)
4
+ hooks = Builder.new(@mod, "#{Terraspace.root}/config/hooks/#{dsl_file}", name)
5
+ hooks.build # build hooks
6
+ hooks.run_hooks(&block)
7
+ end
8
+ end
9
+ end
@@ -1,4 +1,4 @@
1
- module Terraspace::Terraform::Hooks
1
+ module Terraspace::Hooks
2
2
  module Dsl
3
3
  def before(*commands, **props)
4
4
  commands.each do |name|
@@ -13,7 +13,8 @@ module Terraspace::Terraform::Hooks
13
13
  end
14
14
 
15
15
  def each_hook(type, name, props={})
16
- @hooks[type][name] = props
16
+ @hooks[type][name] ||= []
17
+ @hooks[type][name] << props
17
18
  end
18
19
  end
19
20
  end
@@ -0,0 +1,23 @@
1
+ module Terraspace::Hooks
2
+ class Runner
3
+ include Terraspace::Util
4
+
5
+ def initialize(mod, hook)
6
+ @mod, @hook = mod, hook
7
+ @execute = @hook["execute"]
8
+ end
9
+
10
+ def run
11
+ case @execute
12
+ when String
13
+ Terraspace::Shell.new(@mod, @execute, exit_on_fail: @hook["exit_on_fail"]).run
14
+ when -> (e) { e.respond_to?(:public_instance_methods) && e.public_instance_methods.include?(:call) }
15
+ @execute.new.call
16
+ when -> (e) { e.respond_to?(:call) }
17
+ @execute.call
18
+ else
19
+ logger.warn "WARN: execute option not set for hook: #{@hook.inspect}"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -34,8 +34,17 @@ module Terraspace
34
34
  Terraspace.check_project!
35
35
  return if root
36
36
 
37
- pretty_paths = paths.map { |p| Terraspace::Util.pretty_path(p) }
38
- logger.error "ERROR: Unable to find #{@name.color(:green)} module. Searched paths: #{pretty_paths}"
37
+ pretty_paths = paths.map { |p| Terraspace::Util.pretty_path(p) }.join(", ")
38
+ logger.error <<~EOL
39
+ ERROR: Unable to find #{@name.color(:green)}. Searched paths:
40
+
41
+ #{pretty_paths}
42
+
43
+ To see available stacks, try running:
44
+
45
+ terraspace list
46
+
47
+ EOL
39
48
  ENV['TS_TEST'] ? raise : exit(1)
40
49
  end
41
50
 
@@ -64,7 +64,9 @@ module Terraspace::Plugin::Summary
64
64
 
65
65
  def show_each(path)
66
66
  data = JSON.load(IO.read(path))
67
+ return unless data # edge case: blank file
67
68
  resources = data['resources']
69
+ return unless resources
68
70
  remove_statefile(path) if resources && resources.size == 0 && !ENV['TS_KEEP_EMPTY_STATEFILES']
69
71
  return unless resources && resources.size > 0
70
72
 
@@ -75,7 +77,7 @@ module Terraspace::Plugin::Summary
75
77
  identifier = r['instances'].map do |i|
76
78
  i['attributes']['name'] || i['attributes']['id']
77
79
  end.join(',')
78
- return if @options[:short]
80
+ return unless @options[:details]
79
81
  logger.info " #{r['type']} #{r['name']}: #{identifier}"
80
82
  end
81
83
  end
@@ -6,7 +6,6 @@ module Terraspace
6
6
 
7
7
  def initialize(mod, command, options={})
8
8
  @mod, @command, @options = mod, command, options
9
- @error_type, @error_messages = nil, ''
10
9
  end
11
10
 
12
11
  # requires @mod to be set
@@ -32,10 +31,9 @@ module Terraspace
32
31
  Open3.popen3(env, @command, chdir: @mod.cache_dir) do |stdin, stdout, stderr, wait_thread|
33
32
  mimic_terraform_input(stdin, stdout)
34
33
  while err = stderr.gets
35
- @error_type ||= known_error_type(err)
36
- if @error_type
37
- @error_messages << err
38
- else
34
+ @error ||= Error.new
35
+ @error.lines << err # aggregate all error lines
36
+ unless @error.known?
39
37
  # Sometimes may print a "\e[31m\n" which like during dependencies fetcher init
40
38
  # suppress it so dont get a bunch of annoying "newlines"
41
39
  next if err == "\e[31m\n" && @options[:suppress_error_color]
@@ -48,33 +46,12 @@ module Terraspace
48
46
  end
49
47
  end
50
48
 
51
- def known_error_type(err)
52
- if reinitialization_required?(err)
53
- :reinitialization_required
54
- elsif bucket_not_found?(err)
55
- :bucket_not_found
56
- end
57
- end
58
-
59
- def bucket_not_found?(err)
60
- # Message is included in aws, azurerm, and google. See: https://bit.ly/3iOKDri
61
- err.include?("Failed to get existing workspaces")
62
- end
63
-
64
- def reinitialization_required?(err)
65
- err.include?("reinitialization required") ||
66
- err.include?("terraform init") ||
67
- err.include?("require reinitialization")
68
- end
69
-
70
49
  def exit_status(status)
71
50
  return if status == 0
72
51
 
73
52
  exit_on_fail = @options[:exit_on_fail].nil? ? true : @options[:exit_on_fail]
74
- if @error_type == :reinitialization_required
75
- raise InitRequiredError.new(@error_messages)
76
- elsif @error_type == :bucket_not_found
77
- raise BucketNotFoundError.new(@error_messages)
53
+ if @error && @error.known?
54
+ raise @error.instance
78
55
  elsif exit_on_fail
79
56
  logger.error "Error running command: #{@command}".color(:red)
80
57
  exit status
@@ -0,0 +1,46 @@
1
+ class Terraspace::Shell
2
+ class Error
3
+ attr_accessor :lines
4
+ def initialize
5
+ @lines = '' # holds aggregation of all error lines
6
+ end
7
+
8
+ def known?
9
+ !!instance
10
+ end
11
+
12
+ def instance
13
+ if reinit_required?
14
+ Terraspace::InitRequiredError.new(@lines)
15
+ elsif bucket_not_found?
16
+ Terraspace::BucketNotFound.new(@lines)
17
+ elsif shared_cache_error?
18
+ Terraspace::SharedCacheError.new(@lines)
19
+ end
20
+ end
21
+
22
+ def bucket_not_found?
23
+ # Message is included in aws, azurerm, and google. See: https://bit.ly/3iOKDri
24
+ message.include?("Failed to get existing workspaces")
25
+ end
26
+
27
+ def reinit_required?
28
+ # Example error: https://gist.github.com/tongueroo/f7e0a44b64f0a2e533089b18f331c21e
29
+ general_check = message.include?("terraform init") && message.include?("Error:")
30
+ general_check ||
31
+ message.include?("reinitialization required") ||
32
+ message.include?("terraform init") ||
33
+ message.include?("require reinitialization")
34
+ end
35
+
36
+ def message
37
+ @lines.gsub("\n", ' ').squeeze(' ') # remove double whitespaces and newlines
38
+ end
39
+
40
+ def shared_cache_error?
41
+ # Example: https://gist.github.com/tongueroo/4f2c925709d21f5810229ce9ca482560
42
+ message.include?("Failed to install provider from shared cache") ||
43
+ message.include?("Failed to validate installed provider")
44
+ end
45
+ end
46
+ end
@@ -7,7 +7,7 @@ module Terraspace::Terraform::Args
7
7
  attr_accessor :name
8
8
  def initialize(mod, name)
9
9
  @mod, @name = mod, name
10
- @file = "#{Terraspace.root}/config/cli/args.rb"
10
+ @file = "#{Terraspace.root}/config/args/terraform.rb"
11
11
  @commands = {}
12
12
  end
13
13
 
@@ -26,7 +26,7 @@ module Terraspace::Terraform::Args
26
26
  args << var_files.map { |f| "-var-file #{Dir.pwd}/#{f}" }.join(' ')
27
27
  end
28
28
 
29
- args << input_option if input_option
29
+ args << input_option
30
30
 
31
31
  # must be at the end
32
32
  plan = @options[:plan]
@@ -45,27 +45,17 @@ module Terraspace::Terraform::Args
45
45
  end
46
46
 
47
47
  def input_option
48
- option = nil
49
- if @options[:auto] && @options[:input].nil?
50
- option = " -input=false"
51
- end
52
- unless @options[:input].nil?
53
- input = @options[:input] ? "true" : "false"
54
- option = " -input=#{input}" # = sign required for apply when there's a plan at the end. so input=false works input false doesnt
55
- end
56
- option
48
+ option = if @options[:auto]
49
+ "false"
50
+ else
51
+ @options[:input] ? @options[:input] : "false"
52
+ end
53
+ " -input=#{option}"
57
54
  end
58
55
 
59
56
  def init_args
60
57
  args = "-get"
61
- if @options[:auto] && @options[:input].nil?
62
- args << " -input=false"
63
- end
64
- unless @options[:input].nil?
65
- input = @options[:input] ? "true" : "false"
66
- args << " -input=#{input}"
67
- end
68
-
58
+ args << input_option
69
59
  args << " -reconfigure" if @options[:reconfigure]
70
60
 
71
61
  # must be at the end
@@ -86,7 +76,7 @@ module Terraspace::Terraform::Args
86
76
 
87
77
  def plan_args
88
78
  args = []
89
- args << input_option if input_option
79
+ args << input_option
90
80
  args << "-destroy" if @options[:destroy]
91
81
  args << "-out #{expanded_out}" if @options[:out]
92
82
  args
@@ -11,18 +11,18 @@ module Terraspace::Terraform::RemoteState
11
11
  # Should always return a String
12
12
  def to_s
13
13
  if @mod.resolved
14
- # Dont use NullObject wrapper because Integer get changed to Strings.
14
+ # Dont use Unresolved wrapper because Integer get changed to Strings.
15
15
  # Want raw value to be used for the to_json call
16
16
  value = @raw.nil? ? mock_or_error : @raw
17
17
  value.to_json
18
18
  else
19
- NullObject.new # to_s => (unresolved)
19
+ Unresolved.new
20
20
  end
21
21
  end
22
22
 
23
23
  def to_ruby
24
24
  data = @raw.nil? ? mock_or_error : @raw
25
- @mod.resolved ? data : NullObject.new
25
+ @mod.resolved ? data : Unresolved.new
26
26
  end
27
27
 
28
28
  private
@@ -1,5 +1,5 @@
1
1
  module Terraspace::Terraform::RemoteState
2
- class NullObject
2
+ class Unresolved
3
3
  def to_a
4
4
  []
5
5
  end
@@ -1,13 +1,13 @@
1
1
  module Terraspace::Terraform
2
2
  class Runner < Terraspace::CLI::Base
3
3
  extend Memoist
4
+ include Terraspace::Hooks::Concern
4
5
  include Terraspace::Util
5
6
 
6
7
  attr_reader :name
7
8
  def initialize(name, options={})
8
9
  @name = name
9
10
  super(options)
10
- @retries = 1
11
11
  end
12
12
 
13
13
  def run
@@ -20,24 +20,17 @@ module Terraspace::Terraform
20
20
  current_dir_message # only show once
21
21
 
22
22
  params = args.flatten.join(' ')
23
- command = "terraform #{name} #{params}"
24
- run_hooks(name) do
23
+ command = "terraform #{name} #{params}".squish
24
+ run_hooks("terraform.rb", name) do
25
25
  Terraspace::Shell.new(@mod, command, @options.merge(env: custom.env_vars)).run
26
26
  end
27
- rescue Terraspace::InitRequiredError => e
28
- logger.info "Terraform reinitialization required detected. Will run `terraform init` and try again."
29
- logger.debug "Retry attempt: #{@retries}"
30
- logger.debug "#{e.class}"
31
- Runner.new("init", @options).run
32
- if @retries <= 3
33
- backoff = 2 ** @retries # 2, 4, 8
34
- logger.debug "Waiting #{backoff}s before retrying"
35
- sleep(backoff)
36
- @retries += 1
27
+ rescue Terraspace::SharedCacheError, Terraspace::InitRequiredError
28
+ @retryer ||= Retryer.new(@mod, @options, name, $!)
29
+ if @retryer.retry?
30
+ @retryer.run
37
31
  retry
38
32
  else
39
- logger.info "ERROR: #{e.message}"
40
- exit 1
33
+ exit(1)
41
34
  end
42
35
  end
43
36
 
@@ -53,12 +46,6 @@ module Terraspace::Terraform
53
46
  @options[:quiet] ? logger.debug(msg) : logger.info(msg)
54
47
  end
55
48
 
56
- def run_hooks(name, &block)
57
- hooks = Hooks::Builder.new(@mod, name)
58
- hooks.build # build hooks
59
- hooks.run_hooks(&block)
60
- end
61
-
62
49
  def args
63
50
  # base at end in case of redirection. IE: terraform output > /path
64
51
  custom.args + custom.var_files + default.args
@@ -0,0 +1,65 @@
1
+ class Terraspace::Terraform::Runner
2
+ class Retryer
3
+ include Terraspace::Util::Logging
4
+ include Terraspace::Util::Pretty
5
+
6
+ def initialize(mod, options, command_name, exception)
7
+ @mod, @options, @command_name, @exception = mod, options, command_name, exception
8
+ @retries = 1
9
+ end
10
+
11
+ def retry?
12
+ if @retries <= 3
13
+ true # will retry
14
+ else
15
+ logger.info "ERROR: #{@exception.message}"
16
+ false # will not retry
17
+ end
18
+ end
19
+
20
+ def run
21
+ backoff = 2 ** @retries # 2, 4, 8
22
+ logger.debug "Waiting #{backoff}s before retrying"
23
+ sleep(backoff)
24
+ @retries += 1
25
+
26
+ case @exception
27
+ when Terraspace::SharedCacheError
28
+ shared_cache_error
29
+ when Terraspace::InitRequiredError
30
+ init_required_error
31
+ end
32
+ end
33
+
34
+ def shared_cache_error
35
+ logger.info "Terraform Shared Cache error detected. Will purge caches and run `terraform init` to try again."
36
+ logger.debug "Retry attempt: #{@retries}"
37
+ logger.debug "#{@exception.class}"
38
+ logger.debug "#{@exception.message}"
39
+ purge_caches # Purging the cache "fixes" this terraform bug
40
+ reinit
41
+ end
42
+
43
+ def init_required_error
44
+ logger.info "Terraform reinitialization required detected. Will run `terraform init` and try again."
45
+ logger.debug "Retry attempt: #{@retries}"
46
+ logger.debug "#{@exception.class}"
47
+ reinit
48
+ end
49
+
50
+ def reinit
51
+ Terraspace::Terraform::Runner.new("init", @options).run unless @command_name == "init"
52
+ end
53
+
54
+ def purge_caches
55
+ dir = "#{@mod.cache_dir}/.terraform"
56
+ logger.info "Purging #{pretty_path(dir)}"
57
+ FileUtils.rm_rf(dir)
58
+
59
+ dir = "#{Terraspace.config.terraform.plugin_cache.dir}"
60
+ logger.info "Purging #{pretty_path(dir)}"
61
+ FileUtils.rm_rf(dir)
62
+ FileUtils.mkdir_p(dir) # need /tmp/terraspace/plugin_cache dir to exist
63
+ end
64
+ end
65
+ end