1# -*- coding: utf-8 -*-
2# Copyright: (c) 2017 Ansible Project
3# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
4
5from __future__ import (absolute_import, division, print_function)
6
7__metaclass__ = type
8
9DOCUMENTATION = r'''
10    name: scaleway
11    author:
12      - Remy Leone (@sieben)
13    short_description: Scaleway inventory source
14    description:
15        - Get inventory hosts from Scaleway.
16    requirements:
17        - PyYAML
18    options:
19        plugin:
20            description: Token that ensures this is a source file for the 'scaleway' plugin.
21            required: True
22            choices: ['scaleway', 'community.general.scaleway']
23        regions:
24            description: Filter results on a specific Scaleway region.
25            type: list
26            default:
27                - ams1
28                - par1
29                - par2
30                - waw1
31        tags:
32            description: Filter results on a specific tag.
33            type: list
34        oauth_token:
35            description:
36            - Scaleway OAuth token.
37            - If not explicitly defined or in environment variables, it will try to lookup in the scaleway-cli configuration file
38              (C($SCW_CONFIG_PATH), C($XDG_CONFIG_HOME/scw/config.yaml), or C(~/.config/scw/config.yaml)).
39            - More details on L(how to generate token, https://www.scaleway.com/en/docs/generate-api-keys/).
40            env:
41                # in order of precedence
42                - name: SCW_TOKEN
43                - name: SCW_API_KEY
44                - name: SCW_OAUTH_TOKEN
45        hostnames:
46            description: List of preference about what to use as an hostname.
47            type: list
48            default:
49                - public_ipv4
50            choices:
51                - public_ipv4
52                - private_ipv4
53                - public_ipv6
54                - hostname
55                - id
56        variables:
57            description: 'Set individual variables: keys are variable names and
58                          values are templates. Any value returned by the
59                          L(Scaleway API, https://developer.scaleway.com/#servers-server-get)
60                          can be used.'
61            type: dict
62'''
63
64EXAMPLES = r'''
65# scaleway_inventory.yml file in YAML format
66# Example command line: ansible-inventory --list -i scaleway_inventory.yml
67
68# use hostname as inventory_hostname
69# use the private IP address to connect to the host
70plugin: community.general.scaleway
71regions:
72  - ams1
73  - par1
74tags:
75  - foobar
76hostnames:
77  - hostname
78variables:
79  ansible_host: private_ip
80  state: state
81
82# use hostname as inventory_hostname and public IP address to connect to the host
83plugin: community.general.scaleway
84hostnames:
85  - hostname
86regions:
87  - par1
88variables:
89  ansible_host: public_ip.address
90
91# Using static strings as variables
92plugin: community.general.scaleway
93hostnames:
94  - hostname
95variables:
96  ansible_host: public_ip.address
97  ansible_connection: "'ssh'"
98  ansible_user: "'admin'"
99'''
100
101import os
102import json
103
104try:
105    import yaml
106except ImportError as exc:
107    YAML_IMPORT_ERROR = exc
108else:
109    YAML_IMPORT_ERROR = None
110
111from ansible.errors import AnsibleError
112from ansible.plugins.inventory import BaseInventoryPlugin, Constructable
113from ansible_collections.community.general.plugins.module_utils.scaleway import SCALEWAY_LOCATION, parse_pagination_link
114from ansible.module_utils.urls import open_url
115from ansible.module_utils.common.text.converters import to_native, to_text
116from ansible.module_utils.six import raise_from
117
118import ansible.module_utils.six.moves.urllib.parse as urllib_parse
119
120
121def _fetch_information(token, url):
122    results = []
123    paginated_url = url
124    while True:
125        try:
126            response = open_url(paginated_url,
127                                headers={'X-Auth-Token': token,
128                                         'Content-type': 'application/json'})
129        except Exception as e:
130            raise AnsibleError("Error while fetching %s: %s" % (url, to_native(e)))
131        try:
132            raw_json = json.loads(to_text(response.read()))
133        except ValueError:
134            raise AnsibleError("Incorrect JSON payload")
135
136        try:
137            results.extend(raw_json["servers"])
138        except KeyError:
139            raise AnsibleError("Incorrect format from the Scaleway API response")
140
141        link = response.headers['Link']
142        if not link:
143            return results
144        relations = parse_pagination_link(link)
145        if 'next' not in relations:
146            return results
147        paginated_url = urllib_parse.urljoin(paginated_url, relations['next'])
148
149
150def _build_server_url(api_endpoint):
151    return "/".join([api_endpoint, "servers"])
152
153
154def extract_public_ipv4(server_info):
155    try:
156        return server_info["public_ip"]["address"]
157    except (KeyError, TypeError):
158        return None
159
160
161def extract_private_ipv4(server_info):
162    try:
163        return server_info["private_ip"]
164    except (KeyError, TypeError):
165        return None
166
167
168def extract_hostname(server_info):
169    try:
170        return server_info["hostname"]
171    except (KeyError, TypeError):
172        return None
173
174
175def extract_server_id(server_info):
176    try:
177        return server_info["id"]
178    except (KeyError, TypeError):
179        return None
180
181
182def extract_public_ipv6(server_info):
183    try:
184        return server_info["ipv6"]["address"]
185    except (KeyError, TypeError):
186        return None
187
188
189def extract_tags(server_info):
190    try:
191        return server_info["tags"]
192    except (KeyError, TypeError):
193        return None
194
195
196def extract_zone(server_info):
197    try:
198        return server_info["location"]["zone_id"]
199    except (KeyError, TypeError):
200        return None
201
202
203extractors = {
204    "public_ipv4": extract_public_ipv4,
205    "private_ipv4": extract_private_ipv4,
206    "public_ipv6": extract_public_ipv6,
207    "hostname": extract_hostname,
208    "id": extract_server_id
209}
210
211
212class InventoryModule(BaseInventoryPlugin, Constructable):
213    NAME = 'community.general.scaleway'
214
215    def _fill_host_variables(self, host, server_info):
216        targeted_attributes = (
217            "arch",
218            "commercial_type",
219            "id",
220            "organization",
221            "state",
222            "hostname",
223        )
224        for attribute in targeted_attributes:
225            self.inventory.set_variable(host, attribute, server_info[attribute])
226
227        self.inventory.set_variable(host, "tags", server_info["tags"])
228
229        if extract_public_ipv6(server_info=server_info):
230            self.inventory.set_variable(host, "public_ipv6", extract_public_ipv6(server_info=server_info))
231
232        if extract_public_ipv4(server_info=server_info):
233            self.inventory.set_variable(host, "public_ipv4", extract_public_ipv4(server_info=server_info))
234
235        if extract_private_ipv4(server_info=server_info):
236            self.inventory.set_variable(host, "private_ipv4", extract_private_ipv4(server_info=server_info))
237
238    def _get_zones(self, config_zones):
239        return set(SCALEWAY_LOCATION.keys()).intersection(config_zones)
240
241    def match_groups(self, server_info, tags):
242        server_zone = extract_zone(server_info=server_info)
243        server_tags = extract_tags(server_info=server_info)
244
245        # If a server does not have a zone, it means it is archived
246        if server_zone is None:
247            return set()
248
249        # If no filtering is defined, all tags are valid groups
250        if tags is None:
251            return set(server_tags).union((server_zone,))
252
253        matching_tags = set(server_tags).intersection(tags)
254
255        if not matching_tags:
256            return set()
257        return matching_tags.union((server_zone,))
258
259    def _filter_host(self, host_infos, hostname_preferences):
260
261        for pref in hostname_preferences:
262            if extractors[pref](host_infos):
263                return extractors[pref](host_infos)
264
265        return None
266
267    def do_zone_inventory(self, zone, token, tags, hostname_preferences):
268        self.inventory.add_group(zone)
269        zone_info = SCALEWAY_LOCATION[zone]
270
271        url = _build_server_url(zone_info["api_endpoint"])
272        raw_zone_hosts_infos = _fetch_information(url=url, token=token)
273
274        for host_infos in raw_zone_hosts_infos:
275
276            hostname = self._filter_host(host_infos=host_infos,
277                                         hostname_preferences=hostname_preferences)
278
279            # No suitable hostname were found in the attributes and the host won't be in the inventory
280            if not hostname:
281                continue
282
283            groups = self.match_groups(host_infos, tags)
284
285            for group in groups:
286                self.inventory.add_group(group=group)
287                self.inventory.add_host(group=group, host=hostname)
288                self._fill_host_variables(host=hostname, server_info=host_infos)
289
290                # Composed variables
291                self._set_composite_vars(self.get_option('variables'), host_infos, hostname, strict=False)
292
293    def get_oauth_token(self):
294        oauth_token = self.get_option('oauth_token')
295
296        if 'SCW_CONFIG_PATH' in os.environ:
297            scw_config_path = os.getenv('SCW_CONFIG_PATH')
298        elif 'XDG_CONFIG_HOME' in os.environ:
299            scw_config_path = os.path.join(os.getenv('XDG_CONFIG_HOME'), 'scw', 'config.yaml')
300        else:
301            scw_config_path = os.path.join(os.path.expanduser('~'), '.config', 'scw', 'config.yaml')
302
303        if not oauth_token and os.path.exists(scw_config_path):
304            with open(scw_config_path) as fh:
305                scw_config = yaml.safe_load(fh)
306                active_profile = scw_config.get('active_profile', 'default')
307                if active_profile == 'default':
308                    oauth_token = scw_config.get('secret_key')
309                else:
310                    oauth_token = scw_config['profiles'][active_profile].get('secret_key')
311
312        return oauth_token
313
314    def parse(self, inventory, loader, path, cache=True):
315        if YAML_IMPORT_ERROR:
316            raise_from(AnsibleError('PyYAML is probably missing'), YAML_IMPORT_ERROR)
317        super(InventoryModule, self).parse(inventory, loader, path)
318        self._read_config_data(path=path)
319
320        config_zones = self.get_option("regions")
321        tags = self.get_option("tags")
322        token = self.get_oauth_token()
323        if not token:
324            raise AnsibleError("'oauth_token' value is null, you must configure it either in inventory, envvars or scaleway-cli config.")
325        hostname_preference = self.get_option("hostnames")
326
327        for zone in self._get_zones(config_zones):
328            self.do_zone_inventory(zone=zone, token=token, tags=tags, hostname_preferences=hostname_preference)
329