1. add test cases, try tdd
2. refactor to more generic method instead of aws method 3. ready for release to pypi
This commit is contained in:
parent
f1a414760b
commit
2ff59f3630
9 changed files with 268 additions and 164 deletions
2
VERSION
2
VERSION
|
@ -1 +1 @@
|
||||||
0.0.4
|
0.7.1
|
|
@ -3,180 +3,186 @@ import os
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from python_terraform.tfstate import Tfstate
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Terraform:
|
class Terraform:
|
||||||
def __init__(self, targets=None, state='terraform.tfstate', variables=None):
|
"""
|
||||||
|
Wrapper of terraform command line tool
|
||||||
|
https://www.terraform.io/
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, working_dir=None,
|
||||||
|
targets=None,
|
||||||
|
state=None,
|
||||||
|
variables=None,
|
||||||
|
parallelism=None,
|
||||||
|
var_file=None):
|
||||||
|
self.working_dir = working_dir
|
||||||
|
self.state = state
|
||||||
self.targets = [] if targets is None else targets
|
self.targets = [] if targets is None else targets
|
||||||
self.variables = dict() if variables is None else variables
|
self.variables = dict() if variables is None else variables
|
||||||
|
self.parallelism = parallelism
|
||||||
self.state_filename = state
|
self.terraform_bin_path = 'terraform'
|
||||||
self.state_data = dict()
|
self.var_file = var_file
|
||||||
self.parallelism = 50
|
self.input = False
|
||||||
|
|
||||||
def apply(self, targets=None, variables=None, **kargs):
|
# store the tfstate data
|
||||||
|
self.tfstate = dict()
|
||||||
|
|
||||||
|
def apply(self,
|
||||||
|
working_dir=None,
|
||||||
|
no_color=True,
|
||||||
|
**kwargs):
|
||||||
"""
|
"""
|
||||||
refer to https://terraform.io/docs/commands/apply.html
|
refer to https://terraform.io/docs/commands/apply.html
|
||||||
:param variables: variables in dict type
|
:param working_dir: working folder
|
||||||
:param targets: targets in list
|
:param no_color: Disables output with coloring.
|
||||||
:returns return_code, stdout, stderr
|
:returns return_code, stdout, stderr
|
||||||
"""
|
"""
|
||||||
variables = self.variables if variables is None else variables
|
if not working_dir:
|
||||||
targets = self.targets if targets is None else targets
|
working_dir = self.working_dir
|
||||||
|
|
||||||
parameters = []
|
option_dict = dict()
|
||||||
parameters += self._generate_targets(targets)
|
option_dict['state'] = self.state
|
||||||
parameters += self._generate_var_string(variables)
|
option_dict['target'] = self.targets
|
||||||
parameters += self._gen_param_string(kargs)
|
option_dict['var'] = self.variables
|
||||||
|
option_dict['var_file'] = self.var_file
|
||||||
|
option_dict['parallelism'] = self.parallelism
|
||||||
|
if no_color:
|
||||||
|
option_dict['no_color'] = ''
|
||||||
|
option_dict['input'] = self.input
|
||||||
|
|
||||||
parameters = \
|
option_dict.update(kwargs)
|
||||||
['terraform', 'apply', '-state=%s' % self.state_filename] + parameters
|
|
||||||
|
|
||||||
cmd = ' '.join(parameters)
|
args = [working_dir] if working_dir else []
|
||||||
return self._run_cmd(cmd)
|
|
||||||
|
|
||||||
def _gen_param_string(self, kargs):
|
ret, out, err = self.cmd('apply', *args, **option_dict)
|
||||||
params = []
|
|
||||||
for key, value in kargs.items():
|
|
||||||
if value:
|
|
||||||
params += ['-%s=%s' % (key, value)]
|
|
||||||
else:
|
|
||||||
params += ['-%s' % key]
|
|
||||||
return params
|
|
||||||
|
|
||||||
def _run_cmd(self, cmd):
|
if ret != 0:
|
||||||
log.debug('command: ' + cmd)
|
raise RuntimeError(err)
|
||||||
|
|
||||||
p = subprocess.Popen(
|
def generate_cmd_string(self, cmd, *args, **kwargs):
|
||||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)
|
"""
|
||||||
|
for any generate_cmd_string doesn't written as public method of terraform
|
||||||
|
|
||||||
|
examples:
|
||||||
|
1. call import command,
|
||||||
|
ref to https://www.terraform.io/docs/commands/import.html
|
||||||
|
--> generate_cmd_string call:
|
||||||
|
terraform import -input=true aws_instance.foo i-abcd1234
|
||||||
|
--> python call:
|
||||||
|
tf.generate_cmd_string('import', 'aws_instance.foo', 'i-abcd1234', input=True)
|
||||||
|
|
||||||
|
2. call apply command,
|
||||||
|
--> generate_cmd_string call:
|
||||||
|
terraform apply -var='a=b' -var='c=d' -no-color the_folder
|
||||||
|
--> python call:
|
||||||
|
tf.generate_cmd_string('apply', the_folder, no_color='', var={'a':'b', 'c':'d'})
|
||||||
|
|
||||||
|
:param cmd: command and sub-command of terraform, seperated with space
|
||||||
|
refer to https://www.terraform.io/docs/commands/index.html
|
||||||
|
:param args: argument other than options of a command
|
||||||
|
:param kwargs: same as kwags in method 'cmd'
|
||||||
|
:return: string of valid terraform command
|
||||||
|
"""
|
||||||
|
cmds = cmd.split()
|
||||||
|
cmds = [self.terraform_bin_path] + cmds
|
||||||
|
|
||||||
|
for k, v in kwargs.items():
|
||||||
|
if '_' in k:
|
||||||
|
k = k.replace('_', '-')
|
||||||
|
|
||||||
|
if type(v) is list:
|
||||||
|
for sub_v in v:
|
||||||
|
cmds += ['-{k}={v}'.format(k=k, v=sub_v)]
|
||||||
|
continue
|
||||||
|
|
||||||
|
if type(v) is dict:
|
||||||
|
for sub_k, sub_v in v.items():
|
||||||
|
cmds += ["-{k}='{var_k}={var_v}'".format(k=k,
|
||||||
|
var_k=sub_k,
|
||||||
|
var_v=sub_v)]
|
||||||
|
continue
|
||||||
|
|
||||||
|
# simple flag,
|
||||||
|
if v == '':
|
||||||
|
cmds += ['-{k}'.format(k=k)]
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not v:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if type(v) is bool:
|
||||||
|
v = 'true' if v else 'false'
|
||||||
|
|
||||||
|
cmds += ['-{k}={v}'.format(k=k, v=v)]
|
||||||
|
|
||||||
|
cmds += args
|
||||||
|
cmd = ' '.join(cmds)
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
def cmd(self, cmd, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
run a terraform command, if success, will try to read state file
|
||||||
|
:param cmd: command and sub-command of terraform, seperated with space
|
||||||
|
refer to https://www.terraform.io/docs/commands/index.html
|
||||||
|
:param args: argument other than options of a command
|
||||||
|
:param kwargs: any option flag with key value other than variables,
|
||||||
|
if there's a dash in the option name, use under line instead of dash, ex -no-color --> no_color
|
||||||
|
if it's a simple flag with no value, value should be empty string
|
||||||
|
if it's a boolean value flag, assign True or false
|
||||||
|
if it's a flag could be used multiple times, assign list to it's value
|
||||||
|
if it's a "var" variable flag, assign dictionary to it
|
||||||
|
if a value is None, will skip this option
|
||||||
|
:return: ret_code, out, err
|
||||||
|
"""
|
||||||
|
cmd_string = self.generate_cmd_string(cmd, *args, **kwargs)
|
||||||
|
log.debug('command: {c}'.format(c=cmd_string))
|
||||||
|
|
||||||
|
p = subprocess.Popen(cmd_string, stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE, shell=True)
|
||||||
out, err = p.communicate()
|
out, err = p.communicate()
|
||||||
ret_code = p.returncode
|
ret_code = p.returncode
|
||||||
log.debug('output: ' + out)
|
log.debug('output: {o}'.format(o=out))
|
||||||
|
|
||||||
if ret_code == 0:
|
if ret_code == 0:
|
||||||
log.debug('error: ' + err)
|
|
||||||
self.read_state_file()
|
self.read_state_file()
|
||||||
return ret_code, out, err
|
else:
|
||||||
|
log.warn('error: {e}'.format(e=err))
|
||||||
|
return ret_code, out.decode('utf-8'), err.decode('utf-8')
|
||||||
|
|
||||||
def destroy(self, targets=None, variables=None, **kwargs):
|
def output(self, name):
|
||||||
variables = self.variables if variables is None else variables
|
"""
|
||||||
targets = self.targets if targets is None else targets
|
https://www.terraform.io/docs/commands/output.html
|
||||||
|
:param name: name of output
|
||||||
|
:return: output value
|
||||||
|
"""
|
||||||
|
ret, out, err = self.cmd('output', name, json='')
|
||||||
|
|
||||||
parameters = []
|
if ret != 0:
|
||||||
parameters += self._generate_targets(targets)
|
return None
|
||||||
parameters += self._generate_var_string(variables)
|
out = out.lstrip()
|
||||||
|
|
||||||
parameters = \
|
output_dict = json.loads(out)
|
||||||
['terraform', 'destroy', '-force', '-state=%s' % self.state_filename] + \
|
return output_dict['value']
|
||||||
parameters
|
|
||||||
cmd = ' '.join(parameters)
|
|
||||||
return self._run_cmd(cmd)
|
|
||||||
|
|
||||||
def refresh(self, targets=None, variables=None):
|
def read_state_file(self, file_path=None):
|
||||||
variables = self.variables if variables is None else variables
|
|
||||||
targets = self.targets if targets is None else targets
|
|
||||||
|
|
||||||
parameters = []
|
|
||||||
parameters += self._generate_targets(targets)
|
|
||||||
parameters += self._generate_var_string(variables)
|
|
||||||
parameters = \
|
|
||||||
['terraform', 'refresh', '-state=%s' % self.state_filename] + \
|
|
||||||
parameters
|
|
||||||
cmd = ' '.join(parameters)
|
|
||||||
return self._run_cmd(cmd)
|
|
||||||
|
|
||||||
def read_state_file(self):
|
|
||||||
"""
|
"""
|
||||||
read .tfstate file
|
read .tfstate file
|
||||||
|
:param file_path: relative path to working dir
|
||||||
:return: states file in dict type
|
:return: states file in dict type
|
||||||
"""
|
"""
|
||||||
if os.path.exists(self.state_filename):
|
|
||||||
with open(self.state_filename) as f:
|
|
||||||
json_data = json.load(f)
|
|
||||||
self.state_data = json_data
|
|
||||||
log.debug("state_data=%s" % str(self.state_data))
|
|
||||||
return json_data
|
|
||||||
|
|
||||||
return dict()
|
if not file_path:
|
||||||
|
file_path = self.state
|
||||||
def is_any_aws_instance_alive(self):
|
|
||||||
self.refresh()
|
|
||||||
if not os.path.exists(self.state_filename):
|
|
||||||
log.debug("can't find %s " % self.state_data)
|
|
||||||
return False
|
|
||||||
|
|
||||||
self.read_state_file()
|
|
||||||
try:
|
|
||||||
main_module = self._get_main_module()
|
|
||||||
for resource_key, info in main_module['resources'].items():
|
|
||||||
if 'aws_instance' in resource_key:
|
|
||||||
log.debug("%s is found when read state" % resource_key)
|
|
||||||
return True
|
|
||||||
log.debug("no aws_instance found in resource key")
|
|
||||||
return False
|
|
||||||
except KeyError as err:
|
|
||||||
log.debug(str(err))
|
|
||||||
return False
|
|
||||||
except TypeError as err:
|
|
||||||
log.debug(str(err))
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _get_main_module(self):
|
|
||||||
return self.state_data['modules'][0]
|
|
||||||
|
|
||||||
def get_aws_instances(self):
|
|
||||||
instances = dict()
|
|
||||||
|
|
||||||
try:
|
|
||||||
main_module = self._get_main_module()
|
|
||||||
for resource_key, info in main_module['resources'].items():
|
|
||||||
if 'aws_instance' in resource_key:
|
|
||||||
instances[resource_key] = info
|
|
||||||
except KeyError:
|
|
||||||
return instances
|
|
||||||
except TypeError:
|
|
||||||
return instances
|
|
||||||
|
|
||||||
return instances
|
|
||||||
|
|
||||||
def get_aws_instance(self, resource_name):
|
|
||||||
"""
|
|
||||||
:param resource_name:
|
|
||||||
name of terraform resource, make source count is attached
|
|
||||||
:return: return None if not exist, dict type if exist
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return self.get_aws_instances()[resource_name]
|
|
||||||
except KeyError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_output_value(self, output_name):
|
|
||||||
"""
|
|
||||||
|
|
||||||
:param output_name:
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
main_module = self._get_main_module()
|
|
||||||
return main_module['outputs'][output_name]
|
|
||||||
except KeyError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _generate_var_string(d):
|
|
||||||
str_t = []
|
|
||||||
for k, v in d.iteritems():
|
|
||||||
str_t += ['-var'] + ["%s=%s" % (k, v)]
|
|
||||||
|
|
||||||
return str_t
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _generate_targets(targets):
|
|
||||||
str_t = []
|
|
||||||
for t in targets:
|
|
||||||
str_t += ['-target=%s' % t]
|
|
||||||
return str_t
|
|
||||||
|
|
||||||
|
if not file_path:
|
||||||
|
file_path = 'terraform.tfstate'
|
||||||
|
|
||||||
|
if self.working_dir:
|
||||||
|
file_path = os.path.join(self.working_dir, file_path)
|
||||||
|
|
||||||
|
self.tfstate = Tfstate.load_file(file_path)
|
||||||
|
|
31
python_terraform/tfstate.py
Normal file
31
python_terraform/tfstate.py
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Tfstate(object):
|
||||||
|
def __init__(self, data=None):
|
||||||
|
self.tfstate_file = None
|
||||||
|
self.native_data = data
|
||||||
|
if data:
|
||||||
|
self.__dict__ = data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def load_file(file_path):
|
||||||
|
"""
|
||||||
|
Read the tfstate file and load its contents, parses then as JSON and put the result into the object
|
||||||
|
"""
|
||||||
|
log.debug('read data from {0}'.format(file_path))
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
with open(file_path) as f:
|
||||||
|
json_data = json.load(f)
|
||||||
|
|
||||||
|
tf_state = Tfstate(json_data)
|
||||||
|
tf_state.tfstate_file = file_path
|
||||||
|
return tf_state
|
||||||
|
|
||||||
|
log.warn('{0} is not exist'.format(file_path))
|
||||||
|
|
||||||
|
return Tfstate()
|
2
setup.cfg
Normal file
2
setup.cfg
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
[wheel]
|
||||||
|
universal = 1
|
4
setup.py
4
setup.py
|
@ -38,8 +38,8 @@ setup(
|
||||||
# 'Development Status :: 1 - Planning',
|
# 'Development Status :: 1 - Planning',
|
||||||
# 'Development Status :: 2 - Pre-Alpha',
|
# 'Development Status :: 2 - Pre-Alpha',
|
||||||
# 'Development Status :: 3 - Alpha',
|
# 'Development Status :: 3 - Alpha',
|
||||||
'Development Status :: 4 - Beta',
|
# 'Development Status :: 4 - Beta',
|
||||||
# 'Development Status :: 5 - Production/Stable',
|
'Development Status :: 5 - Production/Stable',
|
||||||
# 'Development Status :: 6 - Mature',
|
# 'Development Status :: 6 - Mature',
|
||||||
# 'Development Status :: 7 - Inactive',
|
# 'Development Status :: 7 - Inactive',
|
||||||
'Environment :: Console',
|
'Environment :: Console',
|
||||||
|
|
8
test/apply_tf/test.tf
Normal file
8
test/apply_tf/test.tf
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
variable "test_var" {}
|
||||||
|
|
||||||
|
provider "archive" {
|
||||||
|
}
|
||||||
|
|
||||||
|
output "test_output" {
|
||||||
|
value = "${var.test_var}"
|
||||||
|
}
|
|
@ -10,15 +10,4 @@ provider "aws" {
|
||||||
resource "aws_instance" "ubuntu-1404" {
|
resource "aws_instance" "ubuntu-1404" {
|
||||||
ami = "ami-9abea4fb"
|
ami = "ami-9abea4fb"
|
||||||
instance_type = "t2.micro"
|
instance_type = "t2.micro"
|
||||||
security_groups = ["terraform-salty-splunk"]
|
|
||||||
tags {
|
|
||||||
Name = "python-terraform-test"
|
|
||||||
}
|
|
||||||
// key_name = "${aws_key_pair.key.key_name}"
|
|
||||||
// connection {
|
|
||||||
// type = "ssh"
|
|
||||||
// user = "ubuntu"
|
|
||||||
// key_file = "${var.key_path}"
|
|
||||||
// timeout = "10m"
|
|
||||||
// }
|
|
||||||
}
|
}
|
|
@ -1,15 +1,83 @@
|
||||||
from python_terraform import Terraform
|
from python_terraform import Terraform
|
||||||
|
import pytest
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
|
||||||
|
ACCESS_KEY = os.environ['PACKER_access_key']
|
||||||
|
SECRET_KEY = os.environ['PACKER_secret_key']
|
||||||
|
|
||||||
|
STRING_CASES = [
|
||||||
|
[
|
||||||
|
lambda x: x.generate_cmd_string('apply', 'the_folder',
|
||||||
|
no_color='',
|
||||||
|
var={'a': 'b', 'c': 'd'}),
|
||||||
|
"terraform apply -var='a=b' -var='c=d' -no-color the_folder"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
lambda x: x.generate_cmd_string('push', 'path',
|
||||||
|
var={'a': 'b'}, vcs=True,
|
||||||
|
token='token',
|
||||||
|
atlas_address='url'),
|
||||||
|
"terraform push -var='a=b' -vcs=true -token=token -atlas-address=url path"
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
|
CMD_CASES = [
|
||||||
|
['method', 'expected_output'],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
lambda x: x.cmd('plan', 'aws_tf', no_color='', var={'access_key': ACCESS_KEY, 'secret_key': SECRET_KEY}) ,
|
||||||
|
'Plan: 1 to add, 0 to change, 0 to destroy'
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class TestTerraform:
|
class TestTerraform:
|
||||||
def test_apply_and_destory(self):
|
def teardown_method(self, method):
|
||||||
|
""" teardown any state that was previously setup with a setup_method
|
||||||
|
call.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def purge(dir, pattern):
|
||||||
|
for f in os.listdir(dir):
|
||||||
|
if re.search(pattern, f):
|
||||||
|
if os.path.isfile(f):
|
||||||
|
os.remove(os.path.join(dir, f))
|
||||||
|
|
||||||
|
purge('.', '.tfstate')
|
||||||
|
|
||||||
|
@pytest.mark.parametrize([
|
||||||
|
"method", "expected"
|
||||||
|
], STRING_CASES)
|
||||||
|
def test_generate_cmd_string(self, method, expected):
|
||||||
tf = Terraform()
|
tf = Terraform()
|
||||||
ret_code, out, err = tf.apply()
|
result = method(tf)
|
||||||
|
|
||||||
print out
|
strs = expected.split()
|
||||||
print err
|
for s in strs:
|
||||||
# assert ret_code, 0
|
assert s in result
|
||||||
|
|
||||||
ret_code, out, err = tf.destroy()
|
@pytest.mark.parametrize(*CMD_CASES)
|
||||||
|
def test_cmd(self, method, expected_output):
|
||||||
|
tf = Terraform()
|
||||||
|
ret, out, err = method(tf)
|
||||||
|
assert expected_output in out
|
||||||
|
assert ret == 0
|
||||||
|
|
||||||
assert ret_code, 0
|
def test_state_data(self):
|
||||||
|
tf = Terraform(working_dir='test_tfstate_file')
|
||||||
|
tf.read_state_file()
|
||||||
|
assert tf.tfstate.modules[0]['path'] == ['root']
|
||||||
|
|
||||||
|
def test_apply(self):
|
||||||
|
tf = Terraform(working_dir='apply_tf', variables={'test_var': 'test'})
|
||||||
|
tf.apply(var={'test_var': 'test2'})
|
||||||
|
|
||||||
|
def test_get_output(self):
|
||||||
|
tf = Terraform(working_dir='apply_tf', variables={'test_var': 'test'})
|
||||||
|
tf.apply()
|
||||||
|
assert tf.output('test_output') == 'test'
|
||||||
|
|
Loading…
Reference in a new issue