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.

471 lines
18 KiB

  1. #!/usr/bin/env python
  2. '''
  3. DigitalOcean external inventory script
  4. ======================================
  5. Generates Ansible inventory of DigitalOcean Droplets.
  6. In addition to the --list and --host options used by Ansible, there are options
  7. for generating JSON of other DigitalOcean data. This is useful when creating
  8. droplets. For example, --regions will return all the DigitalOcean Regions.
  9. This information can also be easily found in the cache file, whose default
  10. location is /tmp/ansible-digital_ocean.cache).
  11. The --pretty (-p) option pretty-prints the output for better human readability.
  12. ----
  13. Although the cache stores all the information received from DigitalOcean,
  14. the cache is not used for current droplet information (in --list, --host,
  15. --all, and --droplets). This is so that accurate droplet information is always
  16. found. You can force this script to use the cache with --force-cache.
  17. ----
  18. Configuration is read from `digital_ocean.ini`, then from environment variables,
  19. then and command-line arguments.
  20. Most notably, the DigitalOcean API Token must be specified. It can be specified
  21. in the INI file or with the following environment variables:
  22. export DO_API_TOKEN='abc123' or
  23. export DO_API_KEY='abc123'
  24. Alternatively, it can be passed on the command-line with --api-token.
  25. If you specify DigitalOcean credentials in the INI file, a handy way to
  26. get them into your environment (e.g., to use the digital_ocean module)
  27. is to use the output of the --env option with export:
  28. export $(digital_ocean.py --env)
  29. ----
  30. The following groups are generated from --list:
  31. - ID (droplet ID)
  32. - NAME (droplet NAME)
  33. - image_ID
  34. - image_NAME
  35. - distro_NAME (distribution NAME from image)
  36. - region_NAME
  37. - size_NAME
  38. - status_STATUS
  39. For each host, the following variables are registered:
  40. - do_backup_ids
  41. - do_created_at
  42. - do_disk
  43. - do_features - list
  44. - do_id
  45. - do_image - object
  46. - do_ip_address
  47. - do_private_ip_address
  48. - do_kernel - object
  49. - do_locked
  50. - do_memory
  51. - do_name
  52. - do_networks - object
  53. - do_next_backup_window
  54. - do_region - object
  55. - do_size - object
  56. - do_size_slug
  57. - do_snapshot_ids - list
  58. - do_status
  59. - do_tags
  60. - do_vcpus
  61. - do_volume_ids
  62. -----
  63. ```
  64. usage: digital_ocean.py [-h] [--list] [--host HOST] [--all]
  65. [--droplets] [--regions] [--images] [--sizes]
  66. [--ssh-keys] [--domains] [--pretty]
  67. [--cache-path CACHE_PATH]
  68. [--cache-max_age CACHE_MAX_AGE]
  69. [--force-cache]
  70. [--refresh-cache]
  71. [--api-token API_TOKEN]
  72. Produce an Ansible Inventory file based on DigitalOcean credentials
  73. optional arguments:
  74. -h, --help show this help message and exit
  75. --list List all active Droplets as Ansible inventory
  76. (default: True)
  77. --host HOST Get all Ansible inventory variables about a specific
  78. Droplet
  79. --all List all DigitalOcean information as JSON
  80. --droplets List Droplets as JSON
  81. --regions List Regions as JSON
  82. --images List Images as JSON
  83. --sizes List Sizes as JSON
  84. --ssh-keys List SSH keys as JSON
  85. --domains List Domains as JSON
  86. --pretty, -p Pretty-print results
  87. --cache-path CACHE_PATH
  88. Path to the cache files (default: .)
  89. --cache-max_age CACHE_MAX_AGE
  90. Maximum age of the cached items (default: 0)
  91. --force-cache Only use data from the cache
  92. --refresh-cache Force refresh of cache by making API requests to
  93. DigitalOcean (default: False - use cache files)
  94. --api-token API_TOKEN, -a API_TOKEN
  95. DigitalOcean API Token
  96. ```
  97. '''
  98. # (c) 2013, Evan Wies <evan@neomantra.net>
  99. #
  100. # Inspired by the EC2 inventory plugin:
  101. # https://github.com/ansible/ansible/blob/devel/contrib/inventory/ec2.py
  102. #
  103. # This file is part of Ansible,
  104. #
  105. # Ansible is free software: you can redistribute it and/or modify
  106. # it under the terms of the GNU General Public License as published by
  107. # the Free Software Foundation, either version 3 of the License, or
  108. # (at your option) any later version.
  109. #
  110. # Ansible is distributed in the hope that it will be useful,
  111. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  112. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  113. # GNU General Public License for more details.
  114. #
  115. # You should have received a copy of the GNU General Public License
  116. # along with Ansible. If not, see <http://www.gnu.org/licenses/>.
  117. ######################################################################
  118. import os
  119. import sys
  120. import re
  121. import argparse
  122. from time import time
  123. import ConfigParser
  124. import ast
  125. try:
  126. import json
  127. except ImportError:
  128. import simplejson as json
  129. try:
  130. from dopy.manager import DoManager
  131. except ImportError as e:
  132. sys.exit("failed=True msg='`dopy` library required for this script'")
  133. class DigitalOceanInventory(object):
  134. ###########################################################################
  135. # Main execution path
  136. ###########################################################################
  137. def __init__(self):
  138. ''' Main execution path '''
  139. # DigitalOceanInventory data
  140. self.data = {} # All DigitalOcean data
  141. self.inventory = {} # Ansible Inventory
  142. # Define defaults
  143. self.cache_path = '.'
  144. self.cache_max_age = 0
  145. self.use_private_network = False
  146. self.group_variables = {}
  147. # Read settings, environment variables, and CLI arguments
  148. self.read_settings()
  149. self.read_environment()
  150. self.read_cli_args()
  151. # Verify credentials were set
  152. if not hasattr(self, 'api_token'):
  153. sys.stderr.write('''Could not find values for DigitalOcean api_token.
  154. They must be specified via either ini file, command line argument (--api-token),
  155. or environment variables (DO_API_TOKEN)\n''')
  156. sys.exit(-1)
  157. # env command, show DigitalOcean credentials
  158. if self.args.env:
  159. print("DO_API_TOKEN=%s" % self.api_token)
  160. sys.exit(0)
  161. # Manage cache
  162. self.cache_filename = self.cache_path + "/ansible-digital_ocean.cache"
  163. self.cache_refreshed = False
  164. if self.is_cache_valid():
  165. self.load_from_cache()
  166. if len(self.data) == 0:
  167. if self.args.force_cache:
  168. sys.stderr.write('''Cache is empty and --force-cache was specified\n''')
  169. sys.exit(-1)
  170. self.manager = DoManager(None, self.api_token, api_version=2)
  171. # Pick the json_data to print based on the CLI command
  172. if self.args.droplets:
  173. self.load_from_digital_ocean('droplets')
  174. json_data = {'droplets': self.data['droplets']}
  175. elif self.args.regions:
  176. self.load_from_digital_ocean('regions')
  177. json_data = {'regions': self.data['regions']}
  178. elif self.args.images:
  179. self.load_from_digital_ocean('images')
  180. json_data = {'images': self.data['images']}
  181. elif self.args.sizes:
  182. self.load_from_digital_ocean('sizes')
  183. json_data = {'sizes': self.data['sizes']}
  184. elif self.args.ssh_keys:
  185. self.load_from_digital_ocean('ssh_keys')
  186. json_data = {'ssh_keys': self.data['ssh_keys']}
  187. elif self.args.domains:
  188. self.load_from_digital_ocean('domains')
  189. json_data = {'domains': self.data['domains']}
  190. elif self.args.all:
  191. self.load_from_digital_ocean()
  192. json_data = self.data
  193. elif self.args.host:
  194. json_data = self.load_droplet_variables_for_host()
  195. else: # '--list' this is last to make it default
  196. self.load_from_digital_ocean('droplets')
  197. self.build_inventory()
  198. json_data = self.inventory
  199. if self.cache_refreshed:
  200. self.write_to_cache()
  201. if self.args.pretty:
  202. print(json.dumps(json_data, sort_keys=True, indent=2))
  203. else:
  204. print(json.dumps(json_data))
  205. # That's all she wrote...
  206. ###########################################################################
  207. # Script configuration
  208. ###########################################################################
  209. def read_settings(self):
  210. ''' Reads the settings from the digital_ocean.ini file '''
  211. config = ConfigParser.SafeConfigParser()
  212. config.read(os.path.dirname(os.path.realpath(__file__)) + '/digital_ocean.ini')
  213. # Credentials
  214. if config.has_option('digital_ocean', 'api_token'):
  215. self.api_token = config.get('digital_ocean', 'api_token')
  216. # Cache related
  217. if config.has_option('digital_ocean', 'cache_path'):
  218. self.cache_path = config.get('digital_ocean', 'cache_path')
  219. if config.has_option('digital_ocean', 'cache_max_age'):
  220. self.cache_max_age = config.getint('digital_ocean', 'cache_max_age')
  221. # Private IP Address
  222. if config.has_option('digital_ocean', 'use_private_network'):
  223. self.use_private_network = config.getboolean('digital_ocean', 'use_private_network')
  224. # Group variables
  225. if config.has_option('digital_ocean', 'group_variables'):
  226. self.group_variables = ast.literal_eval(config.get('digital_ocean', 'group_variables'))
  227. def read_environment(self):
  228. ''' Reads the settings from environment variables '''
  229. # Setup credentials
  230. if os.getenv("DO_API_TOKEN"):
  231. self.api_token = os.getenv("DO_API_TOKEN")
  232. if os.getenv("DO_API_KEY"):
  233. self.api_token = os.getenv("DO_API_KEY")
  234. def read_cli_args(self):
  235. ''' Command line argument processing '''
  236. parser = argparse.ArgumentParser(description='Produce an Ansible Inventory file based on DigitalOcean credentials')
  237. parser.add_argument('--list', action='store_true', help='List all active Droplets as Ansible inventory (default: True)')
  238. parser.add_argument('--host', action='store', help='Get all Ansible inventory variables about a specific Droplet')
  239. parser.add_argument('--all', action='store_true', help='List all DigitalOcean information as JSON')
  240. parser.add_argument('--droplets', '-d', action='store_true', help='List Droplets as JSON')
  241. parser.add_argument('--regions', action='store_true', help='List Regions as JSON')
  242. parser.add_argument('--images', action='store_true', help='List Images as JSON')
  243. parser.add_argument('--sizes', action='store_true', help='List Sizes as JSON')
  244. parser.add_argument('--ssh-keys', action='store_true', help='List SSH keys as JSON')
  245. parser.add_argument('--domains', action='store_true', help='List Domains as JSON')
  246. parser.add_argument('--pretty', '-p', action='store_true', help='Pretty-print results')
  247. parser.add_argument('--cache-path', action='store', help='Path to the cache files (default: .)')
  248. parser.add_argument('--cache-max_age', action='store', help='Maximum age of the cached items (default: 0)')
  249. parser.add_argument('--force-cache', action='store_true', default=False, help='Only use data from the cache')
  250. parser.add_argument('--refresh-cache', '-r', action='store_true', default=False,
  251. help='Force refresh of cache by making API requests to DigitalOcean (default: False - use cache files)')
  252. parser.add_argument('--env', '-e', action='store_true', help='Display DO_API_TOKEN')
  253. parser.add_argument('--api-token', '-a', action='store', help='DigitalOcean API Token')
  254. self.args = parser.parse_args()
  255. if self.args.api_token:
  256. self.api_token = self.args.api_token
  257. # Make --list default if none of the other commands are specified
  258. if (not self.args.droplets and not self.args.regions and
  259. not self.args.images and not self.args.sizes and
  260. not self.args.ssh_keys and not self.args.domains and
  261. not self.args.all and not self.args.host):
  262. self.args.list = True
  263. ###########################################################################
  264. # Data Management
  265. ###########################################################################
  266. def load_from_digital_ocean(self, resource=None):
  267. '''Get JSON from DigitalOcean API'''
  268. if self.args.force_cache and os.path.isfile(self.cache_filename):
  269. return
  270. # We always get fresh droplets
  271. if self.is_cache_valid() and not (resource == 'droplets' or resource is None):
  272. return
  273. if self.args.refresh_cache:
  274. resource = None
  275. if resource == 'droplets' or resource is None:
  276. self.data['droplets'] = self.manager.all_active_droplets()
  277. self.cache_refreshed = True
  278. if resource == 'regions' or resource is None:
  279. self.data['regions'] = self.manager.all_regions()
  280. self.cache_refreshed = True
  281. if resource == 'images' or resource is None:
  282. self.data['images'] = self.manager.all_images(filter=None)
  283. self.cache_refreshed = True
  284. if resource == 'sizes' or resource is None:
  285. self.data['sizes'] = self.manager.sizes()
  286. self.cache_refreshed = True
  287. if resource == 'ssh_keys' or resource is None:
  288. self.data['ssh_keys'] = self.manager.all_ssh_keys()
  289. self.cache_refreshed = True
  290. if resource == 'domains' or resource is None:
  291. self.data['domains'] = self.manager.all_domains()
  292. self.cache_refreshed = True
  293. def build_inventory(self):
  294. '''Build Ansible inventory of droplets'''
  295. self.inventory = {
  296. 'all': {
  297. 'hosts': [],
  298. 'vars': self.group_variables
  299. },
  300. '_meta': {'hostvars': {}}
  301. }
  302. # add all droplets by id and name
  303. for droplet in self.data['droplets']:
  304. # when using private_networking, the API reports the private one in "ip_address".
  305. if 'private_networking' in droplet['features'] and not self.use_private_network:
  306. for net in droplet['networks']['v4']:
  307. if net['type'] == 'public':
  308. dest = net['ip_address']
  309. else:
  310. continue
  311. else:
  312. dest = droplet['ip_address']
  313. self.inventory['all']['hosts'].append(dest)
  314. self.inventory[droplet['id']] = [dest]
  315. self.inventory[droplet['name']] = [dest]
  316. # groups that are always present
  317. for group in ('region_' + droplet['region']['slug'],
  318. 'image_' + str(droplet['image']['id']),
  319. 'size_' + droplet['size']['slug'],
  320. 'distro_' + self.to_safe(droplet['image']['distribution']),
  321. 'status_' + droplet['status']):
  322. if group not in self.inventory:
  323. self.inventory[group] = {'hosts': [], 'vars': {}}
  324. self.inventory[group]['hosts'].append(dest)
  325. # groups that are not always present
  326. for group in (droplet['image']['slug'],
  327. droplet['image']['name']):
  328. if group:
  329. image = 'image_' + self.to_safe(group)
  330. if image not in self.inventory:
  331. self.inventory[image] = {'hosts': [], 'vars': {}}
  332. self.inventory[image]['hosts'].append(dest)
  333. if droplet['tags']:
  334. for tag in droplet['tags']:
  335. if tag not in self.inventory:
  336. self.inventory[tag] = {'hosts': [], 'vars': {}}
  337. self.inventory[tag]['hosts'].append(dest)
  338. # hostvars
  339. info = self.do_namespace(droplet)
  340. self.inventory['_meta']['hostvars'][dest] = info
  341. def load_droplet_variables_for_host(self):
  342. '''Generate a JSON response to a --host call'''
  343. host = int(self.args.host)
  344. droplet = self.manager.show_droplet(host)
  345. info = self.do_namespace(droplet)
  346. return {'droplet': info}
  347. ###########################################################################
  348. # Cache Management
  349. ###########################################################################
  350. def is_cache_valid(self):
  351. ''' Determines if the cache files have expired, or if it is still valid '''
  352. if os.path.isfile(self.cache_filename):
  353. mod_time = os.path.getmtime(self.cache_filename)
  354. current_time = time()
  355. if (mod_time + self.cache_max_age) > current_time:
  356. return True
  357. return False
  358. def load_from_cache(self):
  359. ''' Reads the data from the cache file and assigns it to member variables as Python Objects'''
  360. try:
  361. cache = open(self.cache_filename, 'r')
  362. json_data = cache.read()
  363. cache.close()
  364. data = json.loads(json_data)
  365. except IOError:
  366. data = {'data': {}, 'inventory': {}}
  367. self.data = data['data']
  368. self.inventory = data['inventory']
  369. def write_to_cache(self):
  370. ''' Writes data in JSON format to a file '''
  371. data = {'data': self.data, 'inventory': self.inventory}
  372. json_data = json.dumps(data, sort_keys=True, indent=2)
  373. cache = open(self.cache_filename, 'w')
  374. cache.write(json_data)
  375. cache.close()
  376. ###########################################################################
  377. # Utilities
  378. ###########################################################################
  379. def push(self, my_dict, key, element):
  380. ''' Pushed an element onto an array that may not have been defined in the dict '''
  381. if key in my_dict:
  382. my_dict[key].append(element)
  383. else:
  384. my_dict[key] = [element]
  385. def to_safe(self, word):
  386. ''' Converts 'bad' characters in a string to underscores so they can be used as Ansible groups '''
  387. return re.sub("[^A-Za-z0-9\-\.]", "_", word)
  388. def do_namespace(self, data):
  389. ''' Returns a copy of the dictionary with all the keys put in a 'do_' namespace '''
  390. info = {}
  391. for k, v in data.items():
  392. info['do_' + k] = v
  393. return info
  394. ###########################################################################
  395. # Run the script
  396. DigitalOceanInventory()