terraspace 0.1.1 → 0.2.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -2
  3. data/README.md +20 -12
  4. data/lib/templates/base/git_hook/hook.sh +5 -0
  5. data/lib/templates/base/project/.gitignore +0 -1
  6. data/lib/templates/base/shim/terraspace +7 -0
  7. data/lib/templates/hcl/project/config/terraform/backend.tf.tt +3 -3
  8. data/lib/templates/plugin/lib/templates/hcl/project/config/terraform/backend.tf.tt +2 -2
  9. data/lib/terraspace/app.rb +8 -0
  10. data/lib/terraspace/builder.rb +6 -45
  11. data/lib/terraspace/cli.rb +34 -18
  12. data/lib/terraspace/cli/build/placeholder.rb +40 -0
  13. data/lib/terraspace/cli/cloud.rb +24 -0
  14. data/lib/terraspace/cli/commander.rb +8 -1
  15. data/lib/terraspace/cli/init.rb +67 -0
  16. data/lib/terraspace/cli/list.rb +13 -0
  17. data/lib/terraspace/cli/new.rb +8 -0
  18. data/lib/terraspace/cli/new/git_hook.rb +33 -0
  19. data/lib/terraspace/cli/new/shim.rb +58 -0
  20. data/lib/terraspace/cli/summary.rb +9 -12
  21. data/lib/terraspace/compiler/backend.rb +9 -37
  22. data/lib/terraspace/compiler/backend/parser.rb +42 -0
  23. data/lib/terraspace/compiler/builder.rb +6 -2
  24. data/lib/terraspace/compiler/cleaner.rb +19 -2
  25. data/lib/terraspace/compiler/cleaner/backend_change.rb +1 -1
  26. data/lib/terraspace/compiler/dsl/syntax/mod.rb +1 -0
  27. data/lib/terraspace/compiler/dsl/syntax/mod/backend.rb +16 -3
  28. data/lib/terraspace/compiler/expander.rb +28 -1
  29. data/lib/terraspace/compiler/writer.rb +1 -1
  30. data/lib/terraspace/core.rb +7 -1
  31. data/lib/terraspace/mod.rb +37 -12
  32. data/lib/terraspace/mod/remote.rb +1 -1
  33. data/lib/terraspace/plugin/expander/interface.rb +48 -5
  34. data/lib/terraspace/plugin/infer_provider.rb +15 -0
  35. data/lib/terraspace/plugin/layer/interface.rb +5 -0
  36. data/lib/terraspace/plugin/summary/interface.rb +1 -0
  37. data/lib/terraspace/seeder.rb +4 -4
  38. data/lib/terraspace/terraform/api.rb +58 -0
  39. data/lib/terraspace/terraform/api/client.rb +10 -0
  40. data/lib/terraspace/terraform/api/http.rb +106 -0
  41. data/lib/terraspace/terraform/api/var.rb +72 -0
  42. data/lib/terraspace/terraform/api/vars.rb +38 -0
  43. data/lib/terraspace/terraform/api/vars/base.rb +7 -0
  44. data/lib/terraspace/terraform/api/vars/json.rb +14 -0
  45. data/lib/terraspace/terraform/api/vars/rb.rb +21 -0
  46. data/lib/terraspace/terraform/args/custom.rb +1 -1
  47. data/lib/terraspace/terraform/args/default.rb +16 -2
  48. data/lib/terraspace/terraform/cloud.rb +25 -0
  49. data/lib/terraspace/terraform/cloud/workspace.rb +95 -0
  50. data/lib/terraspace/terraform/runner.rb +1 -1
  51. data/lib/terraspace/util/sh.rb +1 -1
  52. data/lib/terraspace/version.rb +1 -1
  53. data/spec/fixtures/{cache_build_dir → cache_dir}/variables.tf +0 -0
  54. data/spec/fixtures/projects/hcl/aws/config/backend.tf +1 -1
  55. data/spec/fixtures/projects/hcl/google/config/backend.tf +1 -1
  56. data/spec/terraspace/seeder_spec.rb +1 -1
  57. data/spec/terraspace/terraform/hooks/builder_spec.rb +1 -1
  58. data/terraspace.gemspec +5 -4
  59. metadata +47 -13
  60. data/lib/terraspace/cli/build.rb +0 -7
@@ -0,0 +1,106 @@
1
+ require 'net/http'
2
+
3
+ class Terraspace::Terraform::Api
4
+ class Http
5
+ include Terraspace::Util::Logging
6
+
7
+ API = ENV['TERRAFORM_API'] || 'https://app.terraform.io/api/v2'
8
+ extend Memoist
9
+
10
+ def get(path)
11
+ request(Net::HTTP::Get, path)
12
+ end
13
+
14
+ def post(path, data={})
15
+ request(Net::HTTP::Post, path, data)
16
+ end
17
+
18
+ def patch(path, data={})
19
+ request(Net::HTTP::Patch, path, data)
20
+ end
21
+
22
+ def delete(path, data={})
23
+ request(Net::HTTP::Delete, path, data)
24
+ end
25
+
26
+ # Always translate raw json response to ruby Hash
27
+ def request(klass, path, data={})
28
+ url = url(path)
29
+ http = http(url)
30
+ req = build_request(klass, url, data)
31
+ resp = http.request(req) # send request
32
+ load_json(resp)
33
+ end
34
+
35
+ def build_request(klass, url, data={})
36
+ req = klass.new(url) # url includes query string and uri.path does not, must used url
37
+ set_headers!(req)
38
+ if [Net::HTTP::Delete, Net::HTTP::Patch, Net::HTTP::Post, Net::HTTP::Put].include?(klass)
39
+ text = JSON.dump(data)
40
+ req.body = text
41
+ req.content_length = text.bytesize
42
+ end
43
+ req
44
+ end
45
+
46
+ def set_headers!(req)
47
+ req['Authorization'] = "Bearer #{token}"
48
+ req['Content-Type'] = "application/vnd.api+json"
49
+ end
50
+
51
+ def http(url)
52
+ uri = URI(url)
53
+ http = Net::HTTP.new(uri.host, uri.port)
54
+ http.open_timeout = http.read_timeout = 30
55
+ http.use_ssl = true if uri.scheme == 'https'
56
+ http
57
+ end
58
+ memoize :http
59
+
60
+ # API does not include the /. IE: https://app.terraform.io/api/v2
61
+ def url(path)
62
+ "#{API}/#{path}"
63
+ end
64
+
65
+ def load_json(res)
66
+ if res.code == "200"
67
+ JSON.load(res.body)
68
+ else
69
+ if ENV['TERRASPACE_DEBUG_API']
70
+ puts "Error: Non-successful http response status code: #{res.code}"
71
+ puts "headers: #{res.each_header.to_h.inspect}"
72
+ end
73
+ nil
74
+ end
75
+ end
76
+
77
+ def token
78
+ token ||= ENV['TERRAFORM_TOKEN']
79
+ return token if token
80
+
81
+ creds_path = "#{ENV['HOME']}/.terraform.d/credentials.tfrc.json"
82
+ if File.exist?(creds_path)
83
+ data = JSON.load(IO.read(creds_path))
84
+ token = data.dig('credentials', 'app.terraform.io', 'token')
85
+ end
86
+
87
+ # Note only way to get here is to bypass init. Example:
88
+ #
89
+ # terraspace up demo --no-init
90
+ #
91
+ unless token
92
+ logger.error "ERROR: Unable to not find a Terraform token. A Terraform token is needed for Terraspace to call the Terraform API.".color(:red)
93
+ logger.error <<~EOL
94
+ Here are some ways to provide the Terraform token:
95
+
96
+ 1. By running: terraform login
97
+ 2. With an env variable: export TERRAFORM_TOKEN=xxx
98
+
99
+ Please configure a Terraform token and try again.
100
+ EOL
101
+ exit 1
102
+ end
103
+ token
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,72 @@
1
+ class Terraspace::Terraform::Api
2
+ class Var
3
+ extend Memoist
4
+ include Client
5
+ include Terraspace::Util::Logging
6
+
7
+ def initialize(workspace, attrs={})
8
+ @workspace, @attrs = workspace, attrs
9
+ @workspace_id = @workspace['id']
10
+ end
11
+
12
+ def sync
13
+ exist? ? update : create
14
+ end
15
+
16
+ def update
17
+ return unless overwrite?
18
+ logger.debug "Updating Terraform Cloud #{category} variable: #{@attrs['key']}"
19
+ variable_id = variable_id(@attrs['key'])
20
+ payload = payload(variable_id)
21
+ http.patch("workspaces/#{@workspace_id}/vars/#{variable_id}", payload)
22
+ end
23
+
24
+ def overwrite?
25
+ cloud = Terraspace.config.cloud
26
+ if @attrs['sensitive']
27
+ cloud.overwrite_sensitive
28
+ else
29
+ cloud.overwrite
30
+ end
31
+ end
32
+
33
+ def variable_id(key)
34
+ current_var_resp['id']
35
+ end
36
+
37
+ def create
38
+ logger.info "Creating Terraform Cloud #{category} variable: #{@attrs['key']}"
39
+ http.post("workspaces/#{@workspace_id}/vars", payload)
40
+ end
41
+
42
+ def payload(id=nil)
43
+ data = {
44
+ type: "vars",
45
+ attributes: @attrs
46
+ }
47
+ data[:id] = id if id
48
+ { data: data }
49
+ end
50
+
51
+ def exist?
52
+ !!current_var_resp
53
+ end
54
+
55
+ def current_var_resp
56
+ current_vars_resp['data'].find do |item|
57
+ attributes = item['attributes']
58
+ attributes['key'] == @attrs['key'] &&
59
+ attributes['category'] == category
60
+ end
61
+ end
62
+
63
+ def category
64
+ @attrs['category'] || 'terraform' # default category when not set is terraform
65
+ end
66
+
67
+ @@current_vars_resp = nil
68
+ def current_vars_resp
69
+ @@current_vars_resp ||= http.get("workspaces/#{@workspace_id}/vars")
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,38 @@
1
+ class Terraspace::Terraform::Api
2
+ class Vars
3
+ extend Memoist
4
+ include Client
5
+
6
+ def initialize(mod, workspace)
7
+ @mod, @workspace = mod, workspace
8
+ end
9
+
10
+ def run
11
+ return unless exist?
12
+
13
+ vars = vars_class.new(@mod, vars_path).vars
14
+ vars.each do |attrs|
15
+ Var.new(@workspace, attrs).sync
16
+ end
17
+ end
18
+
19
+ # Return value examples:
20
+ #
21
+ # Terraspace::Terraform::Api::Vars::Json
22
+ # Terraspace::Terraform::Api::Vars::Rb
23
+ #
24
+ def vars_class
25
+ ext = File.extname(vars_path).sub('.','')
26
+ "Terraspace::Terraform::Api::Vars::#{ext.camelize}".constantize
27
+ end
28
+
29
+ def exist?
30
+ !!vars_path
31
+ end
32
+
33
+ def vars_path
34
+ # .rb takes higher precedence
35
+ Dir.glob("#{Terraspace.root}/config/terraform/cloud/vars.{rb,json}").first
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,7 @@
1
+ class Terraspace::Terraform::Api::Vars
2
+ class Base
3
+ def initialize(mod, vars_path)
4
+ @mod, @vars_path = mod, vars_path
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,14 @@
1
+ class Terraspace::Terraform::Api::Vars
2
+ class Json < Base
3
+ def vars
4
+ context = Terraspace::Compiler::Erb::Context.new(@mod)
5
+ result = RenderMePretty.result(@vars_path, context: context)
6
+
7
+ data = JSON.load(result)
8
+ items = data.select do |item|
9
+ item['data']['type'] == 'vars'
10
+ end
11
+ items.map { |i| i['data']['attributes'] }
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,21 @@
1
+ class Terraspace::Terraform::Api::Vars
2
+ class Rb < Base
3
+ include DslEvaluator
4
+
5
+ def initialize(*)
6
+ super
7
+ @vars = [] # holds results
8
+ end
9
+
10
+ def vars
11
+ evaluate_file(@vars_path)
12
+ @vars
13
+ end
14
+
15
+ def var(attrs={})
16
+ default = { category: "terraform" } # required field
17
+ var = default.deep_merge(attrs).deep_stringify_keys!
18
+ @vars << var
19
+ end
20
+ end
21
+ end
@@ -37,7 +37,7 @@ module Terraspace::Terraform::Args
37
37
  end
38
38
 
39
39
  def var_file_exist?(var_file)
40
- File.exist?("#{@mod.cache_build_dir}/#{var_file}")
40
+ File.exist?("#{@mod.cache_dir}/#{var_file}")
41
41
  end
42
42
 
43
43
  def dig(prop, default=[])
@@ -1,3 +1,5 @@
1
+ require "tempfile"
2
+
1
3
  module Terraspace::Terraform::Args
2
4
  class Default
3
5
  def initialize(mod, name, options={})
@@ -40,7 +42,7 @@ module Terraspace::Terraform::Args
40
42
  dest = src
41
43
  else
42
44
  src = "#{Dir.pwd}/#{plan}"
43
- dest = "#{@mod.cache_build_dir}/#{File.basename(src)}"
45
+ dest = "#{@mod.cache_dir}/#{File.basename(src)}"
44
46
  end
45
47
  FileUtils.cp(src, dest)
46
48
  args << " #{dest}"
@@ -58,9 +60,11 @@ module Terraspace::Terraform::Args
58
60
  args << " -input=#{input}"
59
61
  end
60
62
 
63
+ args << " -reconfigure" if @options[:reconfigure]
64
+
61
65
  # must be at the end
62
66
  if @quiet && !ENV['TS_INIT_LOUD']
63
- out_path = "#{Terraspace.tmp_root}/out/terraform-init.out"
67
+ out_path = self.class.terraform_init_log
64
68
  FileUtils.mkdir_p(File.dirname(out_path))
65
69
  args << " > #{out_path}"
66
70
  end
@@ -92,5 +96,15 @@ module Terraspace::Terraform::Args
92
96
  def auto_approve_arg
93
97
  @options[:yes] || @options[:auto] ? ["-auto-approve"] : []
94
98
  end
99
+
100
+ class << self
101
+ # 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"
107
+ end
108
+ end
95
109
  end
96
110
  end
@@ -0,0 +1,25 @@
1
+ module Terraspace::Terraform
2
+ class Cloud < Terraspace::CLI::Base
3
+ extend Memoist
4
+
5
+ def run
6
+ return unless workspaces?
7
+ api = Api.new(@mod, remote)
8
+ api.set_working_dir
9
+ api.set_env_vars
10
+ end
11
+
12
+ def workspaces?
13
+ remote && remote['workspaces']
14
+ end
15
+
16
+ def remote
17
+ backend["remote"]
18
+ end
19
+
20
+ def backend
21
+ Terraspace::Compiler::Backend::Parser.new(@mod).result
22
+ end
23
+ memoize :backend
24
+ end
25
+ end
@@ -0,0 +1,95 @@
1
+ class Terraspace::Terraform::Cloud
2
+ class Workspace < Terraspace::CLI::Base
3
+ extend Memoist
4
+ include Terraspace::Util::Logging
5
+ include Terraspace::Terraform::Api::Client
6
+
7
+ # List will not have @mod set.
8
+ def list
9
+ @mod = Terraspace::CLI::Build::Placeholder.new(@options).build
10
+ unless remote && remote['organization']
11
+ logger.info "ERROR: There was no organization found. Are you sure you configured backend.tf with it?".color(:red)
12
+ exit 1
13
+ end
14
+
15
+ org = remote['organization']
16
+ payload = http.get("/organizations/#{org}/workspaces") # list using api client directly
17
+ names = payload['data'].map { |i| i['attributes']['name'] }.sort
18
+ logger.info "Workspaces for #{org}:"
19
+ logger.info names.join("\n")
20
+ end
21
+
22
+ def setup
23
+ build
24
+ unless backend.dig('remote','workspaces') # in case called by terraspace down demo -y --destroy-workspace with a non-remote backend
25
+ logger.info "ERROR: Workspace not configured in backend.tf"
26
+ exit 1
27
+ end
28
+ init
29
+ end
30
+
31
+ def init
32
+ Terraspace::CLI::Init.new(@options.merge(calling_command: "cloud-setup")).run
33
+ end
34
+
35
+ def destroy
36
+ build
37
+ return unless backend.dig('remote','workspaces') # in case called by terraspace down demo -y --destroy-workspace with a non-remote backend
38
+ api = Terraspace::Terraform::Api.new(@mod, remote)
39
+ workspace = api.workspace(exit_on_fail: false)
40
+ unless workspace
41
+ logger.info "Workspace #{workspace_name} not found for #{@mod.type}: #{@mod.name}"
42
+ exit 0
43
+ end
44
+ sure?
45
+ logger.info "Destroying workspace #{workspace_name}"
46
+ api.destroy_workspace
47
+ end
48
+
49
+ def build
50
+ Terraspace::Builder.new(@options).run
51
+ end
52
+
53
+ def workspace_name
54
+ remote['workspaces']['name']
55
+ end
56
+
57
+ def remote
58
+ backend["remote"]
59
+ end
60
+
61
+ def backend
62
+ Terraspace::Compiler::Backend::Parser.new(@mod).result
63
+ end
64
+ memoize :backend
65
+
66
+ def sure?
67
+ message = <<~EOL.chop + " " # chop to remove newline
68
+ You are about to delete the workspace: #{workspace_name}
69
+ All variables, settings, run history, and state history will be removed.
70
+ This cannot be undone.
71
+
72
+ This will NOT remove any infrastructure managed by this workspace.
73
+ If needed, destroy the infrastructure prior to deleting the workspace with:
74
+
75
+ terraspace down #{@mod.name}
76
+
77
+ This will delete the workspace: #{workspace_name}.
78
+ Are you sure? (y/N)
79
+ EOL
80
+
81
+ if @options[:yes]
82
+ sure = 'y'
83
+ else
84
+ print message
85
+ sure = $stdin.gets
86
+ end
87
+
88
+ unless sure =~ /^y/
89
+ puts "Whew! Exiting."
90
+ exit 0
91
+ end
92
+ end
93
+ end
94
+ end
95
+
@@ -28,7 +28,7 @@ module Terraspace::Terraform
28
28
  @@current_dir_message_shown = false
29
29
  def current_dir_message
30
30
  return if @@current_dir_message_shown
31
- logger.info "Current directory: #{Terraspace::Util.pretty_path(@mod.cache_build_dir)}"
31
+ logger.info "Current directory: #{Terraspace::Util.pretty_path(@mod.cache_dir)}"
32
32
  @@current_dir_message_shown = true
33
33
  end
34
34