2021-08-01 15:59:11 +02:00
|
|
|
# coding: utf-8 -*-
|
2020-09-30 13:12:42 +02:00
|
|
|
# Copyright (c) 2020 Shay Rybak <shay.rybak@stackpath.com>
|
|
|
|
# Copyright (c) 2020 Ansible Project
|
|
|
|
# GNU General Public License v3.0+
|
|
|
|
# (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
|
|
|
|
|
|
|
from __future__ import (absolute_import, division, print_function)
|
|
|
|
__metaclass__ = type
|
|
|
|
|
|
|
|
DOCUMENTATION = '''
|
|
|
|
name: stackpath_compute
|
|
|
|
short_description: StackPath Edge Computing inventory source
|
|
|
|
version_added: 1.2.0
|
2021-05-21 19:44:08 +02:00
|
|
|
author:
|
|
|
|
- UNKNOWN (@shayrybak)
|
2020-09-30 13:12:42 +02:00
|
|
|
extends_documentation_fragment:
|
|
|
|
- inventory_cache
|
|
|
|
- constructed
|
|
|
|
description:
|
|
|
|
- Get inventory hosts from StackPath Edge Computing.
|
|
|
|
- Uses a YAML configuration file that ends with stackpath_compute.(yml|yaml).
|
|
|
|
options:
|
|
|
|
plugin:
|
|
|
|
description:
|
|
|
|
- A token that ensures this is a source file for the plugin.
|
|
|
|
required: true
|
|
|
|
choices: ['community.general.stackpath_compute']
|
|
|
|
client_id:
|
|
|
|
description:
|
|
|
|
- An OAuth client ID generated from the API Management section of the StackPath customer portal
|
|
|
|
U(https://control.stackpath.net/api-management).
|
|
|
|
required: true
|
|
|
|
type: str
|
|
|
|
client_secret:
|
|
|
|
description:
|
|
|
|
- An OAuth client secret generated from the API Management section of the StackPath customer portal
|
|
|
|
U(https://control.stackpath.net/api-management).
|
|
|
|
required: true
|
|
|
|
type: str
|
|
|
|
stack_slugs:
|
|
|
|
description:
|
|
|
|
- A list of Stack slugs to query instances in. If no entry then get instances in all stacks on the account.
|
|
|
|
type: list
|
|
|
|
elements: str
|
|
|
|
use_internal_ip:
|
|
|
|
description:
|
|
|
|
- Whether or not to use internal IP addresses, If false, uses external IP addresses, internal otherwise.
|
|
|
|
- If an instance doesn't have an external IP it will not be returned when this option is set to false.
|
|
|
|
type: bool
|
|
|
|
'''
|
|
|
|
|
|
|
|
EXAMPLES = '''
|
|
|
|
# Example using credentials to fetch all workload instances in a stack.
|
|
|
|
---
|
|
|
|
plugin: community.general.stackpath_compute
|
|
|
|
client_id: my_client_id
|
|
|
|
client_secret: my_client_secret
|
|
|
|
stack_slugs:
|
|
|
|
- my_first_stack_slug
|
|
|
|
- my_other_stack_slug
|
|
|
|
use_internal_ip: false
|
|
|
|
'''
|
|
|
|
|
|
|
|
import traceback
|
|
|
|
import json
|
|
|
|
|
|
|
|
from ansible.errors import AnsibleError
|
|
|
|
from ansible.module_utils.urls import open_url
|
|
|
|
from ansible.plugins.inventory import (
|
|
|
|
BaseInventoryPlugin,
|
|
|
|
Constructable,
|
|
|
|
Cacheable
|
|
|
|
)
|
|
|
|
from ansible.utils.display import Display
|
|
|
|
|
|
|
|
|
|
|
|
display = Display()
|
|
|
|
|
|
|
|
|
|
|
|
class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable):
|
|
|
|
|
|
|
|
NAME = 'community.general.stackpath_compute'
|
|
|
|
|
|
|
|
def __init__(self):
|
|
|
|
super(InventoryModule, self).__init__()
|
|
|
|
|
|
|
|
# credentials
|
|
|
|
self.client_id = None
|
|
|
|
self.client_secret = None
|
|
|
|
self.stack_slug = None
|
|
|
|
self.api_host = "https://gateway.stackpath.com"
|
|
|
|
self.group_keys = [
|
|
|
|
"stackSlug",
|
|
|
|
"workloadId",
|
|
|
|
"cityCode",
|
|
|
|
"countryCode",
|
|
|
|
"continent",
|
|
|
|
"target",
|
|
|
|
"name",
|
|
|
|
"workloadSlug"
|
|
|
|
]
|
|
|
|
|
|
|
|
def _validate_config(self, config):
|
|
|
|
if config['plugin'] != 'community.general.stackpath_compute':
|
|
|
|
raise AnsibleError("plugin doesn't match this plugin")
|
|
|
|
try:
|
|
|
|
client_id = config['client_id']
|
2021-05-09 22:46:17 +02:00
|
|
|
if len(client_id) != 32:
|
2020-09-30 13:12:42 +02:00
|
|
|
raise AnsibleError("client_id must be 32 characters long")
|
|
|
|
except KeyError:
|
|
|
|
raise AnsibleError("config missing client_id, a required option")
|
|
|
|
try:
|
|
|
|
client_secret = config['client_secret']
|
2021-05-09 22:46:17 +02:00
|
|
|
if len(client_secret) != 64:
|
2020-09-30 13:12:42 +02:00
|
|
|
raise AnsibleError("client_secret must be 64 characters long")
|
|
|
|
except KeyError:
|
|
|
|
raise AnsibleError("config missing client_id, a required option")
|
|
|
|
return True
|
|
|
|
|
|
|
|
def _set_credentials(self):
|
|
|
|
'''
|
|
|
|
:param config_data: contents of the inventory config file
|
|
|
|
'''
|
|
|
|
self.client_id = self.get_option('client_id')
|
|
|
|
self.client_secret = self.get_option('client_secret')
|
|
|
|
|
|
|
|
def _authenticate(self):
|
|
|
|
payload = json.dumps(
|
|
|
|
{
|
|
|
|
"client_id": self.client_id,
|
|
|
|
"client_secret": self.client_secret,
|
|
|
|
"grant_type": "client_credentials",
|
|
|
|
}
|
|
|
|
)
|
|
|
|
headers = {
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
}
|
|
|
|
resp = open_url(
|
|
|
|
self.api_host + '/identity/v1/oauth2/token',
|
|
|
|
headers=headers,
|
|
|
|
data=payload,
|
|
|
|
method="POST"
|
|
|
|
)
|
|
|
|
status_code = resp.code
|
|
|
|
if status_code == 200:
|
|
|
|
body = resp.read()
|
|
|
|
self.auth_token = json.loads(body)["access_token"]
|
|
|
|
|
|
|
|
def _query(self):
|
|
|
|
results = []
|
|
|
|
workloads = []
|
|
|
|
self._authenticate()
|
|
|
|
for stack_slug in self.stack_slugs:
|
|
|
|
try:
|
|
|
|
workloads = self._stackpath_query_get_list(self.api_host + '/workload/v1/stacks/' + stack_slug + '/workloads')
|
|
|
|
except Exception:
|
|
|
|
raise AnsibleError("Failed to get workloads from the StackPath API: %s" % traceback.format_exc())
|
|
|
|
for workload in workloads:
|
|
|
|
try:
|
|
|
|
workload_instances = self._stackpath_query_get_list(
|
|
|
|
self.api_host + '/workload/v1/stacks/' + stack_slug + '/workloads/' + workload["id"] + '/instances'
|
|
|
|
)
|
|
|
|
except Exception:
|
|
|
|
raise AnsibleError("Failed to get workload instances from the StackPath API: %s" % traceback.format_exc())
|
|
|
|
for instance in workload_instances:
|
|
|
|
if instance["phase"] == "RUNNING":
|
|
|
|
instance["stackSlug"] = stack_slug
|
|
|
|
instance["workloadId"] = workload["id"]
|
|
|
|
instance["workloadSlug"] = workload["slug"]
|
|
|
|
instance["cityCode"] = instance["location"]["cityCode"]
|
|
|
|
instance["countryCode"] = instance["location"]["countryCode"]
|
|
|
|
instance["continent"] = instance["location"]["continent"]
|
|
|
|
instance["target"] = instance["metadata"]["labels"]["workload.platform.stackpath.net/target-name"]
|
|
|
|
try:
|
|
|
|
if instance[self.hostname_key]:
|
|
|
|
results.append(instance)
|
|
|
|
except KeyError:
|
|
|
|
pass
|
|
|
|
return results
|
|
|
|
|
|
|
|
def _populate(self, instances):
|
|
|
|
for instance in instances:
|
|
|
|
for group_key in self.group_keys:
|
|
|
|
group = group_key + "_" + instance[group_key]
|
|
|
|
group = group.lower().replace(" ", "_").replace("-", "_")
|
|
|
|
self.inventory.add_group(group)
|
|
|
|
self.inventory.add_host(instance[self.hostname_key],
|
|
|
|
group=group)
|
|
|
|
|
|
|
|
def _stackpath_query_get_list(self, url):
|
|
|
|
self._authenticate()
|
|
|
|
headers = {
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
"Authorization": "Bearer " + self.auth_token,
|
|
|
|
}
|
|
|
|
next_page = True
|
|
|
|
result = []
|
|
|
|
cursor = '-1'
|
|
|
|
while next_page:
|
|
|
|
resp = open_url(
|
|
|
|
url + '?page_request.first=10&page_request.after=%s' % cursor,
|
|
|
|
headers=headers,
|
|
|
|
method="GET"
|
|
|
|
)
|
|
|
|
status_code = resp.code
|
|
|
|
if status_code == 200:
|
|
|
|
body = resp.read()
|
|
|
|
body_json = json.loads(body)
|
|
|
|
result.extend(body_json["results"])
|
|
|
|
next_page = body_json["pageInfo"]["hasNextPage"]
|
|
|
|
if next_page:
|
|
|
|
cursor = body_json["pageInfo"]["endCursor"]
|
|
|
|
return result
|
|
|
|
|
|
|
|
def _get_stack_slugs(self, stacks):
|
|
|
|
self.stack_slugs = [stack["slug"] for stack in stacks]
|
|
|
|
|
|
|
|
def verify_file(self, path):
|
|
|
|
'''
|
|
|
|
:param loader: an ansible.parsing.dataloader.DataLoader object
|
|
|
|
:param path: the path to the inventory config file
|
|
|
|
:return the contents of the config file
|
|
|
|
'''
|
|
|
|
if super(InventoryModule, self).verify_file(path):
|
|
|
|
if path.endswith(('stackpath_compute.yml', 'stackpath_compute.yaml')):
|
|
|
|
return True
|
|
|
|
display.debug(
|
|
|
|
"stackpath_compute inventory filename must end with \
|
|
|
|
'stackpath_compute.yml' or 'stackpath_compute.yaml'"
|
|
|
|
)
|
|
|
|
return False
|
|
|
|
|
|
|
|
def parse(self, inventory, loader, path, cache=True):
|
|
|
|
|
|
|
|
super(InventoryModule, self).parse(inventory, loader, path)
|
|
|
|
|
|
|
|
config = self._read_config_data(path)
|
|
|
|
self._validate_config(config)
|
|
|
|
self._set_credentials()
|
|
|
|
|
|
|
|
# get user specifications
|
|
|
|
self.use_internal_ip = self.get_option('use_internal_ip')
|
|
|
|
if self.use_internal_ip:
|
|
|
|
self.hostname_key = "ipAddress"
|
|
|
|
else:
|
|
|
|
self.hostname_key = "externalIpAddress"
|
|
|
|
|
|
|
|
self.stack_slugs = self.get_option('stack_slugs')
|
|
|
|
if not self.stack_slugs:
|
|
|
|
try:
|
|
|
|
stacks = self._stackpath_query_get_list(self.api_host + '/stack/v1/stacks')
|
|
|
|
self._get_stack_slugs(stacks)
|
|
|
|
except Exception:
|
|
|
|
raise AnsibleError("Failed to get stack IDs from the Stackpath API: %s" % traceback.format_exc())
|
|
|
|
|
|
|
|
cache_key = self.get_cache_key(path)
|
|
|
|
# false when refresh_cache or --flush-cache is used
|
|
|
|
if cache:
|
|
|
|
# get the user-specified directive
|
|
|
|
cache = self.get_option('cache')
|
|
|
|
|
|
|
|
# Generate inventory
|
|
|
|
cache_needs_update = False
|
|
|
|
if cache:
|
|
|
|
try:
|
|
|
|
results = self._cache[cache_key]
|
|
|
|
except KeyError:
|
|
|
|
# if cache expires or cache file doesn't exist
|
|
|
|
cache_needs_update = True
|
|
|
|
|
|
|
|
if not cache or cache_needs_update:
|
|
|
|
results = self._query()
|
|
|
|
|
|
|
|
self._populate(results)
|
|
|
|
|
|
|
|
# If the cache has expired/doesn't exist or
|
|
|
|
# if refresh_inventory/flush cache is used
|
|
|
|
# when the user is using caching, update the cached inventory
|
|
|
|
try:
|
|
|
|
if cache_needs_update or (not cache and self.get_option('cache')):
|
|
|
|
self._cache[cache_key] = results
|
|
|
|
except Exception:
|
|
|
|
raise AnsibleError("Failed to populate data: %s" % traceback.format_exc())
|