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,38 @@
1
+ // Settings:
2
+ :idprefix:
3
+ :idseparator: -
4
+ ifndef::env-github[:icons: font]
5
+ ifdef::env-github,env-browser[]
6
+ :toc: macro
7
+ :toclevels: 1
8
+ endif::[]
9
+ ifdef::env-github[]
10
+ :branch: master
11
+ :status:
12
+ :outfilesuffix: .adoc
13
+ :!toc-title:
14
+ :caution-caption: :fire:
15
+ :important-caption: :exclamation:
16
+ :note-caption: :paperclip:
17
+ :tip-caption: :bulb:
18
+ :warning-caption: :warning:
19
+ endif::[]
20
+
21
+ = IAM roles
22
+
23
+ Tfctl usually requires three IAM roles to be configured:
24
+
25
+ * `TfctlRole` - read only access to AWS Organizations set up in the primary account.
26
+ * `TerraformStateRole` - access to remote state resources (S3, DynamoDB) in the
27
+ account where your state is stored (can be any account).
28
+ * `TerraformExecutionRole` - configured in all spoke accounts and used for executing Terraform.
29
+
30
+ The user executing tfctl needs permission to assume all three roles cross
31
+ account. Tfctl will assume roles automatically for you.
32
+
33
+ It's possible to configure different Terraform execution roles in different
34
+ spoke accounts based on OU or account names. This can be used to restrict
35
+ Terraform in certain accounts.
36
+
37
+ We usually set those roles up using CloudFormation as part of the bootstrapping
38
+ process. See example templates in `examples/bootstrap/`.
@@ -0,0 +1,65 @@
1
+ // Settings:
2
+ :idprefix:
3
+ :idseparator: -
4
+ ifndef::env-github[:icons: font]
5
+ ifdef::env-github,env-browser[]
6
+ :toc: macro
7
+ :toclevels: 1
8
+ endif::[]
9
+ ifdef::env-github[]
10
+ :branch: master
11
+ :status:
12
+ :outfilesuffix: .adoc
13
+ :!toc-title:
14
+ :caution-caption: :fire:
15
+ :important-caption: :exclamation:
16
+ :note-caption: :paperclip:
17
+ :tip-caption: :bulb:
18
+ :warning-caption: :warning:
19
+ endif::[]
20
+
21
+ = Project layout
22
+
23
+ Example project structure
24
+ ----
25
+ project_dir/
26
+ ├── conf
27
+ │   └── example.yaml
28
+ ├── modules
29
+ │   └── s3-bucket
30
+ │   ├── main.tf
31
+ │   └── variables.tf
32
+ └── profiles
33
+ └── example-profile
34
+ ├── data.tf
35
+ ├── main.tf
36
+ └── variables.tf
37
+ ----
38
+
39
+ toc::[]
40
+
41
+ == tfctl configuration file
42
+
43
+ Assigns Terraform profiles and configuration to accounts based on:
44
+
45
+ * Globally for all accounts
46
+ * Account's organization unit
47
+ * Individual accounts
48
+
49
+ The configuration data is exposed to terraform via a profile `config` variable.
50
+
51
+ It also defines Terraform and tfctl configuration such as state tracking and
52
+ what IAM roles to use.
53
+
54
+ == profiles
55
+
56
+ Profiles are re-usable collections of resources which can be applied to
57
+ accounts. They are implemented just like usual modules but provide an
58
+ intermediate bridge between re-usable modules and tfctl configuration (and/or
59
+ other data sources). Profiles often compose multiple modules and provide
60
+ configuration data to them. This approach makes it possible to re-use standard
61
+ modules (e.g. Terraform module registry).
62
+
63
+ == modules
64
+
65
+ Standard Terraform modules.
@@ -0,0 +1,24 @@
1
+ AWSTemplateFormatVersion: 2010-09-09
2
+ Description: Configure Terraform execution role in spoke accounts
3
+
4
+ Parameters:
5
+ PrimaryAccountId:
6
+ Type: String
7
+
8
+ Resources:
9
+ TerraformExecutionRole:
10
+ Type: AWS::IAM::Role
11
+ Properties:
12
+ RoleName: TerraformExecutionRole
13
+ AssumeRolePolicyDocument:
14
+ Version: 2012-10-17
15
+ Statement:
16
+ - Effect: Allow
17
+ Principal:
18
+ AWS:
19
+ - !Sub 'arn:aws:iam::${PrimaryAccountId}:root'
20
+ Action:
21
+ - sts:AssumeRole
22
+ Path: /
23
+ ManagedPolicyArns:
24
+ - arn:aws:iam::aws:policy/AdministratorAccess
@@ -0,0 +1,81 @@
1
+ AWSTemplateFormatVersion: 2010-09-09
2
+ Description: Resources for managing Terraform state
3
+
4
+ Parameters:
5
+
6
+ PrimaryAccountId:
7
+ Type: String
8
+ Description: "Primany account ID"
9
+ TerraformStateBucket:
10
+ Type: String
11
+ Description: "Name of S3 bucket used for storing Terraform state"
12
+ TerraformDynamoDbTable:
13
+ Type: String
14
+ Description: "Name of DynamoDB table used for Terraform state locking"
15
+ Default: "terraform-lock"
16
+
17
+ Resources:
18
+
19
+ StateBucket:
20
+ Type: AWS::S3::Bucket
21
+ Properties:
22
+ AccessControl: Private
23
+ BucketName: !Ref TerraformStateBucket
24
+ VersioningConfiguration:
25
+ Status: Enabled
26
+ BucketEncryption:
27
+ ServerSideEncryptionConfiguration:
28
+ - ServerSideEncryptionByDefault:
29
+ SSEAlgorithm: AES256
30
+ PublicAccessBlockConfiguration:
31
+ BlockPublicAcls: True
32
+ BlockPublicPolicy: True
33
+ IgnorePublicAcls: True
34
+ RestrictPublicBuckets: True
35
+
36
+ DynamoLockTable:
37
+ Type: AWS::DynamoDB::Table
38
+ Properties:
39
+ TableName: !Ref TerraformDynamoDbTable
40
+ AttributeDefinitions:
41
+ - AttributeName: LockID
42
+ AttributeType: S
43
+ KeySchema:
44
+ - AttributeName: LockID
45
+ KeyType: HASH
46
+ ProvisionedThroughput:
47
+ ReadCapacityUnits: 5
48
+ WriteCapacityUnits: 5
49
+
50
+ TerraformStateRole:
51
+ Type: AWS::IAM::Role
52
+ Properties:
53
+ RoleName: TerraformStateRole
54
+ AssumeRolePolicyDocument:
55
+ Version: 2012-10-17
56
+ Statement:
57
+ - Effect: Allow
58
+ Principal:
59
+ AWS:
60
+ - !Sub 'arn:aws:iam::${PrimaryAccountId}:root'
61
+ Action:
62
+ - sts:AssumeRole
63
+ Path: /
64
+ Policies:
65
+ - PolicyName: "terraform-state"
66
+ PolicyDocument:
67
+ Version: "2012-10-17"
68
+ Statement:
69
+ - Effect: "Allow"
70
+ Action:
71
+ - "s3:PutObject"
72
+ - "s3:GetBucketPolicy"
73
+ - "s3:GetObject"
74
+ - "s3:ListBucket"
75
+ - "dynamodb:PutItem"
76
+ - "dynamodb:DeleteItem"
77
+ - "dynamodb:GetItem"
78
+ Resource:
79
+ - !GetAtt StateBucket.Arn
80
+ - !Sub "${StateBucket.Arn}/*"
81
+ - !GetAtt DynamoLockTable.Arn
@@ -0,0 +1,42 @@
1
+ AWSTemplateFormatVersion: 2010-09-09
2
+ Description: Create Tfctl IAM role used to workout AWS account topolgy
3
+
4
+ Parameters:
5
+ PrimaryAccountId:
6
+ Type: String
7
+ Description: Primary organization account ID
8
+
9
+ Resources:
10
+ TfCtlRole:
11
+ Type: AWS::IAM::Role
12
+ Properties:
13
+ RoleName: TfctlRole
14
+ AssumeRolePolicyDocument:
15
+ Version: '2012-10-17'
16
+ Statement:
17
+ - Effect: Allow
18
+ Principal:
19
+ AWS:
20
+ - !Sub 'arn:aws:iam::${PrimaryAccountId}:root'
21
+ Action:
22
+ - sts:AssumeRole
23
+ Path: /
24
+ Policies:
25
+ - PolicyName: TfctlOrgAccess
26
+ PolicyDocument:
27
+ Version: '2012-10-17'
28
+ Statement:
29
+ - Effect: Allow
30
+ Action:
31
+ - organizations:ListAccounts
32
+ - organizations:ListAccountsForParent
33
+ - organizations:ListChildren
34
+ - organizations:ListRoots
35
+ - organizations:DescribeOrganizationalUnit
36
+ Resource:
37
+ - '*'
38
+
39
+ Outputs:
40
+ TfCtlUserArn:
41
+ Description: tfctl role arn
42
+ Value: !GetAtt 'TfCtlRole.Arn'
@@ -0,0 +1,80 @@
1
+ #
2
+ # Example Tfctl configuration for AWS Control Tower
3
+ #
4
+ # The data in this file is merged with data from AWS Organizations API to
5
+ # create final configuration used by tfctl. You can view the merged
6
+ # configuration by running:
7
+ #
8
+ # tfctl -c conf/example.yaml -s
9
+ #
10
+
11
+ #
12
+ # Terraform configuration
13
+ #
14
+
15
+ tf_state_bucket: 'CHANGEME'
16
+ tf_state_dynamodb_table: 'terraform-lock'
17
+ tf_state_region: 'eu-west-1'
18
+ # Role for accessing state resources
19
+ tf_state_role_arn: 'arn:aws:iam::SHARED_SERVICES_ACCOUNT_ID:role/TerraformStateRole'
20
+ tf_required_version: '>= 0.12.0'
21
+ aws_provider_version: '>= 2.14'
22
+ # Role used by tfctl to retrieve data from AWS Organizations
23
+ # Has to be set up in the primary org account
24
+ tfctl_role_arn: 'arn:aws:iam::PRIMARY_ACCOUNT_ID:role/TfctlRole'
25
+
26
+ #
27
+ # Data
28
+ #
29
+ # Here you can add arbitrary data which will be accessible from Terraform
30
+ # profiles. Data can also be defined per account in the organization sections
31
+ # below.
32
+ #
33
+ # data:
34
+ # my_parameter: some_value
35
+
36
+ #
37
+ # Organization configuration
38
+ #
39
+ # Assign resources and data to accounts based on the organization structure.
40
+ #
41
+ # IMPORTANT: Removing a Terraform profile here will remove all of it's
42
+ # associated resources during next apply!
43
+
44
+ # Configuration to apply to all accounts
45
+ organization_root:
46
+ # Role assumed by Terraform for execution in each account
47
+ tf_execution_role: 'AWSControlTowerExecution'
48
+ region: 'eu-west-1'
49
+ data:
50
+ # Bucket name used by example profile it will be prefixed with the target
51
+ # account number for uniqueness across accounts.
52
+ example_bucket_name: 'tfctl-example-bucket'
53
+ # Assign example-profile to all accounts in managed OUs
54
+ profiles:
55
+ - example-profile
56
+
57
+ # Configuration to apply to accounts in Organization Units
58
+ # OU's not listed here will be ignored.
59
+ organization_units:
60
+ # Core: {} # Uncomment if you want to include Core OU accounts
61
+ live: {}
62
+ test: {}
63
+ mgmt:
64
+ data:
65
+ # Override the example bucket name in mgmt OU accounts
66
+ example_bucket_name: 'tfctl-ou-override-example'
67
+
68
+ # Configuration to apply to individual accounts
69
+ account_overrides:
70
+ test-example1:
71
+ data:
72
+ # Override the bucket name in a specific account
73
+ example_bucket_name: 'tfctl-account-override-example'
74
+
75
+
76
+ # Exclude individual accounts from Terraform runs
77
+ #
78
+ # exclude_accounts:
79
+ # - Audit
80
+ # - 'Log archive'
@@ -0,0 +1,4 @@
1
+ resource aws_s3_bucket bucket {
2
+ bucket = var.name
3
+ acl = "private"
4
+ }
@@ -0,0 +1,4 @@
1
+ variable "name" {
2
+ description = "Bucket name"
3
+ type = string
4
+ }
@@ -0,0 +1 @@
1
+ data "aws_caller_identity" "current" {}
@@ -0,0 +1,4 @@
1
+ module "bucket" {
2
+ source = "../../modules/s3-bucket"
3
+ name = "${local.account_id}-${local.account["example_bucket_name"]}"
4
+ }
@@ -0,0 +1,12 @@
1
+ # This variable must always be present in a profile
2
+ variable "config" {
3
+ description = "Configuration generated by tfctl"
4
+ type = string
5
+ }
6
+
7
+ locals {
8
+ config = jsondecode(var.config)
9
+ account_id = "${data.aws_caller_identity.current.account_id}"
10
+ # get current account configuration from tfctl config
11
+ account = [ for account in local.config["accounts"]: account if account["id"] == local.account_id ][0]
12
+ }
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Add a deep_merge method to a Hash.
4
+ # It unions arrays (for terraform profiles behaviour)
5
+ class Hash
6
+ def deep_merge(second)
7
+ merger = proc { |_key, v1, v2|
8
+ if v1.is_a?(Hash) && v2.is_a?(Hash)
9
+ v1.merge(v2, &merger)
10
+ elsif v1.is_a?(Array) && v2.is_a?(Array)
11
+ v1 | v2
12
+ elsif [:undefined, nil, :nil].include?(v2)
13
+ v1
14
+ else
15
+ v2
16
+ end
17
+ }
18
+ merge(second.to_h, &merger)
19
+ end
20
+
21
+ # Copied from ruby 2.6 Psych for 2.3 compatibility.
22
+ def symbolize_names!(result = self)
23
+ case result
24
+ when Hash
25
+ result.keys.each do |key|
26
+ result[key.to_sym] = symbolize_names!(result.delete(key))
27
+ end
28
+ when Array
29
+ result.map! { |r| symbolize_names!(r) }
30
+ end
31
+ result
32
+ end
33
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'tfctl/aws_org.rb'
4
+ require_relative 'tfctl/config.rb'
5
+ require_relative 'tfctl/error.rb'
6
+ require_relative 'tfctl/executor.rb'
7
+ require_relative 'tfctl/generator.rb'
8
+ require_relative 'tfctl/logger.rb'
9
+ require_relative 'tfctl/schema.rb'
10
+ require_relative 'tfctl/version.rb'
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'error.rb'
4
+ require 'aws-sdk-organizations'
5
+
6
+ module Tfctl
7
+ class AwsOrg
8
+
9
+ def initialize(role_arn)
10
+ @aws_org_client = Aws::Organizations::Client.new(
11
+ region: 'us-east-1',
12
+ # Assume role in primary account to read AWS organization API
13
+ credentials: aws_assume_role(role_arn),
14
+ )
15
+ end
16
+
17
+ # Gets account data for specified OUs from AWS Organizations API
18
+ def accounts(org_units)
19
+ output = { accounts: [] }
20
+
21
+ aws_ou_ids = aws_ou_list
22
+
23
+ org_units.each do |ou_path|
24
+ raise Tfctl::Error, "Error: OU: #{ou_path}, does not exists in AWS organization" unless aws_ou_ids.key?(ou_path)
25
+
26
+ parent_id = aws_ou_ids[ou_path]
27
+
28
+ @aws_org_client.list_accounts_for_parent(parent_id: parent_id).accounts.each do |account|
29
+ next unless account.status == 'ACTIVE'
30
+
31
+ output[:accounts] << {
32
+ name: account.name,
33
+ id: account.id,
34
+ arn: account.arn,
35
+ email: account.email,
36
+ ou_path: ou_path.to_s,
37
+ ou_parents: ou_path.to_s.split('/'),
38
+ profiles: [],
39
+ }
40
+ end
41
+ end
42
+ output
43
+ end
44
+
45
+ private
46
+
47
+ # Get a mapping of ou_name => ou_id from AWS organizations
48
+ def aws_ou_list
49
+ output = {}
50
+ root_ou_id = @aws_org_client.list_roots.roots[0].id
51
+
52
+ ou_recurse = lambda do |ous|
53
+ ous.each do |ou_name, ou_id|
54
+ children = aws_ou_list_children(ou_id, ou_name)
55
+ unless children.empty?
56
+ output.merge!(children)
57
+ ou_recurse.call(children)
58
+ end
59
+ end
60
+ end
61
+ ou_recurse.call(root: root_ou_id)
62
+
63
+ output
64
+ end
65
+
66
+ # Get a list of child ou's for a parent
67
+ def aws_ou_list_children(parent_id, parent_name)
68
+ output = {}
69
+ retries = 0
70
+
71
+ @aws_org_client.list_children(
72
+ child_type: 'ORGANIZATIONAL_UNIT',
73
+ parent_id: parent_id,
74
+ ).children.each do |child|
75
+
76
+ begin
77
+ ou = @aws_org_client.describe_organizational_unit(
78
+ organizational_unit_id: child.id,
79
+ ).organizational_unit
80
+ rescue Aws::Organizations::Errors::TooManyRequestsException
81
+ # FIXME: - use logger
82
+ puts 'AWS Organizations: too many requests. Retrying in 5 secs.'
83
+ sleep 5
84
+ retries += 1
85
+ retry if retries < 10
86
+ end
87
+
88
+ ou_name = parent_name == :root ? ou.name.to_sym : "#{parent_name}/#{ou.name}".to_sym
89
+
90
+ output[ou_name] = ou.id
91
+ end
92
+ output
93
+ end
94
+
95
+ def aws_assume_role(role_arn)
96
+ begin
97
+ sts = Aws::STS::Client.new
98
+
99
+ role_credentials = Aws::AssumeRoleCredentials.new(
100
+ client: sts,
101
+ role_arn: role_arn,
102
+ role_session_name: 'tfctl',
103
+ )
104
+ rescue StandardError => e
105
+ raise Tfctl::Error, "Error assuming role: #{role_arn}, #{e.message}"
106
+ end
107
+
108
+ role_credentials
109
+ end
110
+
111
+ end
112
+ end