terraspace 0.1.2 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/lib/templates/base/git_hook/hook.sh +5 -0
  4. data/lib/templates/base/project/.gitignore +0 -1
  5. data/lib/templates/base/shim/terraspace +7 -0
  6. data/lib/templates/hcl/project/config/terraform/backend.tf.tt +3 -3
  7. data/lib/templates/plugin/lib/templates/hcl/project/config/terraform/backend.tf.tt +2 -2
  8. data/lib/terraspace/app.rb +6 -0
  9. data/lib/terraspace/builder.rb +2 -46
  10. data/lib/terraspace/cli.rb +34 -18
  11. data/lib/terraspace/cli/build/placeholder.rb +40 -0
  12. data/lib/terraspace/cli/cloud.rb +24 -0
  13. data/lib/terraspace/cli/commander.rb +8 -1
  14. data/lib/terraspace/cli/init.rb +67 -0
  15. data/lib/terraspace/cli/list.rb +13 -0
  16. data/lib/terraspace/cli/new.rb +8 -0
  17. data/lib/terraspace/cli/new/git_hook.rb +33 -0
  18. data/lib/terraspace/cli/new/shim.rb +58 -0
  19. data/lib/terraspace/cli/summary.rb +9 -12
  20. data/lib/terraspace/compiler/backend.rb +9 -37
  21. data/lib/terraspace/compiler/backend/parser.rb +42 -0
  22. data/lib/terraspace/compiler/builder.rb +6 -2
  23. data/lib/terraspace/compiler/cleaner.rb +19 -2
  24. data/lib/terraspace/compiler/cleaner/backend_change.rb +1 -1
  25. data/lib/terraspace/compiler/dsl/syntax/mod.rb +1 -0
  26. data/lib/terraspace/compiler/dsl/syntax/mod/backend.rb +16 -3
  27. data/lib/terraspace/compiler/expander.rb +28 -1
  28. data/lib/terraspace/compiler/writer.rb +1 -1
  29. data/lib/terraspace/core.rb +7 -1
  30. data/lib/terraspace/mod.rb +37 -12
  31. data/lib/terraspace/mod/remote.rb +1 -1
  32. data/lib/terraspace/plugin/expander/interface.rb +48 -5
  33. data/lib/terraspace/plugin/infer_provider.rb +15 -0
  34. data/lib/terraspace/plugin/layer/interface.rb +5 -0
  35. data/lib/terraspace/seeder.rb +4 -4
  36. data/lib/terraspace/terraform/api.rb +53 -0
  37. data/lib/terraspace/terraform/api/client.rb +10 -0
  38. data/lib/terraspace/terraform/api/http.rb +106 -0
  39. data/lib/terraspace/terraform/api/var.rb +72 -0
  40. data/lib/terraspace/terraform/api/vars.rb +38 -0
  41. data/lib/terraspace/terraform/api/vars/base.rb +7 -0
  42. data/lib/terraspace/terraform/api/vars/json.rb +14 -0
  43. data/lib/terraspace/terraform/api/vars/rb.rb +21 -0
  44. data/lib/terraspace/terraform/args/custom.rb +1 -1
  45. data/lib/terraspace/terraform/args/default.rb +16 -2
  46. data/lib/terraspace/terraform/cloud.rb +25 -0
  47. data/lib/terraspace/terraform/cloud/workspace.rb +95 -0
  48. data/lib/terraspace/terraform/runner.rb +1 -1
  49. data/lib/terraspace/util/sh.rb +1 -1
  50. data/lib/terraspace/version.rb +1 -1
  51. data/spec/fixtures/{cache_build_dir → cache_dir}/variables.tf +0 -0
  52. data/spec/fixtures/projects/hcl/aws/config/backend.tf +1 -1
  53. data/spec/fixtures/projects/hcl/google/config/backend.tf +1 -1
  54. data/spec/terraspace/seeder_spec.rb +1 -1
  55. data/spec/terraspace/terraform/hooks/builder_spec.rb +1 -1
  56. data/terraspace.gemspec +3 -3
  57. metadata +37 -17
  58. data/lib/terraspace/cli/build.rb +0 -7
@@ -18,7 +18,7 @@ module Terraspace
18
18
  end
19
19
 
20
20
  def cache_root
21
- ENV['TS_CACHE_ROOT'] || "#{root}/.terraspace-cache"
21
+ ENV['TS_CACHE_ROOT'] || config.build.cache_root || "#{root}/.terraspace-cache"
22
22
  end
23
23
  memoize :cache_root
24
24
 
@@ -42,5 +42,11 @@ module Terraspace
42
42
  config.logger
43
43
  end
44
44
  memoize :logger
45
+
46
+ def check_project!
47
+ return if File.exist?("#{Terraspace.root}/config/app.rb")
48
+ logger.error "ERROR: It doesnt look like this is a terraspace project. Are you sure you are in a terraspace project?".color(:red)
49
+ ENV['TS_TEST'] ? raise : exit(1)
50
+ end
45
51
  end
46
52
  end
@@ -11,18 +11,26 @@ module Terraspace
11
11
 
12
12
  attr_reader :name, :consider_stacks, :instance, :options
13
13
  def initialize(name, options={})
14
- @name, @options = name, options
14
+ @name, @options = placeholder(name), options
15
15
  @consider_stacks = options[:consider_stacks].nil? ? true : options[:consider_stacks]
16
16
  @instance = options[:instance]
17
17
  end
18
18
 
19
+ def placeholder(name)
20
+ if name == "placeholder"
21
+ Terraspace::CLI::Build::Placeholder.new(@options).find_mod
22
+ else
23
+ name
24
+ end
25
+ end
26
+
19
27
  attr_accessor :root_module
20
28
  def root_module?
21
29
  @root_module
22
30
  end
23
31
 
24
32
  def check_exist!
25
- check_terraspace_project!
33
+ Terraspace.check_project!
26
34
  return if root
27
35
 
28
36
  pretty_paths = paths.map { |p| Terraspace::Util.pretty_path(p) }
@@ -30,16 +38,10 @@ module Terraspace
30
38
  ENV['TS_TEST'] ? raise : exit(1)
31
39
  end
32
40
 
33
- def check_terraspace_project!
34
- return if File.exist?("#{Terraspace.root}/config/app.rb")
35
- logger.error "ERROR: It doesnt look like this is a terraspace project. Are you sure you are in a terraspace project?".color(:red)
36
- ENV['TS_TEST'] ? raise : exit(1)
37
- end
38
-
39
41
  def to_info
40
42
  {
41
43
  build_dir: build_dir,
42
- cache_build_dir: Terraspace::Util.pretty_path(cache_build_dir),
44
+ cache_dir: Terraspace::Util.pretty_path(cache_dir),
43
45
  name: name,
44
46
  root: Terraspace::Util.pretty_path(root),
45
47
  type: type,
@@ -48,10 +50,30 @@ module Terraspace
48
50
  end
49
51
 
50
52
  def root
51
- paths.find { |p| File.exist?(p) }
53
+ root = paths.find { |p| File.exist?(p) }
54
+ if root.nil?
55
+ possible_fake_root
56
+ else
57
+ root
58
+ end
52
59
  end
53
60
  memoize :root
54
61
 
62
+ # If the app/stacks/NAME has been removed in source code but stack still exist in the cloud.
63
+ # allow user to delete by materializing an empty stack with the backend.tf
64
+ # Note this does not seem to work for Terraform Cloud as terraform init doesnt seem to download the plugins
65
+ # required. SIt only works for s3, azurerm, and gcs backends. On TFC, you can delete the stack via the GUI though.
66
+ #
67
+ # down - so user can delete stacks w/o needing to create an empty app/stacks/demo folder
68
+ # null - for the terraspace summary command when there are zero stacks.
69
+ # Also useful for terraspace cloud list_workspaces
70
+ #
71
+ def possible_fake_root
72
+ if @options[:command] == "down"
73
+ "#{Terraspace.root}/app/stacks/#{@name}" # fake stack root
74
+ end
75
+ end
76
+
55
77
  # Relative folder path without app or vendor. For example, the actual location can be found in a couple of places
56
78
  #
57
79
  # app/modules/vpc
@@ -74,9 +96,12 @@ module Terraspace
74
96
  end
75
97
 
76
98
  # Full path with build_dir
77
- def cache_build_dir
78
- "#{Terraspace.cache_root}/#{Terraspace.env}/#{build_dir}"
99
+ def cache_dir
100
+ pattern = Terraspace.config.build.cache_dir # IE: :CACHE_ROOT/:REGION/:ENV/:BUILD_DIR
101
+ expander = Terraspace::Compiler::Expander.autodetect(self)
102
+ expander.expansion(pattern)
79
103
  end
104
+ memoize :cache_dir
80
105
 
81
106
  def type
82
107
  root.include?("/stacks/") ? "stack" : "module"
@@ -8,7 +8,7 @@ class Terraspace::Mod
8
8
  end
9
9
 
10
10
  def root
11
- "#{@parent.cache_build_dir}/#{@meta['Dir']}"
11
+ "#{@parent.cache_dir}/#{@meta['Dir']}"
12
12
  end
13
13
 
14
14
  def type
@@ -6,34 +6,64 @@
6
6
  #
7
7
  module Terraspace::Plugin::Expander
8
8
  module Interface
9
- delegate :build_dir, :type_dir, to: :mod
9
+ include Terraspace::Plugin::InferProvider
10
+
11
+ delegate :build_dir, :type_dir, :type, to: :mod
10
12
 
11
13
  attr_reader :mod
12
14
  def initialize(mod)
13
15
  @mod = mod
14
16
  end
15
17
 
18
+ # Handles list of objects. Calls expansion to handle each string expansion.
16
19
  def expand(props={})
17
20
  props.each do |key, value|
18
- props[key] = expand_string(value)
21
+ props[key] = expansion(value)
19
22
  end
20
23
  props
21
24
  end
22
25
 
26
+ # Handles single string
27
+ #
23
28
  # Replaces variables denoted by colon in front with actual values. Example:
24
29
  #
25
30
  # :REGION/:ENV/:BUILD_DIR/terraform.tfstate
26
31
  # =>
27
32
  # us-west-2/dev/stacks/wordpress/terraform.tfstate
28
33
  #
29
- def expand_string(string)
34
+ def expansion(string)
30
35
  return string unless string.is_a?(String) # in case of nil
31
36
 
37
+ string = string.dup
32
38
  vars = string.scan(/:\w+/) # => [":ENV", ":BUILD_DIR"]
33
39
  vars.each do |var|
34
40
  string.gsub!(var, var_value(var))
35
41
  end
36
- string
42
+ strip(string)
43
+ end
44
+
45
+ # remove leading and trailing common separators.
46
+ #
47
+ # This is useful for when INSTANCE is not set.
48
+ # Note: BUILD_DIR includes INSTANCE
49
+ #
50
+ # Examples:
51
+ #
52
+ # cache_dir:
53
+ #
54
+ # :CACHE_ROOT/:REGION/:ENV/:BUILD_DIR/
55
+ #
56
+ # s3 backend key:
57
+ #
58
+ # :REGION/:ENV/:BUILD_DIR/terraform.tfstate
59
+ #
60
+ # workspace:
61
+ #
62
+ # :MOD_NAME-:ENV-:REGION-:INSTANCE
63
+ #
64
+ def strip(string)
65
+ string.sub(/^-+/,'').sub(/-+$/,'') # remove leading and trailing -
66
+ .sub(%r{/+$},'') # only remove trailing / or else /home/ec2-user => home/ec2-user
37
67
  end
38
68
 
39
69
  def var_value(name)
@@ -48,5 +78,18 @@ module Terraspace::Plugin::Expander
48
78
  def env
49
79
  Terraspace.env
50
80
  end
81
+
82
+ def type_instance
83
+ [type, instance].reject { |s| s.blank? }.join('-')
84
+ end
85
+
86
+ def instance
87
+ @mod.options[:instance] || ''
88
+ end
89
+ alias_method :instance_option, :instance
90
+
91
+ def cache_root
92
+ Terraspace.cache_root
93
+ end
51
94
  end
52
- end
95
+ end
@@ -0,0 +1,15 @@
1
+ module Terraspace::Plugin
2
+ module InferProvider
3
+ # Examples:
4
+ # TerraspacePluginAws => aws
5
+ # TerraspacePluginAzurerm => azurerm
6
+ # TerraspacePluginGoogle => google
7
+ #
8
+ # If multiple clouds used in a single Terraspace project. The TS_PROVIDER_EXPANSION env var provides a way to
9
+ # change it. Can possibly use config hooks to set different values based on the module being deployed:
10
+ # https://terraspace.cloud/docs/config/hooks/
11
+ def provider
12
+ ENV['TS_PROVIDER'] || self.class.to_s.split('::').first.sub('TerraspacePlugin','').underscore
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,5 @@
1
+ module Terraspace::Plugin::Layer
2
+ module Interface
3
+ include Terraspace::Plugin::InferProvider
4
+ end
5
+ end
@@ -21,7 +21,7 @@ module Terraspace
21
21
  elsif exist?("variables.tf.json")
22
22
  JSON.load(read("variables.tf.json"))
23
23
  else
24
- logger.warn "WARN: no variables.tf or variables.tf.json found in: #{@mod.cache_build_dir}"
24
+ logger.warn "WARN: no variables.tf or variables.tf.json found in: #{@mod.cache_dir}"
25
25
  ENV['TS_TEST'] ? raise : exit
26
26
  end
27
27
  end
@@ -30,7 +30,7 @@ module Terraspace
30
30
  def load_hcl_variables
31
31
  HclParser.load(read("variables.tf"))
32
32
  rescue Racc::ParseError => e
33
- logger.error "ERROR: Unable to parse the #{Util.pretty_path(@mod.cache_build_dir)}/variables.tf file".color(:red)
33
+ logger.error "ERROR: Unable to parse the #{Util.pretty_path(@mod.cache_dir)}/variables.tf file".color(:red)
34
34
  logger.error "and generate the starter tfvars file. This is probably due to a complex variable type."
35
35
  logger.error "#{e.class}: #{e.message}"
36
36
  puts
@@ -48,12 +48,12 @@ module Terraspace
48
48
  memoize :dest_path
49
49
 
50
50
  def exist?(file)
51
- path = "#{@mod.cache_build_dir}/#{file}"
51
+ path = "#{@mod.cache_dir}/#{file}"
52
52
  File.exist?(path)
53
53
  end
54
54
 
55
55
  def read(file)
56
- path = "#{@mod.cache_build_dir}/#{file}"
56
+ path = "#{@mod.cache_dir}/#{file}"
57
57
  logger.info "Reading: #{Util.pretty_path(path)}"
58
58
  IO.read(path)
59
59
  end
@@ -0,0 +1,53 @@
1
+ module Terraspace::Terraform
2
+ class Api
3
+ extend Memoist
4
+ include Client
5
+ include Terraspace::Util::Logging
6
+
7
+ def initialize(mod, remote)
8
+ @mod = mod
9
+ @organization = remote['organization']
10
+ @workspace_name = remote['workspaces']['name']
11
+ end
12
+
13
+ # Docs: https://www.terraform.io/docs/cloud/api/workspaces.html
14
+ def set_working_dir
15
+ working_directory = @mod.cache_dir.sub("#{Terraspace.root}/", '')
16
+ return if working_directory == workspace['attributes']['working-directory']
17
+
18
+ payload = {
19
+ data: {
20
+ attributes: {
21
+ "working-directory": working_directory
22
+ },
23
+ type: "workspaces"
24
+ }
25
+ }
26
+ http.patch("organizations/#{@organization}/workspaces/#{@workspace_name}", payload)
27
+ end
28
+
29
+ def set_env_vars
30
+ Vars.new(@mod, workspace).run
31
+ end
32
+
33
+ def workspace(options={})
34
+ payload = http.get("organizations/#{@organization}/workspaces/#{@workspace_name}")
35
+ # Note only way to get here is to bypass init. Example:
36
+ #
37
+ # terraspace up demo --no-init
38
+ #
39
+ unless payload || options[:exit_on_fail] == false
40
+ logger.error "ERROR: Unable to find the workspace. The workspace may not exist. Or the Terraform token may be invalid. Please double check your Terraform token.".color(:red)
41
+ exit 1
42
+ end
43
+ return unless payload
44
+ payload['data']
45
+ end
46
+ memoize :workspace
47
+
48
+ def destroy_workspace
49
+ # resp payload from delete operation is nil
50
+ http.delete("/organizations/#{@organization}/workspaces/#{@workspace_name}")
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,10 @@
1
+ class Terraspace::Terraform::Api
2
+ module Client
3
+ extend Memoist
4
+
5
+ def http
6
+ Http.new
7
+ end
8
+ memoize :http
9
+ end
10
+ end
@@ -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