├── .bundle └── config ├── .github ├── dependabot.yml └── workflows │ ├── linter.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── CHANGELOG.adoc ├── Gemfile ├── Guardfile ├── LICENSE ├── Makefile ├── README.adoc ├── RELEASING.adoc ├── bin └── tfctl ├── docs ├── configuration.adoc ├── control_tower.adoc ├── creating_a_profile.adoc ├── iam_permissions.adoc └── project_layout.adoc ├── examples ├── bootstrap │ ├── terraform-exec-role.template │ ├── terraform-state.template │ └── tfctl-org-access.template └── control_tower │ ├── modules │ └── s3-bucket │ │ ├── main.tf │ │ └── variables.tf │ ├── profiles │ └── example-profile │ │ ├── data.tf │ │ ├── main.tf │ │ ├── terraform.tf │ │ └── variables.tf │ └── tfctl.yaml ├── lib ├── hash.rb ├── tfctl.rb └── tfctl │ ├── aws_org.rb │ ├── config.rb │ ├── error.rb │ ├── executor.rb │ ├── generator.rb │ ├── logger.rb │ ├── schema.rb │ └── version.rb ├── spec ├── config_spec.rb ├── data │ ├── aws_org.yaml │ └── config.yaml ├── generator_spec.rb ├── schema_spec.rb └── spec_helper.rb └── tfctl.gemspec /.bundle/config: -------------------------------------------------------------------------------- 1 | --- 2 | BUNDLE_PATH: "vendor/bundle" 3 | BUNDLE_BIN: "vendor/bin" 4 | BUNDLE_WITH: "developement" 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /.github/workflows/linter.yml: -------------------------------------------------------------------------------- 1 | name: Linting 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: [ master ] 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: ruby/setup-ruby@v1 14 | with: 15 | ruby-version: 3.0 16 | - name: Rubocop 17 | run: make rubocop 18 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | publish: 10 | name: Build + Publish 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Set up Ruby 2.6 15 | uses: ruby/setup-ruby@v1 16 | with: 17 | ruby-version: 2.6 18 | - name: Build gem 19 | run: make pkg 20 | - name: Publish to RubyGems 21 | run: | 22 | mkdir -p $HOME/.gem 23 | touch $HOME/.gem/credentials 24 | chmod 0600 $HOME/.gem/credentials 25 | printf -- "---\n:rubygems_api_key: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials 26 | gem push pkg/*.gem 27 | env: 28 | GEM_HOST_API_KEY: "${{secrets.RUBYGEMS_AUTH_TOKEN}}" 29 | - name: Release on GitHub 30 | uses: ncipollo/release-action@v1 31 | with: 32 | body: 'See [CHANGELOG](https://github.com/scalefactory/tfctl/blob/master/CHANGELOG.adoc) for details.' 33 | token: "${{ secrets.GITHUB_TOKEN }}" 34 | draft: false 35 | prerelease: false 36 | artifacts: pkg/*.gem 37 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | rspec: 11 | runs-on: ${{ matrix.os }}-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | os: [ubuntu, macos] 16 | ruby: [2.6, 2.7, 3.0] 17 | continue-on-error: ${{ endsWith(matrix.ruby, 'head') || matrix.ruby == 'debug' }} 18 | steps: 19 | - uses: actions/checkout@v2 20 | - uses: ruby/setup-ruby@v1 21 | with: 22 | ruby-version: ${{ matrix.ruby }} 23 | - run: make spec 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.swp 3 | .tfctl 4 | pkg/ 5 | *.gem 6 | vendor/ 7 | bin/bundle 8 | bin/htmldiff 9 | bin/ldiff 10 | bin/rspec 11 | spec/reports 12 | Gemfile.lock 13 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | --- 2 | AllCops: 3 | TargetRubyVersion: 2.5 4 | DisplayCopNames: true 5 | NewCops: enable 6 | 7 | Layout/IndentationWidth: 8 | Width: 4 9 | 10 | Layout/HeredocIndentation: 11 | Enabled: false 12 | 13 | Layout/EmptyLines: 14 | Enabled: false 15 | 16 | Layout/EmptyLinesAroundMethodBody: 17 | Enabled: false 18 | 19 | Layout/HashAlignment: 20 | EnforcedHashRocketStyle: 21 | - table 22 | EnforcedColonStyle: 23 | - table 24 | 25 | Layout/SpaceAroundOperators: 26 | Enabled: false 27 | 28 | Layout/ExtraSpacing: 29 | Enabled: false 30 | 31 | Layout/EmptyLinesAroundBlockBody: 32 | Enabled: false 33 | 34 | Layout/EmptyLinesAroundClassBody: 35 | Enabled: false 36 | 37 | Metrics/CyclomaticComplexity: 38 | Enabled: false 39 | 40 | Metrics/PerceivedComplexity: 41 | Enabled: false 42 | 43 | Metrics/BlockLength: 44 | Enabled: false 45 | 46 | Metrics/MethodLength: 47 | Enabled: false 48 | 49 | Layout/LineLength: 50 | Max: 140 51 | 52 | Metrics/AbcSize: 53 | Enabled: false 54 | 55 | Metrics/ParameterLists: 56 | Enabled: false 57 | 58 | Metrics/ClassLength: 59 | Enabled: false 60 | 61 | Style/IfUnlessModifier: 62 | Enabled: false 63 | 64 | Style/AndOr: 65 | Enabled: false 66 | 67 | Style/Documentation: 68 | Enabled: false 69 | 70 | Style/TrailingCommaInArguments: 71 | EnforcedStyleForMultiline: comma 72 | 73 | Style/TrailingCommaInArrayLiteral: 74 | EnforcedStyleForMultiline: comma 75 | 76 | Style/TrailingCommaInHashLiteral: 77 | EnforcedStyleForMultiline: comma 78 | 79 | Style/RedundantReturn: 80 | Enabled: false 81 | 82 | # don't break older Rubies just because of style 83 | Style/RedundantBegin: 84 | Enabled: false 85 | -------------------------------------------------------------------------------- /CHANGELOG.adoc: -------------------------------------------------------------------------------- 1 | = Changelog 2 | 3 | == 1.7.0 4 | 5 | * feat: added a `-p` flag to configure parallelism 6 | * feat: enhanced the executor output when raising `Tfctl::Error`, it will now 7 | report the `account_name` that raised the error along with the full command 8 | that was being run 9 | 10 | == 1.6.1 11 | 12 | * fix: pagination problem when listing accounts in an OU. 13 | 14 | == 1.6.0 15 | 16 | * fix: pass the default AWS provider explicitly from tfctl generated configuration. 17 | This fixes provider inheritance issues when using multiple providers which 18 | was introduced in 1.3.0. You may need to add a terraform block with 19 | `required_provides` to your profiles if you don't have it defined already. 20 | Terraform will warn about this during `init`. Here's an example block: 21 | 22 | ---- 23 | terraform { 24 | required_providers { 25 | aws = { 26 | source = "hashicorp/aws" 27 | } 28 | } 29 | } 30 | ---- 31 | 32 | == 1.5.0 33 | 34 | * feat: support for setting default tags at AWS provider level. (Thanks @patrickli) 35 | For details see: https://www.hashicorp.com/blog/default-tags-in-the-terraform-aws-provider 36 | * feat: new `tf_state_prefix` config parameter. (Thanks @patrickli) 37 | Allows setting an path prefix for state files stored in S3. 38 | * feat: print version number in output log 39 | 40 | == 1.4.0 41 | 42 | * feat: support yaml anchors and aliases in configuration file. 43 | 44 | == 1.3.0 45 | 46 | * feat: support new Terraform provider syntax 47 | 48 | BREAKING CHANGE: The minimum supported Terraform version has been bumped to 49 | 0.12.29. If you are running an older version of Terraform you will need to 50 | update to the latest Terraform in 0.12.x series before updating tfctl. Once 51 | tfctl is updated you can upgrade Terraform to further versions. 52 | 53 | == 1.2.2 54 | * chore: reverted PR #11 - not necessary and introduced regression. See PR #13 for details. 55 | 56 | == 1.2.1 57 | * chore: added required Ruby version to Gemspec. 58 | 59 | == 1.2.0 60 | 61 | * feat: pass TF_ environment variables to terraform (PR #11). 62 | 63 | == 1.1.1 64 | 65 | * fix: handle empty response from Organizations API containing children (thanks @grothja) 66 | * chore: stopped testing on EOL Rubies 2.3 and 2.4 (but should still currently work) 67 | * chore: dependencies minimum version bump 68 | 69 | == 1.1.0 70 | 71 | * feat: look for configuration in `tfctl.yaml` by default. 72 | 73 | == 1.0.0 74 | 75 | * feat(config): JSON schema config validation 76 | * feat(config): added 'data' parameter 77 | 78 | BREAKING CHANGE: This release moves user defined data under a separate `data` 79 | parameter so it can be easily distinguished from parameters required by tfctl. 80 | Configuration file will need to be updated to reflect this to pass validation. 81 | 82 | 83 | == 0.2.0 84 | 85 | * feat: configurable Terraform and AWS provider version requirements 86 | * fix: use provider region from config file 87 | * fix: fail when terraform command is missing 88 | 89 | == 0.1.0 90 | 91 | * feat: Added `-l` switch to list discovered accounts. 92 | 93 | == 0.0.2 94 | 95 | * fix: Fixed an exception when `exclude_accounts` is not set. 96 | 97 | == 0.0.1 98 | 99 | * Initial release 100 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec 6 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | guard :rspec, cmd: 'bundle exec rspec' do 4 | watch(%r{^spec/.+_spec\.rb$}) 5 | watch(%r{^lib/tfctl/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } 6 | watch('spec/spec_helper.rb') { 'spec' } 7 | end 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Essentia Analytics Ltd 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean install test rubocop spec guard 2 | 3 | vendor: 4 | $(info => Installing Ruby dependencies) 5 | @bundle install 6 | @bundle binstubs --all --path vendor/bin 7 | 8 | test: vendor rubocop spec 9 | 10 | guard: vendor 11 | $(info => Starting guard) 12 | @bundle exec guard 13 | 14 | rubocop: vendor 15 | $(info => Running rubocop) 16 | @vendor/bin/rubocop 17 | 18 | spec: vendor 19 | $(info => Running spec tests) 20 | @vendor/bin/rspec 21 | 22 | pkg: 23 | $(info => Building gem package in pkg/) 24 | @mkdir pkg/ 25 | @gem build tfctl.gemspec 26 | @mv *.gem pkg/ 27 | 28 | install: pkg 29 | gem install pkg/*.gem 30 | 31 | clean: 32 | $(info => Cleaning) 33 | @rm -rf pkg/ 34 | @rm -rf vendor/ 35 | @rm -f Gemfile.lock 36 | @rm -rf spec/reports/ 37 | -------------------------------------------------------------------------------- /README.adoc: -------------------------------------------------------------------------------- 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 | = tfctl 22 | 23 | image:https://github.com/scalefactory/tfctl/actions/workflows/linter.yml/badge.svg["Linter", link="https://github.com/scalefactory/tfctl/actions/workflows/linter.yml"] 24 | image:https://github.com/scalefactory/tfctl/actions/workflows/test.yml/badge.svg["Tests", link="https://github.com/scalefactory/tfctl/actions/workflows/test.yml"] 25 | image:https://badge.fury.io/rb/tfctl.svg["Gem Version", link="https://badge.fury.io/rb/tfctl"] 26 | image:https://img.shields.io/badge/terraform-0.12-blue.svg["Terraform 0.12", link="https://img.shields.io/badge/terraform-0.12-blue"] 27 | 28 | toc::[] 29 | 30 | == Overview 31 | 32 | `tfctl` is a small Terraform wrapper for working with multi-account AWS 33 | infrastructures where new accounts may be created dynamically and on-demand. 34 | 35 | It discovers accounts by reading the AWS Organizations API, and can assign 36 | Terraform resources to multiple accounts based on the organization hierarchy. 37 | Resources can be assigned globally, based on organization unit (OU) or to individual 38 | accounts. It supports hierarchies of nested OUs, and helps keep your Terraform DRY. 39 | 40 | The Scale Factory originally created tfctl to integrate Terraform with 41 | https://aws.amazon.com/solutions/aws-landing-zone/[AWS Landing Zone] and 42 | https://aws.amazon.com/controltower/[Control Tower] but should work with most 43 | other ways of managing accounts in AWS Organizations. 44 | 45 | == Features 46 | 47 | * Discovers AWS accounts automatically. 48 | * Automatically generates Terraform account configuration. 49 | * Parallel execution across multiple accounts. 50 | * Hierarchical configuration based on AWS Organization units structure. 51 | * Supports per account configuration overrides for handling exceptions. 52 | * Supports nested organization units. 53 | * Terraform state tracking in S3 and locking in DynamoDB. 54 | * Account targeting by OU path regular expressions. 55 | * Automatic role assumption in target accounts. 56 | * Works with CI/CD pipelines. 57 | 58 | == Requirements 59 | 60 | * Terraform >= 0.12.29 61 | * Ruby >= 2.5 62 | * Accounts managed in AWS Organizations (by Landing Zone, Control Tower, some 63 | other means) 64 | 65 | == Installation 66 | 67 | To install the latest release from RubyGems run: 68 | 69 | [source,shell] 70 | ---- 71 | gem install tfctl 72 | ---- 73 | 74 | Alternatively, you can build and install from this repo with: 75 | 76 | [source,shell] 77 | ---- 78 | make install 79 | ---- 80 | 81 | == Documentation 82 | 83 | * https://github.com/scalefactory/tfctl/tree/master/docs/control_tower.adoc[Control Tower quick start guide] 84 | * https://github.com/scalefactory/tfctl/tree/master/docs/project_layout.adoc[Project layout] 85 | * https://github.com/scalefactory/tfctl/tree/master/docs/configuration.adoc[Configuration] 86 | * https://github.com/scalefactory/tfctl/tree/master/docs/iam_permissions.adoc[IAM permissions] 87 | * https://github.com/scalefactory/tfctl/tree/master/docs/creating_a_profile.adoc[Creating a profile] 88 | 89 | == Running tfctl 90 | 91 | You should run `tfctl` from the root of your project directory. It will generate 92 | Terraform configuration in `.tfctl/` (add this to your `.gitignore`). 93 | 94 | Anatomy of a tfctl command: 95 | 96 | [source,shell] 97 | ---- 98 | tfctl -c CONFIG_FILE TARGET_OPTIONS -- TERRAFORM_COMMAND 99 | ---- 100 | 101 | * `-c` specifies which tfctl config file to use (defaults to `tfctl.yaml` in 102 | current working directory if not set) 103 | * `TARGET_OPTIONS` specifies which accounts to target. This could be an individual 104 | account, a group of accounts in an organizational unit or all accounts. 105 | * `TERRAFORM_COMMAND` will be passed to `terraform` along with any 106 | options. See https://www.terraform.io/docs/commands/index.html[Terraform 107 | commands] for details. 108 | 109 | NOTE: You must have your AWS credentials configured before you run `tfctl`, or run 110 | it using an AWS credentials helper such as 111 | https://github.com/99designs/aws-vault[aws-vault]. 112 | 113 | === Example commands 114 | 115 | Show help: 116 | 117 | [source,shell] 118 | ---- 119 | tfctl -h 120 | ---- 121 | 122 | Show merged configuration: 123 | 124 | [source,shell] 125 | ---- 126 | tfctl -s 127 | ---- 128 | 129 | List all discovered accounts: 130 | 131 | [source,shell] 132 | ---- 133 | tfctl --all -l 134 | ---- 135 | 136 | TIP: This can be narrowed down using targeting options and is a good way to 137 | test what accounts match. 138 | 139 | Run `terraform init` across all accounts: 140 | 141 | [source,shell] 142 | ---- 143 | tfctl --all -- init 144 | ---- 145 | 146 | Plan Terraform across all accounts in the `test` OU: 147 | 148 | [source,shell] 149 | ---- 150 | tfctl -o test -- plan 151 | ---- 152 | 153 | Plan Terraform in `live` accounts, assuming that `live` is a child OU in multiple 154 | organization units: 155 | 156 | [source,shell] 157 | ---- 158 | tfctl -o '.*/live' -- plan 159 | ---- 160 | 161 | Run a plan for an individual account: 162 | 163 | [source,shell] 164 | ---- 165 | tfctl -a example-account - plan 166 | ---- 167 | 168 | Apply Terraform changes across all accounts: 169 | 170 | [source,shell] 171 | ---- 172 | tfctl --all -- apply 173 | ---- 174 | 175 | Destroy Terraform-managed resources in all the `test` OU accounts: 176 | 177 | [source,shell] 178 | ---- 179 | tfctl -o test -- destroy -auto-approve 180 | ---- 181 | 182 | Don't buffer the output: 183 | 184 | [source,shell] 185 | ---- 186 | tfctl -a example-account -u -- plan 187 | ---- 188 | 189 | This will show output in real time. Usually output is buffered and displayed 190 | after the Terraform command finishes, to make it more readable when running 191 | across multiple accounts in parallel. 192 | 193 | == Project status 194 | 195 | `tfctl` is an open source project published by The Scale Factory. 196 | 197 | We currently consider this project to be maintained but we don't actively 198 | develop new features. We keep it security patched and ready for use in 199 | production environments. 200 | 201 | We’ll take a look at any issues or PRs you open and get back to you as soon as 202 | we can. We don’t offer any formal SLA, but we’ll be checking on this project 203 | periodically. 204 | 205 | If your issue is urgent, you can flag it as such, and we’ll attempt to triage 206 | appropriately, but we have paying customers who also have demands on our time. 207 | If your business depends on this project and you have an urgent problem, then 208 | you can talk to our sales team about paying us to support you. 209 | -------------------------------------------------------------------------------- /RELEASING.adoc: -------------------------------------------------------------------------------- 1 | = Releasing 2 | 3 | This document is aimed at `tfctl` maintainers and describes the process of 4 | releasing a new gem version. 5 | 6 | == Process 7 | 8 | * Smoke test in SF test accounts: https://github.com/scalefactory/tfctl-test 9 | * Bump version in `lib/tfctl/version.rb`. 10 | * Update `CHANGELOG.adoc`. 11 | * Commit. 12 | * Tag the release using format: vX.X.X and push the tag. 13 | 14 | ---- 15 | git tag vX.X.X 16 | git push origin vX.X.X 17 | ---- 18 | 19 | * GitHub actions will build and release the gem and create a GitHub release automatically. 20 | -------------------------------------------------------------------------------- /bin/tfctl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | if File.directory?("#{File.dirname(__FILE__)}/../vendor") 5 | require 'bundler/setup' 6 | end 7 | require 'optparse' 8 | require 'fileutils' 9 | require 'parallel' 10 | require 'English' 11 | require 'terminal-table' 12 | require_relative '../lib/tfctl' 13 | 14 | PROJECT_ROOT = Dir.pwd 15 | 16 | # 17 | # Process CLI arguments 18 | # 19 | 20 | options = { 21 | account: nil, 22 | ou: nil, 23 | all: nil, 24 | show_config: false, 25 | config_file: 'tfctl.yaml', 26 | unbuffered: false, 27 | debug: false, 28 | use_cache: false, 29 | parallelism: 8, 30 | } 31 | 32 | optparse = OptionParser.new do |opts| 33 | opts.on('-a', '--account=name', 'Target a specific AWS account') do |o| 34 | options[:account] = o 35 | end 36 | opts.on('-o', '--ou=organization_unit', 'Target accounts in an Organization Unit (uses regex matching)') do |o| 37 | options[:ou] = o 38 | end 39 | opts.on('--all', 'Target all accounts') do 40 | options[:all] = true 41 | end 42 | opts.on('-c', '--config-file=config', 'Path to config file') do |o| 43 | options[:config_file] = o 44 | end 45 | opts.on('-s', '--show-config', 'Display configuration') do 46 | options[:show_config] = true 47 | end 48 | opts.on('-l', '--list-accounts', 'List discovered accounts') do 49 | options[:list_accounts] = true 50 | end 51 | opts.on('-x', '--use-cache', 'Use cached AWS organization data') do 52 | options[:use_cache] = true 53 | end 54 | opts.on('-u', '--unbuffered', 'Disable buffering of Terraform output') do 55 | options[:unbuffered] = true 56 | end 57 | opts.on('-d', '--debug', 'Turn on debug messages') do 58 | options[:debug] = true 59 | end 60 | opts.on('-p', '--parallelism=num', 'How many terraform processes to execute in parallel') do |o| 61 | options[:parallelism] = o.to_i 62 | end 63 | opts.on('-v', '--version', 'Show version') do 64 | puts Tfctl::VERSION 65 | exit 66 | end 67 | end 68 | 69 | 70 | begin 71 | optparse.parse! 72 | 73 | # Validate CLI arguments 74 | 75 | unless File.exist? options[:config_file] 76 | raise OptionParser::InvalidOption, 77 | "Config file not found in: #{options[:config_file]}" 78 | end 79 | 80 | unless File.exist? options[:config_file] 81 | raise OptionParser::InvalidOption, "Config file #{options[:config_file]} not found." 82 | end 83 | 84 | # Validate targets 85 | targetting_opts = %i[account ou all] 86 | targets_set = [] 87 | options.each do |k, v| 88 | if targetting_opts.include?(k) and !v.nil? 89 | targets_set << k.to_s 90 | end 91 | end 92 | if targets_set.length > 1 93 | raise OptionParser::InvalidOption, 94 | "Too many target options set: #{targets_set.join(', ')}. Only one can be specified." 95 | end 96 | if targets_set.empty? and options[:show_config] == false 97 | raise OptionParser::InvalidOption, 'Please specify target' 98 | end 99 | rescue OptionParser::InvalidOption, OptionParser::MissingArgument 100 | warn $ERROR_INFO 101 | warn optparse 102 | exit 2 103 | end 104 | 105 | 106 | 107 | # Execute terraform in target accounts 108 | def run_account(config, account, options, tf_argv, log) 109 | 110 | # Skip excluded accounts 111 | if account[:excluded] == true 112 | log.info "#{account[:name]}: excluded, skipping" 113 | return 114 | end 115 | 116 | # Generate Terraform run directory with configured providers, backend and 117 | # profiles for the target account. This is where Terraform will be 118 | # executed from. 119 | log.info "#{account[:name]}: Generating Terraform run directory" 120 | Tfctl::Generator.make( 121 | account: account, 122 | config: config, 123 | ) 124 | 125 | log.info "#{account[:name]}: Executing Terraform #{tf_argv[0]}" 126 | Tfctl::Executor.run( 127 | account_name: account[:name], 128 | config_name: config[:config_name], 129 | unbuffered: options[:unbuffered], 130 | log: log, 131 | argv: tf_argv, 132 | ) 133 | end 134 | 135 | 136 | # 137 | # Main 138 | # 139 | 140 | begin 141 | # Set up logging 142 | log_level = options[:debug] ? Logger::DEBUG : Logger::INFO 143 | log = Tfctl::Logger.new(log_level) 144 | 145 | log.info "tfctl #{Tfctl::VERSION} running" 146 | 147 | config_name = File.basename(options[:config_file]).chomp('.yaml') 148 | config_name = 'default' if config_name == 'tfctl' 149 | log.info "Using config: #{config_name}" 150 | 151 | log.info 'Working out AWS account topology' 152 | 153 | yaml_config = YAML.safe_load(File.read(options[:config_file]), aliases: true) 154 | Tfctl::Schema.validate(yaml_config) 155 | yaml_config.symbolize_names! 156 | 157 | org_units = yaml_config[:organization_units].keys 158 | aws_org_accounts = Tfctl::AwsOrg.new(yaml_config[:tfctl_role_arn]).accounts(org_units) 159 | 160 | log.info 'Merging configuration' 161 | 162 | config = Tfctl::Config.new( 163 | config_name: config_name, 164 | yaml_config: yaml_config, 165 | aws_org_config: aws_org_accounts, 166 | use_cache: options[:use_cache], 167 | ) 168 | 169 | if options[:show_config] 170 | puts config.to_yaml 171 | exit 0 172 | end 173 | 174 | # Find target accounts 175 | 176 | if options[:account] 177 | accounts = config.find_accounts(:name, options[:account]) 178 | elsif options[:ou] 179 | accounts = config.find_accounts_regex(:ou_path, options[:ou]) 180 | elsif options[:all] 181 | accounts = config[:accounts] 182 | else 183 | raise Tfctl::Error, 'Missing target' 184 | end 185 | 186 | # List target accounts 187 | 188 | if options[:list_accounts] 189 | log.info "Listing accounts\n" 190 | table = Terminal::Table.new do |t| 191 | t.style = { 192 | border_x: '', 193 | border_y: '', 194 | border_i: '', 195 | padding_left: 0, 196 | } 197 | t << %w[ACCOUNT_ID OU NAME] 198 | accounts.each do |account| 199 | t << [account[:id], account[:ou_path], account[:name]] 200 | end 201 | end 202 | 203 | puts table 204 | exit 0 205 | end 206 | 207 | # Execute Terraform in target accounts 208 | 209 | Parallel.each(accounts, in_processes: options[:parallelism]) do |ac| 210 | run_account(config, ac, options, ARGV, log) 211 | end 212 | 213 | log.info 'Done' 214 | rescue Tfctl::Error => e 215 | log.error(e) 216 | exit 1 217 | rescue Tfctl::ValidationError => e 218 | log.error(e) 219 | e.issues.each do |issue| 220 | log.error("Parameter: #{issue[:data_pointer]}") unless issue[:data_pointer] == '' 221 | log.error(issue[:details]) unless issue[:details].nil? 222 | end 223 | exit 2 224 | end 225 | -------------------------------------------------------------------------------- /docs/configuration.adoc: -------------------------------------------------------------------------------- 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 | = Configuration 22 | 23 | toc::[] 24 | 25 | == Overview 26 | 27 | `tfctl` retrieves initial account configuration from AWS Organizations and merges 28 | it with configuration specified in YAML format (`tfctl.yaml` by default). 29 | 30 | The configuration is merged in the following order: 31 | 32 | * AWS Organizations data is fetched and stored in an `accounts` array. 33 | * `organization_root` settings are merged with all accounts. 34 | * `organization_units` settings are merged with accounts matching the OU. 35 | * `account_overrides` are merged with individual accounts matching the account name. 36 | 37 | Parameters further down the hierarchy take precedence. For example: 38 | 39 | [source, yaml] 40 | ---- 41 | organization_root: 42 | data: 43 | example_param: 'will be overriden further down' 44 | 45 | organization_units: 46 | team: 47 | data: 48 | example_param: 'will win in team ou' 49 | team/live: 50 | data: 51 | example_param: 'will win in team/live ou' 52 | ---- 53 | 54 | One exception to this rule is the `profiles` parameter. Profiles are additive: 55 | 56 | [source, yaml] 57 | ---- 58 | organization_root: 59 | profiles: 60 | - profile-one 61 | - profile-two 62 | 63 | organization_units: 64 | team: 65 | profiles: 66 | - profile-three 67 | ---- 68 | 69 | This will result in all three profiles deployed to accounts in `team` OU. 70 | 71 | TIP: You can display the fully merged configuration by running `tfctl -s`. 72 | It's safe to run as it doesn't make any changes to AWS resources. It's a good 73 | way to test your configuration. 74 | 75 | == Defining arbitrary data 76 | 77 | You can define arbitrary data under the `data:` parameter, both in the root of 78 | the config and in the organization sections. It will be available in Terraform 79 | profiles to use by your modules. You can use this to define things like VPC 80 | subnet ranges, s3 bucket names and so on. `data:` in organization sections 81 | will be merged with accounts following the usual merge order as described 82 | above. 83 | 84 | == Handling secrets 85 | 86 | CAUTION: Do not commit secrets into your Terraform or tfctl configuration. 87 | 88 | Instead, use AWS Secrets Manager and retrieve secrets in Terraform profiles using 89 | the 90 | https://www.terraform.io/docs/providers/aws/d/secretsmanager_secret.html[secrets 91 | manager data source]. 92 | 93 | == Configuration Schema 94 | 95 | The configuration file is validated using https://json-schema.org/[JSON Schema]. 96 | 97 | The schema is defined in 98 | https://github.com/scalefactory/tfctl/blob/master/lib/tfctl/schema.rb[lib/tfctl/schema.rb] 99 | and is a good place to look up all available options. 100 | -------------------------------------------------------------------------------- /docs/control_tower.adoc: -------------------------------------------------------------------------------- 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 | = Control Tower integration guide 22 | 23 | This guide will help you integrate Terraform with AWS Control Tower using the 24 | tfctl wrapper. This involves setting up resources for remote state tracking, 25 | necessary IAM roles and a tfctl project. 26 | 27 | toc::[] 28 | 29 | == Overview 30 | 31 | For state tracking we're going to create a dedicated `shared-services` account 32 | under a `mgmt` organization unit. We'll use S3 for state storage and DynamoDB 33 | for locking. `TerraformState` IAM role will be created for cross account 34 | access to state resources from the primary AWS account. 35 | 36 | In the primary account we'll create a `TfctlOrgAccess` role. It gives tfctl 37 | read only access to AWS Organizations which is used to discover accounts and 38 | the organization unit structure. 39 | 40 | We'll use CloudFormation stacks and stack-sets to bootstrap these resources. 41 | 42 | For executing Terraform in spoke accounts we'll use the 43 | `AWSControlTowerExecution` role which is automatically created by Control Tower 44 | account factory and can be assumed from the primary account. 45 | 46 | We're going to create a `live` and `test` organization units in Control Tower 47 | and provision a couple of accounts for testing. 48 | 49 | == Prerequisites 50 | 51 | Before starting you'll need: 52 | 53 | * An AWS account that is part of an Organization. 54 | * Control Tower set up in your primary AWS account. 55 | * A user with `AdministratorAccess` privileges in your primary AWS account. 56 | * AWS CLI tools installed on your local machine. 57 | * Terraform 0.12 or higher. 58 | 59 | == Configure Control Tower 60 | 61 | Create the following organization units in Control Tower: 62 | 63 | * `mgmt` 64 | * `live` 65 | * `test` 66 | 67 | Then provision accounts: 68 | 69 | * In `mgmt` OU create an account called `mgmt-shared-services` 70 | * In `live` create `live-example1` 71 | * In `test` create `test-example1` 72 | 73 | NOTE: Control Tower accounts need to be provisioned one at a time. It takes 74 | approximately 20 mins to provision one. 75 | 76 | == Install tfctl 77 | 78 | [source,shell] 79 | ---- 80 | git clone git@github.com:scalefactory/tfctl.git 81 | cd tfctl/ && sudo make install 82 | ---- 83 | 84 | == Set up AWS resources 85 | 86 | It's assumed you have configured AWS CLI access to the primary account. 87 | 88 | We'll use CloudFormation templates in `examples/bootstrap/`. 89 | 90 | First export configuration using environment variables, making sure to change to 91 | values to suit your setup: 92 | 93 | [source,shell] 94 | ---- 95 | # Change these to match your setup 96 | export PRIMARY_ACCOUNT_ID=123456789012 97 | export SHARED_SERVICES_ACCOUNT_ID=345678901234 98 | export STATE_BUCKET_NAME='example-terraform-state' 99 | export AWS_REGION=eu-west-1 100 | ---- 101 | 102 | Create the remote state resources stack set: 103 | 104 | [source,shell] 105 | ---- 106 | cd examples/bootstrap/ 107 | 108 | aws cloudformation create-stack-set \ 109 | --stack-set-name TerraformState \ 110 | --template-body file://terraform-state.template \ 111 | --description "Resources for managing Terraform state" \ 112 | --capabilities CAPABILITY_NAMED_IAM CAPABILITY_IAM \ 113 | --execution-role-name AWSControlTowerExecution \ 114 | --administration-role-arn arn:aws:iam::${PRIMARY_ACCOUNT_ID}:role/service-role/AWSControlTowerStackSetRole \ 115 | --parameters ParameterKey=PrimaryAccountId,ParameterValue=${PRIMARY_ACCOUNT_ID} \ 116 | ParameterKey=TerraformStateBucket,ParameterValue="${STATE_BUCKET_NAME}" 117 | ---- 118 | 119 | Create a stack set instance in your shared services account: 120 | 121 | [source,shell] 122 | ---- 123 | aws cloudformation create-stack-instances \ 124 | --stack-set-name TerraformState \ 125 | --accounts ${SHARED_SERVICES_ACCOUNT_ID} \ 126 | --regions ${AWS_REGION} 127 | ---- 128 | 129 | Check status: 130 | 131 | [source,shell] 132 | ---- 133 | aws cloudformation describe-stack-instance \ 134 | --stack-set-name TerraformState \ 135 | --stack-instance-account ${SHARED_SERVICES_ACCOUNT_ID} \ 136 | --stack-instance-region ${AWS_REGION} 137 | ---- 138 | 139 | NOTE: Initial status will be `OUTDATED`, it should change to `CURRENT` once deployed. 140 | 141 | Deploy the `TfctlOrgAccess` IAM role stack: 142 | 143 | [source,shell] 144 | ---- 145 | aws cloudformation create-stack \ 146 | --stack-name TfctlOrgAccess \ 147 | --template-body file://tfctl-org-access.template \ 148 | --capabilities CAPABILITY_NAMED_IAM CAPABILITY_IAM \ 149 | --parameters ParameterKey=PrimaryAccountId,ParameterValue=${PRIMARY_ACCOUNT_ID} 150 | ---- 151 | 152 | Check status: 153 | 154 | [source,shell] 155 | ---- 156 | aws cloudformation describe-stacks --stack-name TfctlOrgAccess 157 | ---- 158 | 159 | NOTE: Successful status should read: `CREATE_COMPLETE`. 160 | 161 | == Configure tfctl 162 | 163 | Copy the example project directory `examples/control_tower` somewhere convenient 164 | and edit `tfctl.yaml`. 165 | 166 | You need to modify the following parameters: 167 | 168 | * `tf_state_bucket` - set to `$STATE_BUCKET_NAME` 169 | * `tf_state_role_arn` - set shared services account ID 170 | * `tfctl_role_arn` - set primary account ID 171 | * `primary_account` - set the primary account name. You can find it through AWS Organizations in the console. 172 | 173 | TIP: You should keep your project directory under version control. 174 | 175 | == Deploy example tfctl profile 176 | 177 | The example profile will create an S3 bucket in accounts under `test`, `live` 178 | and `mgmt` OUs. 179 | 180 | NOTE: Run tfctl commands from the root of you project directory. 181 | 182 | First, dump the configuration to verify everything works: 183 | 184 | [source,shell] 185 | ---- 186 | tfctl -s 187 | ---- 188 | 189 | This will not make any changes but will print out YAML containing the final, 190 | merged configuration data. It should contain a list of discovered accounts and 191 | their configuration. 192 | 193 | Initialise Terraform for all discovered accounts: 194 | 195 | [source,shell] 196 | ---- 197 | tfctl --all -- init 198 | ---- 199 | 200 | Tfctl will run Terraform against all accounts in parallel. 201 | 202 | `plan` the Terraform: 203 | 204 | [source,shell] 205 | ---- 206 | tfctl --all -- plan 207 | ---- 208 | 209 | and `apply` it: 210 | 211 | [source,shell] 212 | ---- 213 | tfctl --all -- apply 214 | ---- 215 | 216 | 217 | == Clean up 218 | 219 | To destroy created resources, run: 220 | 221 | [source,shell] 222 | ---- 223 | tfctl --all -- destroy -auto-approve 224 | ---- 225 | 226 | That's it! You can now execute Terraform across your Control Tower estate. 227 | 228 | TIP: Your project directory should be under version control excluding the 229 | `.tfctl` directory which is automatically generated. 230 | -------------------------------------------------------------------------------- /docs/creating_a_profile.adoc: -------------------------------------------------------------------------------- 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 | = Creating and deploying a tfctl profile 22 | 23 | This guide will show you how to create a tfctl profile, declare some resources 24 | in it and deploy it to to a group of accounts in an organization unit. 25 | 26 | toc::[] 27 | 28 | == Create a new profile 29 | 30 | In your tfctl project directory create a new profile: 31 | 32 | ---- 33 | mkdir profiles/example-profile 34 | ---- 35 | 36 | Withing the profile create `data.tf`: 37 | 38 | .data.tf 39 | [source, tf] 40 | ---- 41 | data "aws_caller_identity" "current" {} 42 | ---- 43 | 44 | This file contains Terraform 45 | https://www.terraform.io/docs/configuration/data-sources.html[data source] 46 | declarations. Data sources are a way of getting data not directly managed in 47 | Terraform into Terraform. In this case we're using the 48 | https://www.terraform.io/docs/providers/aws/d/caller_identity.html[aws_caller_identity] 49 | . One of the outputs of this source is `account_id` which will 50 | return the id of the account Terraform is currently running in. 51 | 52 | Now create `variables.tf`: 53 | 54 | .variables.tf 55 | [source, tf] 56 | ---- 57 | variable "config" { 58 | description = "Configuration generated by tfctl in string encoded JSON" 59 | type = string 60 | } 61 | 62 | # local variables 63 | locals { 64 | # Decode config JSON into a Terraform data structure 65 | config = jsondecode(var.config) 66 | 67 | # Get current account id from aws_caller_identity data source 68 | current_account_id = "${data.aws_caller_identity.current.account_id}" 69 | 70 | # Get tfctl configuration for the current account 71 | current_account_conf = [ for account in local.config["accounts"]: account if account["id"] == local.current_account_id ][0] 72 | } 73 | ---- 74 | 75 | This file contains 76 | https://www.terraform.io/docs/configuration/variables.html[input variables] for 77 | the profile. 78 | 79 | The `config` variable is special and must always be declared in a tfctl 80 | profile. Tfctl configuration can be accessed using this variable. This It 81 | includes an array of all discovered accounts as well their parameters from 82 | tfctl config file. 83 | 84 | TIP: You can run `tfctl -s` to show the config data in 85 | yaml format. This exact data is available in the `config` variable in your 86 | profile. 87 | 88 | We also have few https://www.terraform.io/docs/configuration/locals.html[local 89 | variables] in the `locals` block. We assign the current account id from the 90 | data source we defined previously to `current_account_id`. This is mainly for 91 | convenience to make the next statement easier to read. `current_account` loops 92 | over the `config` data and returns configuration for an account which matches 93 | the current account id (i.e. the current account configuration). 94 | 95 | Now that we have our data inputs sorted we can start declaring actual AWS 96 | resources to manage. 97 | 98 | Create `main.tf`: 99 | 100 | .main.tf 101 | [source, tf] 102 | ---- 103 | resource "aws_s3_bucket" "example" { 104 | bucket = "tfctl-${local.current_account_conf["name"]}" 105 | acl = "private" 106 | } 107 | ---- 108 | 109 | This will create an S3 bucket with a name containing the current account name 110 | (which will vary depending on which account it's deployed to). 111 | 112 | == Assign profile to accounts 113 | 114 | Before you can deploy the new profile you need to tell `tfctl` which accounts 115 | to deploy it to. 116 | 117 | You have few options here: 118 | 119 | * deploy to all accounts 120 | * deploy to specific organization unit (OU) 121 | * deploy to individual account 122 | 123 | 124 | For the sake of this example we're going to deploy our bucket to all accounts 125 | in `test` OU. 126 | 127 | In `tfctl.yaml` add the profile to the `test` OU: 128 | 129 | [source, yaml] 130 | ---- 131 | organization_units: 132 | test: 133 | profiles: 134 | - example-profile 135 | ---- 136 | 137 | 138 | == Plan 139 | 140 | To see what would happen when the change is applied run: 141 | 142 | ---- 143 | tfctl -o test -- init 144 | tfctl -o test -- plan 145 | ---- 146 | 147 | This will run `terraform init` to initialise terraform and then `terraform 148 | plan` across all accounts in the `test` OU in parallel. It will display a diff 149 | of changes for each account. 150 | 151 | .example terraform plan 152 | ---- 153 | info: test-example: Terraform will perform the following actions: 154 | info: test-example: 155 | info: test-example: # module.example-profile.aws_s3_bucket.example will be created 156 | info: test-example: + resource "aws_s3_bucket" "example" { 157 | info: test-example: + acceleration_status = (known after apply) 158 | info: test-example: + acl = "private" 159 | info: test-example: + arn = (known after apply) 160 | info: test-example: + bucket = "tfctl-test-example" 161 | info: test-example: + bucket_domain_name = (known after apply) 162 | info: test-example: + bucket_regional_domain_name = (known after apply) 163 | info: test-example: + force_destroy = false 164 | info: test-example: + hosted_zone_id = (known after apply) 165 | info: test-example: + id = (known after apply) 166 | info: test-example: + region = (known after apply) 167 | info: test-example: + request_payer = (known after apply) 168 | info: test-example: + website_domain = (known after apply) 169 | info: test-example: + website_endpoint = (known after apply) 170 | info: test-example: 171 | info: test-example: + versioning { 172 | info: test-example: + enabled = (known after apply) 173 | info: test-example: + mfa_delete = (known after apply) 174 | info: test-example: } 175 | info: test-example: } 176 | info: test-example: 177 | info: test-example: Plan: 1 to add, 0 to change, 0 to destroy. 178 | ---- 179 | 180 | If there are errors in your profile, terraform will fail and usually indicate 181 | what went wrong. 182 | 183 | tfctl will generate a plan file automatically and use it with `apply` in the 184 | next step. 185 | 186 | == Apply 187 | 188 | Once you're happy with the plan, apply it. 189 | ---- 190 | tfctl -o test -- apply 191 | ---- 192 | -------------------------------------------------------------------------------- /docs/iam_permissions.adoc: -------------------------------------------------------------------------------- 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. Once these are configured, tfctl automatically assumes roles 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/`. 39 | -------------------------------------------------------------------------------- /docs/project_layout.adoc: -------------------------------------------------------------------------------- 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 | ├── tfctl.conf 27 | ├── modules 28 | │   └── s3-bucket 29 | │   ├── main.tf 30 | │   └── variables.tf 31 | └── profiles 32 | └── example-profile 33 | ├── data.tf 34 | ├── main.tf 35 | └── variables.tf 36 | ---- 37 | 38 | toc::[] 39 | 40 | == tfctl configuration file 41 | 42 | Assigns Terraform profiles and configuration to accounts based on: 43 | 44 | * Globally for all accounts 45 | * Account's organization unit 46 | * Individual accounts 47 | 48 | The configuration data is exposed to terraform via a profile `config` variable. 49 | 50 | It also defines Terraform and tfctl configuration such as state tracking and 51 | what IAM roles to use. 52 | 53 | By default, tfctl will use `tfctl.yaml` in its current working directory. You 54 | can specify a different file using `-c`. Multiple configurations are supported 55 | in the same project directory and generated data will be stored separately for 56 | each config file in `.tfctl/`. 57 | 58 | == `profiles` 59 | 60 | Profiles are re-usable collections of resources which can be applied to 61 | accounts. They are implemented just like usual modules but provide an 62 | intermediate bridge between re-usable modules and tfctl configuration (and/or 63 | other data sources). Profiles often compose multiple modules and provide 64 | configuration data to them. This approach makes it possible to re-use standard 65 | modules (e.g. Terraform module registry). 66 | 67 | == `modules` 68 | 69 | Standard Terraform modules. 70 | -------------------------------------------------------------------------------- /examples/bootstrap/terraform-exec-role.template: -------------------------------------------------------------------------------- 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 25 | -------------------------------------------------------------------------------- /examples/bootstrap/terraform-state.template: -------------------------------------------------------------------------------- 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 82 | -------------------------------------------------------------------------------- /examples/bootstrap/tfctl-org-access.template: -------------------------------------------------------------------------------- 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' 43 | -------------------------------------------------------------------------------- /examples/control_tower/modules/s3-bucket/main.tf: -------------------------------------------------------------------------------- 1 | resource aws_s3_bucket bucket { 2 | bucket = var.name 3 | acl = "private" 4 | } 5 | 6 | output "arn" { 7 | value = aws_s3_bucket.bucket.arn 8 | } 9 | -------------------------------------------------------------------------------- /examples/control_tower/modules/s3-bucket/variables.tf: -------------------------------------------------------------------------------- 1 | variable "name" { 2 | description = "Bucket name" 3 | type = string 4 | } 5 | -------------------------------------------------------------------------------- /examples/control_tower/profiles/example-profile/data.tf: -------------------------------------------------------------------------------- 1 | data "aws_caller_identity" "current" {} 2 | -------------------------------------------------------------------------------- /examples/control_tower/profiles/example-profile/main.tf: -------------------------------------------------------------------------------- 1 | resource "random_pet" "bucket_prefix" { 2 | } 3 | 4 | module "bucket" { 5 | source = "../../modules/s3-bucket" 6 | name = "${random_pet.bucket_prefix.id}-${local.account["data"]["example_bucket_name"]}" 7 | } 8 | 9 | output "bucket_arn" { 10 | value = module.bucket.arn 11 | } 12 | -------------------------------------------------------------------------------- /examples/control_tower/profiles/example-profile/terraform.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | aws = { 4 | source = "hashicorp/aws" 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /examples/control_tower/profiles/example-profile/variables.tf: -------------------------------------------------------------------------------- 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 account configuration from tfctl config 11 | account = [for account in local.config["accounts"] : account if account["id"] == local.account_id][0] 12 | } 13 | -------------------------------------------------------------------------------- /examples/control_tower/tfctl.yaml: -------------------------------------------------------------------------------- 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/tfctl.yaml -s 9 | # 10 | 11 | # 12 | # Terraform configuration 13 | # 14 | 15 | tf_state_bucket: 'CHANGEME' 16 | # tf_state_prefix: '' 17 | tf_state_dynamodb_table: 'terraform-lock' 18 | tf_state_region: 'eu-west-1' 19 | # Role for accessing state resources 20 | tf_state_role_arn: 'arn:aws:iam::SHARED_SERVICES_ACCOUNT_ID:role/TerraformStateRole' 21 | tf_required_version: '>= 0.12.29' 22 | aws_provider_version: '>= 2.14' 23 | # Role used by tfctl to retrieve data from AWS Organizations 24 | # Has to be set up in the primary org account 25 | tfctl_role_arn: 'arn:aws:iam::PRIMARY_ACCOUNT_ID:role/TfctlRole' 26 | # default_tags: {} 27 | 28 | # 29 | # Data 30 | # 31 | # Here you can add arbitrary data which will be accessible from Terraform 32 | # profiles. Data can also be defined per account in the organization sections 33 | # below. 34 | # 35 | # data: 36 | # my_parameter: some_value 37 | 38 | # 39 | # Organization configuration 40 | # 41 | # Assign resources and data to accounts based on the organization structure. 42 | # 43 | # IMPORTANT: Removing a Terraform profile here will remove all of it's 44 | # associated resources during next apply! 45 | 46 | # Configuration to apply to all accounts 47 | organization_root: 48 | # Role assumed by Terraform for execution in each account 49 | tf_execution_role: 'AWSControlTowerExecution' 50 | region: 'eu-west-1' 51 | data: 52 | # Bucket name used by example profile it will be prefixed with the target 53 | # account number for uniqueness across accounts. 54 | example_bucket_name: 'tfctl-example-bucket' 55 | # Assign example-profile to all accounts in managed OUs 56 | profiles: 57 | - example-profile 58 | 59 | # Configuration to apply to accounts in Organization Units 60 | # OU's not listed here will be ignored. 61 | organization_units: 62 | # Core: {} # Uncomment if you want to include Core OU accounts 63 | live: {} 64 | test: {} 65 | mgmt: 66 | data: 67 | # Override the example bucket name in mgmt OU accounts 68 | example_bucket_name: 'tfctl-ou-override-example' 69 | 70 | # Configuration to apply to individual accounts 71 | account_overrides: 72 | test-example1: 73 | data: 74 | # Override the bucket name in a specific account 75 | example_bucket_name: 'tfctl-account-override-example' 76 | 77 | 78 | # Exclude individual accounts from Terraform runs 79 | # 80 | # exclude_accounts: 81 | # - Audit 82 | # - 'Log archive' 83 | -------------------------------------------------------------------------------- /lib/hash.rb: -------------------------------------------------------------------------------- 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 | def symbolize_names!(result = self) 22 | case result 23 | when Hash 24 | # rubocop:disable Style/HashEachMethods 25 | result.keys.each do |key| 26 | result[key.to_sym] = symbolize_names!(result.delete(key)) 27 | end 28 | # rubocop:enable Style/HashEachMethods 29 | when Array 30 | result.map! { |r| symbolize_names!(r) } 31 | end 32 | result 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/tfctl.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'tfctl/aws_org' 4 | require_relative 'tfctl/config' 5 | require_relative 'tfctl/error' 6 | require_relative 'tfctl/executor' 7 | require_relative 'tfctl/generator' 8 | require_relative 'tfctl/logger' 9 | require_relative 'tfctl/schema' 10 | require_relative 'tfctl/version' 11 | -------------------------------------------------------------------------------- /lib/tfctl/aws_org.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'error' 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).each do |response| 29 | response.accounts.each do |account| 30 | next unless account.status == 'ACTIVE' 31 | 32 | output[:accounts] << { 33 | name: account.name, 34 | id: account.id, 35 | arn: account.arn, 36 | email: account.email, 37 | ou_path: ou_path.to_s, 38 | ou_parents: ou_path.to_s.split('/'), 39 | profiles: [], 40 | } 41 | end 42 | end 43 | 44 | end 45 | output 46 | end 47 | 48 | private 49 | 50 | # Get a mapping of ou_name => ou_id from AWS organizations 51 | def aws_ou_list 52 | output = {} 53 | root_ou_id = @aws_org_client.list_roots.roots[0].id 54 | 55 | ou_recurse = lambda do |ous| 56 | ous.each do |ou_name, ou_id| 57 | children = aws_ou_list_children(ou_id, ou_name) 58 | unless children.empty? 59 | output.merge!(children) 60 | ou_recurse.call(children) 61 | end 62 | end 63 | end 64 | ou_recurse.call(root: root_ou_id) 65 | 66 | output 67 | end 68 | 69 | # Get a list of child ou's for a parent 70 | def aws_ou_list_children(parent_id, parent_name) 71 | output = {} 72 | retries = 0 73 | 74 | @aws_org_client.list_children( 75 | child_type: 'ORGANIZATIONAL_UNIT', 76 | parent_id: parent_id, 77 | ).each do |response| 78 | response.children.each do |child| 79 | 80 | begin 81 | ou = @aws_org_client.describe_organizational_unit( 82 | organizational_unit_id: child.id, 83 | ).organizational_unit 84 | rescue Aws::Organizations::Errors::TooManyRequestsException 85 | # FIXME: - use logger 86 | puts 'AWS Organizations: too many requests. Retrying in 5 secs.' 87 | sleep 5 88 | retries += 1 89 | retry if retries < 10 90 | end 91 | 92 | ou_name = parent_name == :root ? ou.name.to_sym : "#{parent_name}/#{ou.name}".to_sym 93 | 94 | output[ou_name] = ou.id 95 | end 96 | end 97 | output 98 | end 99 | 100 | def aws_assume_role(role_arn) 101 | begin 102 | sts = Aws::STS::Client.new 103 | 104 | role_credentials = Aws::AssumeRoleCredentials.new( 105 | client: sts, 106 | role_arn: role_arn, 107 | role_session_name: 'tfctl', 108 | ) 109 | rescue StandardError => e 110 | raise Tfctl::Error, "Error assuming role: #{role_arn}, #{e.message}" 111 | end 112 | 113 | role_credentials 114 | end 115 | 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /lib/tfctl/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../hash' 4 | require_relative 'error' 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 an 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 | # AWS Organizations data 92 | config = aws_org_config 93 | # Merge organization sections from yaml file 94 | config = merge_accounts_config(config, yaml_config) 95 | # Import remaining parameters from yaml file 96 | config = import_yaml_config(config, yaml_config) 97 | # Set excluded property on any excluded accounts 98 | config = mark_excluded_accounts(config) 99 | # Remove any profiles that are unset 100 | config = remove_unset_profiles(config) 101 | # Set config name property (based on yaml config file name) 102 | config[:config_name] = config_name 103 | config 104 | end 105 | 106 | def write_cache(cache_file) 107 | FileUtils.mkdir_p File.dirname(cache_file) 108 | File.write(cache_file, to_yaml) 109 | end 110 | 111 | def read_cache(cache_file) 112 | unless File.exist?(cache_file) 113 | raise Tfctl::Error, "Cached configuration not found in: #{cache_file}" 114 | end 115 | 116 | YAML.load_file(cache_file) 117 | end 118 | 119 | # Sets :excluded property on any excluded accounts 120 | def mark_excluded_accounts(config) 121 | return config unless config.key?(:exclude_accounts) 122 | 123 | config[:accounts].each_with_index do |account, idx| 124 | # rubocop:disable Style/IfWithBooleanLiteralBranches 125 | config[:accounts][idx][:excluded] = config[:exclude_accounts].include?(account[:name]) ? true : false 126 | # rubocop:enable Style/IfWithBooleanLiteralBranches 127 | end 128 | 129 | config 130 | end 131 | 132 | def remove_unset_profiles(config) 133 | config[:accounts].each do |account| 134 | profiles_to_unset = [] 135 | account[:profiles].each do |profile| 136 | if profile =~ /\.unset$/ 137 | profiles_to_unset << profile 138 | profiles_to_unset << profile.chomp('.unset') 139 | end 140 | end 141 | account[:profiles] = account[:profiles] - profiles_to_unset 142 | end 143 | config 144 | end 145 | 146 | # Import yaml config other than organisation defaults sections which are merged elsewhere. 147 | def import_yaml_config(config, yaml_config) 148 | yaml_config.delete(:organization_root) 149 | yaml_config.delete(:organization_units) 150 | yaml_config.delete(:account_overrides) 151 | config.merge(yaml_config) 152 | end 153 | 154 | # Merge AWS Organizations accounts config with defaults from yaml config 155 | def merge_accounts_config(config, yaml_config) 156 | 157 | config[:accounts].each_with_index do |account_config, idx| 158 | account_name = account_config[:name].to_sym 159 | account_ou_parents = account_config[:ou_parents] 160 | 161 | # merge any root settings 162 | account_config = account_config.deep_merge(yaml_config[:organization_root]) 163 | 164 | # merge all OU levels settings 165 | account_ou_parents.each_with_index do |_, i| 166 | account_ou = account_ou_parents[0..i].join('/').to_sym 167 | if yaml_config[:organization_units].key?(account_ou) 168 | account_config = account_config.deep_merge(yaml_config[:organization_units][account_ou]) 169 | end 170 | end 171 | 172 | # merge any account overrides 173 | if yaml_config[:account_overrides].key?(account_name) 174 | account_config = account_config.deep_merge(yaml_config[:account_overrides][account_name]) 175 | end 176 | 177 | config[:accounts][idx] = account_config 178 | end 179 | config 180 | end 181 | 182 | end 183 | end 184 | -------------------------------------------------------------------------------- /lib/tfctl/error.rb: -------------------------------------------------------------------------------- 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 16 | -------------------------------------------------------------------------------- /lib/tfctl/executor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'open3' 4 | require 'fileutils' 5 | require 'shellwords' 6 | require_relative 'error' 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 | runcmd = if Gem.win_platform? 45 | exec.join(' ') 46 | else 47 | exec.shelljoin 48 | end 49 | 50 | # Set environment variables for Terraform 51 | env = { 52 | 'TF_INPUT' => '0', 53 | 'CHECKPOINT_DISABLE' => '1', 54 | 'TF_IN_AUTOMATION' => 'true', 55 | # 'TF_LOG' => 'TRACE' 56 | } 57 | 58 | log.debug "#{account_name}: Executing: #{runcmd}" 59 | 60 | FileUtils.cd path 61 | Open3.popen3(env, runcmd) do |stdin, stdout, stderr, wait_thr| 62 | stdin.close_write 63 | 64 | # capture stdout and stderr in separate threads to prevent deadlocks 65 | Thread.new do 66 | stdout.each do |line| 67 | semaphore.synchronize do 68 | unbuffered ? log.info("#{account_name}: #{line.chomp}") : output << ['info', line] 69 | end 70 | end 71 | end 72 | Thread.new do 73 | stderr.each do |line| 74 | semaphore.synchronize do 75 | unbuffered ? log.error("#{account_name}: #{line.chomp}") : output << ['error', line] 76 | end 77 | end 78 | end 79 | 80 | status = wait_thr.value 81 | 82 | # log the output 83 | output.each do |line| 84 | log.send(line[0], "#{account_name}: #{line[1].chomp}") 85 | end 86 | 87 | FileUtils.cd cwd 88 | FileUtils.rm_f plan_file if args[0] == 'apply' # tidy up the plan file 89 | 90 | unless status.exitstatus.zero? 91 | raise Tfctl::Error, "#{account_name}: #{runcmd} failed with exit code: #{status.exitstatus}" 92 | end 93 | end 94 | end 95 | 96 | # Adds plan file to `plan` and `apply` sub commands 97 | def plan_file_args(plan_file, subcmd) 98 | return ["-out=#{plan_file}"] if subcmd == 'plan' 99 | 100 | if subcmd == 'apply' 101 | raise Tfctl::Error, "Plan file not found in #{plan_file}. Run plan first." unless File.exist?(plan_file) 102 | 103 | return [plan_file.to_s] 104 | end 105 | 106 | return [] 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /lib/tfctl/generator.rb: -------------------------------------------------------------------------------- 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.write(path, "#{JSON.pretty_generate(block)}\n") 13 | end 14 | 15 | def make(account:, config:) 16 | target_dir = "#{PROJECT_ROOT}/.tfctl/#{config[:config_name]}/#{account[:name]}" 17 | tf_state_prefix = config.fetch(:tf_state_prefix, '').delete_suffix('/') 18 | tf_version = config.fetch(:tf_required_version, '>= 0.12.29') 19 | aws_provider_version = config.fetch(:aws_provider_version, '>= 2.14') 20 | 21 | FileUtils.mkdir_p target_dir 22 | 23 | terraform_block = { 24 | 'terraform' => { 25 | 'required_version' => tf_version, 26 | 'required_providers' => { 27 | 'aws' => { 28 | 'source' => 'hashicorp/aws', 29 | 'version' => aws_provider_version, 30 | }, 31 | }, 32 | 'backend' => { 33 | 's3' => { 34 | 'bucket' => config[:tf_state_bucket], 35 | 'key' => [tf_state_prefix, account[:name], 'tfstate'].join('/').delete_prefix('/'), 36 | 'region' => config[:tf_state_region], 37 | 'role_arn' => config[:tf_state_role_arn], 38 | 'dynamodb_table' => config[:tf_state_dynamodb_table], 39 | 'encrypt' => 'true', 40 | }, 41 | }, 42 | }, 43 | } 44 | write_json_block("#{target_dir}/terraform.tf.json", terraform_block) 45 | 46 | provider_block = { 47 | 'provider' => { 48 | 'aws' => { 49 | 'region' => account[:region], 50 | 'assume_role' => { 51 | 'role_arn' => "arn:aws:iam::#{account[:id]}:role/#{account[:tf_execution_role]}", 52 | }, 53 | 'default_tags' => { 54 | 'tags' => config.fetch(:default_tags, {}), 55 | }, 56 | }, 57 | }, 58 | } 59 | write_json_block("#{target_dir}/provider.tf.json", provider_block) 60 | 61 | vars_block = { 62 | 'variable' => { 63 | 'config' => { 64 | 'type' => 'string', 65 | }, 66 | }, 67 | } 68 | write_json_block("#{target_dir}/vars.tf.json", vars_block) 69 | 70 | # config is passed to profiles as a json encoded string. It can be 71 | # decoded in profile using jsondecode() function. 72 | config_block = { 'config' => config.to_json } 73 | write_json_block("#{target_dir}/config.auto.tfvars.json", config_block) 74 | 75 | FileUtils.rm Dir.glob("#{target_dir}/profile_*.tf.json") 76 | 77 | account[:profiles].each do |profile| 78 | profile_block = { 79 | 'module' => { 80 | profile => { 81 | 'source' => "../../../profiles/#{profile}", 82 | 'config' => '${var.config}', 83 | 'providers' => { 84 | 'aws' => 'aws', 85 | }, 86 | }, 87 | }, 88 | } 89 | 90 | write_json_block("#{target_dir}/profile_#{profile}.tf.json", profile_block) 91 | end 92 | 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/tfctl/logger.rb: -------------------------------------------------------------------------------- 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 53 | -------------------------------------------------------------------------------- /lib/tfctl/schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'json_schemer' 4 | require_relative 'error' 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/HashAlignment 33 | { 34 | 'type' => 'object', 35 | 'properties' => { 36 | 'tf_state_bucket' => { 'type' => 'string' }, 37 | 'tf_state_prefix' => { 'type' => 'string' }, 38 | 'tf_state_role_arn' => { 39 | 'type' => 'string', 40 | 'pattern' => iam_arn_pattern, 41 | }, 42 | 'tf_state_dynamodb_table' => { 'type' => 'string' }, 43 | 'tf_state_region' => { 'type' => 'string' }, 44 | 'tf_required_version' => { 'type' => 'string' }, 45 | 'aws_provider_version' => { 'type' => 'string' }, 46 | 'tfctl_role_arn' => { 47 | 'type' => 'string', 48 | 'pattern' => iam_arn_pattern, 49 | }, 50 | 'data' => { 'type' => 'object' }, 51 | 'exclude_accounts' => { 'type' => 'array' }, 52 | 'default_tags' => { 'type' => 'object' }, 53 | 'organization_root' => org_schema, 54 | 'organization_units' => org_schema, 55 | 'account_overrides' => org_schema, 56 | }, 57 | 'required' => %w[ 58 | tf_state_bucket 59 | tf_state_role_arn 60 | tf_state_dynamodb_table 61 | tf_state_region 62 | tfctl_role_arn 63 | ], 64 | 'additionalProperties' => false, 65 | } 66 | # rubocop:enable Layout/HashAlignment 67 | end 68 | 69 | def org_schema 70 | { 71 | 'type' => 'object', 72 | 'properties' => { 73 | 'profiles' => { 'type'=> 'array' }, 74 | 'data' => { 'type'=> 'object' }, 75 | 'tf_execution_role' => { 'type'=> 'string' }, 76 | 'region' => { 'type'=> 'string' }, 77 | }, 78 | } 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/tfctl/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tfctl 4 | VERSION = '1.7.0' 5 | end 6 | -------------------------------------------------------------------------------- /spec/config_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../lib/tfctl/config' 4 | 5 | RSpec.describe Tfctl::Config do 6 | # test data 7 | def yaml_config 8 | YAML.safe_load(File.read("#{PROJECT_ROOT}/spec/data/config.yaml"), aliases: true).symbolize_names! 9 | end 10 | 11 | def aws_org_config 12 | # rubocop:disable Security/YAMLLoad 13 | YAML.load(File.read("#{PROJECT_ROOT}/spec/data/aws_org.yaml")) 14 | # rubocop:enable Security/YAMLLoad 15 | end 16 | 17 | subject(:config) do 18 | Tfctl::Config.new( 19 | config_name: 'test', 20 | yaml_config: yaml_config, 21 | aws_org_config: aws_org_config, 22 | ) 23 | end 24 | 25 | it 'sets config name' do 26 | expect(config[:config_name]).to eq('test') 27 | end 28 | 29 | it 'contains root parameters' do 30 | yaml = yaml_config 31 | 32 | # remove merged parameters 33 | yaml.delete(:organization_root) 34 | yaml.delete(:organization_units) 35 | yaml.delete(:account_overrides) 36 | 37 | # check all the other's are present 38 | yaml.each do |k, v| 39 | expect(config[k]).to eq(v) 40 | end 41 | end 42 | 43 | it 'merges organization root parameters with all accounts' do 44 | root_params = yaml_config[:organization_root] 45 | 46 | config[:accounts].each do |account| 47 | root_params.each do |k, v| 48 | next if k == :data # overriden on some levels 49 | 50 | if k == :profiles 51 | root_params[:profiles].each do |profile| 52 | expect(account[:profiles]).to include(profile) 53 | end 54 | else 55 | expect(account[k]).to eq(v) 56 | end 57 | end 58 | end 59 | end 60 | 61 | it 'excludes account parameters from root config' do 62 | expect(config).not_to have_key(:organization_root) 63 | expect(config).not_to have_key(:organization_units) 64 | expect(config).not_to have_key(:account_overrides) 65 | end 66 | 67 | it 'adds profiles from all hierarchy levels to accounts' do 68 | root_profiles = ['global'] 69 | team_profiles = ['team-shared'] 70 | team_live_profiles = root_profiles + team_profiles + ['team-live'] 71 | team_test_profiles = root_profiles + team_profiles + ['team-test'] 72 | team_live_1_profiles = team_live_profiles + ['team-live-1'] 73 | 74 | config[:accounts].each do |account| 75 | if account[:ou_path] == 'core' 76 | expect(account[:profiles]).to match_array(root_profiles) 77 | end 78 | if account[:name] == 'team-live-1' 79 | expect(account[:profiles]).to match_array(team_live_1_profiles) 80 | end 81 | if account[:name] == 'team-live-2' 82 | expect(account[:profiles]).to match_array(team_live_profiles) 83 | end 84 | if account[:name] == 'team-test-1' 85 | expect(account[:profiles]).to match_array(team_test_profiles) 86 | end 87 | if account[:name] == 'team-test-2' 88 | expect(account[:profiles]).to match_array(team_test_profiles) 89 | end 90 | end 91 | end 92 | 93 | it 'overrides parameters set lower in the hierarchy' do 94 | config[:accounts].each do |account| 95 | if account[:ou_path] == 'core' 96 | expect(account[:data][:root_param]).to eq('root') 97 | expect(account[:data]).not_to have_key(:team_param) 98 | end 99 | if account[:ou_path] == 'team/test' 100 | expect(account[:data][:team_param]).to eq('ou_override') 101 | expect(account[:data][:root_param]).to eq('ou_override') 102 | end 103 | if account[:name] == 'team-live-1' 104 | expect(account[:data][:root_param]).to eq('account_override') 105 | expect(account[:data][:team_param]).to eq('account_override') 106 | end 107 | if account[:name] == 'team-live-2' 108 | expect(account[:data][:root_param]).to eq('root') 109 | expect(account[:data][:team_param]).to eq('shared') 110 | end 111 | end 112 | end 113 | 114 | it 'merges account specific parameters' do 115 | config[:accounts].each do |account| 116 | if account[:name] == 'team-live-1' 117 | expect(account[:data][:account_param]).to eq('account') 118 | else 119 | expect(account[:data]).not_to have_key(:account_param) 120 | end 121 | end 122 | end 123 | 124 | it 'finds accounts by parameter name and value' do 125 | accounts = config.find_accounts(:name, 'team-live-1') 126 | expect(accounts[0][:name]).to eq('team-live-1') 127 | accounts = config.find_accounts(:ou_path, 'team/test') 128 | expect(accounts.length).to eq(2) 129 | accounts = config.find_accounts(:id, '6123456789') 130 | expect(accounts[0][:name]).to eq('team-test-1') 131 | end 132 | 133 | it 'finds accounts by parameter name and regex value' do 134 | accounts = config.find_accounts_regex(:ou_path, '.*/test') 135 | expect(accounts.length).to eq(2) 136 | accounts.each do |account| 137 | expect(account[:ou_parents]).to include('test') 138 | expect(account[:ou_parents]).not_to include('live') 139 | expect(account[:ou_parents]).not_to include('core') 140 | end 141 | end 142 | 143 | it 'flags excluded accounts' do 144 | config[:accounts].each do |account| 145 | if %w[primary security log-archive].include?(account[:name]) 146 | expect(account[:excluded]).to eq(true) 147 | else 148 | expect(account[:excluded]).to eq(false) 149 | end 150 | end 151 | end 152 | 153 | end 154 | -------------------------------------------------------------------------------- /spec/data/aws_org.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | :accounts: 3 | - :name: shared-services 4 | :id: '0123456789' 5 | :arn: arn:aws:organizations::0123456789:account/o-lfhekdlsn/0123456789 6 | :email: shared-services+aws@example.com 7 | :ou_path: core 8 | :ou_parents: 9 | - core 10 | :profiles: [] 11 | - :name: log-archive 12 | :id: '1123456789' 13 | :arn: arn:aws:organizations::0123456789:account/o-lfhekdlsn/1123456789 14 | :email: log-archive+aws@example.com 15 | :ou_path: core 16 | :ou_parents: 17 | - core 18 | :profiles: [] 19 | - :name: security 20 | :id: '2123456789' 21 | :arn: arn:aws:organizations::0123456789:account/o-lfhekdlsn/2123456789 22 | :email: security+aws@example.com 23 | :ou_parents: 24 | - core 25 | :profiles: [] 26 | - :name: primary 27 | :id: '3123456789' 28 | :arn: arn:aws:organizations::0123456789:account/o-lfhekdlsn/3123456789 29 | :email: primary+aws@example.com 30 | :ou_path: root 31 | :ou_parents: [] 32 | :profiles: [] 33 | - :name: team-live-1 34 | :id: '4123456789' 35 | :arn: arn:aws:organizations::0123456789:account/o-lfhekdlsn/4123456789 36 | :email: team-live-1+aws@example.com 37 | :ou_path: team/live 38 | :ou_parents: 39 | - team 40 | - live 41 | :profiles: [] 42 | - :name: team-live-2 43 | :id: '5123456789' 44 | :arn: arn:aws:organizations::0123456789:account/o-lfhekdlsn/5123456789 45 | :email: team-live-2+aws@example.com 46 | :ou_path: team/live 47 | :ou_parents: 48 | - team 49 | - live 50 | :profiles: [] 51 | - :name: team-test-1 52 | :id: '6123456789' 53 | :arn: arn:aws:organizations::0123456789:account/o-lfhekdlsn/6123456789 54 | :email: team-test-1+aws@example.com 55 | :ou_path: team/test 56 | :ou_parents: 57 | - team 58 | - test 59 | :profiles: [] 60 | - :name: team-test-2 61 | :id: '7123456789' 62 | :arn: arn:aws:organizations::0123456789:account/o-lfhekdlsn/7123456789 63 | :email: team-test-2+aws@example.com 64 | :ou_path: team/test 65 | :ou_parents: 66 | - team 67 | - test 68 | :profiles: [] 69 | -------------------------------------------------------------------------------- /spec/data/config.yaml: -------------------------------------------------------------------------------- 1 | # Tfctl test configuration 2 | 3 | # Terraform settings 4 | 5 | tf_state_bucket: 'terraform-state' 6 | tf_state_prefix: 'prefix' 7 | tf_state_role_arn: 'arn:aws:iam::012345678900:role/TerraformStateUser' 8 | tf_state_dynamodb_table: 'terraform-lock' 9 | tf_state_region: 'eu-west-1' 10 | tf_required_version: '>= 0.12.29' 11 | aws_provider_version: '>= 2.14' 12 | tfctl_role_arn: 'arn:aws:iam::012345678900:role/TfCtlUser' 13 | default_tags: 14 | terraform: 'yes' 15 | 16 | # test yaml aliases 17 | data: &global_params 18 | global_param: 'global' 19 | 20 | data: *global_params 21 | 22 | # Organization config 23 | 24 | organization_root: 25 | tf_execution_role: 'TerraformUser' 26 | region: 'eu-west-1' 27 | data: 28 | root_param: 'root' 29 | profiles: 30 | - global 31 | 32 | organization_units: 33 | core: {} 34 | 35 | team: 36 | data: 37 | team_param: 'shared' 38 | profiles: 39 | - team-shared 40 | team/live: 41 | profiles: 42 | - team-live 43 | team/test: 44 | data: 45 | team_param: 'ou_override' 46 | root_param: 'ou_override' 47 | profiles: 48 | - team-test 49 | 50 | account_overrides: 51 | 52 | team-live-1: 53 | data: 54 | account_param: 'account' 55 | team_param: 'account_override' 56 | root_param: 'account_override' 57 | profiles: 58 | - team-live-1 59 | 60 | exclude_accounts: 61 | - primary 62 | - security 63 | - log-archive 64 | -------------------------------------------------------------------------------- /spec/generator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../lib/tfctl/config' 4 | require_relative '../lib/tfctl/generator' 5 | 6 | RSpec.describe Tfctl::Generator do 7 | # test data 8 | def yaml_config 9 | YAML.safe_load(File.read("#{PROJECT_ROOT}/spec/data/config.yaml"), aliases: true).symbolize_names! 10 | end 11 | 12 | def aws_org_config 13 | # rubocop:disable Security/YAMLLoad 14 | YAML.load(File.read("#{PROJECT_ROOT}/spec/data/aws_org.yaml")) 15 | # rubocop:enable Security/YAMLLoad 16 | end 17 | 18 | 19 | before(:all) do 20 | @config = Tfctl::Config.new( 21 | config_name: 'test', 22 | yaml_config: yaml_config, 23 | aws_org_config: aws_org_config, 24 | ) 25 | @account = @config[:accounts][0] 26 | @generated_dir = "#{PROJECT_ROOT}/.tfctl/#{@config[:config_name]}/#{@account[:name]}" 27 | 28 | Tfctl::Generator.make( 29 | config: @config, 30 | account: @account, 31 | ) 32 | end 33 | 34 | it 'generates valid provider resource' do 35 | file = File.read("#{@generated_dir}/provider.tf.json") 36 | provider = JSON.parse(file)['provider'] 37 | 38 | expect(provider['aws']['region']).to eq(@account[:region]) 39 | expect(provider['aws']['assume_role']['role_arn']).to eq("arn:aws:iam::#{@account[:id]}:role/#{@account[:tf_execution_role]}") 40 | expect(provider['aws']['default_tags']['tags']['terraform']).to eq('yes') 41 | end 42 | 43 | it 'generates valid terraform resource' do 44 | file = File.read("#{@generated_dir}/terraform.tf.json") 45 | terraform = JSON.parse(file)['terraform'] 46 | 47 | expect(terraform['required_version']).to eq(@config[:tf_required_version]) 48 | expect(terraform['required_providers']['aws']['source']).to eq('hashicorp/aws') 49 | expect(terraform['required_providers']['aws']['version']).to eq(@config[:aws_provider_version]) 50 | expect(terraform['backend']['s3']['bucket']).to eq(@config[:tf_state_bucket]) 51 | expect(terraform['backend']['s3']['key']).to eq("#{@config[:tf_state_prefix]}/#{@account[:name]}/tfstate") 52 | expect(terraform['backend']['s3']['region']).to eq(@account[:region]) 53 | expect(terraform['backend']['s3']['role_arn']).to eq(@config[:tf_state_role_arn]) 54 | expect(terraform['backend']['s3']['dynamodb_table']).to eq(@config[:tf_state_dynamodb_table]) 55 | expect(terraform['backend']['s3']['encrypt']).to eq('true') 56 | end 57 | 58 | it 'generates valid variables' do 59 | file = File.read("#{@generated_dir}/vars.tf.json") 60 | variable = JSON.parse(file)['variable'] 61 | 62 | expect(variable['config']['type']).to eq('string') 63 | end 64 | 65 | it 'generates valid profile module' do 66 | profile_name = @account[:profiles][0] 67 | file = File.read("#{@generated_dir}/profile_#{profile_name}.tf.json") 68 | profile_module = JSON.parse(file)['module'] 69 | 70 | expect(profile_module[profile_name]['source']).to eq("../../../profiles/#{profile_name}") 71 | expect(profile_module[profile_name]['config']).to eq('${var.config}') 72 | end 73 | 74 | it 'generates valid config auto tfvars' do 75 | file = File.read("#{@generated_dir}/config.auto.tfvars.json") 76 | config_var = JSON.parse(file)['config'] 77 | 78 | expect(config_var).to eq(@config.to_json) 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /spec/schema_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'yaml' 4 | require_relative '../lib/hash' 5 | require_relative '../lib/tfctl/schema' 6 | 7 | RSpec.describe Tfctl::Schema do 8 | let(:yaml_config) do 9 | YAML.safe_load(File.read("#{PROJECT_ROOT}/spec/data/config.yaml"), aliases: true) 10 | end 11 | 12 | subject do 13 | Tfctl::Schema.validate(yaml_config) 14 | end 15 | 16 | it 'validates correct configuration' do 17 | expect { subject }.to_not raise_error 18 | end 19 | 20 | it 'fails when required parameter is missing' do 21 | yaml_config.delete('tf_state_bucket') 22 | expect { subject }.to raise_error Tfctl::ValidationError 23 | end 24 | 25 | it 'fails when parameter type is incorrect' do 26 | yaml_config['tf_state_region'] = 1 27 | expect { subject }.to raise_error Tfctl::ValidationError 28 | end 29 | 30 | it 'fails when string regex doesnt match' do 31 | yaml_config['tf_state_role_arn'] = 'some other string' 32 | expect { subject }.to raise_error Tfctl::ValidationError 33 | end 34 | 35 | it 'fails when unexpected parameters are found' do 36 | yaml_config['new_parameter'] = 'some value' 37 | expect { subject }.to raise_error Tfctl::ValidationError 38 | end 39 | 40 | end 41 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | 4 | PROJECT_ROOT = File.expand_path("#{__dir__}/../") 5 | 6 | # This file was generated by the `rspec --init` command. Conventionally, all 7 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 8 | # The generated `.rspec` file contains `--require spec_helper` which will cause 9 | # this file to always be loaded, without a need to explicitly require it in any 10 | # files. 11 | # 12 | # Given that it is always loaded, you are encouraged to keep this file as 13 | # light-weight as possible. Requiring heavyweight dependencies from this file 14 | # will add to the boot time of your test suite on EVERY test run, even for an 15 | # individual file that may not need all of that loaded. Instead, consider making 16 | # a separate helper file that requires the additional dependencies and performs 17 | # the additional setup, and require it from the spec files that actually need 18 | # it. 19 | # 20 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 21 | RSpec.configure do |config| 22 | # rspec-expectations config goes here. You can use an alternate 23 | # assertion/expectation library such as wrong or the stdlib/minitest 24 | # assertions if you prefer. 25 | config.expect_with :rspec do |expectations| 26 | # This option will default to `true` in RSpec 4. It makes the `description` 27 | # and `failure_message` of custom matchers include text for helper methods 28 | # defined using `chain`, e.g.: 29 | # be_bigger_than(2).and_smaller_than(4).description 30 | # # => "be bigger than 2 and smaller than 4" 31 | # ...rather than: 32 | # # => "be bigger than 2" 33 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 34 | end 35 | 36 | # rspec-mocks config goes here. You can use an alternate test double 37 | # library (such as bogus or mocha) by changing the `mock_with` option here. 38 | config.mock_with :rspec do |mocks| 39 | # Prevents you from mocking or stubbing a method that does not exist on 40 | # a real object. This is generally recommended, and will default to 41 | # `true` in RSpec 4. 42 | mocks.verify_partial_doubles = true 43 | end 44 | 45 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 46 | # have no way to turn it off -- the option exists only for backwards 47 | # compatibility in RSpec 3). It causes shared context metadata to be 48 | # inherited by the metadata hash of host groups and examples, rather than 49 | # triggering implicit auto-inclusion in groups with matching metadata. 50 | config.shared_context_metadata_behavior = :apply_to_host_groups 51 | 52 | 53 | config.after(:all) do 54 | FileUtils.rm_rf "#{PROJECT_ROOT}/.tfctl" 55 | end 56 | 57 | # The settings below are suggested to provide a good initial experience 58 | # with RSpec, but feel free to customize to your heart's content. 59 | begin 60 | # This allows you to limit a spec run to individual examples or groups 61 | # you care about by tagging them with `:focus` metadata. When nothing 62 | # is tagged with `:focus`, all examples get run. RSpec also provides 63 | # aliases for `it`, `describe`, and `context` that include `:focus` 64 | # metadata: `fit`, `fdescribe` and `fcontext`, respectively. 65 | config.filter_run_when_matching :focus 66 | 67 | # Allows RSpec to persist some state between runs in order to support 68 | # the `--only-failures` and `--next-failure` CLI options. We recommend 69 | # you configure your source control system to ignore this file. 70 | config.example_status_persistence_file_path = 'spec/reports/status.txt' 71 | 72 | # Limits the available syntax to the non-monkey patched syntax that is 73 | # recommended. For more details, see: 74 | # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ 75 | # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 76 | # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode 77 | config.disable_monkey_patching! 78 | 79 | # This setting enables warnings. It's recommended, but in some cases may 80 | # be too noisy due to issues in dependencies. 81 | config.warnings = true 82 | 83 | # Many RSpec users commonly either run the entire suite or an individual 84 | # file, and it's useful to allow more verbose output when running an 85 | # individual spec file. 86 | if config.files_to_run.one? 87 | # Use the documentation formatter for detailed output, 88 | # unless a formatter has already been configured 89 | # (e.g. via a command-line flag). 90 | config.default_formatter = 'doc' 91 | end 92 | 93 | # Print the 10 slowest examples and example groups at the 94 | # end of the spec run, to help surface which specs are running 95 | # particularly slow. 96 | config.profile_examples = 10 97 | 98 | # Run specs in random order to surface order dependencies. If you find an 99 | # order dependency and want to debug it, you can fix the order by providing 100 | # the seed, which is printed after each run. 101 | # --seed 1234 102 | config.order = :random 103 | 104 | # Seed global randomization in this process using the `--seed` CLI option. 105 | # Setting this allows you to use `--seed` to deterministically reproduce 106 | # test failures related to randomization by passing the same `--seed` value 107 | # as the one that triggered the failure. 108 | Kernel.srand config.seed 109 | end 110 | 111 | end 112 | -------------------------------------------------------------------------------- /tfctl.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH << File.expand_path('lib', __dir__) 4 | require 'tfctl/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'tfctl' 8 | spec.version = Tfctl::VERSION 9 | spec.authors = [ 10 | 'Andrew Wasilczuk', 11 | ] 12 | spec.email = [ 13 | 'akw@scalefactory.com', 14 | ] 15 | spec.summary = 'Terraform wrapper for managing multi-account AWS infrastructures' 16 | spec.homepage = 'https://github.com/scalefactory/tfctl' 17 | spec.license = 'MIT' 18 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 19 | f.match(%r{^(test|spec|features)/}) 20 | end 21 | spec.bindir = 'bin' 22 | spec.executables = spec.files.grep(%r{^bin/tfctl}) { |f| File.basename(f) } 23 | spec.require_paths = ['lib'] 24 | 25 | spec.required_ruby_version = '>= 2.5.0' 26 | 27 | # Think when adding new dependencies. Is it really necessary? 28 | # "The things you own end up owning you" etc. 29 | spec.add_dependency 'aws-sdk-organizations', '~> 1.40' 30 | spec.add_dependency 'json_schemer', '>= 0.2', '< 3.0' 31 | spec.add_dependency 'parallel', '~> 1.19' 32 | spec.add_dependency 'terminal-table', '>= 1.8', '< 4.0' 33 | 34 | spec.add_development_dependency 'guard-rspec', '~> 4.7' 35 | spec.add_development_dependency 'rspec', '~> 3.9' 36 | spec.add_development_dependency 'rubocop', '~> 1.3' 37 | spec.add_development_dependency 'rubocop-rspec', '~> 2.2' 38 | spec.metadata['rubygems_mfa_required'] = 'true' 39 | end 40 | --------------------------------------------------------------------------------