2023-10-05 12:08:18 +00:00
|
|
|
#!/usr/bin/env python
|
|
|
|
|
|
|
|
# source: https://github.com/nbering/terraform-inventory
|
|
|
|
|
|
|
|
'''
|
|
|
|
Terraform Inventory Script
|
|
|
|
==========================
|
|
|
|
This inventory script generates dynamic inventory by reading Terraform state
|
|
|
|
contents. Servers and groups a defined inside the Terraform state using special
|
|
|
|
resources defined by the Terraform Provider for Ansible.
|
|
|
|
|
|
|
|
Configuration
|
|
|
|
=============
|
|
|
|
|
|
|
|
State is fetched using the "terraform state pull" subcommand. The behaviour of
|
|
|
|
this action can be configured using some environment variables.
|
|
|
|
|
|
|
|
Environment Variables:
|
|
|
|
......................
|
|
|
|
|
|
|
|
ANSIBLE_TF_BIN
|
|
|
|
Override the path to the Terraform command executable. This is useful if
|
|
|
|
you have multiple copies or versions installed and need to specify a
|
|
|
|
specific binary. The inventory script runs the `terraform state pull`
|
|
|
|
command to fetch the Terraform state, so that remote state will be
|
|
|
|
fetched seemlessly regardless of the backend configuration.
|
|
|
|
|
|
|
|
ANSIBLE_TF_DIR
|
|
|
|
Set the working directory for the `terraform` command when the scripts
|
|
|
|
shells out to it. This is useful if you keep your terraform and ansible
|
|
|
|
configuration in separate directories. Defaults to using the current
|
|
|
|
working directory.
|
|
|
|
|
|
|
|
ANSIBLE_TF_WS_NAME
|
|
|
|
Sets the workspace for the `terraform` command when the scripts shells
|
|
|
|
out to it, defaults to `default` workspace - if you don't use workspaces
|
|
|
|
this is the one you'll be using.
|
|
|
|
'''
|
|
|
|
|
|
|
|
import sys
|
|
|
|
import json
|
|
|
|
import os
|
|
|
|
import re
|
|
|
|
import traceback
|
|
|
|
from subprocess import Popen, PIPE
|
|
|
|
|
|
|
|
TERRAFORM_DIR = os.environ.get('ANSIBLE_TF_DIR', os.getcwd())
|
|
|
|
TERRAFORM_ENV = os.path.join(TERRAFORM_DIR, '.terraform/environment')
|
|
|
|
TERRAFORM_PATH = os.environ.get('ANSIBLE_TF_BIN', 'terraform')
|
|
|
|
TERRAFORM_BPK = os.path.join(TERRAFORM_DIR, '.terraform/terraform.tfstate.backup')
|
|
|
|
|
|
|
|
def _tf_env():
|
|
|
|
# way to figure out currenly used TF workspace
|
|
|
|
try:
|
|
|
|
with open(TERRAFORM_ENV) as f:
|
|
|
|
return f.read()
|
|
|
|
except:
|
|
|
|
return 'default'
|
|
|
|
|
|
|
|
TERRAFORM_WS_NAME = os.environ.get('ANSIBLE_TF_WS_NAME', _tf_env())
|
|
|
|
ANSIBLE_BKP = os.path.join(TERRAFORM_DIR, 'ansible/inventory', _tf_env())
|
|
|
|
|
|
|
|
class TerraformState(object):
|
|
|
|
'''
|
|
|
|
TerraformState wraps the state content to provide some helpers for iterating
|
|
|
|
over resources.
|
|
|
|
'''
|
|
|
|
|
|
|
|
def __init__(self, state_json):
|
|
|
|
self.state_json = state_json
|
|
|
|
|
|
|
|
if "modules" in state_json:
|
|
|
|
# uses pre-0.12
|
|
|
|
self.flat_attrs = True
|
|
|
|
else:
|
|
|
|
# state format for 0.12+
|
|
|
|
self.flat_attrs = False
|
|
|
|
|
|
|
|
def resources(self):
|
|
|
|
'''Generator method to iterate over resources in the state file.'''
|
|
|
|
if self.flat_attrs:
|
|
|
|
modules = self.state_json["modules"]
|
|
|
|
for module in modules:
|
|
|
|
for resource in module["resources"].values():
|
|
|
|
yield TerraformResource(resource, flat_attrs=True)
|
|
|
|
else:
|
|
|
|
resources = self.state_json["resources"]
|
|
|
|
for resource in resources:
|
|
|
|
for instance in resource["instances"]:
|
|
|
|
yield TerraformResource(instance, resource_type=resource["type"])
|
|
|
|
|
|
|
|
|
|
|
|
class TerraformResource(object):
|
|
|
|
'''
|
|
|
|
TerraformResource wraps individual resource content and provide some helper
|
|
|
|
methods for reading older-style dictionary and list values from attributes
|
|
|
|
defined as a single-level map.
|
|
|
|
'''
|
|
|
|
DEFAULT_PRIORITIES = {
|
|
|
|
'ansible_host': 50,
|
|
|
|
'ansible_group': 50,
|
|
|
|
'ansible_host_var': 60,
|
|
|
|
'ansible_group_var': 60
|
|
|
|
}
|
|
|
|
|
|
|
|
def __init__(self, source_json, flat_attrs=False, resource_type=None):
|
|
|
|
self.flat_attrs = flat_attrs
|
|
|
|
self._type = resource_type
|
|
|
|
self._priority = None
|
|
|
|
self.source_json = source_json
|
|
|
|
|
|
|
|
def is_ansible(self):
|
|
|
|
'''Check if the resource is provided by the ansible provider.'''
|
|
|
|
return self.type().startswith("ansible_")
|
|
|
|
|
|
|
|
def priority(self):
|
|
|
|
'''Get the merge priority of the resource.'''
|
|
|
|
if self._priority is not None:
|
|
|
|
return self._priority
|
|
|
|
|
|
|
|
priority = 0
|
|
|
|
|
|
|
|
if self.read_int_attr("variable_priority") is not None:
|
|
|
|
priority = self.read_int_attr("variable_priority")
|
|
|
|
elif self.type() in TerraformResource.DEFAULT_PRIORITIES:
|
|
|
|
priority = TerraformResource.DEFAULT_PRIORITIES[self.type()]
|
|
|
|
|
|
|
|
self._priority = priority
|
|
|
|
|
|
|
|
return self._priority
|
|
|
|
|
|
|
|
def type(self):
|
|
|
|
'''Returns the Terraform resource type identifier.'''
|
|
|
|
if self._type:
|
|
|
|
return self._type
|
|
|
|
return self.source_json["type"]
|
|
|
|
|
|
|
|
def read_dict_attr(self, key):
|
|
|
|
'''
|
|
|
|
Read a dictionary attribute from the resource, handling old-style
|
|
|
|
Terraform state where maps are stored as multiple keys in the resource's
|
|
|
|
attributes.
|
|
|
|
'''
|
|
|
|
attrs = self._raw_attributes()
|
|
|
|
|
|
|
|
if self.flat_attrs:
|
|
|
|
out = {}
|
|
|
|
for k in attrs.keys():
|
|
|
|
match = re.match(r"^" + key + r"\.(.*)", k)
|
|
|
|
if not match or match.group(1) == "%":
|
|
|
|
continue
|
|
|
|
|
|
|
|
out[match.group(1)] = attrs[k]
|
|
|
|
return out
|
|
|
|
return attrs.get(key, {})
|
|
|
|
|
|
|
|
def read_list_attr(self, key):
|
|
|
|
'''
|
|
|
|
Read a list attribute from the resource, handling old-style Terraform
|
|
|
|
state where lists are stored as multiple keys in the resource's
|
|
|
|
attributes.
|
|
|
|
'''
|
|
|
|
attrs = self._raw_attributes()
|
|
|
|
|
|
|
|
if self.flat_attrs:
|
|
|
|
out = []
|
|
|
|
|
|
|
|
length_key = key + ".#"
|
|
|
|
if length_key not in attrs.keys():
|
|
|
|
return []
|
|
|
|
|
|
|
|
length = int(attrs[length_key])
|
|
|
|
if length < 1:
|
|
|
|
return []
|
|
|
|
|
|
|
|
for i in range(0, length):
|
|
|
|
out.append(attrs["{}.{}".format(key, i)])
|
|
|
|
|
|
|
|
return out
|
|
|
|
return attrs.get(key, None)
|
|
|
|
|
|
|
|
def read_int_attr(self, key):
|
|
|
|
'''
|
|
|
|
Read an attribute from state an convert it to type Int.
|
|
|
|
'''
|
|
|
|
val = self.read_attr(key)
|
|
|
|
|
|
|
|
if val is not None:
|
|
|
|
val = int(val)
|
|
|
|
|
|
|
|
return val
|
|
|
|
|
|
|
|
def read_attr(self, key):
|
|
|
|
'''
|
|
|
|
Read an attribute from the underlaying state content.
|
|
|
|
'''
|
|
|
|
return self._raw_attributes().get(key, None)
|
|
|
|
|
|
|
|
def _raw_attributes(self):
|
|
|
|
if self.flat_attrs:
|
|
|
|
return self.source_json["primary"]["attributes"]
|
|
|
|
return self.source_json["attributes"]
|
|
|
|
|
|
|
|
|
|
|
|
class AnsibleInventory(object):
|
|
|
|
'''
|
|
|
|
AnsibleInventory handles conversion from Terraform resource content to
|
|
|
|
Ansible inventory entities, and building of the final inventory json.
|
|
|
|
'''
|
|
|
|
|
|
|
|
def __init__(self):
|
|
|
|
self.groups = {}
|
|
|
|
self.hosts = {}
|
|
|
|
self.inner_json = {}
|
|
|
|
|
|
|
|
def add_host_resource(self, resource):
|
|
|
|
'''Upsert type action for host resources.'''
|
|
|
|
hostname = resource.read_attr("inventory_hostname")
|
|
|
|
|
|
|
|
if hostname in self.hosts:
|
|
|
|
host = self.hosts[hostname]
|
|
|
|
host.add_source(resource)
|
|
|
|
else:
|
|
|
|
host = AnsibleHost(hostname, source=resource)
|
|
|
|
self.hosts[hostname] = host
|
|
|
|
|
|
|
|
def add_group_resource(self, resource):
|
|
|
|
'''Upsert type action for group resources.'''
|
|
|
|
groupname = resource.read_attr("inventory_group_name")
|
|
|
|
|
|
|
|
if groupname in self.groups:
|
|
|
|
group = self.groups[groupname]
|
|
|
|
group.add_source(resource)
|
|
|
|
else:
|
|
|
|
group = AnsibleGroup(groupname, source=resource)
|
|
|
|
self.groups[groupname] = group
|
|
|
|
|
|
|
|
def update_groups(self, groupname, children=None, hosts=None, group_vars=None):
|
|
|
|
'''Upsert type action for group resources'''
|
|
|
|
if groupname in self.groups:
|
|
|
|
group = self.groups[groupname]
|
|
|
|
group.update(children=children, hosts=hosts, group_vars=group_vars)
|
|
|
|
else:
|
|
|
|
group = AnsibleGroup(groupname)
|
|
|
|
group.update(children, hosts, group_vars)
|
|
|
|
self.groups[groupname] = group
|
|
|
|
|
|
|
|
def add_resource(self, resource):
|
|
|
|
'''
|
|
|
|
Process a Terraform resource, passing to the correct handler function
|
|
|
|
by type.
|
|
|
|
'''
|
|
|
|
if resource.type().startswith("ansible_host"):
|
|
|
|
self.add_host_resource(resource)
|
|
|
|
elif resource.type().startswith("ansible_group"):
|
|
|
|
self.add_group_resource(resource)
|
|
|
|
|
|
|
|
def to_dict(self):
|
|
|
|
'''
|
|
|
|
Generate the file Ansible inventory structure to be serialized into JSON
|
|
|
|
for consumption by Ansible proper.
|
|
|
|
'''
|
|
|
|
out = {
|
|
|
|
"_meta": {
|
|
|
|
"hostvars": {}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
for hostname, host in self.hosts.items():
|
|
|
|
host.build()
|
|
|
|
for group in host.groups:
|
|
|
|
self.update_groups(group, hosts=[host.hostname])
|
|
|
|
out["_meta"]["hostvars"][hostname] = host.get_vars()
|
|
|
|
|
|
|
|
for groupname, group in self.groups.items():
|
|
|
|
group.build()
|
|
|
|
out[groupname] = group.to_dict()
|
|
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
class AnsibleHost(object):
|
|
|
|
'''
|
|
|
|
AnsibleHost represents a host for the Ansible inventory.
|
|
|
|
'''
|
|
|
|
|
|
|
|
def __init__(self, hostname, source=None):
|
|
|
|
self.sources = []
|
|
|
|
self.hostname = hostname
|
|
|
|
self.groups = set(["all"])
|
|
|
|
self.host_vars = {}
|
|
|
|
|
|
|
|
if source:
|
|
|
|
self.add_source(source)
|
|
|
|
|
|
|
|
def update(self, groups=None, host_vars=None):
|
|
|
|
'''Update host resource with additional groups and vars.'''
|
|
|
|
if host_vars:
|
|
|
|
self.host_vars.update(host_vars)
|
|
|
|
if groups:
|
|
|
|
self.groups.update(groups)
|
|
|
|
|
|
|
|
def add_source(self, source):
|
|
|
|
'''Add a Terraform resource to the sources list.'''
|
|
|
|
self.sources.append(source)
|
|
|
|
|
|
|
|
def build(self):
|
|
|
|
'''Assemble host details from registered sources.'''
|
|
|
|
self.sources.sort(key=lambda source: source.priority())
|
|
|
|
for source in self.sources:
|
|
|
|
if source.type() == "ansible_host":
|
|
|
|
groups = source.read_list_attr("groups")
|
|
|
|
host_vars = source.read_dict_attr("vars")
|
|
|
|
|
|
|
|
self.update(groups=groups, host_vars=host_vars)
|
|
|
|
elif source.type() == "ansible_host_var":
|
|
|
|
host_vars = {source.read_attr(
|
|
|
|
"key"): source.read_attr("value")}
|
|
|
|
|
|
|
|
self.update(host_vars=host_vars)
|
|
|
|
self.groups = sorted(self.groups)
|
|
|
|
|
|
|
|
def get_vars(self):
|
|
|
|
'''Get the host's variable dictionary.'''
|
|
|
|
return dict(self.host_vars)
|
|
|
|
|
|
|
|
|
|
|
|
class AnsibleGroup(object):
|
|
|
|
'''
|
|
|
|
AnsibleGroup represents a group for the Ansible inventory.
|
|
|
|
'''
|
|
|
|
|
|
|
|
def __init__(self, groupname, source=None):
|
|
|
|
self.groupname = groupname
|
|
|
|
self.sources = []
|
|
|
|
self.hosts = set()
|
|
|
|
self.children = set()
|
|
|
|
self.group_vars = {}
|
|
|
|
|
|
|
|
if source:
|
|
|
|
self.add_source(source)
|
|
|
|
|
|
|
|
def update(self, children=None, hosts=None, group_vars=None):
|
|
|
|
'''
|
|
|
|
Update host resource with additional children, hosts, or group variables.
|
|
|
|
'''
|
|
|
|
if hosts:
|
|
|
|
self.hosts.update(hosts)
|
|
|
|
if children:
|
|
|
|
self.children.update(children)
|
|
|
|
if group_vars:
|
|
|
|
self.group_vars.update(group_vars)
|
|
|
|
|
|
|
|
def add_source(self, source):
|
|
|
|
'''Add a Terraform resource to the sources list.'''
|
|
|
|
self.sources.append(source)
|
|
|
|
|
|
|
|
def build(self):
|
|
|
|
'''Assemble group details from registered sources.'''
|
|
|
|
self.sources.sort(key=lambda source: source.priority())
|
|
|
|
for source in self.sources:
|
|
|
|
if source.type() == "ansible_group":
|
|
|
|
children = source.read_list_attr("children")
|
|
|
|
group_vars = source.read_dict_attr("vars")
|
|
|
|
|
|
|
|
self.update(children=children, group_vars=group_vars)
|
|
|
|
elif source.type() == "ansible_group_var":
|
|
|
|
group_vars = {source.read_attr(
|
|
|
|
"key"): source.read_attr("value")}
|
|
|
|
|
|
|
|
self.update(group_vars=group_vars)
|
|
|
|
|
|
|
|
self.hosts = sorted(self.hosts)
|
|
|
|
self.children = sorted(self.children)
|
|
|
|
|
|
|
|
def to_dict(self):
|
|
|
|
'''Prepare structure for final Ansible inventory JSON.'''
|
|
|
|
return {
|
|
|
|
"children": list(self.children),
|
|
|
|
"hosts": list(self.hosts),
|
|
|
|
"vars": dict(self.group_vars)
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def _execute_shell():
|
|
|
|
tf_workspace = [TERRAFORM_PATH, 'workspace', 'select', TERRAFORM_WS_NAME]
|
|
|
|
proc_ws = Popen(tf_workspace, cwd=TERRAFORM_DIR, stdout=PIPE,
|
|
|
|
stderr=PIPE, universal_newlines=True)
|
|
|
|
_, err_ws = proc_ws.communicate()
|
|
|
|
if err_ws != '':
|
|
|
|
sys.stderr.write(str(err_ws)+'\n')
|
|
|
|
sys.exit(1)
|
|
|
|
else:
|
|
|
|
tf_command = [TERRAFORM_PATH, 'state', 'pull']
|
|
|
|
proc_tf_cmd = Popen(tf_command, cwd=TERRAFORM_DIR,
|
|
|
|
stdout=PIPE, stderr=PIPE, universal_newlines=True)
|
|
|
|
out_cmd, err_cmd = proc_tf_cmd.communicate()
|
|
|
|
if err_cmd != '':
|
|
|
|
sys.stderr.write(str(err_cmd)+'\n')
|
|
|
|
sys.exit(1)
|
|
|
|
else:
|
|
|
|
return json.loads(out_cmd)
|
|
|
|
|
|
|
|
|
|
|
|
def _backup_tf(tfstate):
|
|
|
|
# Crates a state backup in case we lose Consul
|
|
|
|
with open(TERRAFORM_BPK, 'w') as f:
|
|
|
|
f.write(json.dumps(tfstate.state_json))
|
|
|
|
|
|
|
|
def _backup_ansible(inventory):
|
|
|
|
# Crates a state backup in Ansible inventory format
|
|
|
|
text = '# NOTE: This file is generated by terraform.py\n'
|
|
|
|
text += '# For emergency use when Consul fails\n'
|
|
|
|
text += '[all]\n'
|
|
|
|
for hostname, host in sorted(inventory.hosts.items()):
|
2024-09-13 15:04:16 +00:00
|
|
|
host_vars = {k: v for k, v in host.host_vars.items() if k != 'hostname'}
|
|
|
|
vars_text = " ".join(f"{k}={v}" for k,v in host_vars.items())
|
|
|
|
text += f"{hostname} {vars_text}\n"
|
2023-10-05 12:08:18 +00:00
|
|
|
text += '\n'
|
|
|
|
for name, hosts in sorted(inventory.groups.items()):
|
|
|
|
if name in ['_meta', 'all']:
|
|
|
|
continue
|
|
|
|
text += '[{}]\n'.format(name)
|
|
|
|
for hostname in sorted(hosts.hosts):
|
|
|
|
text += '{}\n'.format(hostname)
|
|
|
|
text += '\n'
|
|
|
|
with open(ANSIBLE_BKP, 'w') as f:
|
|
|
|
f.write(text)
|
|
|
|
|
|
|
|
def _main():
|
|
|
|
try:
|
|
|
|
tfstate = TerraformState(_execute_shell())
|
|
|
|
inventory = AnsibleInventory()
|
|
|
|
|
|
|
|
for resource in tfstate.resources():
|
|
|
|
if resource.is_ansible():
|
|
|
|
inventory.add_resource(resource)
|
|
|
|
|
|
|
|
sys.stdout.write(json.dumps(inventory.to_dict(), indent=2))
|
|
|
|
# backup raw TF state
|
|
|
|
_backup_tf(tfstate)
|
|
|
|
# backup ansible inventory
|
|
|
|
_backup_ansible(inventory)
|
|
|
|
except Exception:
|
|
|
|
traceback.print_exc(file=sys.stderr)
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
|
|
_main()
|