terraspace 0.2.3 → 0.3.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +14 -1
  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 +5 -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 -9
  14. data/lib/terraspace/booter.rb +9 -0
  15. data/lib/terraspace/builder.rb +59 -22
  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 +18 -2
  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 +46 -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/backend.rb +10 -0
  39. data/lib/terraspace/compiler/builder.rb +5 -4
  40. data/lib/terraspace/compiler/cleaner.rb +1 -1
  41. data/lib/terraspace/compiler/cleaner/backend_change.rb +21 -7
  42. data/lib/terraspace/compiler/commands_concern.rb +18 -0
  43. data/lib/terraspace/compiler/dirs_concern.rb +47 -0
  44. data/lib/terraspace/compiler/dsl/syntax/helpers/common.rb +26 -1
  45. data/lib/terraspace/core.rb +11 -2
  46. data/lib/terraspace/dependency/graph.rb +139 -0
  47. data/lib/terraspace/dependency/node.rb +38 -0
  48. data/lib/terraspace/dependency/registry.rb +11 -0
  49. data/lib/terraspace/logger.rb +6 -18
  50. data/lib/terraspace/logger/formatter.rb +13 -0
  51. data/lib/terraspace/mod.rb +7 -1
  52. data/lib/terraspace/seeder/where.rb +6 -2
  53. data/lib/terraspace/shell.rb +97 -0
  54. data/lib/terraspace/terraform/api.rb +7 -45
  55. data/lib/terraspace/terraform/api/base.rb +7 -0
  56. data/lib/terraspace/terraform/api/client.rb +23 -3
  57. data/lib/terraspace/terraform/api/http.rb +14 -34
  58. data/lib/terraspace/terraform/api/http/concern.rb +10 -0
  59. data/lib/terraspace/terraform/api/runs.rb +28 -0
  60. data/lib/terraspace/terraform/api/token.rb +65 -0
  61. data/lib/terraspace/terraform/api/var.rb +20 -6
  62. data/lib/terraspace/terraform/api/vars.rb +2 -1
  63. data/lib/terraspace/terraform/api/workspace.rb +98 -0
  64. data/lib/terraspace/terraform/args/default.rb +48 -21
  65. data/lib/terraspace/terraform/cloud/runs.rb +13 -0
  66. data/lib/terraspace/terraform/cloud/runs/base.rb +33 -0
  67. data/lib/terraspace/terraform/cloud/runs/item_presenter.rb +37 -0
  68. data/lib/terraspace/terraform/cloud/runs/lister.rb +22 -0
  69. data/lib/terraspace/terraform/cloud/runs/pruner.rb +109 -0
  70. data/lib/terraspace/terraform/cloud/sync.rb +41 -0
  71. data/lib/terraspace/terraform/cloud/syncer.rb +52 -0
  72. data/lib/terraspace/terraform/cloud/workspace.rb +10 -21
  73. data/lib/terraspace/terraform/hooks/builder.rb +1 -1
  74. data/lib/terraspace/terraform/remote_state/fetcher.rb +143 -0
  75. data/lib/terraspace/terraform/remote_state/marker/output.rb +39 -0
  76. data/lib/terraspace/terraform/remote_state/marker/pretty_tracer.rb +37 -0
  77. data/lib/terraspace/terraform/remote_state/output_proxy.rb +29 -0
  78. data/lib/terraspace/terraform/runner.rb +24 -14
  79. data/lib/terraspace/util.rb +1 -5
  80. data/lib/terraspace/util/pretty.rb +18 -0
  81. data/lib/terraspace/version.rb +1 -1
  82. data/spec/fixtures/fetcher/c1.json +37 -0
  83. data/spec/fixtures/parser/cache_dirs/all/01-test.auto.tfvars +5 -0
  84. data/spec/fixtures/parser/cache_dirs/depends_on/01-test.auto.tfvars +2 -0
  85. data/spec/fixtures/parser/cache_dirs/output/01-test.auto.tfvars +2 -0
  86. data/spec/fixtures/summary/down.log +12 -0
  87. data/spec/fixtures/summary/output.log +5 -0
  88. data/spec/fixtures/summary/plan/error.log +20 -0
  89. data/spec/fixtures/summary/plan/success.log +17 -0
  90. data/spec/fixtures/summary/show.log +22 -0
  91. data/spec/fixtures/summary/up/error.log +13 -0
  92. data/spec/fixtures/summary/up/success.log +63 -0
  93. data/spec/fixtures/summary/validate/error.log +13 -0
  94. data/spec/fixtures/summary/validate/success.log +5 -0
  95. data/spec/terraspace/all/grapher_spec.rb +38 -0
  96. data/spec/terraspace/all/runner_spec.rb +48 -0
  97. data/spec/terraspace/all/summary_spec.rb +93 -0
  98. data/spec/terraspace/dependency/graph_spec.rb +162 -0
  99. data/spec/terraspace/seeder_spec.rb +0 -1
  100. data/spec/terraspace/terraform/remote_state/fetcher_spec.rb +52 -0
  101. data/terraspace.gemspec +5 -1
  102. metadata +138 -5
  103. data/lib/terraspace/terraform/cloud.rb +0 -25
  104. data/lib/terraspace/util/sh.rb +0 -19
@@ -0,0 +1,38 @@
1
+ module Terraspace::Dependency
2
+ class Node
3
+ attr_reader :name
4
+ attr_accessor :children, :parents, :filtered
5
+ def initialize(name)
6
+ @name = name
7
+ @children, @parents = Set.new, Set.new
8
+ end
9
+
10
+ def highlighted?
11
+ @filtered
12
+ end
13
+
14
+ def inspect
15
+ @name
16
+ end
17
+
18
+ def parent!(parent)
19
+ @parents << parent
20
+ parent.children << self
21
+ end
22
+
23
+ class << self
24
+ @@nodes = []
25
+ def find_or_create_by(name:)
26
+ node = find_by(name: name)
27
+ return node if node
28
+ node = Node.new(name)
29
+ @@nodes << node
30
+ node
31
+ end
32
+
33
+ def find_by(name:)
34
+ @@nodes.find { |n| n.name == name }
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,11 @@
1
+ module Terraspace::Dependency
2
+ class Registry
3
+ cattr_accessor :data, default: Set.new
4
+
5
+ class << self
6
+ def register(parent_name, child_name)
7
+ @@data << "#{parent_name}:#{child_name}"
8
+ end
9
+ end
10
+ end
11
+ end
@@ -2,25 +2,13 @@ require 'logger'
2
2
 
3
3
  module Terraspace
4
4
  class Logger < ::Logger
5
- # Only need to override the add method as the other calls all lead to it.
6
- def add(severity, message = nil, progname = nil)
7
- # Taken from Logger#add source
8
- # https://ruby-doc.org/stdlib-2.5.1/libdoc/logger/rdoc/Logger.html#method-i-add
9
- if message.nil?
10
- if block_given?
11
- message = yield
12
- else
13
- message = progname
14
- progname = @progname
15
- end
5
+ def format_message(severity, datetime, progname, msg)
6
+ line = if @logdev.dev == $stdout || @logdev.dev == $stderr
7
+ msg # super simple format if stdout
8
+ else
9
+ super # use the configured formatter
16
10
  end
17
-
18
- super # original logic
19
- end
20
-
21
- # plain formatting
22
- def format_message(severity, timestamp, progname, msg)
23
- "#{msg}\n"
11
+ line =~ /\n$/ ? line : "#{line}\n"
24
12
  end
25
13
  end
26
14
  end
@@ -0,0 +1,13 @@
1
+ class Terraspace::Logger
2
+ class Formatter < ::Logger::Formatter
3
+ def call(severity, time, progname, msg)
4
+ # careful changing the format. All::Summary uses a regexp on this format to remove the timestamp
5
+ "[#{format_datetime(time)} ##{Process.pid} #{progname}]: #{msg}"
6
+ end
7
+
8
+ private
9
+ def format_datetime(time)
10
+ time.strftime("%Y-%m-%dT%H:%M:%S")
11
+ end
12
+ end
13
+ end
@@ -10,6 +10,7 @@ module Terraspace
10
10
  include Terraspace::Util
11
11
 
12
12
  attr_reader :name, :consider_stacks, :instance, :options
13
+ attr_accessor :resolved # dependencies resolved
13
14
  def initialize(name, options={})
14
15
  @name, @options = placeholder(name), options
15
16
  @consider_stacks = options[:consider_stacks].nil? ? true : options[:consider_stacks]
@@ -74,6 +75,10 @@ module Terraspace
74
75
  end
75
76
  end
76
77
 
78
+ def exist?
79
+ !!root
80
+ end
81
+
77
82
  # Relative folder path without app or vendor. For example, the actual location can be found in a couple of places
78
83
  #
79
84
  # app/modules/vpc
@@ -86,8 +91,9 @@ module Terraspace
86
91
  # modules/vpc
87
92
  #
88
93
  def build_dir(disable_instance: false)
89
- if !disable_instance && !@instance.nil?
94
+ if !@instance.nil? && type_dir == "stacks" && !disable_instance
90
95
  # add _ in front so instance doesnt collide with other default stacks
96
+ # never add for app/modules sources
91
97
  instance_name = [name, @instance].compact.join('.')
92
98
  else
93
99
  instance_name = name
@@ -20,11 +20,15 @@ class Terraspace::Seeder
20
20
  end
21
21
 
22
22
  def app_path
23
- "#{Terraspace.root}/app/#{@mod.build_dir}/tfvars/#{Terraspace.env}.tfvars"
23
+ "#{Terraspace.root}/app/#{@mod.build_dir}/tfvars/#{seed_file}.tfvars"
24
24
  end
25
25
 
26
26
  def seed_path
27
- "#{Terraspace.root}/seed/tfvars/#{@mod.build_dir}/#{Terraspace.env}.tfvars"
27
+ "#{Terraspace.root}/seed/tfvars/#{@mod.build_dir}/#{seed_file}.tfvars"
28
+ end
29
+
30
+ def seed_file
31
+ @options[:instance] || Terraspace.env
28
32
  end
29
33
  end
30
34
  end
@@ -0,0 +1,97 @@
1
+ require 'open3'
2
+
3
+ module Terraspace
4
+ class Shell
5
+ include Util::Logging
6
+
7
+ def initialize(mod, command, options={})
8
+ @mod, @command, @options = mod, command, options
9
+ @error_type, @error_messages = nil, ''
10
+ end
11
+
12
+ # requires @mod to be set
13
+ def run
14
+ env = @options[:env] || {}
15
+ env.stringify_keys!
16
+
17
+ # quiet useful for RemoteState::Fetcher
18
+ msg = "=> #{@command}"
19
+ @options[:quiet] ? logger.debug(msg) : logger.info(msg)
20
+ return if ENV['TS_TEST']
21
+
22
+ Open3.popen3(env, @command, chdir: @mod.cache_dir) do |stdin, stdout, stderr, wait_thread|
23
+ mimic_terraform_input(stdin, stdout)
24
+ while err = stderr.gets
25
+ @error_type ||= known_error_type(err)
26
+ if @error_type
27
+ @error_messages << err
28
+ else
29
+ # Sometimes may print a "\e[31m\n" which like during dependencies fetcher init
30
+ # suppress it so dont get a bunch of annoying "newlines"
31
+ next if err == "\e[31m\n" && @options[:suppress_error_color]
32
+ logger.error(err)
33
+ end
34
+ end
35
+
36
+ status = wait_thread.value.exitstatus
37
+ exit_status(status)
38
+ end
39
+ end
40
+
41
+ def known_error_type(err)
42
+ if reinitialization_required?(err)
43
+ :reinitialization_required
44
+ elsif bucket_not_found?(err)
45
+ :bucket_not_found
46
+ end
47
+ end
48
+
49
+ def bucket_not_found?(err)
50
+ # Message is included in aws, azurerm, and google. See: https://bit.ly/3iOKDri
51
+ err.include?("Failed to get existing workspaces")
52
+ end
53
+
54
+ def reinitialization_required?(err)
55
+ err.include?("reinitialization required") ||
56
+ err.include?("terraform init") ||
57
+ err.include?("require reinitialization")
58
+ end
59
+
60
+ def exit_status(status)
61
+ return if status == 0
62
+
63
+ exit_on_fail = @options[:exit_on_fail].nil? ? true : @options[:exit_on_fail]
64
+ if @error_type == :reinitialization_required
65
+ raise InitRequiredError.new(@error_messages)
66
+ elsif @error_type == :bucket_not_found
67
+ raise BucketNotFoundError.new(@error_messages)
68
+ elsif exit_on_fail
69
+ logger.error "Error running command: #{@command}".color(:red)
70
+ exit status
71
+ end
72
+ end
73
+
74
+ # Terraform doesnt seem to stream the line that prompts with "Enter a value:" when using Open3.popen3
75
+ # Hack around it by mimicking the "Enter a value:" prompt
76
+ #
77
+ # Note: system does stream the prompt but using Open3.popen3 so we can capture output to save to logs.
78
+ def mimic_terraform_input(stdin, stdout)
79
+ shown = false
80
+ patterns = [
81
+ "Only 'yes' will be accepted", # prompt for apply. can happen on apply
82
+ "\e[0m\e[1mvar.", # prompts for variable input. can happen on plan or apply. looking for bold marker also in case "var." shows up somewhere else
83
+ ]
84
+ while out = stdout.gets
85
+ logger.info(out) unless shown && out.include?("Enter a value:")
86
+ shown = false if out.include?("Enter a value:") # reset shown in case of multiple input prompts
87
+
88
+ # Sometimes stdout doesnt flush and show "Enter a value: ", so mimic it
89
+ if patterns.any? { |pattern| out.include?(pattern) }
90
+ print " Enter a value: ".bright
91
+ shown = true
92
+ stdin.write_nonblock($stdin.gets)
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -1,58 +1,20 @@
1
1
  module Terraspace::Terraform
2
2
  class Api
3
3
  extend Memoist
4
- include Client
5
- include Terraspace::Util::Logging
6
4
 
7
5
  def initialize(mod, remote)
8
- @mod = mod
9
- @organization = remote['organization']
10
- @workspace_name = remote['workspaces']['name']
6
+ @mod, @remote = mod, remote
11
7
  end
12
8
 
13
- # Docs: https://www.terraform.io/docs/cloud/api/workspaces.html
14
- def set_working_dir
15
- return if working_directory == workspace['attributes']['working-directory']
16
-
17
- payload = {
18
- data: {
19
- attributes: {
20
- "working-directory": working_directory
21
- },
22
- type: "workspaces"
23
- }
24
- }
25
- http.patch("organizations/#{@organization}/workspaces/#{@workspace_name}", payload)
26
- end
27
-
28
- def working_directory
29
- cache_dir = @mod.cache_dir.sub("#{Terraspace.root}/", '')
30
- relative_root = Terraspace.config.cloud.relative_root # prepended to TFC Working Directory
31
- relative_root ? "#{relative_root}/#{cache_dir}" : cache_dir
32
- end
33
-
34
- def set_env_vars
35
- Vars.new(@mod, workspace).run
36
- end
37
-
38
- def workspace(options={})
39
- payload = http.get("organizations/#{@organization}/workspaces/#{@workspace_name}")
40
- # Note only way to get here is to bypass init. Example:
41
- #
42
- # terraspace up demo --no-init
43
- #
44
- unless payload || options[:exit_on_fail] == false
45
- 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)
46
- exit 1
47
- end
48
- return unless payload
49
- payload['data']
9
+ def workspace
10
+ Workspace.new(@mod, @remote['organization'], @remote['workspaces']['name'])
50
11
  end
51
12
  memoize :workspace
52
13
 
53
- def destroy_workspace
54
- # resp payload from delete operation is nil
55
- http.delete("/organizations/#{@organization}/workspaces/#{@workspace_name}")
14
+ def runs
15
+ workspace_id = workspace.details['id']
16
+ Runs.new(workspace_id)
56
17
  end
18
+ memoize :runs
57
19
  end
58
20
  end
@@ -0,0 +1,7 @@
1
+ class Terraspace::Terraform::Api
2
+ class Base
3
+ extend Memoist
4
+ include Http::Concern
5
+ include Terraspace::Util::Logging
6
+ end
7
+ end
@@ -1,10 +1,30 @@
1
1
  class Terraspace::Terraform::Api
2
2
  module Client
3
3
  extend Memoist
4
+ def remote
5
+ backend["remote"]
6
+ end
7
+
8
+ def workspace_name
9
+ remote['workspaces']['name']
10
+ end
11
+
12
+ def build
13
+ Terraspace::Builder.new(@options).run
14
+ end
15
+ memoize :build
16
+
17
+ # backend may be overridden in classes including this Concern
18
+ def backend
19
+ Terraspace::Compiler::Backend::Parser.new(@mod).result
20
+ end
21
+ memoize :backend
4
22
 
5
- def http
6
- Http.new
23
+ # api may be overridden in classes including this Concern
24
+ def api
25
+ return unless backend.dig('remote','workspaces') # in case called by terraspace down demo -y --destroy-workspace with a non-remote backend
26
+ Terraspace::Terraform::Api.new(@mod, remote)
7
27
  end
8
- memoize :http
28
+ memoize :api
9
29
  end
10
30
  end
@@ -2,10 +2,10 @@ require 'net/http'
2
2
 
3
3
  class Terraspace::Terraform::Api
4
4
  class Http
5
- include Terraspace::Util::Logging
6
-
7
5
  API = ENV['TERRAFORM_API'] || 'https://app.terraform.io/api/v2'
6
+
8
7
  extend Memoist
8
+ include Terraspace::Util::Logging
9
9
 
10
10
  def get(path)
11
11
  request(Net::HTTP::Get, path)
@@ -63,44 +63,24 @@ class Terraspace::Terraform::Api
63
63
  end
64
64
 
65
65
  def load_json(res)
66
- if res.code == "200"
66
+ if ok?(res.code)
67
67
  JSON.load(res.body)
68
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
69
+ logger.info "Error: Non-successful http response status code: #{res.code}"
70
+ logger.info "headers: #{res.each_header.to_h.inspect}"
71
+ raise "TFC API called failed"
74
72
  end
75
73
  end
76
74
 
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
75
+ # Note: 422 is Unprocessable Entity. This means an invalid data payload was sent.
76
+ # We want that to error and raise
77
+ def ok?(http_code)
78
+ http_code =~ /^20/ ||
79
+ http_code =~ /^40/
80
+ end
98
81
 
99
- Please configure a Terraform token and try again.
100
- EOL
101
- exit 1
102
- end
103
- token
82
+ def token
83
+ Token.get
104
84
  end
105
85
  end
106
86
  end
@@ -0,0 +1,10 @@
1
+ class Terraspace::Terraform::Api::Http
2
+ module Concern
3
+ extend Memoist
4
+
5
+ def http
6
+ Terraspace::Terraform::Api::Http.new
7
+ end
8
+ memoize :http
9
+ end
10
+ end
@@ -0,0 +1,28 @@
1
+ class Terraspace::Terraform::Api
2
+ class Runs < Base
3
+ extend Memoist
4
+
5
+ attr_reader :workspace_id
6
+ def initialize(workspace_id)
7
+ @workspace_id = workspace_id
8
+ end
9
+
10
+ def list
11
+ payload = http.get("workspaces/#{@workspace_id}/runs")
12
+ payload['data'] if payload
13
+ end
14
+
15
+ def discard(id)
16
+ action("discard", id)
17
+ end
18
+
19
+ def cancel(id)
20
+ action("cancel", id)
21
+ end
22
+
23
+ def action(action, id)
24
+ payload = http.post("runs/#{id}/actions/#{action}")
25
+ payload['data'] if payload
26
+ end
27
+ end
28
+ end