1#!/usr/bin/env python
2
3'''
4Packet.net external inventory script
5=================================
6
7Generates inventory that Ansible can understand by making API request to
8Packet.net using the Packet library.
9
10NOTE: This script assumes Ansible is being executed where the environment
11variable needed for Packet API Token already been set:
12    export PACKET_API_TOKEN=Bfse9F24SFtfs423Gsd3ifGsd43sSdfs
13
14This script also assumes there is a packet_net.ini file alongside it.  To specify a
15different path to packet_net.ini, define the PACKET_NET_INI_PATH environment variable:
16
17    export PACKET_NET_INI_PATH=/path/to/my_packet_net.ini
18
19'''
20
21# (c) 2016, Peter Sankauskas
22# (c) 2017, Tomas Karasek
23#
24# This file is part of Ansible,
25#
26# Ansible is free software: you can redistribute it and/or modify
27# it under the terms of the GNU General Public License as published by
28# the Free Software Foundation, either version 3 of the License, or
29# (at your option) any later version.
30#
31# Ansible is distributed in the hope that it will be useful,
32# but WITHOUT ANY WARRANTY; without even the implied warranty of
33# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
34# GNU General Public License for more details.
35#
36# You should have received a copy of the GNU General Public License
37# along with Ansible.  If not, see <http://www.gnu.org/licenses/>.
38
39######################################################################
40
41import sys
42import os
43import argparse
44import re
45from time import time
46
47from ansible.module_utils import six
48from ansible.module_utils.six.moves import configparser
49
50try:
51    import packet
52except ImportError as e:
53    sys.exit("failed=True msg='`packet-python` library required for this script'")
54
55import traceback
56
57
58import json
59
60
61ini_section = 'packet'
62
63
64class PacketInventory(object):
65
66    def _empty_inventory(self):
67        return {"_meta": {"hostvars": {}}}
68
69    def __init__(self):
70        ''' Main execution path '''
71
72        # Inventory grouped by device IDs, tags, security groups, regions,
73        # and availability zones
74        self.inventory = self._empty_inventory()
75
76        # Index of hostname (address) to device ID
77        self.index = {}
78
79        # Read settings and parse CLI arguments
80        self.parse_cli_args()
81        self.read_settings()
82
83        # Cache
84        if self.args.refresh_cache:
85            self.do_api_calls_update_cache()
86        elif not self.is_cache_valid():
87            self.do_api_calls_update_cache()
88
89        # Data to print
90        if self.args.host:
91            data_to_print = self.get_host_info()
92
93        elif self.args.list:
94            # Display list of devices for inventory
95            if self.inventory == self._empty_inventory():
96                data_to_print = self.get_inventory_from_cache()
97            else:
98                data_to_print = self.json_format_dict(self.inventory, True)
99
100        print(data_to_print)
101
102    def is_cache_valid(self):
103        ''' Determines if the cache files have expired, or if it is still valid '''
104
105        if os.path.isfile(self.cache_path_cache):
106            mod_time = os.path.getmtime(self.cache_path_cache)
107            current_time = time()
108            if (mod_time + self.cache_max_age) > current_time:
109                if os.path.isfile(self.cache_path_index):
110                    return True
111
112        return False
113
114    def read_settings(self):
115        ''' Reads the settings from the packet_net.ini file '''
116        if six.PY3:
117            config = configparser.ConfigParser()
118        else:
119            config = configparser.SafeConfigParser()
120
121        _ini_path_raw = os.environ.get('PACKET_NET_INI_PATH')
122
123        if _ini_path_raw:
124            packet_ini_path = os.path.expanduser(os.path.expandvars(_ini_path_raw))
125        else:
126            packet_ini_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'packet_net.ini')
127        config.read(packet_ini_path)
128
129        # items per page
130        self.items_per_page = 999
131        if config.has_option(ini_section, 'items_per_page'):
132            config.get(ini_section, 'items_per_page')
133
134        # Instance states to be gathered in inventory. Default is all of them.
135        packet_valid_device_states = [
136            'active',
137            'inactive',
138            'queued',
139            'provisioning'
140        ]
141        self.packet_device_states = []
142        if config.has_option(ini_section, 'device_states'):
143            for device_state in config.get(ini_section, 'device_states').split(','):
144                device_state = device_state.strip()
145                if device_state not in packet_valid_device_states:
146                    continue
147                self.packet_device_states.append(device_state)
148        else:
149            self.packet_device_states = packet_valid_device_states
150
151        # Cache related
152        cache_dir = os.path.expanduser(config.get(ini_section, 'cache_path'))
153        if not os.path.exists(cache_dir):
154            os.makedirs(cache_dir)
155
156        self.cache_path_cache = cache_dir + "/ansible-packet.cache"
157        self.cache_path_index = cache_dir + "/ansible-packet.index"
158        self.cache_max_age = config.getint(ini_section, 'cache_max_age')
159
160        # Configure nested groups instead of flat namespace.
161        if config.has_option(ini_section, 'nested_groups'):
162            self.nested_groups = config.getboolean(ini_section, 'nested_groups')
163        else:
164            self.nested_groups = False
165
166        # Replace dash or not in group names
167        if config.has_option(ini_section, 'replace_dash_in_groups'):
168            self.replace_dash_in_groups = config.getboolean(ini_section, 'replace_dash_in_groups')
169        else:
170            self.replace_dash_in_groups = True
171
172        # Configure which groups should be created.
173        group_by_options = [
174            'group_by_device_id',
175            'group_by_hostname',
176            'group_by_facility',
177            'group_by_project',
178            'group_by_operating_system',
179            'group_by_plan_type',
180            'group_by_tags',
181            'group_by_tag_none',
182        ]
183        for option in group_by_options:
184            if config.has_option(ini_section, option):
185                setattr(self, option, config.getboolean(ini_section, option))
186            else:
187                setattr(self, option, True)
188
189        # Do we need to just include hosts that match a pattern?
190        try:
191            pattern_include = config.get(ini_section, 'pattern_include')
192            if pattern_include and len(pattern_include) > 0:
193                self.pattern_include = re.compile(pattern_include)
194            else:
195                self.pattern_include = None
196        except configparser.NoOptionError:
197            self.pattern_include = None
198
199        # Do we need to exclude hosts that match a pattern?
200        try:
201            pattern_exclude = config.get(ini_section, 'pattern_exclude')
202            if pattern_exclude and len(pattern_exclude) > 0:
203                self.pattern_exclude = re.compile(pattern_exclude)
204            else:
205                self.pattern_exclude = None
206        except configparser.NoOptionError:
207            self.pattern_exclude = None
208
209        # Projects
210        self.projects = []
211        configProjects = config.get(ini_section, 'projects')
212        configProjects_exclude = config.get(ini_section, 'projects_exclude')
213        if (configProjects == 'all'):
214            for projectInfo in self.get_projects():
215                if projectInfo.name not in configProjects_exclude:
216                    self.projects.append(projectInfo.name)
217        else:
218            self.projects = configProjects.split(",")
219
220    def parse_cli_args(self):
221        ''' Command line argument processing '''
222
223        parser = argparse.ArgumentParser(description='Produce an Ansible Inventory file based on Packet')
224        parser.add_argument('--list', action='store_true', default=True,
225                            help='List Devices (default: True)')
226        parser.add_argument('--host', action='store',
227                            help='Get all the variables about a specific device')
228        parser.add_argument('--refresh-cache', action='store_true', default=False,
229                            help='Force refresh of cache by making API requests to Packet (default: False - use cache files)')
230        self.args = parser.parse_args()
231
232    def do_api_calls_update_cache(self):
233        ''' Do API calls to each region, and save data in cache files '''
234
235        for projectInfo in self.get_projects():
236            if projectInfo.name in self.projects:
237                self.get_devices_by_project(projectInfo)
238
239        self.write_to_cache(self.inventory, self.cache_path_cache)
240        self.write_to_cache(self.index, self.cache_path_index)
241
242    def connect(self):
243        ''' create connection to api server'''
244        token = os.environ.get('PACKET_API_TOKEN')
245        if token is None:
246            raise Exception("Error reading token from environment (PACKET_API_TOKEN)!")
247        manager = packet.Manager(auth_token=token)
248        return manager
249
250    def get_projects(self):
251        '''Makes a Packet API call to get the list of projects'''
252        try:
253            manager = self.connect()
254            projects = manager.list_projects()
255            return projects
256        except Exception as e:
257            traceback.print_exc()
258            self.fail_with_error(e, 'getting Packet projects')
259
260    def get_devices_by_project(self, project):
261        ''' Makes an Packet API call to the list of devices in a particular
262        project '''
263
264        params = {
265            'per_page': self.items_per_page
266        }
267
268        try:
269            manager = self.connect()
270            devices = manager.list_devices(project_id=project.id, params=params)
271
272            for device in devices:
273                self.add_device(device, project)
274
275        except Exception as e:
276            traceback.print_exc()
277            self.fail_with_error(e, 'getting Packet devices')
278
279    def fail_with_error(self, err_msg, err_operation=None):
280        '''log an error to std err for ansible-playbook to consume and exit'''
281        if err_operation:
282            err_msg = 'ERROR: "{err_msg}", while: {err_operation}\n'.format(
283                err_msg=err_msg, err_operation=err_operation)
284        sys.stderr.write(err_msg)
285        sys.exit(1)
286
287    def get_device(self, device_id):
288        manager = self.connect()
289
290        device = manager.get_device(device_id)
291        return device
292
293    def add_device(self, device, project):
294        ''' Adds a device to the inventory and index, as long as it is
295        addressable '''
296
297        # Only return devices with desired device states
298        if device.state not in self.packet_device_states:
299            return
300
301        # Select the best destination address. Only include management
302        # addresses as non-management (elastic) addresses need manual
303        # host configuration to be routable.
304        # See https://help.packet.net/article/54-elastic-ips.
305        dest = None
306        for ip_address in device.ip_addresses:
307            if ip_address['public'] is True and \
308               ip_address['address_family'] == 4 and \
309               ip_address['management'] is True:
310                dest = ip_address['address']
311
312        if not dest:
313            # Skip devices we cannot address (e.g. private VPC subnet)
314            return
315
316        # if we only want to include hosts that match a pattern, skip those that don't
317        if self.pattern_include and not self.pattern_include.match(device.hostname):
318            return
319
320        # if we need to exclude hosts that match a pattern, skip those
321        if self.pattern_exclude and self.pattern_exclude.match(device.hostname):
322            return
323
324        # Add to index
325        self.index[dest] = [project.id, device.id]
326
327        # Inventory: Group by device ID (always a group of 1)
328        if self.group_by_device_id:
329            self.inventory[device.id] = [dest]
330            if self.nested_groups:
331                self.push_group(self.inventory, 'devices', device.id)
332
333        # Inventory: Group by device name (hopefully a group of 1)
334        if self.group_by_hostname:
335            self.push(self.inventory, device.hostname, dest)
336            if self.nested_groups:
337                self.push_group(self.inventory, 'hostnames', project.name)
338
339        # Inventory: Group by project
340        if self.group_by_project:
341            self.push(self.inventory, project.name, dest)
342            if self.nested_groups:
343                self.push_group(self.inventory, 'projects', project.name)
344
345        # Inventory: Group by facility
346        if self.group_by_facility:
347            self.push(self.inventory, device.facility['code'], dest)
348            if self.nested_groups:
349                if self.group_by_facility:
350                    self.push_group(self.inventory, project.name, device.facility['code'])
351
352        # Inventory: Group by OS
353        if self.group_by_operating_system:
354            self.push(self.inventory, device.operating_system.slug, dest)
355            if self.nested_groups:
356                self.push_group(self.inventory, 'operating_systems', device.operating_system.slug)
357
358        # Inventory: Group by plan type
359        if self.group_by_plan_type:
360            self.push(self.inventory, device.plan['slug'], dest)
361            if self.nested_groups:
362                self.push_group(self.inventory, 'plans', device.plan['slug'])
363
364        # Inventory: Group by tag keys
365        if self.group_by_tags:
366            for k in device.tags:
367                key = self.to_safe("tag_" + k)
368                self.push(self.inventory, key, dest)
369                if self.nested_groups:
370                    self.push_group(self.inventory, 'tags', self.to_safe("tag_" + k))
371
372        # Global Tag: devices without tags
373        if self.group_by_tag_none and len(device.tags) == 0:
374            self.push(self.inventory, 'tag_none', dest)
375            if self.nested_groups:
376                self.push_group(self.inventory, 'tags', 'tag_none')
377
378        # Global Tag: tag all Packet devices
379        self.push(self.inventory, 'packet', dest)
380
381        self.inventory["_meta"]["hostvars"][dest] = self.get_host_info_dict_from_device(device)
382
383    def get_host_info_dict_from_device(self, device):
384        device_vars = {}
385        for key in vars(device):
386            value = getattr(device, key)
387            key = self.to_safe('packet_' + key)
388
389            # Handle complex types
390            if key == 'packet_state':
391                device_vars[key] = device.state or ''
392            elif key == 'packet_hostname':
393                device_vars[key] = value
394            elif isinstance(value, (int, bool)):
395                device_vars[key] = value
396            elif isinstance(value, six.string_types):
397                device_vars[key] = value.strip()
398            elif value is None:
399                device_vars[key] = ''
400            elif key == 'packet_facility':
401                device_vars[key] = value['code']
402            elif key == 'packet_operating_system':
403                device_vars[key] = value.slug
404            elif key == 'packet_plan':
405                device_vars[key] = value['slug']
406            elif key == 'packet_tags':
407                for k in value:
408                    key = self.to_safe('packet_tag_' + k)
409                    device_vars[key] = k
410            else:
411                pass
412                # print key
413                # print type(value)
414                # print value
415
416        return device_vars
417
418    def get_host_info(self):
419        ''' Get variables about a specific host '''
420
421        if len(self.index) == 0:
422            # Need to load index from cache
423            self.load_index_from_cache()
424
425        if self.args.host not in self.index:
426            # try updating the cache
427            self.do_api_calls_update_cache()
428            if self.args.host not in self.index:
429                # host might not exist anymore
430                return self.json_format_dict({}, True)
431
432        (project_id, device_id) = self.index[self.args.host]
433
434        device = self.get_device(device_id)
435        return self.json_format_dict(self.get_host_info_dict_from_device(device), True)
436
437    def push(self, my_dict, key, element):
438        ''' Push an element onto an array that may not have been defined in
439        the dict '''
440        group_info = my_dict.setdefault(key, [])
441        if isinstance(group_info, dict):
442            host_list = group_info.setdefault('hosts', [])
443            host_list.append(element)
444        else:
445            group_info.append(element)
446
447    def push_group(self, my_dict, key, element):
448        ''' Push a group as a child of another group. '''
449        parent_group = my_dict.setdefault(key, {})
450        if not isinstance(parent_group, dict):
451            parent_group = my_dict[key] = {'hosts': parent_group}
452        child_groups = parent_group.setdefault('children', [])
453        if element not in child_groups:
454            child_groups.append(element)
455
456    def get_inventory_from_cache(self):
457        ''' Reads the inventory from the cache file and returns it as a JSON
458        object '''
459
460        cache = open(self.cache_path_cache, 'r')
461        json_inventory = cache.read()
462        return json_inventory
463
464    def load_index_from_cache(self):
465        ''' Reads the index from the cache file sets self.index '''
466
467        cache = open(self.cache_path_index, 'r')
468        json_index = cache.read()
469        self.index = json.loads(json_index)
470
471    def write_to_cache(self, data, filename):
472        ''' Writes data in JSON format to a file '''
473
474        json_data = self.json_format_dict(data, True)
475        cache = open(filename, 'w')
476        cache.write(json_data)
477        cache.close()
478
479    def uncammelize(self, key):
480        temp = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', key)
481        return re.sub('([a-z0-9])([A-Z])', r'\1_\2', temp).lower()
482
483    def to_safe(self, word):
484        ''' Converts 'bad' characters in a string to underscores so they can be used as Ansible groups '''
485        regex = r"[^A-Za-z0-9\_"
486        if not self.replace_dash_in_groups:
487            regex += r"\-"
488        return re.sub(regex + "]", "_", word)
489
490    def json_format_dict(self, data, pretty=False):
491        ''' Converts a dict to a JSON object and dumps it as a formatted
492        string '''
493
494        if pretty:
495            return json.dumps(data, sort_keys=True, indent=2)
496        else:
497            return json.dumps(data)
498
499
500# Run the script
501PacketInventory()
502