terraspace 0.2.1 → 0.3.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +34 -13
  4. data/lib/templates/base/git_hook/hook.sh +1 -1
  5. data/lib/templates/base/project/.gitignore +1 -0
  6. data/lib/templates/base/project/README.md +17 -0
  7. data/lib/terraspace.rb +4 -0
  8. data/lib/terraspace/all/base.rb +8 -0
  9. data/lib/terraspace/all/grapher.rb +129 -0
  10. data/lib/terraspace/all/preview.rb +43 -0
  11. data/lib/terraspace/all/runner.rb +169 -0
  12. data/lib/terraspace/all/summary.rb +99 -0
  13. data/lib/terraspace/app.rb +31 -8
  14. data/lib/terraspace/booter.rb +9 -0
  15. data/lib/terraspace/builder.rb +65 -20
  16. data/lib/terraspace/cli.rb +39 -12
  17. data/lib/terraspace/cli/all.rb +63 -0
  18. data/lib/terraspace/cli/build/placeholder.rb +2 -5
  19. data/lib/terraspace/cli/bundle.rb +1 -1
  20. data/lib/terraspace/cli/check_setup.rb +17 -5
  21. data/lib/terraspace/cli/cloud.rb +19 -3
  22. data/lib/terraspace/cli/cloud/runs.rb +24 -0
  23. data/lib/terraspace/cli/commander.rb +1 -8
  24. data/lib/terraspace/cli/down.rb +20 -0
  25. data/lib/terraspace/cli/help/cloud/runs/list.md +36 -0
  26. data/lib/terraspace/cli/help/cloud/runs/prune.md +25 -0
  27. data/lib/terraspace/cli/help/cloud/sync.md +19 -0
  28. data/lib/terraspace/cli/help/log.md +42 -0
  29. data/lib/terraspace/cli/init.rb +35 -7
  30. data/lib/terraspace/cli/list.rb +14 -1
  31. data/lib/terraspace/cli/log.rb +112 -0
  32. data/lib/terraspace/cli/log/concern.rb +24 -0
  33. data/lib/terraspace/cli/logs.rb +15 -0
  34. data/lib/terraspace/cli/logs/tasks.rb +32 -0
  35. data/lib/terraspace/cli/new/git_hook.rb +1 -1
  36. data/lib/terraspace/cli/tfc_concern.rb +14 -0
  37. data/lib/terraspace/cli/up.rb +32 -0
  38. data/lib/terraspace/compiler/builder.rb +3 -3
  39. data/lib/terraspace/compiler/cleaner.rb +1 -1
  40. data/lib/terraspace/compiler/cleaner/backend_change.rb +21 -7
  41. data/lib/terraspace/compiler/dirs_concern.rb +47 -0
  42. data/lib/terraspace/compiler/dsl/syntax/helpers/common.rb +26 -1
  43. data/lib/terraspace/core.rb +11 -2
  44. data/lib/terraspace/dependency/graph.rb +139 -0
  45. data/lib/terraspace/dependency/node.rb +38 -0
  46. data/lib/terraspace/dependency/registry.rb +11 -0
  47. data/lib/terraspace/logger.rb +6 -18
  48. data/lib/terraspace/logger/formatter.rb +13 -0
  49. data/lib/terraspace/mod.rb +7 -1
  50. data/lib/terraspace/seeder/where.rb +6 -2
  51. data/lib/terraspace/shell.rb +79 -0
  52. data/lib/terraspace/terraform/api.rb +7 -40
  53. data/lib/terraspace/terraform/api/base.rb +7 -0
  54. data/lib/terraspace/terraform/api/client.rb +23 -3
  55. data/lib/terraspace/terraform/api/http.rb +14 -34
  56. data/lib/terraspace/terraform/api/http/concern.rb +10 -0
  57. data/lib/terraspace/terraform/api/runs.rb +28 -0
  58. data/lib/terraspace/terraform/api/token.rb +65 -0
  59. data/lib/terraspace/terraform/api/var.rb +20 -6
  60. data/lib/terraspace/terraform/api/vars.rb +2 -1
  61. data/lib/terraspace/terraform/api/workspace.rb +98 -0
  62. data/lib/terraspace/terraform/args/default.rb +48 -21
  63. data/lib/terraspace/terraform/cloud/runs.rb +13 -0
  64. data/lib/terraspace/terraform/cloud/runs/base.rb +33 -0
  65. data/lib/terraspace/terraform/cloud/runs/item_presenter.rb +37 -0
  66. data/lib/terraspace/terraform/cloud/runs/lister.rb +22 -0
  67. data/lib/terraspace/terraform/cloud/runs/pruner.rb +109 -0
  68. data/lib/terraspace/terraform/cloud/sync.rb +41 -0
  69. data/lib/terraspace/terraform/cloud/syncer.rb +52 -0
  70. data/lib/terraspace/terraform/cloud/workspace.rb +10 -21
  71. data/lib/terraspace/terraform/hooks/builder.rb +1 -1
  72. data/lib/terraspace/terraform/remote_state/fetcher.rb +122 -0
  73. data/lib/terraspace/terraform/remote_state/marker/output.rb +39 -0
  74. data/lib/terraspace/terraform/remote_state/marker/pretty_tracer.rb +37 -0
  75. data/lib/terraspace/terraform/remote_state/output_proxy.rb +29 -0
  76. data/lib/terraspace/terraform/runner.rb +24 -14
  77. data/lib/terraspace/util.rb +1 -5
  78. data/lib/terraspace/util/pretty.rb +18 -0
  79. data/lib/terraspace/version.rb +1 -1
  80. data/spec/fixtures/fetcher/c1.json +37 -0
  81. data/spec/fixtures/parser/cache_dirs/all/01-test.auto.tfvars +5 -0
  82. data/spec/fixtures/parser/cache_dirs/depends_on/01-test.auto.tfvars +2 -0
  83. data/spec/fixtures/parser/cache_dirs/output/01-test.auto.tfvars +2 -0
  84. data/spec/fixtures/summary/down.log +12 -0
  85. data/spec/fixtures/summary/output.log +5 -0
  86. data/spec/fixtures/summary/plan/error.log +20 -0
  87. data/spec/fixtures/summary/plan/success.log +17 -0
  88. data/spec/fixtures/summary/show.log +22 -0
  89. data/spec/fixtures/summary/up/error.log +13 -0
  90. data/spec/fixtures/summary/up/success.log +63 -0
  91. data/spec/fixtures/summary/validate/error.log +13 -0
  92. data/spec/fixtures/summary/validate/success.log +5 -0
  93. data/spec/terraspace/all/grapher_spec.rb +38 -0
  94. data/spec/terraspace/all/runner_spec.rb +48 -0
  95. data/spec/terraspace/all/summary_spec.rb +93 -0
  96. data/spec/terraspace/dependency/graph_spec.rb +162 -0
  97. data/spec/terraspace/seeder_spec.rb +0 -1
  98. data/spec/terraspace/terraform/remote_state/fetcher_spec.rb +52 -0
  99. data/terraspace.gemspec +5 -1
  100. metadata +137 -5
  101. data/lib/terraspace/terraform/cloud.rb +0 -25
  102. data/lib/terraspace/util/sh.rb +0 -19
@@ -1,9 +1,10 @@
1
1
  class Terraspace::Terraform::Api
2
2
  class Var
3
3
  extend Memoist
4
- include Client
4
+ include Http::Concern
5
5
  include Terraspace::Util::Logging
6
6
 
7
+ # workspace: details from the api response
7
8
  def initialize(workspace, attrs={})
8
9
  @workspace, @attrs = workspace, attrs
9
10
  @workspace_id = @workspace['id']
@@ -15,27 +16,30 @@ class Terraspace::Terraform::Api
15
16
 
16
17
  def update
17
18
  return unless overwrite?
18
- logger.debug "Updating Terraform Cloud #{category} variable: #{@attrs['key']}"
19
+ updating_message
19
20
  variable_id = variable_id(@attrs['key'])
20
21
  payload = payload(variable_id)
21
22
  http.patch("workspaces/#{@workspace_id}/vars/#{variable_id}", payload)
22
23
  end
23
24
 
24
25
  def overwrite?
25
- cloud = Terraspace.config.cloud
26
26
  if @attrs['sensitive']
27
- cloud.overwrite_sensitive
27
+ vars.overwrite_sensitive
28
28
  else
29
- cloud.overwrite
29
+ vars.overwrite
30
30
  end
31
31
  end
32
32
 
33
+ def vars
34
+ Terraspace.config.cloud.vars
35
+ end
36
+
33
37
  def variable_id(key)
34
38
  current_var_resp['id']
35
39
  end
36
40
 
37
41
  def create
38
- logger.info "Creating Terraform Cloud #{category} variable: #{@attrs['key']}"
42
+ creating_message
39
43
  http.post("workspaces/#{@workspace_id}/vars", payload)
40
44
  end
41
45
 
@@ -48,6 +52,16 @@ class Terraspace::Terraform::Api
48
52
  { data: data }
49
53
  end
50
54
 
55
+ def updating_message
56
+ return unless %w[all update].include?(vars.show_message)
57
+ logger.info "Updating Terraform Cloud #{category} variable: #{@attrs['key']}"
58
+ end
59
+
60
+ def creating_message
61
+ return unless %w[all create].include?(vars.show_message)
62
+ logger.info "Creating Terraform Cloud #{category} variable: #{@attrs['key']}"
63
+ end
64
+
51
65
  def exist?
52
66
  !!current_var_resp
53
67
  end
@@ -1,8 +1,9 @@
1
1
  class Terraspace::Terraform::Api
2
2
  class Vars
3
3
  extend Memoist
4
- include Client
4
+ include Http::Concern
5
5
 
6
+ # workspace: details from the api response
6
7
  def initialize(mod, workspace)
7
8
  @mod, @workspace = mod, workspace
8
9
  end
@@ -0,0 +1,98 @@
1
+ class Terraspace::Terraform::Api
2
+ class Workspace < Base
3
+ extend Memoist
4
+
5
+ attr_reader :name
6
+ def initialize(mod, organization, name)
7
+ @mod, @organization, @name = mod, organization, name
8
+ end
9
+
10
+ # Docs: https://www.terraform.io/docs/cloud/api/workspaces.html
11
+ def set_working_dir
12
+ return if working_directory == details['attributes']['working-directory']
13
+
14
+ payload = {
15
+ data: {
16
+ attributes: {
17
+ "working-directory": working_directory
18
+ },
19
+ type: "workspaces"
20
+ }
21
+ }
22
+ http.patch("organizations/#{@organization}/workspaces/#{@name}", payload)
23
+ end
24
+
25
+ def working_directory
26
+ cache_dir = @mod.cache_dir.sub("#{Terraspace.root}/", '')
27
+ prefix = Terraspace.config.cloud.working_dir_prefix # prepended to TFC Working Directory
28
+ prefix ? "#{prefix}/#{cache_dir}" : cache_dir
29
+ end
30
+
31
+ def set_env_vars
32
+ Vars.new(@mod, details).run
33
+ end
34
+
35
+ def details(options={})
36
+ payload = http.get("organizations/#{@organization}/workspaces/#{@name}")
37
+ # Note only way to get here is to bypass init. Example:
38
+ #
39
+ # terraspace up demo --no-init
40
+ #
41
+ unless payload || options[:exit_on_fail] == false
42
+ logger.error "ERROR: Unable to find the workspace: #{@name}. The workspace may not exist. Or the Terraform token may be invalid. Please double check your Terraform token.".color(:red)
43
+ exit 1
44
+ end
45
+ payload['data'] if payload
46
+ end
47
+ memoize :details
48
+
49
+ def destroy
50
+ # response payload from delete operation is nil
51
+ http.delete("/organizations/#{@organization}/workspaces/#{@name}")
52
+ end
53
+
54
+ # Docs: https://www.terraform.io/docs/cloud/api/workspaces.html
55
+ def create
56
+ payload = upsert_payload
57
+ http.post("organizations/#{@organization}/workspaces", payload)
58
+ end
59
+
60
+ def update
61
+ payload = upsert_payload
62
+ http.patch("organizations/#{@organization}/workspaces/#{@name}", payload)
63
+ self.flush_cache
64
+ end
65
+
66
+ def upsert_payload
67
+ {
68
+ data: {
69
+ attributes: attributes,
70
+ type: "workspaces"
71
+ }
72
+ }
73
+ end
74
+
75
+ def attributes
76
+ attrs = { name: @name }
77
+ config = Terraspace.config.cloud.workspace.attrs
78
+ attrs.merge!(config)
79
+ # Default: run on all changes since app/modules can affect app/stacks
80
+ if config['vcs-repo'] && config['file-triggers-enabled'].nil?
81
+ attrs['file-triggers-enabled'.to_sym] = false
82
+ end
83
+ token = ENV['TS_CLOUD_OAUTH_TOKEN']
84
+ if config['vcs-repo'] && !config.dig('vcs-repo', 'oauth-token-id') && token
85
+ attrs['vcs-repo'.to_sym]['oauth-token-id'.to_sym] ||= token
86
+ end
87
+ attrs
88
+ end
89
+
90
+ def create_or_update
91
+ exist? ? update : create
92
+ end
93
+
94
+ def exist?
95
+ !!details(exit_on_fail: false)
96
+ end
97
+ end
98
+ end
@@ -11,7 +11,7 @@ module Terraspace::Terraform::Args
11
11
  # https://terraspace.cloud/docs/ci-automation/
12
12
  ENV['TF_IN_AUTOMATION'] = '1' if @options[:auto]
13
13
 
14
- if %w[apply destroy init output plan].include?(@name)
14
+ if %w[apply destroy init output plan show].include?(@name)
15
15
  meth = "#{@name}_args"
16
16
  send(meth)
17
17
  else
@@ -26,13 +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
- if @options[:auto] && @options[:input].nil?
30
- args << " -input=false"
31
- end
32
- unless @options[:input].nil?
33
- input = @options[:input] ? "true" : "false"
34
- args << " -input=#{input}" # = sign required for apply when there's a plan at the end. so input=false works input false doesnt
35
- end
29
+ args << input_option if input_option
36
30
 
37
31
  # must be at the end
38
32
  plan = @options[:plan]
@@ -44,12 +38,24 @@ module Terraspace::Terraform::Args
44
38
  src = "#{Dir.pwd}/#{plan}"
45
39
  dest = "#{@mod.cache_dir}/#{File.basename(src)}"
46
40
  end
47
- FileUtils.cp(src, dest)
41
+ FileUtils.cp(src, dest) unless same_file?(src, dest)
48
42
  args << " #{dest}"
49
43
  end
50
44
  args
51
45
  end
52
46
 
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
57
+ end
58
+
53
59
  def init_args
54
60
  args = "-get"
55
61
  if @options[:auto] && @options[:input].nil?
@@ -63,24 +69,33 @@ module Terraspace::Terraform::Args
63
69
  args << " -reconfigure" if @options[:reconfigure]
64
70
 
65
71
  # must be at the end
66
- if @quiet && !ENV['TS_INIT_LOUD']
67
- out_path = self.class.terraform_init_log
68
- FileUtils.mkdir_p(File.dirname(out_path))
69
- args << " > #{out_path}"
72
+ if @quiet
73
+ log_path = self.class.terraform_init_log(@mod.name)
74
+ FileUtils.mkdir_p(File.dirname(log_path))
75
+ args << " >> #{log_path}"
70
76
  end
71
77
  [args]
72
78
  end
73
79
 
80
+ def output_args
81
+ args = []
82
+ args << "-json" if @options[:format] == "json"
83
+ args << "> #{expanded_out}" if @options[:out]
84
+ args
85
+ end
86
+
74
87
  def plan_args
75
88
  args = []
89
+ args << input_option if input_option
90
+ args << "-destroy" if @options[:destroy]
76
91
  args << "-out #{expanded_out}" if @options[:out]
77
92
  args
78
93
  end
79
94
 
80
- def output_args
95
+ def show_args
81
96
  args = []
82
- args << "-json" if @options[:format] == "json"
83
- args << "> #{expanded_out}" if @options[:out]
97
+ args << " -json" if @options[:json]
98
+ args << " #{@options[:plan]}" if @options[:plan] # terraform show /path/to/plan
84
99
  args
85
100
  end
86
101
 
@@ -98,13 +113,25 @@ module Terraspace::Terraform::Args
98
113
  end
99
114
 
100
115
  class << self
116
+ extend Memoist
117
+
101
118
  # Use different tmp log file in case uses run terraspace up in 2 terminals at the same time
102
- @@terraform_init_log = nil
103
- def terraform_init_log
104
- return @@terraform_init_log if @@terraform_init_log
105
- basename = File.basename(Tempfile.new('terraform-init').path)
106
- @@terraform_init_log = "#{Terraspace.tmp_root}/out/#{basename}.out"
119
+ #
120
+ # Log for init is in /tmp because using shell >> redirection
121
+ # It requires full path since we're running terraform within the .terraspace-cache folder
122
+ # This keeps the printed command shorter:
123
+ #
124
+ # => terraform init -get >> /tmp/terraspace/log/init/demo.log
125
+ #
126
+ def terraform_init_log(mod_name)
127
+ "#{Terraspace.tmp_root}/log/init/#{mod_name}.log"
107
128
  end
129
+ memoize :terraform_init_log
130
+ end
131
+
132
+ private
133
+ def same_file?(src, dest)
134
+ src == dest
108
135
  end
109
136
  end
110
137
  end
@@ -0,0 +1,13 @@
1
+ module Terraspace::Terraform::Cloud
2
+ class Runs < Terraspace::CLI::Base
3
+ def list
4
+ lister = Lister.new(@mod, @options)
5
+ lister.run
6
+ end
7
+
8
+ def prune
9
+ pruner = Pruner.new(@mod, @options)
10
+ pruner.run
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,33 @@
1
+ class Terraspace::Terraform::Cloud::Runs
2
+ class Base
3
+ extend Memoist
4
+ include Terraspace::Util::Logging
5
+ include Terraspace::Util::Sure
6
+ include Terraspace::Terraform::Api::Client
7
+
8
+ # Api::Client requires @mod to be set
9
+ def initialize(mod, options={})
10
+ @mod, @options = mod, options
11
+ end
12
+
13
+ def runs
14
+ runs = api.runs.list
15
+ runs.select! do |item|
16
+ @options[:status].nil? ||
17
+ @options[:status].include?("all") ||
18
+ @options[:status].include?(item['attributes']['status'])
19
+ end
20
+ runs
21
+ end
22
+ memoize :runs
23
+
24
+ def build_project
25
+ Terraspace::Builder.new(@options).run
26
+
27
+ unless remote && remote['organization']
28
+ logger.info "ERROR: There was no organization found. Are you sure you configured backend.tf with it?".color(:red)
29
+ exit 1
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,37 @@
1
+ class Terraspace::Terraform::Cloud::Runs
2
+ class ItemPresenter
3
+ attr_reader :id
4
+ def initialize(raw)
5
+ @raw = raw # raw item
6
+ @id = raw['id']
7
+ @attrs = raw['attributes']
8
+ end
9
+
10
+ def method_missing(name, *args, &block)
11
+ attrs = @attrs.transform_keys { |k| k.gsub('-','_').to_sym }
12
+ if attrs.key?(name)
13
+ attrs[name]
14
+ else
15
+ super
16
+ end
17
+ end
18
+
19
+ def message
20
+ max = 25
21
+ message = @attrs['message']
22
+ if message.size >= max
23
+ message[0..max] + "..."
24
+ else
25
+ message
26
+ end
27
+ end
28
+
29
+ def created_at
30
+ pretty_time(@attrs['created-at'])
31
+ end
32
+
33
+ def pretty_time(text)
34
+ text.sub(/\..*/,'')
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,22 @@
1
+ require 'cli-format'
2
+
3
+ class Terraspace::Terraform::Cloud::Runs
4
+ class Lister < Base
5
+ def run
6
+ build_project
7
+ if runs.empty?
8
+ logger.info "No runs found"
9
+ return
10
+ end
11
+
12
+ presenter = CliFormat::Presenter.new(@options)
13
+ presenter.header = ["Id", "Status", "Message", "Created At"]
14
+ runs.each do |item|
15
+ p = ItemPresenter.new(item)
16
+ row = [p.id, p.status, p.message, p.created_at]
17
+ presenter.rows << row
18
+ end
19
+ presenter.show
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,109 @@
1
+ class Terraspace::Terraform::Cloud::Runs
2
+ class Pruner < Base
3
+ include Terraspace::Terraform::Api::Client
4
+
5
+ # @mod required for Api::Client
6
+ def initialize(mod, options={})
7
+ super
8
+ @queue, @kept, @needs_pruning = [], nil, false
9
+ end
10
+
11
+ def run
12
+ build_project
13
+ build_queue
14
+ are_you_sure?
15
+ process_queue
16
+ end
17
+
18
+ private
19
+ def build_queue
20
+ runs.each do |item|
21
+ next unless actionable?(item)
22
+ unless @kept
23
+ @kept = item
24
+ next
25
+ end
26
+ queue(item)
27
+ @needs_pruning = true
28
+ end
29
+ end
30
+
31
+ def are_you_sure?
32
+ unless @needs_pruning
33
+ logger.info "Nothing to prune"
34
+ return
35
+ end
36
+
37
+ keeping = item_message(@kept)
38
+ items = @queue.map { |i| item_message(i) }.join("\n")
39
+ message =<<~EOL
40
+ Will keep:
41
+
42
+ #{keeping}
43
+
44
+ Will prune:
45
+
46
+ #{items}
47
+
48
+ Are you sure?
49
+ EOL
50
+ sure?(message.chop)
51
+ end
52
+
53
+ def item_message(item)
54
+ p = ItemPresenter.new(item)
55
+ " #{p.id} #{p.status} #{p.message} #{p.created_at}"
56
+ end
57
+
58
+ def process_queue
59
+ @queue.each do |item|
60
+ process(item)
61
+ end
62
+ end
63
+
64
+ def process(item)
65
+ id = item['id']
66
+ action = discardable?(item) ? "Discarded" : "Cancelled"
67
+ p = ItemPresenter.new(item)
68
+ msg = "#{action} #{p.id} #{p.message}" # note id is named run-xxx
69
+ logger.info("NOOP: #{msg}") && return if @options[:noop]
70
+
71
+ if discardable?(item)
72
+ api.runs.discard(id)
73
+ elsif cancelable?(item)
74
+ api.runs.cancel(id)
75
+ end
76
+ logger.info msg
77
+ end
78
+
79
+ def queue(item)
80
+ if discardable?(item)
81
+ @queue << item
82
+ elsif cancelable?(item)
83
+ @queue << item
84
+ end
85
+ end
86
+
87
+ # Docs seem to be off: https://www.terraform.io/docs/cloud/api/run.html#apply-a-run
88
+ #
89
+ # This includes runs in the "pending," "needs confirmation," "policy checked," and "policy override" states.
90
+ #
91
+ # Cant really discard a "pending" status, but can discard a "planned" status.
92
+ #
93
+ def actionable?(item)
94
+ discardable?(item) || cancelable?(item)
95
+ end
96
+
97
+ def discardable?(item)
98
+ %w[planned].include?(status(item))
99
+ end
100
+
101
+ def cancelable?(item)
102
+ %w[pending].include?(status(item))
103
+ end
104
+
105
+ def status(item)
106
+ item['attributes']['status']
107
+ end
108
+ end
109
+ end