You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

457 lines
15 KiB

  1. #!/usr/bin/env python3
  2. #
  3. # Copyright 2015 Cisco Systems, Inc.
  4. #
  5. # Licensed under the Apache License, Version 2.0 (the "License");
  6. # you may not use this file except in compliance with the License.
  7. # You may obtain a copy of the License at
  8. #
  9. # http://www.apache.org/licenses/LICENSE-2.0
  10. #
  11. # Unless required by applicable law or agreed to in writing, software
  12. # distributed under the License is distributed on an "AS IS" BASIS,
  13. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  14. # See the License for the specific language governing permissions and
  15. # limitations under the License.
  16. #
  17. # original: https://github.com/CiscoCloud/terraform.py
  18. """\
  19. Dynamic inventory for Terraform - finds all `.tfstate` files below the working
  20. directory and generates an inventory based on them.
  21. """
  22. import argparse
  23. from collections import defaultdict
  24. import random
  25. from functools import wraps
  26. import json
  27. import os
  28. import re
  29. VERSION = '0.4.0pre'
  30. def tfstates(root=None):
  31. root = root or os.getcwd()
  32. for dirpath, _, filenames in os.walk(root):
  33. for name in filenames:
  34. if os.path.splitext(name)[-1] == '.tfstate':
  35. yield os.path.join(dirpath, name)
  36. def convert_to_v3_structure(attributes, prefix=''):
  37. """ Convert the attributes from v4 to v3
  38. Receives a dict and return a dictionary """
  39. result = {}
  40. if isinstance(attributes, str):
  41. # In the case when we receive a string (e.g. values for security_groups)
  42. return {'{}{}'.format(prefix, random.randint(1,10**10)): attributes}
  43. for key, value in attributes.items():
  44. if isinstance(value, list):
  45. if len(value):
  46. result['{}{}.#'.format(prefix, key, hash)] = len(value)
  47. for i, v in enumerate(value):
  48. result.update(convert_to_v3_structure(v, '{}{}.{}.'.format(prefix, key, i)))
  49. elif isinstance(value, dict):
  50. result['{}{}.%'.format(prefix, key)] = len(value)
  51. for k, v in value.items():
  52. result['{}{}.{}'.format(prefix, key, k)] = v
  53. else:
  54. result['{}{}'.format(prefix, key)] = value
  55. return result
  56. def iterresources(filenames):
  57. for filename in filenames:
  58. with open(filename, 'r') as json_file:
  59. state = json.load(json_file)
  60. tf_version = state['version']
  61. if tf_version == 3:
  62. for module in state['modules']:
  63. name = module['path'][-1]
  64. for key, resource in module['resources'].items():
  65. yield name, key, resource
  66. elif tf_version == 4:
  67. # In version 4 the structure changes so we need to iterate
  68. # each instance inside the resource branch.
  69. for resource in state['resources']:
  70. name = resource['provider'].split('.')[-1]
  71. for instance in resource['instances']:
  72. key = "{}.{}".format(resource['type'], resource['name'])
  73. if 'index_key' in instance:
  74. key = "{}.{}".format(key, instance['index_key'])
  75. data = {}
  76. data['type'] = resource['type']
  77. data['provider'] = resource['provider']
  78. data['depends_on'] = instance.get('depends_on', [])
  79. data['primary'] = {'attributes': convert_to_v3_structure(instance['attributes'])}
  80. if 'id' in instance['attributes']:
  81. data['primary']['id'] = instance['attributes']['id']
  82. data['primary']['meta'] = instance['attributes'].get('meta',{})
  83. yield name, key, data
  84. else:
  85. raise KeyError('tfstate version %d not supported' % tf_version)
  86. ## READ RESOURCES
  87. PARSERS = {}
  88. def _clean_dc(dcname):
  89. # Consul DCs are strictly alphanumeric with underscores and hyphens -
  90. # ensure that the consul_dc attribute meets these requirements.
  91. return re.sub('[^\w_\-]', '-', dcname)
  92. def iterhosts(resources):
  93. '''yield host tuples of (name, attributes, groups)'''
  94. for module_name, key, resource in resources:
  95. resource_type, name = key.split('.', 1)
  96. try:
  97. parser = PARSERS[resource_type]
  98. except KeyError:
  99. continue
  100. yield parser(resource, module_name)
  101. def iterips(resources):
  102. '''yield ip tuples of (instance_id, ip)'''
  103. for module_name, key, resource in resources:
  104. resource_type, name = key.split('.', 1)
  105. if resource_type == 'openstack_compute_floatingip_associate_v2':
  106. yield openstack_floating_ips(resource)
  107. def parses(prefix):
  108. def inner(func):
  109. PARSERS[prefix] = func
  110. return func
  111. return inner
  112. def calculate_mantl_vars(func):
  113. """calculate Mantl vars"""
  114. @wraps(func)
  115. def inner(*args, **kwargs):
  116. name, attrs, groups = func(*args, **kwargs)
  117. # attrs
  118. if attrs.get('role', '') == 'control':
  119. attrs['consul_is_server'] = True
  120. else:
  121. attrs['consul_is_server'] = False
  122. # groups
  123. if attrs.get('publicly_routable', False):
  124. groups.append('publicly_routable')
  125. return name, attrs, groups
  126. return inner
  127. def _parse_prefix(source, prefix, sep='.'):
  128. for compkey, value in list(source.items()):
  129. try:
  130. curprefix, rest = compkey.split(sep, 1)
  131. except ValueError:
  132. continue
  133. if curprefix != prefix or rest == '#':
  134. continue
  135. yield rest, value
  136. def parse_attr_list(source, prefix, sep='.'):
  137. attrs = defaultdict(dict)
  138. for compkey, value in _parse_prefix(source, prefix, sep):
  139. idx, key = compkey.split(sep, 1)
  140. attrs[idx][key] = value
  141. return list(attrs.values())
  142. def parse_dict(source, prefix, sep='.'):
  143. return dict(_parse_prefix(source, prefix, sep))
  144. def parse_list(source, prefix, sep='.'):
  145. return [value for _, value in _parse_prefix(source, prefix, sep)]
  146. def parse_bool(string_form):
  147. if type(string_form) is bool:
  148. return string_form
  149. token = string_form.lower()[0]
  150. if token == 't':
  151. return True
  152. elif token == 'f':
  153. return False
  154. else:
  155. raise ValueError('could not convert %r to a bool' % string_form)
  156. @parses('metal_device')
  157. def metal_device(resource, tfvars=None):
  158. raw_attrs = resource['primary']['attributes']
  159. name = raw_attrs['hostname']
  160. groups = []
  161. attrs = {
  162. 'id': raw_attrs['id'],
  163. 'facilities': parse_list(raw_attrs, 'facilities'),
  164. 'hostname': raw_attrs['hostname'],
  165. 'operating_system': raw_attrs['operating_system'],
  166. 'locked': parse_bool(raw_attrs['locked']),
  167. 'tags': parse_list(raw_attrs, 'tags'),
  168. 'plan': raw_attrs['plan'],
  169. 'project_id': raw_attrs['project_id'],
  170. 'state': raw_attrs['state'],
  171. # ansible
  172. 'ansible_ssh_host': raw_attrs['network.0.address'],
  173. 'ansible_ssh_user': 'root', # Use root by default in metal
  174. # generic
  175. 'ipv4_address': raw_attrs['network.0.address'],
  176. 'public_ipv4': raw_attrs['network.0.address'],
  177. 'ipv6_address': raw_attrs['network.1.address'],
  178. 'public_ipv6': raw_attrs['network.1.address'],
  179. 'private_ipv4': raw_attrs['network.2.address'],
  180. 'provider': 'metal',
  181. }
  182. if raw_attrs['operating_system'] == 'flatcar_stable':
  183. # For Flatcar set the ssh_user to core
  184. attrs.update({'ansible_ssh_user': 'core'})
  185. # add groups based on attrs
  186. groups.append('metal_operating_system=' + attrs['operating_system'])
  187. groups.append('metal_locked=%s' % attrs['locked'])
  188. groups.append('metal_state=' + attrs['state'])
  189. groups.append('metal_plan=' + attrs['plan'])
  190. # groups specific to kubespray
  191. groups = groups + attrs['tags']
  192. return name, attrs, groups
  193. def openstack_floating_ips(resource):
  194. raw_attrs = resource['primary']['attributes']
  195. attrs = {
  196. 'ip': raw_attrs['floating_ip'],
  197. 'instance_id': raw_attrs['instance_id'],
  198. }
  199. return attrs
  200. def openstack_floating_ips(resource):
  201. raw_attrs = resource['primary']['attributes']
  202. return raw_attrs['instance_id'], raw_attrs['floating_ip']
  203. @parses('openstack_compute_instance_v2')
  204. @calculate_mantl_vars
  205. def openstack_host(resource, module_name):
  206. raw_attrs = resource['primary']['attributes']
  207. name = raw_attrs['name']
  208. groups = []
  209. attrs = {
  210. 'access_ip_v4': raw_attrs['access_ip_v4'],
  211. 'access_ip_v6': raw_attrs['access_ip_v6'],
  212. 'access_ip': raw_attrs['access_ip_v4'],
  213. 'ip': raw_attrs['network.0.fixed_ip_v4'],
  214. 'flavor': parse_dict(raw_attrs, 'flavor',
  215. sep='_'),
  216. 'id': raw_attrs['id'],
  217. 'image': parse_dict(raw_attrs, 'image',
  218. sep='_'),
  219. 'key_pair': raw_attrs['key_pair'],
  220. 'metadata': parse_dict(raw_attrs, 'metadata'),
  221. 'network': parse_attr_list(raw_attrs, 'network'),
  222. 'region': raw_attrs.get('region', ''),
  223. 'security_groups': parse_list(raw_attrs, 'security_groups'),
  224. # ansible
  225. 'ansible_ssh_port': 22,
  226. # workaround for an OpenStack bug where hosts have a different domain
  227. # after they're restarted
  228. 'host_domain': 'novalocal',
  229. 'use_host_domain': True,
  230. # generic
  231. 'public_ipv4': raw_attrs['access_ip_v4'],
  232. 'private_ipv4': raw_attrs['access_ip_v4'],
  233. 'provider': 'openstack',
  234. }
  235. if 'floating_ip' in raw_attrs:
  236. attrs['private_ipv4'] = raw_attrs['network.0.fixed_ip_v4']
  237. try:
  238. if 'metadata.prefer_ipv6' in raw_attrs and raw_attrs['metadata.prefer_ipv6'] == "1":
  239. attrs.update({
  240. 'ansible_ssh_host': re.sub("[\[\]]", "", raw_attrs['access_ip_v6']),
  241. 'publicly_routable': True,
  242. })
  243. else:
  244. attrs.update({
  245. 'ansible_ssh_host': raw_attrs['access_ip_v4'],
  246. 'publicly_routable': True,
  247. })
  248. except (KeyError, ValueError):
  249. attrs.update({'ansible_ssh_host': '', 'publicly_routable': False})
  250. # Handling of floating IPs has changed: https://github.com/terraform-providers/terraform-provider-openstack/blob/master/CHANGELOG.md#010-june-21-2017
  251. # attrs specific to Ansible
  252. if 'metadata.ssh_user' in raw_attrs:
  253. attrs['ansible_ssh_user'] = raw_attrs['metadata.ssh_user']
  254. if 'volume.#' in list(raw_attrs.keys()) and int(raw_attrs['volume.#']) > 0:
  255. device_index = 1
  256. for key, value in list(raw_attrs.items()):
  257. match = re.search("^volume.*.device$", key)
  258. if match:
  259. attrs['disk_volume_device_'+str(device_index)] = value
  260. device_index += 1
  261. # attrs specific to Mantl
  262. attrs.update({
  263. 'role': attrs['metadata'].get('role', 'none')
  264. })
  265. # add groups based on attrs
  266. groups.append('os_image=' + str(attrs['image']['id']))
  267. groups.append('os_flavor=' + str(attrs['flavor']['name']))
  268. groups.extend('os_metadata_%s=%s' % item
  269. for item in list(attrs['metadata'].items()))
  270. groups.append('os_region=' + str(attrs['region']))
  271. # groups specific to kubespray
  272. for group in attrs['metadata'].get('kubespray_groups', "").split(","):
  273. groups.append(group)
  274. return name, attrs, groups
  275. def iter_host_ips(hosts, ips):
  276. '''Update hosts that have an entry in the floating IP list'''
  277. for host in hosts:
  278. host_id = host[1]['id']
  279. if host_id in ips:
  280. ip = ips[host_id]
  281. host[1].update({
  282. 'access_ip_v4': ip,
  283. 'access_ip': ip,
  284. 'public_ipv4': ip,
  285. 'ansible_ssh_host': ip,
  286. })
  287. if 'use_access_ip' in host[1]['metadata'] and host[1]['metadata']['use_access_ip'] == "0":
  288. host[1].pop('access_ip')
  289. yield host
  290. ## QUERY TYPES
  291. def query_host(hosts, target):
  292. for name, attrs, _ in hosts:
  293. if name == target:
  294. return attrs
  295. return {}
  296. def query_list(hosts):
  297. groups = defaultdict(dict)
  298. meta = {}
  299. for name, attrs, hostgroups in hosts:
  300. for group in set(hostgroups):
  301. # Ansible 2.6.2 stopped supporting empty group names: https://github.com/ansible/ansible/pull/42584/commits/d4cd474b42ed23d8f8aabb2a7f84699673852eaf
  302. # Empty group name defaults to "all" in Ansible < 2.6.2 so we alter empty group names to "all"
  303. if not group: group = "all"
  304. groups[group].setdefault('hosts', [])
  305. groups[group]['hosts'].append(name)
  306. meta[name] = attrs
  307. groups['_meta'] = {'hostvars': meta}
  308. return groups
  309. def query_hostfile(hosts):
  310. out = ['## begin hosts generated by terraform.py ##']
  311. out.extend(
  312. '{}\t{}'.format(attrs['ansible_ssh_host'].ljust(16), name)
  313. for name, attrs, _ in hosts
  314. )
  315. out.append('## end hosts generated by terraform.py ##')
  316. return '\n'.join(out)
  317. def main():
  318. parser = argparse.ArgumentParser(
  319. __file__, __doc__,
  320. formatter_class=argparse.ArgumentDefaultsHelpFormatter, )
  321. modes = parser.add_mutually_exclusive_group(required=True)
  322. modes.add_argument('--list',
  323. action='store_true',
  324. help='list all variables')
  325. modes.add_argument('--host', help='list variables for a single host')
  326. modes.add_argument('--version',
  327. action='store_true',
  328. help='print version and exit')
  329. modes.add_argument('--hostfile',
  330. action='store_true',
  331. help='print hosts as a /etc/hosts snippet')
  332. parser.add_argument('--pretty',
  333. action='store_true',
  334. help='pretty-print output JSON')
  335. parser.add_argument('--nometa',
  336. action='store_true',
  337. help='with --list, exclude hostvars')
  338. default_root = os.environ.get('TERRAFORM_STATE_ROOT',
  339. os.path.abspath(os.path.join(os.path.dirname(__file__),
  340. '..', '..', )))
  341. parser.add_argument('--root',
  342. default=default_root,
  343. help='custom root to search for `.tfstate`s in')
  344. args = parser.parse_args()
  345. if args.version:
  346. print('%s %s' % (__file__, VERSION))
  347. parser.exit()
  348. hosts = iterhosts(iterresources(tfstates(args.root)))
  349. # Perform a second pass on the file to pick up floating_ip entries to update the ip address of referenced hosts
  350. ips = dict(iterips(iterresources(tfstates(args.root))))
  351. if ips:
  352. hosts = iter_host_ips(hosts, ips)
  353. if args.list:
  354. output = query_list(hosts)
  355. if args.nometa:
  356. del output['_meta']
  357. print(json.dumps(output, indent=4 if args.pretty else None))
  358. elif args.host:
  359. output = query_host(hosts, args.host)
  360. print(json.dumps(output, indent=4 if args.pretty else None))
  361. elif args.hostfile:
  362. output = query_hostfile(hosts)
  363. print(output)
  364. parser.exit()
  365. if __name__ == '__main__':
  366. main()