mirror of
https://github.com/ansible-collections/community.general.git
synced 2026-02-04 16:01:55 +00:00
421 lines
14 KiB
Python
421 lines
14 KiB
Python
# Copyright (c) Ansible Project
|
|
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
|
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import sys
|
|
import datetime
|
|
import time
|
|
import traceback
|
|
from urllib.parse import urlencode
|
|
|
|
from ansible.module_utils.basic import env_fallback, missing_required_lib
|
|
from ansible.module_utils.urls import fetch_url
|
|
|
|
from ansible_collections.community.general.plugins.module_utils.datetime import (
|
|
now,
|
|
)
|
|
|
|
SCALEWAY_SECRET_IMP_ERR: str | None = None
|
|
try:
|
|
from passlib.hash import argon2
|
|
|
|
HAS_SCALEWAY_SECRET_PACKAGE = True
|
|
except Exception:
|
|
SCALEWAY_SECRET_IMP_ERR = traceback.format_exc()
|
|
HAS_SCALEWAY_SECRET_PACKAGE = False
|
|
|
|
|
|
def scaleway_argument_spec():
|
|
return dict(
|
|
api_token=dict(
|
|
required=True,
|
|
fallback=(env_fallback, ["SCW_TOKEN", "SCW_API_KEY", "SCW_OAUTH_TOKEN", "SCW_API_TOKEN"]),
|
|
no_log=True,
|
|
aliases=["oauth_token"],
|
|
),
|
|
api_url=dict(
|
|
fallback=(env_fallback, ["SCW_API_URL"]), default="https://api.scaleway.com", aliases=["base_url"]
|
|
),
|
|
api_timeout=dict(type="int", default=30, aliases=["timeout"]),
|
|
query_parameters=dict(type="dict", default={}),
|
|
validate_certs=dict(default=True, type="bool"),
|
|
)
|
|
|
|
|
|
def scaleway_waitable_resource_argument_spec():
|
|
return dict(
|
|
wait=dict(type="bool", default=True),
|
|
wait_timeout=dict(type="int", default=300),
|
|
wait_sleep_time=dict(type="int", default=3),
|
|
)
|
|
|
|
|
|
def payload_from_object(scw_object):
|
|
return {k: v for k, v in scw_object.items() if k != "id" and v is not None}
|
|
|
|
|
|
class ScalewayException(Exception):
|
|
def __init__(self, message):
|
|
self.message = message
|
|
|
|
|
|
# Specify a complete Link header, for validation purposes
|
|
R_LINK_HEADER = r"""<[^>]+>;\srel="(first|previous|next|last)"
|
|
(,<[^>]+>;\srel="(first|previous|next|last)")*"""
|
|
# Specify a single relation, for iteration and string extraction purposes
|
|
R_RELATION = r'</?(?P<target_IRI>[^>]+)>; rel="(?P<relation>first|previous|next|last)"'
|
|
|
|
|
|
def parse_pagination_link(header):
|
|
if not re.match(R_LINK_HEADER, header, re.VERBOSE):
|
|
raise ScalewayException("Scaleway API answered with an invalid Link pagination header")
|
|
else:
|
|
relations = header.split(",")
|
|
parsed_relations = {}
|
|
rc_relation = re.compile(R_RELATION)
|
|
for relation in relations:
|
|
match = rc_relation.match(relation)
|
|
if not match:
|
|
raise ScalewayException("Scaleway API answered with an invalid relation in the Link pagination header")
|
|
data = match.groupdict()
|
|
parsed_relations[data["relation"]] = data["target_IRI"]
|
|
return parsed_relations
|
|
|
|
|
|
def filter_sensitive_attributes(container, attributes):
|
|
"""
|
|
WARNING: This function is effectively private, **do not use it**!
|
|
It will be removed or renamed once changing its name no longer triggers a pylint bug.
|
|
"""
|
|
for attr in attributes:
|
|
container[attr] = "SENSITIVE_VALUE"
|
|
|
|
return container
|
|
|
|
|
|
class SecretVariables:
|
|
@staticmethod
|
|
def ensure_scaleway_secret_package(module):
|
|
if not HAS_SCALEWAY_SECRET_PACKAGE:
|
|
module.fail_json(
|
|
msg=missing_required_lib("passlib[argon2]", url="https://passlib.readthedocs.io/en/stable/"),
|
|
exception=SCALEWAY_SECRET_IMP_ERR,
|
|
)
|
|
|
|
@staticmethod
|
|
def dict_to_list(source_dict):
|
|
return [dict(key=k, value=v) for k, v in source_dict.items()]
|
|
|
|
@staticmethod
|
|
def list_to_dict(source_list, hashed=False):
|
|
key_value = "hashed_value" if hashed else "value"
|
|
return {var["key"]: var[key_value] for var in source_list}
|
|
|
|
@classmethod
|
|
def decode(cls, secrets_list, values_list):
|
|
secrets_dict = cls.list_to_dict(secrets_list, hashed=True)
|
|
values_dict = cls.list_to_dict(values_list, hashed=False)
|
|
for key in values_dict:
|
|
if key in secrets_dict:
|
|
if argon2.verify(values_dict[key], secrets_dict[key]):
|
|
secrets_dict[key] = values_dict[key]
|
|
else:
|
|
secrets_dict[key] = secrets_dict[key]
|
|
|
|
return cls.dict_to_list(secrets_dict)
|
|
|
|
|
|
def resource_attributes_should_be_changed(target, wished, verifiable_mutable_attributes, mutable_attributes):
|
|
diff = dict()
|
|
for attr in verifiable_mutable_attributes:
|
|
if wished[attr] is not None and target[attr] != wished[attr]:
|
|
diff[attr] = wished[attr]
|
|
|
|
if diff:
|
|
return {attr: wished[attr] for attr in mutable_attributes}
|
|
else:
|
|
return diff
|
|
|
|
|
|
class Response:
|
|
def __init__(self, resp, info):
|
|
self.body = None
|
|
if resp:
|
|
self.body = resp.read()
|
|
self.info = info
|
|
|
|
@property
|
|
def json(self):
|
|
if not self.body:
|
|
if "body" in self.info:
|
|
return json.loads(self.info["body"])
|
|
return None
|
|
try:
|
|
return json.loads(self.body)
|
|
except ValueError:
|
|
return None
|
|
|
|
@property
|
|
def status_code(self):
|
|
return self.info["status"]
|
|
|
|
@property
|
|
def ok(self):
|
|
return self.status_code in (200, 201, 202, 204)
|
|
|
|
|
|
class Scaleway:
|
|
def __init__(self, module):
|
|
self.module = module
|
|
self.headers = {
|
|
"X-Auth-Token": self.module.params.get("api_token"),
|
|
"User-Agent": self.get_user_agent_string(module),
|
|
"Content-Type": "application/json",
|
|
}
|
|
self.name = None
|
|
|
|
def get_resources(self):
|
|
results = self.get(f"/{self.name}")
|
|
|
|
if not results.ok:
|
|
raise ScalewayException(
|
|
f"Error fetching {self.name} ({self.module.params.get('api_url')}/{self.name}) [{results.status_code}: {results.json['message']}]"
|
|
)
|
|
|
|
return results.json.get(self.name)
|
|
|
|
def _url_builder(self, path, params):
|
|
d = self.module.params.get("query_parameters")
|
|
if params is not None:
|
|
d.update(params)
|
|
query_string = urlencode(d, doseq=True)
|
|
|
|
if path[0] == "/":
|
|
path = path[1:]
|
|
return f"{self.module.params.get('api_url')}/{path}?{query_string}"
|
|
|
|
def send(self, method, path, data=None, headers=None, params=None):
|
|
url = self._url_builder(path=path, params=params)
|
|
self.warn(url)
|
|
|
|
if headers is not None:
|
|
self.headers.update(headers)
|
|
|
|
if self.headers["Content-Type"] == "application/json":
|
|
data = self.module.jsonify(data)
|
|
|
|
resp, info = fetch_url(
|
|
self.module,
|
|
url,
|
|
data=data,
|
|
headers=self.headers,
|
|
method=method,
|
|
timeout=self.module.params.get("api_timeout"),
|
|
)
|
|
|
|
# Exceptions in fetch_url may result in a status -1, the ensures a proper error to the user in all cases
|
|
if info["status"] == -1:
|
|
self.module.fail_json(msg=info["msg"])
|
|
|
|
return Response(resp, info)
|
|
|
|
@staticmethod
|
|
def get_user_agent_string(module):
|
|
return f"ansible {module.ansible_version} Python {sys.version.split(' ', 1)[0]}"
|
|
|
|
def get(self, path, data=None, headers=None, params=None):
|
|
return self.send(method="GET", path=path, data=data, headers=headers, params=params)
|
|
|
|
def put(self, path, data=None, headers=None, params=None):
|
|
return self.send(method="PUT", path=path, data=data, headers=headers, params=params)
|
|
|
|
def post(self, path, data=None, headers=None, params=None):
|
|
return self.send(method="POST", path=path, data=data, headers=headers, params=params)
|
|
|
|
def delete(self, path, data=None, headers=None, params=None):
|
|
return self.send(method="DELETE", path=path, data=data, headers=headers, params=params)
|
|
|
|
def patch(self, path, data=None, headers=None, params=None):
|
|
return self.send(method="PATCH", path=path, data=data, headers=headers, params=params)
|
|
|
|
def update(self, path, data=None, headers=None, params=None):
|
|
return self.send(method="UPDATE", path=path, data=data, headers=headers, params=params)
|
|
|
|
def warn(self, x):
|
|
self.module.warn(str(x))
|
|
|
|
def fetch_state(self, resource):
|
|
self.module.debug(f"fetch_state of resource: {resource['id']}")
|
|
response = self.get(path=f"{self.api_path}/{resource['id']}")
|
|
|
|
if response.status_code == 404:
|
|
return "absent"
|
|
|
|
if not response.ok:
|
|
msg = f"Error during state fetching: ({response.status_code}) {response.json}"
|
|
self.module.fail_json(msg=msg)
|
|
|
|
try:
|
|
self.module.debug(f"Resource {resource['id']} in state: {response.json['status']}")
|
|
return response.json["status"]
|
|
except KeyError:
|
|
self.module.fail_json(msg=f"Could not fetch state in {response.json}")
|
|
|
|
def fetch_paginated_resources(self, resource_key, **pagination_kwargs):
|
|
response = self.get(path=self.api_path, params=pagination_kwargs)
|
|
|
|
status_code = response.status_code
|
|
if not response.ok:
|
|
self.module.fail_json(
|
|
msg=f"Error getting {resource_key} [{response.status_code}: {response.json['message']}]"
|
|
)
|
|
|
|
return response.json[resource_key]
|
|
|
|
def fetch_all_resources(self, resource_key, **pagination_kwargs):
|
|
resources = []
|
|
|
|
result = [None]
|
|
while len(result) != 0:
|
|
result = self.fetch_paginated_resources(resource_key, **pagination_kwargs)
|
|
resources += result
|
|
if "page" in pagination_kwargs:
|
|
pagination_kwargs["page"] += 1
|
|
else:
|
|
pagination_kwargs["page"] = 2
|
|
|
|
return resources
|
|
|
|
def wait_to_complete_state_transition(self, resource, stable_states, force_wait=False):
|
|
wait = self.module.params["wait"]
|
|
|
|
if not (wait or force_wait):
|
|
return
|
|
|
|
wait_timeout = self.module.params["wait_timeout"]
|
|
wait_sleep_time = self.module.params["wait_sleep_time"]
|
|
|
|
# Prevent requesting the resource status too soon
|
|
time.sleep(wait_sleep_time)
|
|
|
|
start = now()
|
|
end = start + datetime.timedelta(seconds=wait_timeout)
|
|
|
|
while now() < end:
|
|
self.module.debug("We are going to wait for the resource to finish its transition")
|
|
|
|
state = self.fetch_state(resource)
|
|
if state in stable_states:
|
|
self.module.debug("It seems that the resource is not in transition anymore.")
|
|
self.module.debug(f"load-balancer in state: {self.fetch_state(resource)}")
|
|
break
|
|
|
|
time.sleep(wait_sleep_time)
|
|
else:
|
|
self.module.fail_json(msg="Server takes too long to finish its transition")
|
|
|
|
|
|
SCALEWAY_LOCATION = {
|
|
"par1": {
|
|
"name": "Paris 1",
|
|
"country": "FR",
|
|
"api_endpoint": "https://api.scaleway.com/instance/v1/zones/fr-par-1",
|
|
"api_endpoint_vpc": "https://api.scaleway.com/vpc/v1/zones/fr-par-1",
|
|
},
|
|
"EMEA-FR-PAR1": {
|
|
"name": "Paris 1",
|
|
"country": "FR",
|
|
"api_endpoint": "https://api.scaleway.com/instance/v1/zones/fr-par-1",
|
|
"api_endpoint_vpc": "https://api.scaleway.com/vpc/v1/zones/fr-par-1",
|
|
},
|
|
"par2": {
|
|
"name": "Paris 2",
|
|
"country": "FR",
|
|
"api_endpoint": "https://api.scaleway.com/instance/v1/zones/fr-par-2",
|
|
"api_endpoint_vpc": "https://api.scaleway.com/vpc/v1/zones/fr-par-2",
|
|
},
|
|
"EMEA-FR-PAR2": {
|
|
"name": "Paris 2",
|
|
"country": "FR",
|
|
"api_endpoint": "https://api.scaleway.com/instance/v1/zones/fr-par-2",
|
|
"api_endpoint_vpc": "https://api.scaleway.com/vpc/v1/zones/fr-par-2",
|
|
},
|
|
"par3": {
|
|
"name": "Paris 3",
|
|
"country": "FR",
|
|
"api_endpoint": "https://api.scaleway.com/instance/v1/zones/fr-par-3",
|
|
"api_endpoint_vpc": "https://api.scaleway.com/vpc/v1/zones/fr-par-3",
|
|
},
|
|
"ams1": {
|
|
"name": "Amsterdam 1",
|
|
"country": "NL",
|
|
"api_endpoint": "https://api.scaleway.com/instance/v1/zones/nl-ams-1",
|
|
"api_endpoint_vpc": "https://api.scaleway.com/vpc/v1/zones/nl-ams-1",
|
|
},
|
|
"EMEA-NL-EVS": {
|
|
"name": "Amsterdam 1",
|
|
"country": "NL",
|
|
"api_endpoint": "https://api.scaleway.com/instance/v1/zones/nl-ams-1",
|
|
"api_endpoint_vpc": "https://api.scaleway.com/vpc/v1/zones/nl-ams-1",
|
|
},
|
|
"ams2": {
|
|
"name": "Amsterdam 2",
|
|
"country": "NL",
|
|
"api_endpoint": "https://api.scaleway.com/instance/v1/zones/nl-ams-2",
|
|
"api_endpoint_vpc": "https://api.scaleway.com/vpc/v1/zones/nl-ams-2",
|
|
},
|
|
"ams3": {
|
|
"name": "Amsterdam 3",
|
|
"country": "NL",
|
|
"api_endpoint": "https://api.scaleway.com/instance/v1/zones/nl-ams-3",
|
|
"api_endpoint_vpc": "https://api.scaleway.com/vpc/v1/zones/nl-ams-3",
|
|
},
|
|
"waw1": {
|
|
"name": "Warsaw 1",
|
|
"country": "PL",
|
|
"api_endpoint": "https://api.scaleway.com/instance/v1/zones/pl-waw-1",
|
|
"api_endpoint_vpc": "https://api.scaleway.com/vpc/v1/zones/pl-waw-1",
|
|
},
|
|
"EMEA-PL-WAW1": {
|
|
"name": "Warsaw 1",
|
|
"country": "PL",
|
|
"api_endpoint": "https://api.scaleway.com/instance/v1/zones/pl-waw-1",
|
|
"api_endpoint_vpc": "https://api.scaleway.com/vpc/v1/zones/pl-waw-1",
|
|
},
|
|
"waw2": {
|
|
"name": "Warsaw 2",
|
|
"country": "PL",
|
|
"api_endpoint": "https://api.scaleway.com/instance/v1/zones/pl-waw-2",
|
|
"api_endpoint_vpc": "https://api.scaleway.com/vpc/v1/zones/pl-waw-2",
|
|
},
|
|
"waw3": {
|
|
"name": "Warsaw 3",
|
|
"country": "PL",
|
|
"api_endpoint": "https://api.scaleway.com/instance/v1/zones/pl-waw-3",
|
|
"api_endpoint_vpc": "https://api.scaleway.com/vpc/v1/zones/pl-waw-3",
|
|
},
|
|
}
|
|
|
|
SCALEWAY_ENDPOINT = "https://api.scaleway.com"
|
|
|
|
SCALEWAY_REGIONS = [
|
|
"fr-par",
|
|
"nl-ams",
|
|
"pl-waw",
|
|
]
|
|
|
|
SCALEWAY_ZONES = [
|
|
"fr-par-1",
|
|
"fr-par-2",
|
|
"fr-par-3",
|
|
"nl-ams-1",
|
|
"nl-ams-2",
|
|
"nl-ams-3",
|
|
"pl-waw-1",
|
|
"pl-waw-2",
|
|
"pl-waw-3",
|
|
]
|