tfctl 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../hash.rb'
4
+ require_relative 'error.rb'
5
+ require 'yaml'
6
+ require 'json'
7
+
8
+ module Tfctl
9
+ class Config
10
+ include Enumerable
11
+ attr_reader :config
12
+
13
+ def initialize(config_name:, yaml_config:, aws_org_config:, use_cache: false)
14
+ cache_file = "#{PROJECT_ROOT}/.tfctl/#{config_name}_cache.yaml"
15
+
16
+ # Get configuration. Either load from cache or process fresh.
17
+ if use_cache
18
+ @config = read_cache(cache_file)
19
+ else
20
+ @config = load_config(config_name, yaml_config, aws_org_config)
21
+ write_cache(cache_file)
22
+ end
23
+ end
24
+
25
+ def [](key)
26
+ @config[key]
27
+ end
28
+
29
+ def fetch(key, default)
30
+ @config.fetch(key, default)
31
+ end
32
+
33
+ def each(&block)
34
+ @config.each(&block)
35
+ end
36
+
37
+ def key?(key)
38
+ @config.key?(key)
39
+ end
40
+
41
+ alias has_key? key?
42
+
43
+ def to_yaml
44
+ @config.to_yaml
45
+ end
46
+
47
+ def to_json(*_args)
48
+ @config.to_json
49
+ end
50
+
51
+ # Filters accounts by account property
52
+ def find_accounts(property_name, property_value)
53
+ output =[]
54
+ @config[:accounts].each do |account|
55
+ if account[property_name] == property_value
56
+ output << account
57
+ end
58
+ end
59
+
60
+ if output.empty?
61
+ raise Tfctl::Error, "Account not found with #{property_name}: #{property_value}"
62
+ end
63
+
64
+ output
65
+ end
66
+
67
+ def find_accounts_regex(property_name, expr)
68
+ output =[]
69
+ @config[:accounts].each do |account|
70
+ begin
71
+ if account[property_name] =~ /#{expr}/
72
+ output << account
73
+ end
74
+ rescue RegexpError => e
75
+ raise Tfctl::Error, "Regexp: #{e}"
76
+ end
77
+ end
78
+
79
+ if output.empty?
80
+ raise Tfctl::Error, "Account not found with #{property_name} matching regex: #{expr}"
81
+ end
82
+
83
+ output
84
+ end
85
+
86
+
87
+ private
88
+
89
+ # Retrieves AWS Organizations data and merges it with data from yaml config.
90
+ def load_config(config_name, yaml_config, aws_org_config)
91
+
92
+ # AWS Organizations data
93
+ config = aws_org_config
94
+ # Merge organization sections from yaml file
95
+ config = merge_accounts_config(config, yaml_config)
96
+ # Import remaining parameters from yaml file
97
+ config = import_yaml_config(config, yaml_config)
98
+ # Set excluded property on any excluded accounts
99
+ config = mark_excluded_accounts(config)
100
+ # Remove any profiles that are unset
101
+ config = remove_unset_profiles(config)
102
+ # Set config name property (based on yaml config file name)
103
+ config[:config_name] = config_name
104
+ config
105
+ end
106
+
107
+ def write_cache(cache_file)
108
+ FileUtils.mkdir_p File.dirname(cache_file)
109
+ File.open(cache_file, 'w') { |f| f.write to_yaml }
110
+ end
111
+
112
+ def read_cache(cache_file)
113
+ unless File.exist?(cache_file)
114
+ raise Tfctl::Error, "Cached configuration not found in: #{cache_file}"
115
+ end
116
+
117
+ YAML.load_file(cache_file)
118
+ end
119
+
120
+ # Sets :excluded property on any excluded accounts
121
+ def mark_excluded_accounts(config)
122
+ return config unless config.key?(:exclude_accounts)
123
+
124
+ config[:accounts].each_with_index do |account, idx|
125
+ config[:accounts][idx][:excluded] = config[:exclude_accounts].include?(account[:name]) ? true : false
126
+ end
127
+
128
+ config
129
+ end
130
+
131
+ def remove_unset_profiles(config)
132
+ config[:accounts].each do |account|
133
+ profiles_to_unset = []
134
+ account[:profiles].each do |profile|
135
+ if profile =~ /\.unset$/
136
+ profiles_to_unset << profile
137
+ profiles_to_unset << profile.chomp('.unset')
138
+ end
139
+ end
140
+ account[:profiles] = account[:profiles] - profiles_to_unset
141
+ end
142
+ config
143
+ end
144
+
145
+ # Import yaml config other than organisation defaults sections which are merged elsewhere.
146
+ def import_yaml_config(config, yaml_config)
147
+ yaml_config.delete(:organization_root)
148
+ yaml_config.delete(:organization_units)
149
+ yaml_config.delete(:account_overrides)
150
+ config.merge(yaml_config)
151
+ end
152
+
153
+ # Merge AWS Organizations accounts config with defaults from yaml config
154
+ def merge_accounts_config(config, yaml_config)
155
+
156
+ config[:accounts].each_with_index do |account_config, idx|
157
+ account_name = account_config[:name].to_sym
158
+ account_ou_parents = account_config[:ou_parents]
159
+
160
+ # merge any root settings
161
+ account_config = account_config.deep_merge(yaml_config[:organization_root])
162
+
163
+ # merge all OU levels settings
164
+ account_ou_parents.each_with_index do |_, i|
165
+ account_ou = account_ou_parents[0..i].join('/').to_sym
166
+ if yaml_config[:organization_units].key?(account_ou)
167
+ account_config = account_config.deep_merge(yaml_config[:organization_units][account_ou])
168
+ end
169
+ end
170
+
171
+ # merge any account overrides
172
+ if yaml_config[:account_overrides].key?(account_name)
173
+ account_config = account_config.deep_merge(yaml_config[:account_overrides][account_name])
174
+ end
175
+
176
+ config[:accounts][idx] = account_config
177
+ end
178
+ config
179
+ end
180
+
181
+ end
182
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tfctl
4
+ class Error < StandardError
5
+ end
6
+
7
+ class ValidationError < StandardError
8
+ attr_reader :issues
9
+
10
+ def initialize(message, issues = [])
11
+ super(message)
12
+ @issues = issues
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'fileutils'
5
+ require 'shellwords'
6
+ require_relative 'error.rb'
7
+
8
+ module Tfctl
9
+ module Executor
10
+ module_function
11
+
12
+ # Execute terraform command
13
+ def run(account_name:, config_name:, log:, cmd: nil, argv: [], unbuffered: true)
14
+
15
+ # Use bin/terraform from a project dir if available
16
+ # Otherwise rely on PATH.
17
+ if cmd.nil?
18
+ cmd = File.exist?("#{PROJECT_ROOT}/bin/terraform") ? "#{PROJECT_ROOT}/bin/terraform" : 'terraform'
19
+ end
20
+
21
+ # Fail if there are no arguments for terraform and show terraform -help
22
+ if argv.empty?
23
+ help = `#{cmd} -help`.lines.to_a[1..-1].join
24
+ raise Tfctl::Error, "Missing terraform command.\n #{help}"
25
+ end
26
+
27
+ path = "#{PROJECT_ROOT}/.tfctl/#{config_name}/#{account_name}"
28
+ cwd = FileUtils.pwd
29
+ plan_file = "#{path}/tfplan"
30
+ semaphore = Mutex.new
31
+ output = []
32
+
33
+ # Extract terraform sub command from argument list
34
+ args = Array.new(argv)
35
+ subcmd = args[0]
36
+ args.delete_at(0)
37
+
38
+ # Enable plan file for `plan` and `apply` sub commands
39
+ args += plan_file_args(plan_file, subcmd)
40
+
41
+ # Create the command
42
+ exec = [cmd] + [subcmd] + args
43
+
44
+ # Set environment variables for Terraform
45
+ env = {
46
+ 'TF_INPUT' => '0',
47
+ 'CHECKPOINT_DISABLE' => '1',
48
+ 'TF_IN_AUTOMATION' => 'true',
49
+ # 'TF_LOG' => 'TRACE'
50
+ }
51
+
52
+ log.debug "#{account_name}: Executing: #{exec.shelljoin}"
53
+
54
+ FileUtils.cd path
55
+ Open3.popen3(env, exec.shelljoin) do |stdin, stdout, stderr, wait_thr|
56
+ stdin.close_write
57
+
58
+ # capture stdout and stderr in separate threads to prevent deadlocks
59
+ Thread.new do
60
+ stdout.each do |line|
61
+ semaphore.synchronize do
62
+ unbuffered ? log.info("#{account_name}: #{line.chomp}") : output << ['info', line]
63
+ end
64
+ end
65
+ end
66
+ Thread.new do
67
+ stderr.each do |line|
68
+ semaphore.synchronize do
69
+ unbuffered ? log.error("#{account_name}: #{line.chomp}") : output << ['error', line]
70
+ end
71
+ end
72
+ end
73
+
74
+ status = wait_thr.value
75
+
76
+ # log the output
77
+ output.each do |line|
78
+ log.send(line[0], "#{account_name}: #{line[1].chomp}")
79
+ end
80
+
81
+ FileUtils.cd cwd
82
+ FileUtils.rm_f plan_file if args[0] == 'apply' # tidy up the plan file
83
+
84
+ unless status.exitstatus.zero?
85
+ raise Tfctl::Error, "#{cmd} failed with exit code: #{status.exitstatus}"
86
+ end
87
+ end
88
+ end
89
+
90
+ # Adds plan file to `plan` and `apply` sub commands
91
+ def plan_file_args(plan_file, subcmd)
92
+ return ["-out=#{plan_file}"] if subcmd == 'plan'
93
+
94
+ if subcmd == 'apply'
95
+ raise Tfctl::Error, "Plan file not found in #{plan_file}. Run plan first." unless File.exist?(plan_file)
96
+
97
+ return [plan_file.to_s]
98
+ end
99
+
100
+ return []
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ # Generates top level Terraform configuration for an account.
6
+
7
+ module Tfctl
8
+ module Generator
9
+ module_function
10
+
11
+ def write_json_block(path, block)
12
+ File.open(path, 'w') do |f|
13
+ f.write(JSON.pretty_generate(block) + "\n")
14
+ end
15
+ end
16
+
17
+ def make(account:, config:)
18
+ target_dir = "#{PROJECT_ROOT}/.tfctl/#{config[:config_name]}/#{account[:name]}"
19
+ tf_version = config.fetch(:tf_required_version, '>= 0.12.0')
20
+ aws_provider_version = config.fetch(:aws_provider_version, '>= 2.14')
21
+
22
+ FileUtils.mkdir_p target_dir
23
+
24
+ terraform_block = {
25
+ 'terraform' => {
26
+ 'required_version' => tf_version,
27
+ 'backend' => {
28
+ 's3' => {
29
+ 'bucket' => config[:tf_state_bucket],
30
+ 'key' => "#{account[:name]}/tfstate",
31
+ 'region' => config[:tf_state_region],
32
+ 'role_arn' => config[:tf_state_role_arn],
33
+ 'dynamodb_table' => config[:tf_state_dynamodb_table],
34
+ 'encrypt' => 'true',
35
+ },
36
+ },
37
+ },
38
+ }
39
+ write_json_block("#{target_dir}/terraform.tf.json", terraform_block)
40
+
41
+ provider_block = {
42
+ 'provider' => {
43
+ 'aws' => {
44
+ 'version' => aws_provider_version,
45
+ 'region' => account[:region],
46
+ 'assume_role' => {
47
+ 'role_arn' => "arn:aws:iam::#{account[:id]}:role/#{account[:tf_execution_role]}",
48
+ },
49
+ },
50
+ },
51
+ }
52
+ write_json_block("#{target_dir}/provider.tf.json", provider_block)
53
+
54
+ vars_block = {
55
+ 'variable' => {
56
+ 'config' => {
57
+ 'type' => 'string',
58
+ },
59
+ },
60
+ }
61
+ write_json_block("#{target_dir}/vars.tf.json", vars_block)
62
+
63
+ # config is passed to profiles as a json encoded string. It can be
64
+ # decoded in profile using jsondecode() function.
65
+ config_block = { 'config' => config.to_json }
66
+ write_json_block("#{target_dir}/config.auto.tfvars.json", config_block)
67
+
68
+ FileUtils.rm Dir.glob("#{target_dir}/profile_*.tf.json")
69
+
70
+ account[:profiles].each do |profile|
71
+ profile_block = {
72
+ 'module' => {
73
+ profile => {
74
+ 'source' => "../../../profiles/#{profile}",
75
+ 'config' => '${var.config}',
76
+ 'providers' => {
77
+ 'aws' => 'aws',
78
+ },
79
+ },
80
+ },
81
+ }
82
+
83
+ write_json_block("#{target_dir}/profile_#{profile}.tf.json", profile_block)
84
+ end
85
+
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ module Tfctl
6
+ class Logger
7
+
8
+ def initialize(log_level)
9
+ @outlog = ::Logger.new(STDOUT)
10
+
11
+ self.level = log_level
12
+
13
+ @outlog.formatter = proc do |severity, _datetime, _progname, msg|
14
+ # "#{datetime.iso8601} #{severity.downcase}: #{msg}\n"
15
+ "#{severity.downcase}: #{msg}\n"
16
+ end
17
+ end
18
+
19
+ def level=(level)
20
+ @outlog.level = level
21
+ end
22
+
23
+ def level
24
+ @outlog.level
25
+ end
26
+
27
+ def debug(msg)
28
+ log(:debug, msg)
29
+ end
30
+
31
+ def info(msg)
32
+ log(:info, msg)
33
+ end
34
+
35
+ def warn(msg)
36
+ log(:warn, msg)
37
+ end
38
+
39
+ def error(msg)
40
+ log(:error, msg)
41
+ end
42
+
43
+ def fatal(msg)
44
+ log(:fatal, msg)
45
+ end
46
+
47
+ def log(level, msg)
48
+ @outlog.send(level, msg)
49
+ end
50
+
51
+ end
52
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json_schemer'
4
+ require_relative 'error.rb'
5
+
6
+ # Config validator using JSON schema
7
+
8
+ module Tfctl
9
+ module Schema
10
+ class << self
11
+
12
+ def validate(data)
13
+ schemer = JSONSchemer.schema(main_schema)
14
+ issues = []
15
+ schemer.validate(data).each do |issue|
16
+ issues << {
17
+ details: issue['details'],
18
+ data_pointer: issue['data_pointer'],
19
+ }
20
+ end
21
+
22
+ return if issues.empty?
23
+
24
+ raise Tfctl::ValidationError.new('Config validation failed', issues)
25
+ end
26
+
27
+ private
28
+
29
+ def main_schema
30
+ iam_arn_pattern = 'arn:aws:iam:[a-z\-0-9]*:[0-9]{12}:[a-zA-Z\/+@=.,]*'
31
+
32
+ # rubocop:disable Layout/AlignHash
33
+ {
34
+ 'type' => 'object',
35
+ 'properties' => {
36
+ 'tf_state_bucket' => { 'type' => 'string' },
37
+ 'tf_state_role_arn' => {
38
+ 'type' => 'string',
39
+ 'pattern' => iam_arn_pattern,
40
+ },
41
+ 'tf_state_dynamodb_table' => { 'type' => 'string' },
42
+ 'tf_state_region' => { 'type' => 'string' },
43
+ 'tf_required_version' => { 'type' => 'string' },
44
+ 'aws_provider_version' => { 'type' => 'string' },
45
+ 'tfctl_role_arn' => {
46
+ 'type' => 'string',
47
+ 'pattern' => iam_arn_pattern,
48
+ },
49
+ 'data' => { 'type' => 'object' },
50
+ 'exclude_accounts' => { 'type' => 'array' },
51
+ 'organization_root' => org_schema,
52
+ 'organization_units' => org_schema,
53
+ 'account_overrides' => org_schema,
54
+ },
55
+ 'required' => %w[
56
+ tf_state_bucket
57
+ tf_state_role_arn
58
+ tf_state_dynamodb_table
59
+ tf_state_region
60
+ tfctl_role_arn
61
+ ],
62
+ 'additionalProperties' => false,
63
+ }
64
+ # rubocop:enable Layout/AlignHash
65
+ end
66
+
67
+ def org_schema
68
+ {
69
+ 'type' => 'object',
70
+ 'properties' => {
71
+ 'profiles' => { 'type'=> 'array' },
72
+ 'data' => { 'type'=> 'object' },
73
+ 'tf_execution_role' => { 'type'=> 'string' },
74
+ 'region' => { 'type'=> 'string' },
75
+ },
76
+ }
77
+ end
78
+ end
79
+ end
80
+ end