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.
- checksums.yaml +7 -0
- data/.gitignore +13 -0
- data/.rspec +1 -0
- data/.rubocop.yml +79 -0
- data/.travis.yml +18 -0
- data/CHANGELOG.adoc +29 -0
- data/Gemfile +5 -0
- data/Guardfile +7 -0
- data/LICENSE +19 -0
- data/Makefile +36 -0
- data/README.adoc +176 -0
- data/bin/tfctl +223 -0
- data/docs/configuration.adoc +89 -0
- data/docs/control_tower.adoc +211 -0
- data/docs/creating_a_profile.adoc +191 -0
- data/docs/iam_permissions.adoc +38 -0
- data/docs/project_layout.adoc +65 -0
- data/examples/bootstrap/terraform-exec-role.template +24 -0
- data/examples/bootstrap/terraform-state.template +81 -0
- data/examples/bootstrap/tfctl-org-access.template +42 -0
- data/examples/control_tower/conf/example.yaml +80 -0
- data/examples/control_tower/modules/s3-bucket/main.tf +4 -0
- data/examples/control_tower/modules/s3-bucket/variables.tf +4 -0
- data/examples/control_tower/profiles/example-profile/data.tf +1 -0
- data/examples/control_tower/profiles/example-profile/main.tf +4 -0
- data/examples/control_tower/profiles/example-profile/variables.tf +12 -0
- data/lib/hash.rb +33 -0
- data/lib/tfctl.rb +10 -0
- data/lib/tfctl/aws_org.rb +112 -0
- data/lib/tfctl/config.rb +182 -0
- data/lib/tfctl/error.rb +15 -0
- data/lib/tfctl/executor.rb +103 -0
- data/lib/tfctl/generator.rb +88 -0
- data/lib/tfctl/logger.rb +52 -0
- data/lib/tfctl/schema.rb +80 -0
- data/lib/tfctl/version.rb +5 -0
- data/tfctl.gemspec +36 -0
- metadata +179 -0
data/lib/tfctl/config.rb
ADDED
@@ -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
|
data/lib/tfctl/error.rb
ADDED
@@ -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
|
data/lib/tfctl/logger.rb
ADDED
@@ -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
|
data/lib/tfctl/schema.rb
ADDED
@@ -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
|