multi_ec2.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  1. #!/usr/bin/env python2
  2. '''
  3. Fetch and combine multiple ec2 account settings into a single
  4. json hash.
  5. '''
  6. # vim: expandtab:tabstop=4:shiftwidth=4
  7. from time import time
  8. import argparse
  9. import yaml
  10. import os
  11. import subprocess
  12. import json
  13. import errno
  14. import fcntl
  15. import tempfile
  16. import copy
  17. CONFIG_FILE_NAME = 'multi_ec2.yaml'
  18. DEFAULT_CACHE_PATH = os.path.expanduser('~/.ansible/tmp/multi_ec2_inventory.cache')
  19. class MultiEc2(object):
  20. '''
  21. MultiEc2 class:
  22. Opens a yaml config file and reads aws credentials.
  23. Stores a json hash of resources in result.
  24. '''
  25. def __init__(self, args=None):
  26. # Allow args to be passed when called as a library
  27. if not args:
  28. self.args = {}
  29. else:
  30. self.args = args
  31. self.cache_path = DEFAULT_CACHE_PATH
  32. self.config = None
  33. self.all_ec2_results = {}
  34. self.result = {}
  35. self.file_path = os.path.join(os.path.dirname(os.path.realpath(__file__)))
  36. same_dir_config_file = os.path.join(self.file_path, CONFIG_FILE_NAME)
  37. etc_dir_config_file = os.path.join(os.path.sep, 'etc', 'ansible', CONFIG_FILE_NAME)
  38. # Prefer a file in the same directory, fall back to a file in etc
  39. if os.path.isfile(same_dir_config_file):
  40. self.config_file = same_dir_config_file
  41. elif os.path.isfile(etc_dir_config_file):
  42. self.config_file = etc_dir_config_file
  43. else:
  44. self.config_file = None # expect env vars
  45. def run(self):
  46. '''This method checks to see if the local
  47. cache is valid for the inventory.
  48. if the cache is valid; return cache
  49. else the credentials are loaded from multi_ec2.yaml or from the env
  50. and we attempt to get the inventory from the provider specified.
  51. '''
  52. # load yaml
  53. if self.config_file and os.path.isfile(self.config_file):
  54. self.config = self.load_yaml_config()
  55. elif os.environ.has_key("AWS_ACCESS_KEY_ID") and \
  56. os.environ.has_key("AWS_SECRET_ACCESS_KEY"):
  57. # Build a default config
  58. self.config = {}
  59. self.config['accounts'] = [
  60. {
  61. 'name': 'default',
  62. 'cache_location': DEFAULT_CACHE_PATH,
  63. 'provider': 'aws/hosts/ec2.py',
  64. 'env_vars': {
  65. 'AWS_ACCESS_KEY_ID': os.environ["AWS_ACCESS_KEY_ID"],
  66. 'AWS_SECRET_ACCESS_KEY': os.environ["AWS_SECRET_ACCESS_KEY"],
  67. }
  68. },
  69. ]
  70. self.config['cache_max_age'] = 0
  71. else:
  72. raise RuntimeError("Could not find valid ec2 credentials in the environment.")
  73. # Set the default cache path but if its defined we'll assign it.
  74. if self.config.has_key('cache_location'):
  75. self.cache_path = self.config['cache_location']
  76. if self.args.get('refresh_cache', None):
  77. self.get_inventory()
  78. self.write_to_cache()
  79. # if its a host query, fetch and do not cache
  80. elif self.args.get('host', None):
  81. self.get_inventory()
  82. elif not self.is_cache_valid():
  83. # go fetch the inventories and cache them if cache is expired
  84. self.get_inventory()
  85. self.write_to_cache()
  86. else:
  87. # get data from disk
  88. self.get_inventory_from_cache()
  89. def load_yaml_config(self, conf_file=None):
  90. """Load a yaml config file with credentials to query the
  91. respective cloud for inventory.
  92. """
  93. config = None
  94. if not conf_file:
  95. conf_file = self.config_file
  96. with open(conf_file) as conf:
  97. config = yaml.safe_load(conf)
  98. return config
  99. def get_provider_tags(self, provider, env=None):
  100. """Call <provider> and query all of the tags that are usuable
  101. by ansible. If environment is empty use the default env.
  102. """
  103. if not env:
  104. env = os.environ
  105. # Allow relatively path'd providers in config file
  106. if os.path.isfile(os.path.join(self.file_path, provider)):
  107. provider = os.path.join(self.file_path, provider)
  108. # check to see if provider exists
  109. if not os.path.isfile(provider) or not os.access(provider, os.X_OK):
  110. raise RuntimeError("Problem with the provider. Please check path " \
  111. "and that it is executable. (%s)" % provider)
  112. cmds = [provider]
  113. if self.args.get('host', None):
  114. cmds.append("--host")
  115. cmds.append(self.args.get('host', None))
  116. else:
  117. cmds.append('--list')
  118. cmds.append('--refresh-cache')
  119. return subprocess.Popen(cmds, stderr=subprocess.PIPE, \
  120. stdout=subprocess.PIPE, env=env)
  121. @staticmethod
  122. def generate_config(config_data):
  123. """Generate the ec2.ini file in as a secure temp file.
  124. Once generated, pass it to the ec2.py as an environment variable.
  125. """
  126. fildes, tmp_file_path = tempfile.mkstemp(prefix='multi_ec2.ini.')
  127. for section, values in config_data.items():
  128. os.write(fildes, "[%s]\n" % section)
  129. for option, value in values.items():
  130. os.write(fildes, "%s = %s\n" % (option, value))
  131. os.close(fildes)
  132. return tmp_file_path
  133. def run_provider(self):
  134. '''Setup the provider call with proper variables
  135. and call self.get_provider_tags.
  136. '''
  137. try:
  138. all_results = []
  139. tmp_file_paths = []
  140. processes = {}
  141. for account in self.config['accounts']:
  142. env = account['env_vars']
  143. if account.has_key('provider_config'):
  144. tmp_file_paths.append(MultiEc2.generate_config(account['provider_config']))
  145. env['EC2_INI_PATH'] = tmp_file_paths[-1]
  146. name = account['name']
  147. provider = account['provider']
  148. processes[name] = self.get_provider_tags(provider, env)
  149. # for each process collect stdout when its available
  150. for name, process in processes.items():
  151. out, err = process.communicate()
  152. all_results.append({
  153. "name": name,
  154. "out": out.strip(),
  155. "err": err.strip(),
  156. "code": process.returncode
  157. })
  158. finally:
  159. # Clean up the mkstemp file
  160. for tmp_file in tmp_file_paths:
  161. os.unlink(tmp_file)
  162. return all_results
  163. def get_inventory(self):
  164. """Create the subprocess to fetch tags from a provider.
  165. Host query:
  166. Query to return a specific host. If > 1 queries have
  167. results then fail.
  168. List query:
  169. Query all of the different accounts for their tags. Once completed
  170. store all of their results into one merged updated hash.
  171. """
  172. provider_results = self.run_provider()
  173. # process --host results
  174. # For any 0 result, return it
  175. if self.args.get('host', None):
  176. count = 0
  177. for results in provider_results:
  178. if results['code'] == 0 and results['err'] == '' and results['out'] != '{}':
  179. self.result = json.loads(results['out'])
  180. count += 1
  181. if count > 1:
  182. raise RuntimeError("Found > 1 results for --host %s. \
  183. This is an invalid state." % self.args.get('host', None))
  184. # process --list results
  185. else:
  186. # For any non-zero, raise an error on it
  187. for result in provider_results:
  188. if result['code'] != 0:
  189. raise RuntimeError(result['err'])
  190. else:
  191. self.all_ec2_results[result['name']] = json.loads(result['out'])
  192. # Check if user wants extra vars in yaml by
  193. # having hostvars and all_group defined
  194. for acc_config in self.config['accounts']:
  195. self.apply_account_config(acc_config)
  196. # Build results by merging all dictionaries
  197. values = self.all_ec2_results.values()
  198. values.insert(0, self.result)
  199. for result in values:
  200. MultiEc2.merge_destructively(self.result, result)
  201. def apply_account_config(self, acc_config):
  202. ''' Apply account config settings
  203. '''
  204. if not acc_config.has_key('hostvars') and not acc_config.has_key('all_group'):
  205. return
  206. results = self.all_ec2_results[acc_config['name']]
  207. # Update each hostvar with the newly desired key: value
  208. for host_property, value in acc_config['hostvars'].items():
  209. # Verify the account results look sane
  210. # by checking for these keys ('_meta' and 'hostvars' exist)
  211. if results.has_key('_meta') and results['_meta'].has_key('hostvars'):
  212. for data in results['_meta']['hostvars'].values():
  213. data[str(host_property)] = str(value)
  214. # Add this group
  215. results["%s_%s" % (host_property, value)] = \
  216. copy.copy(results[acc_config['all_group']])
  217. # store the results back into all_ec2_results
  218. self.all_ec2_results[acc_config['name']] = results
  219. @staticmethod
  220. def merge_destructively(input_a, input_b):
  221. "merges b into input_a"
  222. for key in input_b:
  223. if key in input_a:
  224. if isinstance(input_a[key], dict) and isinstance(input_b[key], dict):
  225. MultiEc2.merge_destructively(input_a[key], input_b[key])
  226. elif input_a[key] == input_b[key]:
  227. pass # same leaf value
  228. # both lists so add each element in b to a if it does ! exist
  229. elif isinstance(input_a[key], list) and isinstance(input_b[key], list):
  230. for result in input_b[key]:
  231. if result not in input_a[key]:
  232. input_a[key].append(result)
  233. # a is a list and not b
  234. elif isinstance(input_a[key], list):
  235. if input_b[key] not in input_a[key]:
  236. input_a[key].append(input_b[key])
  237. elif isinstance(input_b[key], list):
  238. input_a[key] = [input_a[key]] + [k for k in input_b[key] if k != input_a[key]]
  239. else:
  240. input_a[key] = [input_a[key], input_b[key]]
  241. else:
  242. input_a[key] = input_b[key]
  243. return input_a
  244. def is_cache_valid(self):
  245. ''' Determines if the cache files have expired, or if it is still valid '''
  246. if os.path.isfile(self.cache_path):
  247. mod_time = os.path.getmtime(self.cache_path)
  248. current_time = time()
  249. if (mod_time + self.config['cache_max_age']) > current_time:
  250. return True
  251. return False
  252. def parse_cli_args(self):
  253. ''' Command line argument processing '''
  254. parser = argparse.ArgumentParser(
  255. description='Produce an Ansible Inventory file based on a provider')
  256. parser.add_argument('--refresh-cache', action='store_true', default=False,
  257. help='Fetch cached only instances (default: False)')
  258. parser.add_argument('--list', action='store_true', default=True,
  259. help='List instances (default: True)')
  260. parser.add_argument('--host', action='store', default=False,
  261. help='Get all the variables about a specific instance')
  262. self.args = parser.parse_args().__dict__
  263. def write_to_cache(self):
  264. ''' Writes data in JSON format to a file '''
  265. # if it does not exist, try and create it.
  266. if not os.path.isfile(self.cache_path):
  267. path = os.path.dirname(self.cache_path)
  268. try:
  269. os.makedirs(path)
  270. except OSError as exc:
  271. if exc.errno != errno.EEXIST or not os.path.isdir(path):
  272. raise
  273. json_data = MultiEc2.json_format_dict(self.result, True)
  274. with open(self.cache_path, 'w') as cache:
  275. try:
  276. fcntl.flock(cache, fcntl.LOCK_EX)
  277. cache.write(json_data)
  278. finally:
  279. fcntl.flock(cache, fcntl.LOCK_UN)
  280. def get_inventory_from_cache(self):
  281. ''' Reads the inventory from the cache file and returns it as a JSON
  282. object '''
  283. if not os.path.isfile(self.cache_path):
  284. return None
  285. with open(self.cache_path, 'r') as cache:
  286. self.result = json.loads(cache.read())
  287. return True
  288. @classmethod
  289. def json_format_dict(cls, data, pretty=False):
  290. ''' Converts a dict to a JSON object and dumps it as a formatted
  291. string '''
  292. if pretty:
  293. return json.dumps(data, sort_keys=True, indent=2)
  294. else:
  295. return json.dumps(data)
  296. def result_str(self):
  297. '''Return cache string stored in self.result'''
  298. return self.json_format_dict(self.result, True)
  299. if __name__ == "__main__":
  300. MEC2 = MultiEc2()
  301. MEC2.parse_cli_args()
  302. MEC2.run()
  303. print MEC2.result_str()