├── .github └── workflows │ ├── codeql-analysis.yml │ ├── pytest.yml │ └── python-publish.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── DESCRIPTION.rst ├── LICENSE.txt ├── README.md ├── python_terraform ├── __init__.py ├── terraform.py └── tfstate.py ├── requirements.txt ├── requirements_dev.txt ├── setup.cfg ├── setup.py └── test ├── bad_fmt └── test.tf ├── test_terraform.py ├── test_tfstate_file └── tfstate.test ├── test_tfstate_file2 └── terraform.tfstate ├── test_tfstate_file3 └── .terraform │ └── terraform.tfstate ├── tfvar_files └── test.tfvars ├── var_to_output ├── test.tf └── test_map_var.json └── vars_require_input └── main.tf /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ develop ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ develop ] 20 | schedule: 21 | - cron: '29 6 * * 6' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | -------------------------------------------------------------------------------- /.github/workflows/pytest.yml: -------------------------------------------------------------------------------- 1 | name: "pytest:py3 " 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | pull_request: 8 | branches: [ master ] 9 | 10 | # Allows you to run this workflow manually from the Actions tab 11 | workflow_dispatch: 12 | 13 | jobs: 14 | build: 15 | 16 | runs-on: ${{ matrix.os }} 17 | strategy: 18 | matrix: 19 | python-version: [3.7, 3.8, 3.9] 20 | os: [ubuntu-latest, macOS-latest] 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v2 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | - name: Install dependencies 29 | run: | 30 | python -m pip install --upgrade pip 31 | pip install -r requirements_dev.txt 32 | - name: Test with pytest 33 | run: | 34 | PYTHONPATH=. python -m pytest -v -s --cache-clear --cov=python-terraform test 35 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | deploy: 9 | 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Set up Python 15 | uses: actions/setup-python@v2 16 | with: 17 | python-version: '3.x' 18 | - name: Install dependencies 19 | run: | 20 | python -m pip install --upgrade pip 21 | pip install setuptools wheel twine 22 | - name: Build python package 23 | run: | 24 | python setup.py sdist bdist_wheel 25 | - name: Publish package 26 | uses: pypa/gh-action-pypi-publish@v1.4.2 27 | with: 28 | user: __token__ 29 | password: ${{ secrets.PYPI_API_TOKEN }} 30 | skip_existing: true 31 | verbose: true 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.tfstate 2 | *.tfstate.backup 3 | *.pyc 4 | *.egg-info 5 | .idea 6 | .cache 7 | /.pypirc 8 | /.tox/ 9 | .dropbox 10 | env/ 11 | Icon 12 | /pytestdebug.log 13 | .DS_Store 14 | 15 | # virtualenv 16 | .virtualenv/ 17 | venv/ 18 | 19 | 20 | # Intellij 21 | .idea/ 22 | 23 | # VSCode 24 | .vscode/ 25 | pyrightconfig.json 26 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.6 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v3.1.0 # v2.1.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - id: check-docstring-first 10 | - id: check-json 11 | - id: check-merge-conflict 12 | - id: check-toml 13 | - id: check-yaml 14 | - id: debug-statements 15 | - id: requirements-txt-fixer 16 | - repo: https://github.com/pycqa/isort 17 | rev: 5.5.2 18 | hooks: 19 | - id: isort 20 | - repo: https://github.com/lovesegfault/beautysh 21 | rev: 6.0.1 22 | hooks: 23 | - id: beautysh 24 | - repo: https://github.com/psf/black 25 | rev: 19.10b0 26 | hooks: 27 | - id: black 28 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - '3.6' 4 | - '3.7' 5 | - '3.8' 6 | - '3.9' 7 | before_install: sudo apt-get install unzip 8 | before_script: 9 | - export TFVER=0.13.4 10 | - export TFURL=https://releases.hashicorp.com/terraform/ 11 | - TFURL+=$TFVER 12 | - TFURL+="/terraform_" 13 | - TFURL+=$TFVER 14 | - TFURL+="_linux_amd64.zip" 15 | - wget $TFURL -O terraform_bin.zip 16 | - mkdir tf_bin 17 | - unzip terraform_bin.zip -d tf_bin 18 | install: 19 | - curl https://bootstrap.pypa.io/ez_setup.py -o - | python 20 | - pip install . 21 | script: 22 | - export PATH=$PATH:$PWD/tf_bin 23 | - pytest -v 24 | branches: 25 | only: 26 | - master 27 | - develop 28 | - release/** 29 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | ## [0.9.1] 3 | 1. [#10] log handler error on Linux environment 4 | 1. [#11] Fix reading state file for remote state and support backend config for 5 | init command 6 | 7 | ## [0.9.0] 8 | 1. [#12] Output function doesn't accept parameter 'module' 9 | 1. [#16] Handle empty space/special characters when passing string to command line options 10 | 1. Tested with terraform 0.10.0 11 | 12 | ## [0.10.0] 13 | 1. [#27] No interaction for apply function 14 | 1. [#18] Return access to the subprocess so output can be handled as desired 15 | 1. [#24] Full support for output(); support for raise_on_error 16 | 17 | ## [0.10.1] 18 | 1. [#48] adding extension for temp file to adopt the change in terraform 0.12.0 19 | 1. [#49] add workspace support 20 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beelit94/python-terraform/8a3451c54ce011c91a105b84b18498a0d615473c/CONTRIBUTING.md -------------------------------------------------------------------------------- /DESCRIPTION.rst: -------------------------------------------------------------------------------- 1 | Please see README at github_ 2 | 3 | .. _github: https://github.com/beelit94/python-terraform/blob/master/README.md 4 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 beelit94@gmail.com 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Introduction 2 | 3 | python-terraform is a python module provide a wrapper of `terraform` command line tool. 4 | `terraform` is a tool made by Hashicorp, please refer to https://terraform.io/ 5 | 6 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 7 | [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit) 8 | 9 | ### Status 10 | [![Build Status](https://travis-ci.org/aubustou/python-terraform.svg?branch=develop)](https://travis-ci.org/aubustou/python-terraform) 11 | 12 | ## Installation 13 | pip install python-terraform 14 | 15 | ## Usage 16 | #### For any terraform command 17 | 18 | from python_terraform import * 19 | t = Terraform() 20 | return_code, stdout, stderr = t.(*arguments, **options) 21 | 22 | **Note**: method name same as reserved keyword like `import` won't be accepted by python interpreter, 23 | to be able to call the method, you could call cmd_name by adding `_cmd` after command name, for example, 24 | `import` here could be called by 25 | 26 | from python_terraform import * 27 | t = Terraform() 28 | return_code, stdout, stderr = t.import_cmd(*arguments, **options) 29 | 30 | or just call cmd method directly 31 | 32 | from python_terraform import * 33 | t = Terraform() 34 | return_code, stdout, stderr = t.cmd(, *arguments, **options) 35 | 36 | #### For any argument 37 | simply pass the string to arguments of the method, for example, 38 | 39 | terraform apply target_dir 40 | --> .apply('target_dir') 41 | terraform import aws_instance.foo i-abcd1234 42 | --> .import('aws_instance.foo', 'i-abcd1234') 43 | 44 | #### For any options 45 | 46 | * dash to underscore 47 | 48 | remove first dash, and then use underscore to replace dash symbol as option name 49 | 50 | ex. -no-color --> no_color 51 | 52 | * for a simple flag option 53 | 54 | use ```IsFlagged/None``` as value for raising/not raising flag, for example, 55 | 56 | terraform taint -allow-missing 57 | --> .taint(allow_missing=IsFlagged) 58 | terraform taint 59 | --> .taint(allow_missing=None) or .taint() 60 | terraform apply -no-color 61 | --> .apply(no_color=IsFlagged) 62 | 63 | * for a boolean value option 64 | 65 | assign True or False, for example, 66 | 67 | terraform apply -refresh=true --> .apply(refresh=True) 68 | 69 | * if a flag could be used multiple times, assign a list to it's value 70 | 71 | terraform apply -target=aws_instance.foo[1] -target=aws_instance.foo[2] 72 | ---> 73 | .apply(target=['aws_instance.foo[1]', 'aws_instance.foo[2]']) 74 | * for the "var" flag, assign dictionary to it 75 | 76 | terraform apply -var='a=b' -var='c=d' 77 | --> tf.apply(var={'a':'b', 'c':'d'}) 78 | * if an option with None as value, it won't be used 79 | 80 | #### Terraform Output 81 | 82 | By default, stdout and stderr are captured and returned. This causes the application to appear to hang. To print terraform output in real time, provide the `capture_output` option with any value other than `None`. This will cause the output of terraform to be printed to the terminal in real time. The value of `stdout` and `stderr` below will be `None`. 83 | 84 | 85 | from python_terraform import Terraform 86 | t = Terraform() 87 | return_code, stdout, stderr = t.(capture_output=False) 88 | 89 | ## Examples 90 | ### Have a test.tf file under folder "/home/test" 91 | #### 1. apply with variables a=b, c=d, refresh=false, no color in the output 92 | In shell: 93 | 94 | cd /home/test 95 | terraform apply -var='a=b' -var='c=d' -refresh=false -no-color 96 | 97 | In python-terraform: 98 | 99 | from python_terraform import * 100 | tf = Terraform(working_dir='/home/test') 101 | tf.apply(no_color=IsFlagged, refresh=False, var={'a':'b', 'c':'d'}) 102 | 103 | or 104 | 105 | from python_terraform import * 106 | tf = Terraform() 107 | tf.apply('/home/test', no_color=IsFlagged, refresh=False, var={'a':'b', 'c':'d'}) 108 | 109 | or 110 | 111 | from python_terraform import * 112 | tf = Terraform(working_dir='/home/test', variables={'a':'b', 'c':'d'}) 113 | tf.apply(no_color=IsFlagged, refresh=False) 114 | 115 | #### 2. fmt command, diff=true 116 | In shell: 117 | 118 | cd /home/test 119 | terraform fmt -diff=true 120 | 121 | In python-terraform: 122 | 123 | from python_terraform import * 124 | tf = terraform(working_dir='/home/test') 125 | tf.fmt(diff=True) 126 | 127 | 128 | ## default values 129 | for apply/plan/destroy command, assign with following default value to make 130 | caller easier in python 131 | 132 | 1. ```input=False```, in this case process won't hang because you missing a variable 133 | 1. ```no_color=IsFlagged```, in this case, stdout of result is easier for parsing 134 | 135 | ## Implementation 136 | IMHO, how terraform design boolean options is confusing. 137 | Take `input=True` and `-no-color` option of `apply` command for example, 138 | they're all boolean value but with different option type. 139 | This make api caller don't have a general rule to follow but to do 140 | a exhaustive method implementation which I don't prefer to. 141 | Therefore I end-up with using `IsFlagged` or `IsNotFlagged` as value of option 142 | like `-no-color` and `True/False` value reserved for option like `refresh=true` 143 | -------------------------------------------------------------------------------- /python_terraform/__init__.py: -------------------------------------------------------------------------------- 1 | from .terraform import ( 2 | IsFlagged, 3 | IsNotFlagged, 4 | Terraform, 5 | TerraformCommandError, 6 | VariableFiles, 7 | ) 8 | from .tfstate import Tfstate 9 | -------------------------------------------------------------------------------- /python_terraform/terraform.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | import subprocess 5 | import sys 6 | import tempfile 7 | from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Type, Union 8 | 9 | from python_terraform.tfstate import Tfstate 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | COMMAND_WITH_SUBCOMMANDS = {"workspace"} 14 | 15 | 16 | class TerraformFlag: 17 | pass 18 | 19 | 20 | class IsFlagged(TerraformFlag): 21 | pass 22 | 23 | 24 | class IsNotFlagged(TerraformFlag): 25 | pass 26 | 27 | 28 | CommandOutput = Tuple[Optional[int], Optional[str], Optional[str]] 29 | 30 | 31 | class TerraformCommandError(subprocess.CalledProcessError): 32 | def __init__(self, ret_code: int, cmd: str, out: Optional[str], err: Optional[str]): 33 | super(TerraformCommandError, self).__init__(ret_code, cmd) 34 | self.out = out 35 | self.err = err 36 | logger.error("Error with command %s. Reason: %s", self.cmd, self.err) 37 | 38 | 39 | class Terraform: 40 | """Wrapper of terraform command line tool. 41 | 42 | https://www.terraform.io/ 43 | """ 44 | 45 | def __init__( 46 | self, 47 | working_dir: Optional[str] = None, 48 | targets: Optional[Sequence[str]] = None, 49 | state: Optional[str] = None, 50 | variables: Optional[Dict[str, str]] = None, 51 | parallelism: Optional[str] = None, 52 | var_file: Optional[str] = None, 53 | terraform_bin_path: Optional[str] = None, 54 | is_env_vars_included: bool = True, 55 | ): 56 | """ 57 | :param working_dir: the folder of the working folder, if not given, 58 | will be current working folder 59 | :param targets: list of target 60 | as default value of apply/destroy/plan command 61 | :param state: path of state file relative to working folder, 62 | as a default value of apply/destroy/plan command 63 | :param variables: default variables for apply/destroy/plan command, 64 | will be override by variable passing by apply/destroy/plan method 65 | :param parallelism: default parallelism value for apply/destroy command 66 | :param var_file: passed as value of -var-file option, 67 | could be string or list, list stands for multiple -var-file option 68 | :param terraform_bin_path: binary path of terraform 69 | :type is_env_vars_included: bool 70 | :param is_env_vars_included: included env variables when calling terraform cmd 71 | """ 72 | self.is_env_vars_included = is_env_vars_included 73 | self.working_dir = working_dir 74 | self.state = state 75 | self.targets = [] if targets is None else targets 76 | self.variables = dict() if variables is None else variables 77 | self.parallelism = parallelism 78 | self.terraform_bin_path = ( 79 | terraform_bin_path if terraform_bin_path else "terraform" 80 | ) 81 | self.var_file = var_file 82 | self.temp_var_files = VariableFiles() 83 | 84 | # store the tfstate data 85 | self.tfstate = None 86 | self.read_state_file(self.state) 87 | 88 | def __getattr__(self, item: str) -> Callable: 89 | def wrapper(*args, **kwargs): 90 | cmd_name = str(item) 91 | if cmd_name.endswith("_cmd"): 92 | cmd_name = cmd_name[:-4] 93 | logger.debug("called with %r and %r", args, kwargs) 94 | return self.cmd(cmd_name, *args, **kwargs) 95 | 96 | return wrapper 97 | 98 | def apply( 99 | self, 100 | dir_or_plan: Optional[str] = None, 101 | input: bool = False, 102 | skip_plan: bool = True, 103 | no_color: Type[TerraformFlag] = IsFlagged, 104 | **kwargs, 105 | ) -> CommandOutput: 106 | """Refer to https://terraform.io/docs/commands/apply.html 107 | 108 | no-color is flagged by default 109 | :param no_color: disable color of stdout 110 | :param input: disable prompt for a missing variable 111 | :param dir_or_plan: folder relative to working folder 112 | :param skip_plan: force apply without plan (default: false) 113 | :param kwargs: same as kwags in method 'cmd' 114 | :returns return_code, stdout, stderr 115 | """ 116 | if not skip_plan: 117 | return self.plan(dir_or_plan=dir_or_plan, **kwargs) 118 | default = kwargs.copy() 119 | default["input"] = input 120 | default["no_color"] = no_color 121 | default["auto-approve"] = True # a False value will require an input 122 | option_dict = self._generate_default_options(default) 123 | args = self._generate_default_args(dir_or_plan) 124 | return self.cmd("apply", *args, **option_dict) 125 | 126 | def _generate_default_args(self, dir_or_plan: Optional[str]) -> Sequence[str]: 127 | return [dir_or_plan] if dir_or_plan else [] 128 | 129 | def _generate_default_options( 130 | self, input_options: Dict[str, Any] 131 | ) -> Dict[str, Any]: 132 | return { 133 | "state": self.state, 134 | "target": self.targets, 135 | "var": self.variables, 136 | "var_file": self.var_file, 137 | "parallelism": self.parallelism, 138 | "no_color": IsFlagged, 139 | "input": False, 140 | **input_options, 141 | } 142 | 143 | def destroy( 144 | self, 145 | dir_or_plan: Optional[str] = None, 146 | force: Type[TerraformFlag] = IsFlagged, 147 | **kwargs, 148 | ) -> CommandOutput: 149 | """Refer to https://www.terraform.io/docs/commands/destroy.html 150 | 151 | force/no-color option is flagged by default 152 | :return: ret_code, stdout, stderr 153 | """ 154 | default = kwargs.copy() 155 | default["force"] = force 156 | options = self._generate_default_options(default) 157 | args = self._generate_default_args(dir_or_plan) 158 | return self.cmd("destroy", *args, **options) 159 | 160 | def plan( 161 | self, 162 | dir_or_plan: Optional[str] = None, 163 | detailed_exitcode: Type[TerraformFlag] = IsFlagged, 164 | **kwargs, 165 | ) -> CommandOutput: 166 | """Refer to https://www.terraform.io/docs/commands/plan.html 167 | 168 | :param detailed_exitcode: Return a detailed exit code when the command exits. 169 | :param dir_or_plan: relative path to plan/folder 170 | :param kwargs: options 171 | :return: ret_code, stdout, stderr 172 | """ 173 | options = kwargs.copy() 174 | options["detailed_exitcode"] = detailed_exitcode 175 | options = self._generate_default_options(options) 176 | args = self._generate_default_args(dir_or_plan) 177 | return self.cmd("plan", *args, **options) 178 | 179 | def init( 180 | self, 181 | dir_or_plan: Optional[str] = None, 182 | backend_config: Optional[Dict[str, str]] = None, 183 | reconfigure: Type[TerraformFlag] = IsFlagged, 184 | backend: bool = True, 185 | **kwargs, 186 | ) -> CommandOutput: 187 | """Refer to https://www.terraform.io/docs/commands/init.html 188 | 189 | By default, this assumes you want to use backend config, and tries to 190 | init fresh. The flags -reconfigure and -backend=true are default. 191 | 192 | :param dir_or_plan: relative path to the folder want to init 193 | :param backend_config: a dictionary of backend config options. eg. 194 | t = Terraform() 195 | t.init(backend_config={'access_key': 'myaccesskey', 196 | 'secret_key': 'mysecretkey', 'bucket': 'mybucketname'}) 197 | :param reconfigure: whether or not to force reconfiguration of backend 198 | :param backend: whether or not to use backend settings for init 199 | :param kwargs: options 200 | :return: ret_code, stdout, stderr 201 | """ 202 | options = kwargs.copy() 203 | options.update( 204 | { 205 | "backend_config": backend_config, 206 | "reconfigure": reconfigure, 207 | "backend": backend, 208 | } 209 | ) 210 | options = self._generate_default_options(options) 211 | args = self._generate_default_args(dir_or_plan) 212 | return self.cmd("init", *args, **options) 213 | 214 | def generate_cmd_string(self, cmd: str, *args, **kwargs) -> List[str]: 215 | """For any generate_cmd_string doesn't written as public method of Terraform 216 | 217 | examples: 218 | 1. call import command, 219 | ref to https://www.terraform.io/docs/commands/import.html 220 | --> generate_cmd_string call: 221 | terraform import -input=true aws_instance.foo i-abcd1234 222 | --> python call: 223 | tf.generate_cmd_string('import', 'aws_instance.foo', 'i-abcd1234', input=True) 224 | 225 | 2. call apply command, 226 | --> generate_cmd_string call: 227 | terraform apply -var='a=b' -var='c=d' -no-color the_folder 228 | --> python call: 229 | tf.generate_cmd_string('apply', the_folder, no_color=IsFlagged, var={'a':'b', 'c':'d'}) 230 | 231 | :param cmd: command and sub-command of terraform, seperated with space 232 | refer to https://www.terraform.io/docs/commands/index.html 233 | :param args: arguments of a command 234 | :param kwargs: same as kwags in method 'cmd' 235 | :return: string of valid terraform command 236 | """ 237 | cmds = cmd.split() 238 | cmds = [self.terraform_bin_path] + cmds 239 | if cmd in COMMAND_WITH_SUBCOMMANDS: 240 | args = list(args) 241 | subcommand = args.pop(0) 242 | cmds.append(subcommand) 243 | 244 | for option, value in kwargs.items(): 245 | if "_" in option: 246 | option = option.replace("_", "-") 247 | 248 | if isinstance(value, list): 249 | for sub_v in value: 250 | cmds += [f"-{option}={sub_v}"] 251 | continue 252 | 253 | if isinstance(value, dict): 254 | if "backend-config" in option: 255 | for bk, bv in value.items(): 256 | cmds += [f"-backend-config={bk}={bv}"] 257 | continue 258 | 259 | # since map type sent in string won't work, create temp var file for 260 | # variables, and clean it up later 261 | elif option == "var": 262 | # We do not create empty var-files if there is no var passed. 263 | # An empty var-file would result in an error: An argument or block definition is required here 264 | if value: 265 | filename = self.temp_var_files.create(value) 266 | cmds += [f"-var-file={filename}"] 267 | 268 | continue 269 | 270 | # simple flag, 271 | if value is IsFlagged: 272 | cmds += [f"-{option}"] 273 | continue 274 | 275 | if value is None or value is IsNotFlagged: 276 | continue 277 | 278 | if isinstance(value, bool): 279 | value = "true" if value else "false" 280 | 281 | cmds += [f"-{option}={value}"] 282 | 283 | cmds += args 284 | return cmds 285 | 286 | def cmd( 287 | self, 288 | cmd: str, 289 | *args, 290 | capture_output: Union[bool, str] = True, 291 | raise_on_error: bool = True, 292 | synchronous: bool = True, 293 | **kwargs, 294 | ) -> CommandOutput: 295 | """Run a terraform command, if success, will try to read state file 296 | 297 | :param cmd: command and sub-command of terraform, seperated with space 298 | refer to https://www.terraform.io/docs/commands/index.html 299 | :param args: arguments of a command 300 | :param kwargs: any option flag with key value without prefixed dash character 301 | if there's a dash in the option name, use under line instead of dash, 302 | ex. -no-color --> no_color 303 | if it's a simple flag with no value, value should be IsFlagged 304 | ex. cmd('taint', allow_missing=IsFlagged) 305 | if it's a boolean value flag, assign True or false 306 | if it's a flag could be used multiple times, assign list to it's value 307 | if it's a "var" variable flag, assign dictionary to it 308 | if a value is None, will skip this option 309 | if the option 'capture_output' is passed (with any value other than 310 | True), terraform output will be printed to stdout/stderr and 311 | "None" will be returned as out and err. 312 | if the option 'raise_on_error' is passed (with any value that evaluates to True), 313 | and the terraform command returns a nonzerop return code, then 314 | a TerraformCommandError exception will be raised. The exception object will 315 | have the following properties: 316 | returncode: The command's return code 317 | out: The captured stdout, or None if not captured 318 | err: The captured stderr, or None if not captured 319 | :return: ret_code, out, err 320 | """ 321 | if capture_output is True: 322 | stderr = subprocess.PIPE 323 | stdout = subprocess.PIPE 324 | elif capture_output == "framework": 325 | stderr = None 326 | stdout = None 327 | else: 328 | stderr = sys.stderr 329 | stdout = sys.stdout 330 | 331 | cmds = self.generate_cmd_string(cmd, *args, **kwargs) 332 | logger.info("Command: %s", " ".join(cmds)) 333 | 334 | working_folder = self.working_dir if self.working_dir else None 335 | 336 | environ_vars = {} 337 | if self.is_env_vars_included: 338 | environ_vars = os.environ.copy() 339 | 340 | p = subprocess.Popen( 341 | cmds, stdout=stdout, stderr=stderr, cwd=working_folder, env=environ_vars 342 | ) 343 | 344 | if not synchronous: 345 | return None, None, None 346 | 347 | out, err = p.communicate() 348 | ret_code = p.returncode 349 | logger.info("output: %s", out) 350 | 351 | if ret_code == 0: 352 | self.read_state_file() 353 | else: 354 | logger.warning("error: %s", err) 355 | 356 | self.temp_var_files.clean_up() 357 | if capture_output is True: 358 | out = out.decode() 359 | err = err.decode() 360 | else: 361 | out = None 362 | err = None 363 | 364 | if ret_code and raise_on_error: 365 | raise TerraformCommandError(ret_code, " ".join(cmds), out=out, err=err) 366 | 367 | return ret_code, out, err 368 | 369 | def output( 370 | self, *args, capture_output: bool = True, **kwargs 371 | ) -> Union[None, str, Dict[str, str], Dict[str, Dict[str, str]]]: 372 | """Refer https://www.terraform.io/docs/commands/output.html 373 | 374 | Note that this method does not conform to the (ret_code, out, err) return 375 | convention. To use the "output" command with the standard convention, 376 | call "output_cmd" instead of "output". 377 | 378 | :param args: Positional arguments. There is one optional positional 379 | argument NAME; if supplied, the returned output text 380 | will be the json for a single named output value. 381 | :param kwargs: Named options, passed to the command. In addition, 382 | 'full_value': If True, and NAME is provided, then 383 | the return value will be a dict with 384 | "value', 'type', and 'sensitive' 385 | properties. 386 | :return: None, if an error occured 387 | Output value as a string, if NAME is provided and full_value 388 | is False or not provided 389 | Output value as a dict with 'value', 'sensitive', and 'type' if 390 | NAME is provided and full_value is True. 391 | dict of named dicts each with 'value', 'sensitive', and 'type', 392 | if NAME is not provided 393 | """ 394 | kwargs["json"] = IsFlagged 395 | if capture_output is False: 396 | raise ValueError("capture_output is required for this method") 397 | 398 | ret, out, _ = self.output_cmd(*args, **kwargs) 399 | 400 | if ret: 401 | return None 402 | 403 | return json.loads(out.lstrip()) 404 | 405 | def read_state_file(self, file_path=None) -> None: 406 | """Read .tfstate file 407 | 408 | :param file_path: relative path to working dir 409 | :return: states file in dict type 410 | """ 411 | 412 | working_dir = self.working_dir or "" 413 | 414 | file_path = file_path or self.state or "" 415 | 416 | if not file_path: 417 | backend_path = os.path.join(file_path, ".terraform", "terraform.tfstate") 418 | 419 | if os.path.exists(os.path.join(working_dir, backend_path)): 420 | file_path = backend_path 421 | else: 422 | file_path = os.path.join(file_path, "terraform.tfstate") 423 | 424 | file_path = os.path.join(working_dir, file_path) 425 | 426 | self.tfstate = Tfstate.load_file(file_path) 427 | 428 | def set_workspace(self, workspace, *args, **kwargs) -> CommandOutput: 429 | """Set workspace 430 | 431 | :param workspace: the desired workspace. 432 | :return: status 433 | """ 434 | return self.cmd("workspace", "select", workspace, *args, **kwargs) 435 | 436 | def create_workspace(self, workspace, *args, **kwargs) -> CommandOutput: 437 | """Create workspace 438 | 439 | :param workspace: the desired workspace. 440 | :return: status 441 | """ 442 | return self.cmd("workspace", "new", workspace, *args, **kwargs) 443 | 444 | def delete_workspace(self, workspace, *args, **kwargs) -> CommandOutput: 445 | """Delete workspace 446 | 447 | :param workspace: the desired workspace. 448 | :return: status 449 | """ 450 | return self.cmd("workspace", "delete", workspace, *args, **kwargs) 451 | 452 | def show_workspace(self, **kwargs) -> CommandOutput: 453 | """Show workspace, this command does not need the [DIR] part 454 | 455 | :return: workspace 456 | """ 457 | return self.cmd("workspace", "show", **kwargs) 458 | 459 | def list_workspace(self) -> List[str]: 460 | """List of workspaces 461 | 462 | :return: workspaces 463 | :example: 464 | >>> tf = Terraform() 465 | >>> tf.list_workspace() 466 | ['default', 'test'] 467 | """ 468 | return list( 469 | filter( 470 | lambda workspace: len(workspace) > 0, 471 | map( 472 | lambda workspace: workspace.strip('*').strip(), 473 | (self.cmd("workspace", "list")[1] or '').split() 474 | ) 475 | ) 476 | ) 477 | 478 | def __exit__(self, exc_type, exc_value, traceback) -> None: 479 | self.temp_var_files.clean_up() 480 | 481 | 482 | class VariableFiles: 483 | def __init__(self): 484 | self.files = [] 485 | 486 | def create(self, variables: Dict[str, str]) -> str: 487 | with tempfile.NamedTemporaryFile( 488 | "w+t", suffix=".tfvars.json", delete=False 489 | ) as temp: 490 | logger.debug("%s is created", temp.name) 491 | self.files.append(temp) 492 | logger.debug("variables wrote to tempfile: %s", variables) 493 | temp.write(json.dumps(variables)) 494 | file_name = temp.name 495 | 496 | return file_name 497 | 498 | def clean_up(self): 499 | for f in self.files: 500 | os.unlink(f.name) 501 | 502 | self.files = [] 503 | -------------------------------------------------------------------------------- /python_terraform/tfstate.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | from typing import Dict, Optional 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | class Tfstate: 10 | def __init__(self, data: Optional[Dict[str, str]] = None): 11 | self.tfstate_file: Optional[str] = None 12 | self.native_data = data 13 | if data: 14 | self.__dict__ = data 15 | 16 | @staticmethod 17 | def load_file(file_path: str) -> "Tfstate": 18 | """Read the tfstate file and load its contents. 19 | 20 | Parses then as JSON and put the result into the object. 21 | """ 22 | logger.debug("read data from %s", file_path) 23 | if os.path.exists(file_path): 24 | with open(file_path) as f: 25 | json_data = json.load(f) 26 | 27 | tf_state = Tfstate(json_data) 28 | tf_state.tfstate_file = file_path 29 | return tf_state 30 | 31 | logger.debug("%s does not exist", file_path) 32 | 33 | return Tfstate() 34 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | /bin/bash: q : commande introuvable 2 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | wheel 2 | flake8 3 | pytest 4 | pytest-cov 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | 4 | [isort] 5 | line_length=88 6 | known_third_party= 7 | indent=' ' 8 | multi_line_output=3 9 | sections=FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 10 | include_trailing_comma=true 11 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is a python module provide a wrapper of terraform command line tool 3 | """ 4 | try: 5 | from setuptools import setup 6 | except ImportError: 7 | from distutils.core import setup 8 | 9 | dependencies = [] 10 | module_name = "python-terraform" 11 | short_description = ( 12 | "This is a python module provide a wrapper " "of terraform command line tool" 13 | ) 14 | 15 | try: 16 | with open("DESCRIPTION.rst") as f: 17 | long_description = f.read() 18 | except IOError: 19 | long_description = short_description 20 | 21 | 22 | setup( 23 | name=module_name, 24 | version="0.14.0", 25 | url="https://github.com/beelit94/python-terraform", 26 | license="MIT", 27 | author="Freddy Tan", 28 | author_email="beelit94@gmail.com", 29 | description=short_description, 30 | long_description=long_description, 31 | packages=["python_terraform"], 32 | package_data={}, 33 | platforms="any", 34 | install_requires=dependencies, 35 | tests_require=["pytest"], 36 | python_requires=">=3.6", 37 | classifiers=[ 38 | # As from http://pypi.python.org/pypi?%3Aaction=list_classifiers 39 | # 'Development Status :: 1 - Planning', 40 | # 'Development Status :: 2 - Pre-Alpha', 41 | # 'Development Status :: 3 - Alpha', 42 | "Development Status :: 4 - Beta", 43 | # 'Development Status :: 5 - Production/Stable', 44 | # 'Development Status :: 6 - Mature', 45 | # 'Development Status :: 7 - Inactive', 46 | "Environment :: Console", 47 | "Intended Audience :: Developers", 48 | "License :: OSI Approved :: MIT License", 49 | "Operating System :: POSIX", 50 | "Operating System :: MacOS", 51 | "Operating System :: Unix", 52 | # 'Operating System :: Windows', 53 | "Programming Language :: Python", 54 | "Programming Language :: Python :: 3", 55 | "Topic :: Software Development :: Libraries :: Python Modules", 56 | ], 57 | ) 58 | -------------------------------------------------------------------------------- /test/bad_fmt/test.tf: -------------------------------------------------------------------------------- 1 | variable "test_var" { 2 | default = "" 3 | } 4 | 5 | provider "archive" {} 6 | 7 | variable "test_list_var" { 8 | type = list(string) 9 | default = ["a", "b"] 10 | } 11 | 12 | variable "test_map_var" { 13 | type = map 14 | 15 | default = { 16 | "a" = "a" 17 | "b" = "b" 18 | } 19 | } 20 | 21 | output "test_output" { 22 | value = var.test_var 23 | } 24 | 25 | output "test_list_output" { 26 | value = var.test_list_var 27 | } 28 | 29 | output "test_map_output" { 30 | value = var.test_map_var 31 | } 32 | -------------------------------------------------------------------------------- /test/test_terraform.py: -------------------------------------------------------------------------------- 1 | import fnmatch 2 | import logging 3 | import os 4 | import re 5 | import shutil 6 | from contextlib import contextmanager 7 | from io import StringIO 8 | from typing import Callable 9 | 10 | import pytest 11 | from _pytest.logging import LogCaptureFixture, caplog 12 | 13 | from python_terraform import IsFlagged, IsNotFlagged, Terraform, TerraformCommandError 14 | 15 | logging.basicConfig(level=logging.DEBUG) 16 | root_logger = logging.getLogger() 17 | 18 | current_path = os.path.dirname(os.path.realpath(__file__)) 19 | 20 | FILE_PATH_WITH_SPACE_AND_SPACIAL_CHARS = "test 'test.out!" 21 | STRING_CASES = [ 22 | [ 23 | lambda x: x.generate_cmd_string("apply", "the_folder", no_color=IsFlagged), 24 | "terraform apply -no-color the_folder", 25 | ], 26 | [ 27 | lambda x: x.generate_cmd_string( 28 | "push", "path", vcs=True, token="token", atlas_address="url" 29 | ), 30 | "terraform push -vcs=true -token=token -atlas-address=url path", 31 | ], 32 | ] 33 | 34 | CMD_CASES = [ 35 | [ 36 | "method", 37 | "expected_output", 38 | "expected_ret_code", 39 | "expected_exception", 40 | "expected_logs", 41 | "folder", 42 | ], 43 | [ 44 | [ 45 | lambda x: x.cmd( 46 | "plan", 47 | "var_to_output", 48 | no_color=IsFlagged, 49 | var={"test_var": "test"}, 50 | raise_on_error=False, 51 | ), 52 | # Expected output varies by terraform version 53 | "Plan: 0 to add, 0 to change, 0 to destroy.", 54 | 0, 55 | False, 56 | "", 57 | "var_to_output", 58 | ], 59 | # try import aws instance 60 | [ 61 | lambda x: x.cmd( 62 | "import", 63 | "aws_instance.foo", 64 | "i-abcd1234", 65 | no_color=IsFlagged, 66 | raise_on_error=False, 67 | ), 68 | "", 69 | 1, 70 | False, 71 | "Error: No Terraform configuration files", 72 | "", 73 | ], 74 | # test with space and special character in file path 75 | [ 76 | lambda x: x.cmd( 77 | "plan", 78 | "var_to_output", 79 | out=FILE_PATH_WITH_SPACE_AND_SPACIAL_CHARS, 80 | raise_on_error=False, 81 | ), 82 | "", 83 | 0, 84 | False, 85 | "", 86 | "var_to_output", 87 | ], 88 | # test workspace command (commands with subcommand) 89 | [ 90 | lambda x: x.cmd( 91 | "workspace", "show", no_color=IsFlagged, raise_on_error=False 92 | ), 93 | "", 94 | 0, 95 | False, 96 | "Command: terraform workspace show -no-color", 97 | "", 98 | ], 99 | ], 100 | ] 101 | 102 | 103 | @pytest.fixture(scope="function") 104 | def fmt_test_file(request): 105 | target = os.path.join(current_path, "bad_fmt", "test.backup") 106 | orgin = os.path.join(current_path, "bad_fmt", "test.tf") 107 | shutil.copy(orgin, target) 108 | 109 | def td(): 110 | shutil.move(target, orgin) 111 | 112 | request.addfinalizer(td) 113 | return 114 | 115 | 116 | # @pytest.fixture() 117 | # def string_logger(request) -> Callable[..., str]: 118 | # log_stream = StringIO() 119 | # handler = logging.StreamHandler(log_stream) 120 | # root_logger.addHandler(handler) 121 | 122 | # def td(): 123 | # root_logger.removeHandler(handler) 124 | # log_stream.close() 125 | 126 | # request.addfinalizer(td) 127 | # return lambda: str(log_stream.getvalue()) 128 | 129 | 130 | @pytest.fixture() 131 | def workspace_setup_teardown(): 132 | """Fixture used in workspace related tests. 133 | 134 | Create and tear down a workspace 135 | *Use as a contextmanager* 136 | """ 137 | 138 | @contextmanager 139 | def wrapper(workspace_name, create=True, delete=True, *args, **kwargs): 140 | tf = Terraform(working_dir=current_path) 141 | tf.init() 142 | if create: 143 | tf.create_workspace(workspace_name, *args, **kwargs) 144 | yield tf 145 | if delete: 146 | tf.set_workspace("default") 147 | tf.delete_workspace(workspace_name) 148 | 149 | yield wrapper 150 | 151 | 152 | class TestTerraform: 153 | def teardown_method(self, _) -> None: 154 | """Teardown any state that was previously setup with a setup_method call.""" 155 | exclude = ["test_tfstate_file", "test_tfstate_file2", "test_tfstate_file3"] 156 | 157 | def purge(dir: str, pattern: str) -> None: 158 | for root, dirnames, filenames in os.walk(dir): 159 | dirnames[:] = [d for d in dirnames if d not in exclude] 160 | for filename in fnmatch.filter(filenames, pattern): 161 | f = os.path.join(root, filename) 162 | os.remove(f) 163 | for dirname in fnmatch.filter(dirnames, pattern): 164 | d = os.path.join(root, dirname) 165 | shutil.rmtree(d) 166 | 167 | purge(".", "*.tfstate") 168 | purge(".", "*.tfstate.backup") 169 | purge(".", "*.terraform") 170 | purge(".", FILE_PATH_WITH_SPACE_AND_SPACIAL_CHARS) 171 | 172 | @pytest.mark.parametrize(["method", "expected"], STRING_CASES) 173 | def test_generate_cmd_string(self, method: Callable[..., str], expected: str): 174 | tf = Terraform(working_dir=current_path) 175 | result = method(tf) 176 | 177 | strs = expected.split() 178 | for s in strs: 179 | assert s in result 180 | 181 | @pytest.mark.parametrize(*CMD_CASES) 182 | def test_cmd( 183 | self, 184 | method: Callable[..., str], 185 | expected_output: str, 186 | expected_ret_code: int, 187 | expected_exception: bool, 188 | expected_logs: str, 189 | caplog: LogCaptureFixture, 190 | folder: str, 191 | ): 192 | with caplog.at_level(logging.INFO): 193 | tf = Terraform(working_dir=current_path) 194 | tf.init(folder) 195 | try: 196 | ret, out, _ = method(tf) 197 | assert not expected_exception 198 | except TerraformCommandError as e: 199 | assert expected_exception 200 | ret = e.returncode 201 | out = e.out 202 | 203 | assert expected_output in out 204 | assert expected_ret_code == ret 205 | assert expected_logs in caplog.text 206 | 207 | @pytest.mark.parametrize( 208 | ("folder", "variables", "var_files", "expected_output", "options"), 209 | [ 210 | ("var_to_output", {"test_var": "test"}, None, "test_output=test", {}), 211 | ( 212 | "var_to_output", 213 | {"test_list_var": ["c", "d"]}, 214 | None, 215 | 'test_list_output=["c","d",]', 216 | {}, 217 | ), 218 | ( 219 | "var_to_output", 220 | {"test_map_var": {"c": "c", "d": "d"}}, 221 | None, 222 | 'test_map_output={"c"="c""d"="d"}', 223 | {}, 224 | ), 225 | ( 226 | "var_to_output", 227 | {"test_map_var": {"c": "c", "d": "d"}}, 228 | "var_to_output/test_map_var.json", 229 | # Values are overriden 230 | 'test_map_output={"e"="e""f"="f"}', 231 | {}, 232 | ), 233 | ( 234 | "var_to_output", 235 | {}, 236 | None, 237 | "\x1b[0m\x1b[1m\x1b[32mApplycomplete!", 238 | {"no_color": IsNotFlagged}, 239 | ), 240 | ], 241 | ) 242 | def test_apply(self, folder, variables, var_files, expected_output, options): 243 | tf = Terraform( 244 | working_dir=current_path, variables=variables, var_file=var_files 245 | ) 246 | tf.init(folder) 247 | ret, out, err = tf.apply(folder, **options) 248 | assert ret == 0 249 | assert expected_output in out.replace("\n", "").replace(" ", "") 250 | assert err == "" 251 | 252 | def test_apply_with_var_file(self, caplog: LogCaptureFixture): 253 | with caplog.at_level(logging.INFO): 254 | tf = Terraform(working_dir=current_path) 255 | 256 | folder = "var_to_output" 257 | tf.init(folder) 258 | tf.apply( 259 | folder, 260 | var_file=os.path.join(current_path, "tfvar_files", "test.tfvars"), 261 | ) 262 | for log in caplog.messages: 263 | if log.startswith("Command: terraform apply"): 264 | assert log.count("-var-file=") == 1 265 | 266 | @pytest.mark.parametrize( 267 | ["cmd", "args", "options"], 268 | [ 269 | # bool value 270 | ("fmt", ["bad_fmt"], {"list": False, "diff": False}) 271 | ], 272 | ) 273 | def test_options(self, cmd, args, options, fmt_test_file): 274 | tf = Terraform(working_dir=current_path) 275 | ret, out, err = getattr(tf, cmd)(*args, **options) 276 | assert ret == 0 277 | assert out == "" 278 | 279 | def test_state_data(self): 280 | cwd = os.path.join(current_path, "test_tfstate_file") 281 | tf = Terraform(working_dir=cwd, state="tfstate.test") 282 | tf.read_state_file() 283 | assert tf.tfstate.modules[0]["path"] == ["root"] 284 | 285 | def test_state_default(self): 286 | cwd = os.path.join(current_path, "test_tfstate_file2") 287 | tf = Terraform(working_dir=cwd) 288 | tf.read_state_file() 289 | assert tf.tfstate.modules[0]["path"] == ["default"] 290 | 291 | def test_state_default_backend(self): 292 | cwd = os.path.join(current_path, "test_tfstate_file3") 293 | tf = Terraform(working_dir=cwd) 294 | tf.read_state_file() 295 | assert tf.tfstate.modules[0]["path"] == ["default_backend"] 296 | 297 | def test_pre_load_state_data(self): 298 | cwd = os.path.join(current_path, "test_tfstate_file") 299 | tf = Terraform(working_dir=cwd, state="tfstate.test") 300 | assert tf.tfstate.modules[0]["path"] == ["root"] 301 | 302 | @pytest.mark.parametrize( 303 | ("folder", "variables"), [("var_to_output", {"test_var": "test"})] 304 | ) 305 | def test_override_default(self, folder, variables): 306 | tf = Terraform(working_dir=current_path, variables=variables) 307 | tf.init(folder) 308 | ret, out, err = tf.apply( 309 | folder, var={"test_var": "test2"}, no_color=IsNotFlagged, 310 | ) 311 | out = out.replace("\n", "") 312 | assert "\x1b[0m\x1b[1m\x1b[32mApply" in out 313 | out = tf.output("test_output") 314 | assert "test2" in out 315 | 316 | @pytest.mark.parametrize("output_all", [True, False]) 317 | def test_output(self, caplog: LogCaptureFixture, output_all: bool): 318 | expected_value = "test" 319 | required_output = "test_output" 320 | with caplog.at_level(logging.INFO): 321 | tf = Terraform( 322 | working_dir=current_path, variables={"test_var": expected_value} 323 | ) 324 | tf.init("var_to_output") 325 | tf.apply("var_to_output") 326 | params = tuple() if output_all else (required_output,) 327 | result = tf.output(*params) 328 | if output_all: 329 | assert result[required_output]["value"] == expected_value 330 | else: 331 | assert result == expected_value 332 | assert expected_value in caplog.messages[-1] 333 | 334 | def test_destroy(self): 335 | tf = Terraform(working_dir=current_path, variables={"test_var": "test"}) 336 | tf.init("var_to_output") 337 | ret, out, err = tf.destroy("var_to_output") 338 | assert ret == 0 339 | assert "Destroy complete! Resources: 0 destroyed." in out 340 | 341 | @pytest.mark.parametrize( 342 | ("plan", "variables", "expected_ret"), [("vars_require_input", {}, 1)] 343 | ) 344 | def test_plan(self, plan, variables, expected_ret): 345 | tf = Terraform(working_dir=current_path, variables=variables) 346 | tf.init(plan) 347 | with pytest.raises(TerraformCommandError) as e: 348 | tf.plan(plan) 349 | assert ( 350 | e.value.err 351 | == """\nError: Missing required argument\n\nThe argument "region" is required, but was not set.\n\n""" 352 | ) 353 | 354 | def test_fmt(self, fmt_test_file): 355 | tf = Terraform(working_dir=current_path, variables={"test_var": "test"}) 356 | ret, out, err = tf.fmt(diff=True) 357 | assert ret == 0 358 | 359 | def test_create_workspace(self, workspace_setup_teardown): 360 | workspace_name = "test" 361 | with workspace_setup_teardown(workspace_name, create=False) as tf: 362 | ret, out, err = tf.create_workspace("test") 363 | assert ret == 0 364 | assert err == "" 365 | 366 | def test_create_workspace_with_args(self, workspace_setup_teardown, caplog): 367 | workspace_name = "test" 368 | state_file_path = os.path.join( 369 | current_path, "test_tfstate_file2", "terraform.tfstate" 370 | ) 371 | with workspace_setup_teardown( 372 | workspace_name, create=False 373 | ) as tf, caplog.at_level(logging.INFO): 374 | ret, out, err = tf.create_workspace( 375 | "test", current_path, no_color=IsFlagged 376 | ) 377 | 378 | assert ret == 0 379 | assert err == "" 380 | assert ( 381 | f"Command: terraform workspace new -no-color test {current_path}" 382 | in caplog.messages 383 | ) 384 | 385 | def test_set_workspace(self, workspace_setup_teardown): 386 | workspace_name = "test" 387 | with workspace_setup_teardown(workspace_name) as tf: 388 | ret, out, err = tf.set_workspace(workspace_name) 389 | assert ret == 0 390 | assert err == "" 391 | 392 | def test_set_workspace_with_args(self, workspace_setup_teardown, caplog): 393 | workspace_name = "test" 394 | with workspace_setup_teardown(workspace_name) as tf, caplog.at_level( 395 | logging.INFO 396 | ): 397 | ret, out, err = tf.set_workspace( 398 | workspace_name, current_path, no_color=IsFlagged 399 | ) 400 | 401 | assert ret == 0 402 | assert err == "" 403 | assert ( 404 | f"Command: terraform workspace select -no-color test {current_path}" 405 | in caplog.messages 406 | ) 407 | 408 | def test_show_workspace(self, workspace_setup_teardown): 409 | workspace_name = "test" 410 | with workspace_setup_teardown(workspace_name) as tf: 411 | ret, out, err = tf.show_workspace() 412 | assert ret == 0 413 | assert err == "" 414 | 415 | def test_show_workspace_with_no_color(self, workspace_setup_teardown, caplog): 416 | workspace_name = "test" 417 | with workspace_setup_teardown(workspace_name) as tf, caplog.at_level( 418 | logging.INFO 419 | ): 420 | ret, out, err = tf.show_workspace(no_color=IsFlagged) 421 | 422 | assert ret == 0 423 | assert err == "" 424 | assert "Command: terraform workspace show -no-color" in caplog.messages 425 | 426 | def test_delete_workspace(self, workspace_setup_teardown): 427 | workspace_name = "test" 428 | with workspace_setup_teardown(workspace_name, delete=False) as tf: 429 | tf.set_workspace("default") 430 | ret, out, err = tf.delete_workspace(workspace_name) 431 | assert ret == 0 432 | assert err == "" 433 | 434 | def test_delete_workspace_with_args(self, workspace_setup_teardown, caplog): 435 | workspace_name = "test" 436 | with workspace_setup_teardown( 437 | workspace_name, delete=False 438 | ) as tf, caplog.at_level(logging.INFO): 439 | tf.set_workspace("default") 440 | ret, out, err = tf.delete_workspace( 441 | workspace_name, current_path, force=IsFlagged, 442 | ) 443 | 444 | assert ret == 0 445 | assert err == "" 446 | assert ( 447 | f"Command: terraform workspace delete -force test {current_path}" 448 | in caplog.messages 449 | ) 450 | 451 | def test_list_workspace(self): 452 | tf = Terraform(working_dir=current_path) 453 | workspaces = tf.list_workspace() 454 | assert len(workspaces) > 0 455 | assert 'default' in workspaces 456 | -------------------------------------------------------------------------------- /test/test_tfstate_file/tfstate.test: -------------------------------------------------------------------------------- 1 | { 2 | "version": 3, 3 | "terraform_version": "0.7.10", 4 | "serial": 0, 5 | "lineage": "d03ecdf7-8be0-4593-a952-1d8127875119", 6 | "modules": [ 7 | { 8 | "path": [ 9 | "root" 10 | ], 11 | "outputs": {}, 12 | "resources": { 13 | "aws_instance.ubuntu-1404": { 14 | "type": "aws_instance", 15 | "depends_on": [], 16 | "primary": { 17 | "id": "i-84d10edb", 18 | "attributes": { 19 | "ami": "ami-9abea4fb", 20 | "associate_public_ip_address": "true", 21 | "availability_zone": "us-west-2b", 22 | "disable_api_termination": "false", 23 | "ebs_block_device.#": "0", 24 | "ebs_optimized": "false", 25 | "ephemeral_block_device.#": "0", 26 | "iam_instance_profile": "", 27 | "id": "i-84d10edb", 28 | "instance_state": "running", 29 | "instance_type": "t2.micro", 30 | "key_name": "", 31 | "monitoring": "false", 32 | "network_interface_id": "eni-46544f07", 33 | "private_dns": "ip-172-31-25-244.us-west-2.compute.internal", 34 | "private_ip": "172.31.25.244", 35 | "public_dns": "ec2-35-162-30-219.us-west-2.compute.amazonaws.com", 36 | "public_ip": "35.162.30.219", 37 | "root_block_device.#": "1", 38 | "root_block_device.0.delete_on_termination": "true", 39 | "root_block_device.0.iops": "100", 40 | "root_block_device.0.volume_size": "8", 41 | "root_block_device.0.volume_type": "gp2", 42 | "security_groups.#": "0", 43 | "source_dest_check": "true", 44 | "subnet_id": "subnet-d2c0f0a6", 45 | "tags.%": "0", 46 | "tenancy": "default", 47 | "vpc_security_group_ids.#": "1", 48 | "vpc_security_group_ids.619359045": "sg-9fc7dcfd" 49 | }, 50 | "meta": { 51 | "schema_version": "1" 52 | }, 53 | "tainted": false 54 | }, 55 | "deposed": [], 56 | "provider": "" 57 | } 58 | }, 59 | "depends_on": [] 60 | } 61 | ] 62 | } 63 | -------------------------------------------------------------------------------- /test/test_tfstate_file2/terraform.tfstate: -------------------------------------------------------------------------------- 1 | { 2 | "version": 3, 3 | "terraform_version": "0.7.10", 4 | "serial": 0, 5 | "lineage": "d03ecdf7-8be0-4593-a952-1d8127875119", 6 | "modules": [ 7 | { 8 | "path": [ 9 | "default" 10 | ], 11 | "outputs": {}, 12 | "resources": { 13 | "aws_instance.ubuntu-1404": { 14 | "type": "aws_instance", 15 | "depends_on": [], 16 | "primary": { 17 | "id": "i-84d10edb", 18 | "attributes": { 19 | "ami": "ami-9abea4fb", 20 | "associate_public_ip_address": "true", 21 | "availability_zone": "us-west-2b", 22 | "disable_api_termination": "false", 23 | "ebs_block_device.#": "0", 24 | "ebs_optimized": "false", 25 | "ephemeral_block_device.#": "0", 26 | "iam_instance_profile": "", 27 | "id": "i-84d10edb", 28 | "instance_state": "running", 29 | "instance_type": "t2.micro", 30 | "key_name": "", 31 | "monitoring": "false", 32 | "network_interface_id": "eni-46544f07", 33 | "private_dns": "ip-172-31-25-244.us-west-2.compute.internal", 34 | "private_ip": "172.31.25.244", 35 | "public_dns": "ec2-35-162-30-219.us-west-2.compute.amazonaws.com", 36 | "public_ip": "35.162.30.219", 37 | "root_block_device.#": "1", 38 | "root_block_device.0.delete_on_termination": "true", 39 | "root_block_device.0.iops": "100", 40 | "root_block_device.0.volume_size": "8", 41 | "root_block_device.0.volume_type": "gp2", 42 | "security_groups.#": "0", 43 | "source_dest_check": "true", 44 | "subnet_id": "subnet-d2c0f0a6", 45 | "tags.%": "0", 46 | "tenancy": "default", 47 | "vpc_security_group_ids.#": "1", 48 | "vpc_security_group_ids.619359045": "sg-9fc7dcfd" 49 | }, 50 | "meta": { 51 | "schema_version": "1" 52 | }, 53 | "tainted": false 54 | }, 55 | "deposed": [], 56 | "provider": "" 57 | } 58 | }, 59 | "depends_on": [] 60 | } 61 | ] 62 | } 63 | -------------------------------------------------------------------------------- /test/test_tfstate_file3/.terraform/terraform.tfstate: -------------------------------------------------------------------------------- 1 | { 2 | "version": 3, 3 | "terraform_version": "0.7.10", 4 | "serial": 0, 5 | "lineage": "d03ecdf7-8be0-4593-a952-1d8127875119", 6 | "modules": [ 7 | { 8 | "path": [ 9 | "default_backend" 10 | ], 11 | "outputs": {}, 12 | "resources": { 13 | "aws_instance.ubuntu-1404": { 14 | "type": "aws_instance", 15 | "depends_on": [], 16 | "primary": { 17 | "id": "i-84d10edb", 18 | "attributes": { 19 | "ami": "ami-9abea4fb", 20 | "associate_public_ip_address": "true", 21 | "availability_zone": "us-west-2b", 22 | "disable_api_termination": "false", 23 | "ebs_block_device.#": "0", 24 | "ebs_optimized": "false", 25 | "ephemeral_block_device.#": "0", 26 | "iam_instance_profile": "", 27 | "id": "i-84d10edb", 28 | "instance_state": "running", 29 | "instance_type": "t2.micro", 30 | "key_name": "", 31 | "monitoring": "false", 32 | "network_interface_id": "eni-46544f07", 33 | "private_dns": "ip-172-31-25-244.us-west-2.compute.internal", 34 | "private_ip": "172.31.25.244", 35 | "public_dns": "ec2-35-162-30-219.us-west-2.compute.amazonaws.com", 36 | "public_ip": "35.162.30.219", 37 | "root_block_device.#": "1", 38 | "root_block_device.0.delete_on_termination": "true", 39 | "root_block_device.0.iops": "100", 40 | "root_block_device.0.volume_size": "8", 41 | "root_block_device.0.volume_type": "gp2", 42 | "security_groups.#": "0", 43 | "source_dest_check": "true", 44 | "subnet_id": "subnet-d2c0f0a6", 45 | "tags.%": "0", 46 | "tenancy": "default", 47 | "vpc_security_group_ids.#": "1", 48 | "vpc_security_group_ids.619359045": "sg-9fc7dcfd" 49 | }, 50 | "meta": { 51 | "schema_version": "1" 52 | }, 53 | "tainted": false 54 | }, 55 | "deposed": [], 56 | "provider": "" 57 | } 58 | }, 59 | "depends_on": [] 60 | } 61 | ] 62 | } 63 | -------------------------------------------------------------------------------- /test/tfvar_files/test.tfvars: -------------------------------------------------------------------------------- 1 | test_var = "True" 2 | -------------------------------------------------------------------------------- /test/var_to_output/test.tf: -------------------------------------------------------------------------------- 1 | variable "test_var" { 2 | default = "" 3 | } 4 | 5 | provider "archive" {} 6 | 7 | variable "test_list_var" { 8 | type = list(string) 9 | default = ["a", "b"] 10 | } 11 | 12 | variable "test_map_var" { 13 | type = map 14 | 15 | default = { 16 | "a" = "a" 17 | "b" = "b" 18 | } 19 | } 20 | 21 | output "test_output" { 22 | value = var.test_var 23 | } 24 | 25 | output "test_list_output" { 26 | value = var.test_list_var 27 | } 28 | 29 | output "test_map_output" { 30 | value = var.test_map_var 31 | } 32 | -------------------------------------------------------------------------------- /test/var_to_output/test_map_var.json: -------------------------------------------------------------------------------- 1 | { 2 | "test_map_var": 3 | { 4 | "e": "e", 5 | "f": "f" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /test/vars_require_input/main.tf: -------------------------------------------------------------------------------- 1 | variable "ami" { 2 | default = "foo" 3 | type = "string" 4 | } 5 | 6 | variable "list" { 7 | default = [] 8 | type = "list" 9 | } 10 | 11 | variable "map" { 12 | default = {} 13 | type = "map" 14 | } 15 | 16 | resource "aws_instance" "bar" { 17 | foo = "${var.ami}" 18 | bar = "${join(",", var.list)}" 19 | baz = "${join(",", keys(var.map))}" 20 | } 21 | --------------------------------------------------------------------------------