├── README.md ├── terrastate ├── .gitignore ├── README.md ├── app.py ├── config.json ├── requirements.txt ├── setup.cfg └── terrastate.wsgi └── terratemplate ├── README.md ├── render.py └── requirements.txt /README.md: -------------------------------------------------------------------------------- 1 | # Terratools 2 | 3 | Here you can find some tools that I've made while working with [Terraform](https://www.terraform.io/): 4 | 5 | * **TerraState**: HTTP backend for terraform remote 6 | * **TerraTemplate**: Use the Jinja templating engine in your terraform scripts 7 | -------------------------------------------------------------------------------- /terrastate/.gitignore: -------------------------------------------------------------------------------- 1 | states 2 | -------------------------------------------------------------------------------- /terrastate/README.md: -------------------------------------------------------------------------------- 1 | # TerraState 2 | 3 | This is more ore less a working boilerplate for people who want to use [Terraforms](https://www.terraform.io) remote feature with a HTTP backend. The code is small and simple. As it is, it supports multiple environments and saves the state as plain json files on disk. It can be easily extended to work with several backends. 4 | 5 | ## Quickstart 6 | 7 | **Prerequisites** 8 | 9 | * some terraform code 10 | * flask installed on the http server 11 | 12 | **Clone the repo** 13 | 14 | ```bash 15 | $ git clone https://github.com/Crapworks/terratools.git 16 | ``` 17 | 18 | **Start the server** 19 | 20 | ```bash 21 | $ cd terratools/terrastate && ./app.py 22 | ``` 23 | 24 | The server runs per default an port 5000. This can be changed in the ```app.py``` by adding the ```port``` argument the the ```run()``` function. There is also a ready to use wsgi file for deploying the app into apache or nginx. 25 | 26 | **Configure terraform remote** 27 | ```bash 28 | $ terraform remote config -backend=http -backend-config="address=http://servername:5000/environment" 29 | Remote configuration updated 30 | Remote state configured and pulled. 31 | ``` 32 | 33 | Replace ```servername``` with the hostname of the server where the app is running and ```environment``` with the name of the environment you want to save the state under. 34 | 35 | Now you can use the backend! 36 | 37 | ```bash 38 | $ terraform remote push 39 | State successfully pushed! 40 | ``` 41 | -------------------------------------------------------------------------------- /terrastate/app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: UTF-8 -*- 3 | import os 4 | import json 5 | import errno 6 | 7 | from os.path import dirname 8 | from os.path import join 9 | 10 | from flask import Flask 11 | from flask import request 12 | from flask import jsonify 13 | from flask.views import MethodView 14 | 15 | from werkzeug.exceptions import default_exceptions 16 | from werkzeug.exceptions import HTTPException 17 | 18 | 19 | class TerraformState(dict): 20 | """Representation of a Terraform statefile""" 21 | 22 | def __init__(self, config): 23 | dict.__init__(self) 24 | self.env = None 25 | self.config = config 26 | self.statepath = self._mkstatedir(config['statepath']) 27 | self.update({ 28 | 'version': 1, 29 | 'serial': 0, 30 | 'modules': [{ 31 | 'path': ['root'], 32 | 'outputs': {}, 33 | 'resources': {} 34 | }] 35 | }) 36 | 37 | def _mkstatedir(self, statepath): 38 | try: 39 | os.makedirs(statepath, mode=0o744) 40 | except OSError as err: 41 | if err.errno == errno.EEXIST and os.path.isdir(statepath): 42 | pass 43 | else: 44 | raise 45 | return statepath 46 | 47 | def _getstatefilename(self, env): 48 | return os.path.join(self.statepath, '%s-tfstate.json' % (env, )) 49 | 50 | def _getlockfilename(self, env): 51 | return os.path.join(self.statepath, '%s-tfstate.lock' % (env, )) 52 | 53 | def load(self): 54 | if not os.path.isfile(self._getstatefilename(self.env)): 55 | return 56 | with open(self._getstatefilename(self.env)) as fh: 57 | self.update(json.load(fh)) 58 | 59 | def save(self): 60 | with open(self._getstatefilename(self.env), 'w+') as fh: 61 | json.dump(self, fh, indent=2) 62 | 63 | def destroy(self): 64 | if os.path.isfile(self._getstatefilename(self.env)): 65 | os.unlink(self._getstatefilename(self.env)) 66 | 67 | def lock(self): 68 | if os.path.isfile(self._getlockfilename(self.env)): 69 | raise Exception('Already locked') 70 | with open(self._getlockfilename(self.env), 'w') as fh: 71 | fh.write('locked') 72 | 73 | def unlock(self): 74 | if os.path.isfile(self._getlockfilename(self.env)): 75 | os.unlink(self._getlockfilename(self.env)) 76 | else: 77 | raise Exception('Not locked') 78 | 79 | 80 | class Config(dict): 81 | """A simple json config file loader 82 | 83 | :param str filename: path to the json file 84 | """ 85 | def __init__(self, filename): 86 | dict.__init__(self) 87 | self.update(json.load(open(filename))) 88 | 89 | 90 | class StateView(MethodView): 91 | """Terraform State MethodView""" 92 | 93 | def __init__(self, *args, **kwargs): 94 | MethodView.__init__(self, *args, **kwargs) 95 | self.config = Config(join(dirname(__file__), 'config.json')) 96 | self.state = TerraformState(self.config) 97 | 98 | def get(self, env): 99 | self.state.env = env 100 | self.state.load() 101 | return jsonify(self.state) 102 | 103 | def post(self, env): 104 | self.state.env = env 105 | self.state.update(request.get_json()) 106 | self.state.save() 107 | return jsonify(self.state) 108 | 109 | def delete(self, env): 110 | self.state.env = env 111 | self.state.update(request.get_json()) 112 | self.state.destroy() 113 | return jsonify(self.state) 114 | 115 | def lock(self, env): 116 | self.state.env = env 117 | self.state.lock() 118 | self.state.load() 119 | return jsonify(self.state) 120 | 121 | def unlock(self, env): 122 | self.state.env = env 123 | self.state.unlock() 124 | self.state.load() 125 | return jsonify(self.state) 126 | 127 | 128 | class TerraStateApi(Flask): 129 | def __init__(self, name): 130 | Flask.__init__(self, name) 131 | 132 | dhc_view = StateView.as_view('status') 133 | self.add_url_rule('/', defaults={'env': None}, view_func=dhc_view) 134 | self.add_url_rule('/', view_func=dhc_view, methods=['GET', 'POST', 'DELETE', 'LOCK', 'UNLOCK']) 135 | 136 | # add custom error handler 137 | for code in default_exceptions.iterkeys(): 138 | self.register_error_handler(code, self.make_json_error) 139 | 140 | def make_json_error(self, ex): 141 | if isinstance(ex, HTTPException): 142 | code = ex.code 143 | message = ex.description 144 | else: 145 | code = 500 146 | message = str(ex) 147 | 148 | response = jsonify(code=code, message=message) 149 | response.status_code = code 150 | 151 | return response 152 | 153 | 154 | app = TerraStateApi(__name__) 155 | 156 | if __name__ == '__main__': 157 | app.run(debug=False, host='0.0.0.0') 158 | -------------------------------------------------------------------------------- /terrastate/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "statepath": "./states" 3 | } 4 | -------------------------------------------------------------------------------- /terrastate/requirements.txt: -------------------------------------------------------------------------------- 1 | flask 2 | -------------------------------------------------------------------------------- /terrastate/setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 160 3 | -------------------------------------------------------------------------------- /terrastate/terrastate.wsgi: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), '.', '.')) 5 | from app import app as application 6 | -------------------------------------------------------------------------------- /terratemplate/README.md: -------------------------------------------------------------------------------- 1 | # TerraTemplate 2 | 3 | This is a tool that allows you to use Jinja templating inside of your terraform scripts. Put the `render.py` into the same directory as your terraform scripts, install the requirements and run it. 4 | 5 | ## Quickstart / Example 6 | 7 | ```bash 8 | $ git clone https://github.com/Crapworks/terratools.git 9 | $ cp terratools/terratemplate/render.py /tmp/yourterraformfiles 10 | $ pip install -r terratools/terratemplate/requirements.txt 11 | ``` 12 | 13 | Now switch to your directory containing your terraform files. Only files with the suffix `.jinja` will be rendered. 14 | 15 | ```bash 16 | $ cd /tmp/yourterraformfiles 17 | $ mv mykeys.tf mykeys.jinja 18 | ``` 19 | 20 | TerraState will load all your terraform scripts to check for `variable` definitions with a default value. If you set variables via `.tfvars` files, you need to add them as command line parameters to the script. 21 | 22 | ```bash 23 | $ cat variables.tf 24 | variable "key_count" { 25 | default = "1" 26 | } 27 | 28 | $ cat myoverwrite.tfvars 29 | key_count = "2" 30 | ``` 31 | 32 | You can see the rendered variables if you use the `-s` switch: 33 | 34 | ```bash 35 | $ ./render.py -s ──( :) )─┘ 36 | { 37 | "key_count": "1" 38 | } 39 | 40 | $ ./render.py -s -var-file=myoverwrite.tfvars ──( :) )─┘ 41 | { 42 | "key_count": "2" 43 | } 44 | ``` 45 | 46 | This way, you can use your terraform variables in your jinja templates 47 | 48 | ```bash 49 | $ cat mykeys.jinja 50 | {% for count in range(key_count|int) -%} 51 | resource "openstack_compute_keypair_v2" "user{{ count }}" { 52 | name = "${var.project}" 53 | public_key = "${file(var.public_key_path)}" 54 | } 55 | 56 | {% endfor %} 57 | ``` 58 | 59 | To see what is going to be rendered: 60 | 61 | ```bash 62 | $ ./render.py -var-file=myoverwrite.tfvars --test ──( :) )─┘ 63 | resource "openstack_compute_keypair_v2" "user0" { 64 | name = "${var.project}" 65 | public_key = "${file(var.public_key_path)}" 66 | } 67 | 68 | resource "openstack_compute_keypair_v2" "user1" { 69 | name = "${var.project}" 70 | public_key = "${file(var.public_key_path)}" 71 | } 72 | ``` 73 | 74 | To finally render it to a `.tf` file, just remove the `--test` argument 75 | 76 | ```bash 77 | $ ./render.py -var-file=myoverwrite.tfvars 78 | $ cat mykeys.tf ──( :) )─┘ 79 | resource "openstack_compute_keypair_v2" "user0" { 80 | name = "${var.project}" 81 | public_key = "${file(var.public_key_path)}" 82 | } 83 | 84 | resource "openstack_compute_keypair_v2" "user1" { 85 | name = "${var.project}" 86 | public_key = "${file(var.public_key_path)}" 87 | } 88 | ``` 89 | 90 | Have fun! 91 | -------------------------------------------------------------------------------- /terratemplate/render.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import glob 6 | import json 7 | import argparse 8 | 9 | import hcl 10 | 11 | from jinja2 import Environment 12 | from jinja2 import FileSystemLoader 13 | 14 | 15 | def load_variables(filenames): 16 | """Load terraform variables""" 17 | 18 | variables = {} 19 | 20 | for terrafile in glob.glob('./*.tf'): 21 | with open(terrafile) as fh: 22 | data = hcl.load(fh) 23 | for key, value in data.get('variable', {}).iteritems(): 24 | if 'default' in value: 25 | variables.update({key: value['default']}) 26 | 27 | for varfile in filenames: 28 | with open(varfile) as fh: 29 | data = hcl.load(fh) 30 | variables.update(data) 31 | 32 | return variables 33 | 34 | 35 | def render(template, context): 36 | """Redner Jinja templates""" 37 | 38 | path, filename = os.path.split(template) 39 | return Environment( 40 | loader=FileSystemLoader(path or './') 41 | ).get_template(filename).render(context) 42 | 43 | 44 | def main(): 45 | parser = argparse.ArgumentParser() 46 | parser.add_argument('-var-file', dest='vfile', action='append', default=[]) 47 | parser.add_argument('-t', '--test', action='store_true') 48 | parser.add_argument('-s', '--showvars', action='store_true') 49 | args = parser.parse_args() 50 | 51 | context = load_variables(args.vfile) 52 | if args.showvars: 53 | print(json.dumps(context, indent=2)) 54 | 55 | for template in glob.glob('./*.jinja'): 56 | rendered_filename = '{}.tf'.format(os.path.splitext(template)[0]) 57 | if args.test: 58 | print(render(template, context)) 59 | else: 60 | with open(rendered_filename, 'w') as fh: 61 | fh.write(render(template, context)) 62 | 63 | 64 | if __name__ == '__main__': 65 | main() 66 | -------------------------------------------------------------------------------- /terratemplate/requirements.txt: -------------------------------------------------------------------------------- 1 | hcl 2 | Jinja2 3 | --------------------------------------------------------------------------------