diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index ae458fc5..00d9417a 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -17,6 +17,5 @@ jobs: collection_namespace: infra collection_name: ah_configuration collection_version: 1.1.1-devel - collection_repo: https://github.com/ansible/galaxy_collection - collection_dependencies: ansible.hub + collection_repo: https://github.com/redhat-cop/ah_configuration ... diff --git a/galaxy.yml b/galaxy.yml index bad3a3b7..1be1c512 100644 --- a/galaxy.yml +++ b/galaxy.yml @@ -3,9 +3,9 @@ authors: - Sean Sullivan @sean-m-sullivan - Tom Page @Tompage1994 - David Danielsson @djdanielsson -dependencies: {} # ansible.hub +dependencies: {} description: Ansible content that interacts with the Ansible Automation Hub or Galaxy NG API. -documentation: https://github.com/ansible/galaxy_collection/blob/devel/README.md +documentation: https://github.com/redhat-cop/ah_configuration/blob/master/README.md license: - GPL-3.0-only namespace: infra diff --git a/infra-ah_configuration-2.0.6.tar.gz b/infra-ah_configuration-2.0.6.tar.gz deleted file mode 100644 index 12140a11..00000000 Binary files a/infra-ah_configuration-2.0.6.tar.gz and /dev/null differ diff --git a/meta/runtime.yml b/meta/runtime.yml index e4a431ed..e7aeeb98 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -2,44 +2,6 @@ requires_ansible: '>=2.16.0' plugin_routing: modules: - ah_approval: - redirect: ansible.hub.ah_approval - ah_build: - redirect: ansible.hub.ah_build - ah_collection: - redirect: ansible.hub.ah_collection - ah_collection_upload: - redirect: ansible.hub.ah_collection_upload - ah_ee_image: - redirect: ansible.hub.ah_ee_image - ah_ee_registry: - redirect: ansible.hub.ah_ee_registry - ah_ee_registry_index: - redirect: ansible.hub.ah_ee_registry_index - ah_ee_registry_sync: - redirect: ansible.hub.ah_ee_registry_sync - ah_ee_repository: - redirect: ansible.hub.ah_ee_repository - ah_ee_repository_sync: - redirect: ansible.hub.ah_ee_repository_sync - ah_group: - redirect: ansible.hub.ah_group - ah_namespace: - redirect: ansible.hub.ah_namespace - ah_role: - redirect: ansible.hub.ah_role - ah_token: - redirect: ansible.hub.ah_token - ah_user: - redirect: ansible.hub.ah_user - collection_remote: - redirect: ansible.hub.collection_remote - collection_repository: - redirect: ansible.hub.collection_repository - collection_repository_sync: - redirect: ansible.hub.collection_repository_sync - group_roles: - redirect: ansible.hub.group_roles ah_ee_namespace: deprecation: removal_version: 3.0.0 diff --git a/plugins/module_utils/ah_api_module.py b/plugins/module_utils/ah_api_module.py index 4ce63739..a7a88816 100644 --- a/plugins/module_utils/ah_api_module.py +++ b/plugins/module_utils/ah_api_module.py @@ -11,18 +11,16 @@ __metaclass__ = type import base64 -import re -import socket import json +import os +import socket import time +from ansible.module_utils._text import to_bytes, to_text from ansible.module_utils.basic import AnsibleModule, env_fallback from ansible.module_utils.compat.version import LooseVersion as Version -from ansible.module_utils._text import to_bytes, to_text - -from ansible.module_utils.six.moves.urllib.parse import urlparse, urlencode from ansible.module_utils.six.moves.urllib.error import HTTPError - +from ansible.module_utils.six.moves.urllib.parse import urlencode, urlparse from ansible.module_utils.urls import Request, SSLValidationError @@ -107,8 +105,8 @@ def __init__(self, argument_spec, direct_params=None, **kwargs): setattr(self, short_param, direct_value) # Perform some basic validation - if not re.match("^https{0,1}://", self.host): - self.host = "https://{host}".format(host=self.host) + if not self.host.startswith(("https://", "http://")): + self.host = "https://{0}".format(self.host) # Try to parse the hostname as a url try: @@ -130,9 +128,10 @@ def __init__(self, argument_spec, direct_params=None, **kwargs): self.session = Request(validate_certs=self.verify_ssl, headers=self.headers, follow_redirects=True, timeout=self.request_timeout) # Define the API paths - self.galaxy_path_prefix = "/api/{prefix}".format(prefix=self.path_prefix.strip("/")) + self.galaxy_path_prefix = self.get_galaxy_path_prefix() self.ui_path_prefix = "{galaxy_prefix}/_ui/v1".format(galaxy_prefix=self.galaxy_path_prefix) self.plugin_path_prefix = "{galaxy_prefix}/v3/plugin".format(galaxy_prefix=self.galaxy_path_prefix) + self.ah_logout_path = os.getenv("AH_LOGOUT_PATH", None) self.authenticate() self.server_version = self.get_server_version() if self.server_version < "4.6": @@ -205,17 +204,17 @@ def make_request_raw_reponse(self, method, url, **kwargs): :type method: str :param url: URL to the API endpoint :type url: :py:class:``urllib.parse.ParseResult`` - :param kwargs: Additionnal parameter to pass to the API (headers, data + :param kwargs: Additional parameter to pass to the API (headers, data for PUT and POST requests, ...) :raises AHAPIModuleError: The API request failed. - :return: The reponse from the API call + :return: The response from the API call :rtype: :py:class:``http.client.HTTPResponse`` """ # In case someone is calling us directly; make sure we were given a method, let's not just assume a GET if not method: - raise Exception("The HTTP method must be defined") + raise AHAPIModuleError("The HTTP method must be defined") # Extract the provided headers and data headers = kwargs.get("headers", {}) @@ -237,22 +236,22 @@ def make_request_raw_reponse(self, method, url, **kwargs): "The host sent back a server error: {path}: {error}. Please check the logs and try again later".format(path=url.path, error=he) ) # Sanity check: Did we fail to authenticate properly? If so, fail out now; this is always a failure. - elif he.code == 401: + if he.code == 401: raise AHAPIModuleError("Invalid authentication credentials for {path} (HTTP 401).".format(path=url.path)) # Sanity check: Did we get a forbidden response, which means that the user isn't allowed to do this? Report that. - elif he.code == 403: + if he.code == 403: raise AHAPIModuleError("You do not have permission to {method} {path} (HTTP 403).".format(method=method, path=url.path)) # Sanity check: Did we get a 404 response? # Requests with primary keys will return a 404 if there is no response, and we want to consistently trap these. - elif he.code == 404: + if he.code == 404: raise AHAPIModuleError("The requested object could not be found at {path}.".format(path=url.path)) # Sanity check: Did we get a 405 response? # A 405 means we used a method that isn't allowed. Usually this is a bad request, but it requires special treatment because the # API sends it as a logic error in a few situations (e.g. trying to cancel a job that isn't running). - elif he.code == 405: + if he.code == 405: raise AHAPIModuleError("Cannot make a {method} request to this endpoint {path}".format(method=method, path=url.path)) # Sanity check: Did we get some other kind of error? If so, write an appropriate error message. - elif he.code >= 400: + if he.code >= 400: # We are going to return a 400 so the module can decide what to do with it page_data = he.read() try: @@ -279,12 +278,12 @@ def make_request(self, method, url, wait_for_task=True, **kwargs): :type method: str :param url: URL to the API endpoint :type url: :py:class:``urllib.parse.ParseResult`` - :param kwargs: Additionnal parameter to pass to the API (headers, data + :param kwargs: Additional parameter to pass to the API (headers, data for PUT and POST requests, ...) :raises AHAPIModuleError: The API request failed. - :return: A dictionnary with two entries: ``status_code`` provides the + :return: A dictionary with two entries: ``status_code`` provides the API call returned code and ``json`` provides the returned data in JSON format. :rtype: dict @@ -294,11 +293,14 @@ def make_request(self, method, url, wait_for_task=True, **kwargs): try: response_body = response.read() except Exception as e: - if response["json"]["non_field_errors"]: + if "non_field_errors" in response["json"]: raise AHAPIModuleError("Errors occurred with request (HTTP 400). Errors: {errors}".format(errors=response["json"]["non_field_errors"])) - elif response["json"]["errors"]: - raise AHAPIModuleError("Errors occurred with request (HTTP 400). Errors: {errors}".format(errors=response["json"]["errors"])) - elif response["text"]: + if "errors" in response["json"]: + def get_details(err): + return err["detail"] + raise AHAPIModuleError("Errors occurred with request (HTTP 400). Details: {errors}".format( + errors=", ".join(map(get_details, response["json"]["errors"])))) + if "text" in response: raise AHAPIModuleError("Errors occurred with request (HTTP 400). Errors: {errors}".format(errors=response["text"])) raise AHAPIModuleError("Failed to read response body: {error}".format(error=e)) @@ -364,7 +366,7 @@ def extract_error_msg(self, response): in JSON format. :type response: dict - :return: The error message or an empty string if the reponse does not + :return: The error message or an empty string if the response does not provide a message. :rtype: str """ @@ -381,71 +383,13 @@ def extract_error_msg(self, response): def authenticate(self): """Authenticate with the API.""" - # curl -k -i -X GET -H "Accept: application/json" -H "Content-Type: application/json" https://hub.lab.example.com/api/galaxy/_ui/v1/auth/login/ - - # HTTP/1.1 204 No Content - # Server: nginx/1.18.0 - # Date: Tue, 10 Aug 2021 07:33:37 GMT - # Content-Length: 0 - # Connection: keep-alive - # Vary: Accept, Cookie - # Allow: GET, POST, HEAD, OPTIONS - # X-Frame-Options: SAMEORIGIN - # Set-Cookie: csrftoken=jvdb...kKHo; expires=Tue, 09 Aug 2022 07:33:37 GMT; Max-Age=31449600; Path=/; SameSite=Lax - # Strict-Transport-Security: max-age=15768000 - - url = self.build_ui_url("auth/login") + # Use basic auth + test_url = self.build_ui_url("me") + basic_str = base64.b64encode("{0}:{1}".format(self.username, self.password).encode("ascii")) + header = {"Authorization": "Basic {0}".format(basic_str.decode("ascii"))} try: - response = self.make_request_raw_reponse("GET", url) - except AHAPIModuleError as e: - self.fail_json(msg="Authentication error: {error}".format(error=e)) - # Set-Cookie: csrftoken=jvdb...kKHo; expires=Tue, 09 Aug 2022 07:33:37 GMT - for h in response.getheaders(): - if h[0].lower() == "set-cookie": - k, v = h[1].split("=", 1) - if k.lower() == "csrftoken": - header = {"X-CSRFToken": v.split(";", 1)[0]} - break - else: - header = {} - - # curl -k -i -X POST -H 'referer: https://hub.lab.example.com' -H "Accept: application/json" -H "Content-Type: application/json" - # -H 'X-CSRFToken: jvdb...kKHo' --cookie 'csrftoken=jvdb...kKHo' -d '{"username":"admin","password":"redhat"}' - # https://hub.lab.example.com/api/galaxy/_ui/v1/auth/login/ - - # HTTP/1.1 204 No Content - # Server: nginx/1.18.0 - # Date: Tue, 10 Aug 2021 07:35:33 GMT - # Content-Length: 0 - # Connection: keep-alive - # Vary: Accept, Cookie - # Allow: GET, POST, HEAD, OPTIONS - # X-Frame-Options: SAMEORIGIN - # Set-Cookie: csrftoken=6DVP...at9a; expires=Tue, 09 Aug 2022 07:35:33 GMT; Max-Age=31449600; Path=/; SameSite=Lax - # Set-Cookie: sessionid=87b0iw12wyvy0353rk5fwci0loy5s615; expires=Tue, 24 Aug 2021 07:35:33 GMT; HttpOnly; Max-Age=1209600; Path=/; SameSite=Lax - # Strict-Transport-Security: max-age=15768000 - - try: - try: - response = self.make_request_raw_reponse( - "POST", - url, - data={"username": self.username, "password": self.password}, - headers=header, - ) - for h in response.getheaders(): - if h[0].lower() == "set-cookie": - k, v = h[1].split("=", 1) - if k.lower() == "csrftoken": - header = {"X-CSRFToken": v.split(";", 1)[0]} - self.headers.update(header) - break - except AHAPIModuleError: - test_url = self.build_ui_url("me") - basic_str = base64.b64encode("{0}:{1}".format(self.username, self.password).encode("ascii")) - header = {"Authorization": "Basic {0}".format(basic_str.decode("ascii"))} - response = self.make_request_raw_reponse("GET", test_url, headers=header) - self.headers.update(header) + self.make_request_raw_reponse("GET", test_url, headers=header) + self.headers.update(header) except AHAPIModuleError as e: self.fail_json(msg="Authentication error: {error}".format(error=e)) self.authenticated = True @@ -462,7 +406,10 @@ def logout(self): if not self.authenticated: return - url = self.build_ui_url("auth/logout") + if self.ah_logout_path: + url = self._build_url("/api", self.ah_logout_path.split("/api/")[1]) + else: + url = self.build_ui_url("auth/logout") try: self.make_request_raw_reponse("POST", url) except AHAPIModuleError: @@ -483,6 +430,34 @@ def exit_json(self, **kwargs): self.logout() super(AHAPIModule, self).exit_json(**kwargs) + def get_galaxy_path_prefix(self): + """Return the automation hub/galaxy path prefix + + :return: '/api/{prefix}' unless behind resource_server wherein the api path prefix may differ. + resource_server expected response structure: + { + "description": "", + "apis": { + "galaxy": "/api/galaxy/" + } + } + :rtype: String + """ + + url = self._build_url(prefix="api", endpoint=None, query_params=None) + + try: + response = self.make_request("GET", url) + # No exception this is behind rescource_provider + try: + rs_prefix = response["json"]["apis"]["galaxy"] + return rs_prefix.strip("/") + except KeyError as e: + self.fail_json(msg="Error while getting Galaxy api path prefix: {error}".format(error=e)) + except AHAPIModuleError: + # Indicates standalone galaxy + return "/api/{prefix}".format(prefix=self.path_prefix.strip("/")) + def get_server_version(self): """Return the automation hub/galaxy server version. diff --git a/plugins/module_utils/ah_module.py b/plugins/module_utils/ah_module.py index 6a73c344..0f477247 100644 --- a/plugins/module_utils/ah_module.py +++ b/plugins/module_utils/ah_module.py @@ -1,29 +1,29 @@ +# coding: utf-8 -*- + +# (c) 2020, Sean Sullivan <@sean-m-sullivan> +# 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 -from ansible.module_utils.basic import AnsibleModule, env_fallback -from ansible.module_utils.urls import ( - Request, - SSLValidationError, - ConnectionError, - fetch_file, -) -from ansible.module_utils.six import string_types -from ansible.module_utils.six import PY2, PY3 -from ansible.module_utils.six.moves.urllib.parse import urlparse, urlencode -from ansible.module_utils.six.moves.urllib.error import HTTPError -from ansible.module_utils.six.moves.http_cookiejar import CookieJar -from ansible.module_utils._text import to_bytes, to_native, to_text -import os.path -from socket import gethostbyname -import re -from json import loads, dumps import base64 +import email.mime.application +import email.mime.multipart import os +import os.path import time -import email.mime.multipart -import email.mime.application +from json import dumps, loads +from socket import gethostbyname + +from ansible.module_utils._text import to_bytes, to_native, to_text +from ansible.module_utils.basic import AnsibleModule, env_fallback +from ansible.module_utils.six import PY2, PY3, string_types +from ansible.module_utils.six.moves.http_cookiejar import CookieJar +from ansible.module_utils.six.moves.urllib.error import HTTPError +from ansible.module_utils.six.moves.urllib.parse import urlencode, urlparse +from ansible.module_utils.urls import (ConnectionError, Request, + SSLValidationError, fetch_file) class ItemNotDefined(Exception): @@ -75,7 +75,6 @@ class AHModule(AnsibleModule): "oauth_token": "ah_token", } IDENTITY_FIELDS = {} - ENCRYPTED_STRING = "$encrypted$" host = "127.0.0.1" path_prefix = "galaxy" username = None @@ -102,7 +101,7 @@ def __init__(self, argument_spec=None, direct_params=None, error_callback=None, if direct_params is not None: self.params = direct_params - # else: + super(AHModule, self).__init__(argument_spec=full_argspec, **kwargs) self.session = Request(cookies=CookieJar(), validate_certs=self.verify_ssl, timeout=self.request_timeout) @@ -127,7 +126,7 @@ def __init__(self, argument_spec=None, direct_params=None, error_callback=None, self.fail_json(msg=error_msg) # Perform some basic validation - if not re.match("^https{0,1}://", self.host): + if not self.host.startswith(("https://", "http://")): self.host = "https://{0}".format(self.host) # Try to parse the hostname as a url @@ -150,11 +149,16 @@ def __init__(self, argument_spec=None, direct_params=None, error_callback=None, def build_url(self, endpoint, query_params=None): # Make sure we start with /api/vX - if not endpoint.startswith("/"): + upload_endpoint = "content" in endpoint and "v3" in endpoint and "artifacts" in endpoint + if upload_endpoint and not endpoint.startswith("/api"): + endpoint = "/api/{0}".format(endpoint) + if not endpoint.startswith("/") and not upload_endpoint: endpoint = "/{0}".format(endpoint) - if not endpoint.startswith("/api/"): + if not endpoint.startswith("/api/") and not self.path_prefix.startswith("/api/") and not upload_endpoint: endpoint = "api/{0}/v3{1}".format(self.path_prefix, endpoint) - if not endpoint.endswith("/") and "?" not in endpoint: + if not endpoint.startswith("/api/") and self.path_prefix.startswith("/api/") and not upload_endpoint: + endpoint = "{0}/v3{1}".format(self.path_prefix, endpoint) + if not endpoint.endswith("/") and "?" not in endpoint and not upload_endpoint: endpoint = "{0}/".format(endpoint) # Update the URL path with the endpoint @@ -330,8 +334,7 @@ def get_one(self, endpoint, name_or_id=None, allow_none=True, **kwargs): if response["json"]["meta"]["count"] == 0: if allow_none: return None - else: - self.fail_wanted_one(response, endpoint, new_kwargs.get("data")) + self.fail_wanted_one(response, endpoint, new_kwargs.get("data")) elif response["json"]["meta"]["count"] > 1: if name_or_id: # Since we did a name or ID search and got > 1 return something if the id matches @@ -372,56 +375,19 @@ def get_only(self, endpoint, name_or_id=None, allow_none=True, key="url", **kwar def authenticate(self, **kwargs): if self.username and self.password: - # Attempt to get a token from /v3/auth/token/ by giving it our username/password combo - # If we have a username and password, we need to get a session cookie - api_token_url = self.build_url("auth/token").geturl() - try: - try: - response = self.session.open( - "POST", - api_token_url, - validate_certs=self.verify_ssl, - timeout=self.request_timeout, - follow_redirects=True, - force_basic_auth=True, - url_username=self.username, - url_password=self.password, - headers={"Content-Type": "application/json"}, - ) - except HTTPError: - test_url = self.build_url("namespaces").geturl() - self.basic_auth = True - basic_str = base64.b64encode("{0}:{1}".format(self.username, self.password).encode("ascii")) - response = self.session.open( - "GET", - test_url, - validate_certs=self.verify_ssl, - timeout=self.request_timeout, - headers={ - "Content-Type": "application/json", - "Authorization": "Basic {0}".format(basic_str.decode("ascii")), - }, - ) - except HTTPError as he: - try: - resp = he.read() - except Exception as e: - resp = "unknown {0}".format(e) - self.fail_json(msg="Failed to get token: {0}".format(he), response=resp) - except (Exception) as e: - # Sanity check: Did the server send back some kind of internal error? - self.fail_json(msg="Failed to get token: {0}".format(e)) - - token_response = None - if not self.basic_auth: - try: - token_response = response.read() - response_json = loads(token_response) - self.oauth_token = response_json["token"] - except (Exception) as e: - self.fail_json(msg="Failed to extract token information from login response: {0}".format(e), **{"response": token_response}) - - # If we have neither of these, then we can try un-authenticated access + test_url = self.build_url("namespaces").geturl() + self.basic_auth = True + basic_str = base64.b64encode("{0}:{1}".format(self.username, self.password).encode("ascii")) + self.session.open( + "GET", + test_url, + validate_certs=self.verify_ssl, + timeout=self.request_timeout, + headers={ + "Content-Type": "application/json", + "Authorization": "Basic {0}".format(basic_str.decode("ascii")), + }, + ) self.authenticated = True def existing_item_add_url(self, existing_item, endpoint, key="url"): @@ -436,8 +402,11 @@ def delete_if_needed(self, existing_item, on_delete=None, auto_exit=True): # the on_delete parameter will be called as a method pasing in this object and the json from the response # This will return one of two things: # 1. None if the existing_item is not defined (so no delete needs to happen) - # 2. The response from Automation Hub from calling the delete on the endpont. It's up to you to process the response and exit from the module + # 2. The response from Automation Hub from calling the delete on the endpoint. It's up to you to process the response and exit from the module # Note: common error codes from the Automation Hub API can cause the module to fail + item_id = "" + item_type = "" + item_name = "" if existing_item: if existing_item["type"] == "token": response = self.delete_endpoint(existing_item["endpoint"]) @@ -484,9 +453,8 @@ def delete_if_needed(self, existing_item, on_delete=None, auto_exit=True): self.fail_json(msg="Unable to delete {0} {1}: {2}".format(item_type, item_name, response["status_code"])) def get_item_name(self, item, allow_unknown=False): - if item: - if "name" in item: - return item["name"] + if item and "name" in item: + return item["name"] if allow_unknown: return "unknown" @@ -527,16 +495,15 @@ def create_or_update_if_needed( require_id=require_id, fixed_url=fixed_url, ) - else: - return self.create_if_needed( - existing_item, - new_item, - endpoint, - on_create=on_create, - item_type=item_type, - auto_exit=auto_exit, - associations=associations, - ) + return self.create_if_needed( + existing_item, + new_item, + endpoint, + on_create=on_create, + item_type=item_type, + auto_exit=auto_exit, + associations=associations, + ) def create_if_needed( self, @@ -554,7 +521,7 @@ def create_if_needed( # the on_create parameter will be called as a method pasing in this object and the json from the response # This will return one of two things: # 1. None if the existing_item is already defined (so no create needs to happen) - # 2. The response from Automation Hub from calling the patch on the endpont. It's up to you to process the response and exit from the module + # 2. The response from Automation Hub from calling the patch on the endpoint. It's up to you to process the response and exit from the module # Note: common error codes from the Automation Hub API can cause the module to fail if not endpoint: @@ -610,23 +577,65 @@ def create_if_needed( last_data = response["json"] return last_data - def approve(self, endpoint, timeout=None, interval=10.0, auto_exit=True): + def is_standalone(self): + try: + status_code = self.make_request("GET", "/api/")["status_code"] + if status_code == 200: + return False + except Exception: + # 404 error is standalone + return True - approvalEndpoint = "move/staging/published" + def approve(self, endpoint, namespace=None, name=None, version=None, timeout=None, interval=10.0, auto_exit=True): - if not endpoint: - self.fail_json(msg="Unable to approve due to missing endpoint") + if self.is_standalone(): + approval_endpoint = "move/staging/published" - response = self.post_endpoint("{0}/{1}".format(endpoint, approvalEndpoint), None, **{"return_none_on_404": True}) + if not endpoint: + self.fail_json(msg="Unable to approve due to missing endpoint") - i = 0 - while timeout is None or i < timeout: - if not response: - time.sleep(interval) - response = self.post_endpoint("{0}/{1}".format(endpoint, approvalEndpoint), None, **{"return_none_on_404": True}) - i += interval - else: - break + response = self.post_endpoint("{0}/{1}".format(endpoint, approval_endpoint), None, **{"return_none_on_404": True}) + + i = 0 + while timeout is None or i < timeout: + if not response: + time.sleep(interval) + response = self.post_endpoint("{0}/{1}".format(endpoint, approval_endpoint), None, **{"return_none_on_404": True}) + i += interval + else: + break + else: + cv_endpoint = "/api/galaxy/pulp/api/v3/content/ansible/collection_versions/" + cv = self.make_request("GET", cv_endpoint, data={"namespace": namespace, "name": name, "version": version})["json"] + i = 0 + # Wait for it... + while timeout is None or i < timeout: + if cv["count"] < 1: + time.sleep(interval) + cv = self.make_request("GET", cv_endpoint, data={"namespace": namespace, "name": name, "version": version})["json"] + i += interval + else: + # Legendary. + break + # Get collection version pulp_href + cv_href = cv["results"][0]["pulp_href"] + + # Get staging/published repo pulp_href + repos_endpoint = "/api/galaxy/pulp/api/v3/repositories/" + staging_href = self.make_request("GET", repos_endpoint, data={"name": "staging"})["json"]["results"][0]["pulp_href"] + published_href = self.make_request("GET", repos_endpoint, data={"name": "published"})["json"]["results"][0]["pulp_href"] + + # Approve the collection + move_endpoint = staging_href + "move_collection_version/" + data = { + "collection_versions": [cv_href], + "destination_repositories": [published_href], + } + response = self.make_request( + "POST", + move_endpoint, + data=data + ) if response and response["status_code"] in [202]: self.json_output["changed"] = True @@ -651,7 +660,7 @@ def prepare_multipart(self, filename): mime = "application/x-gzip" m = email.mime.multipart.MIMEMultipart("form-data") - main_type, sep, sub_type = mime.partition("/") + main_type, dummy, sub_type = mime.partition("/") with open(to_bytes(filename, errors="surrogate_or_strict"), "rb") as f: part = email.mime.application.MIMEApplication(f.read()) @@ -687,7 +696,7 @@ def prepare_multipart(self, filename): b_data = email.utils.fix_eols(fp.getvalue()) del m - headers, sep, b_content = b_data.partition(b"\r\n\r\n") + headers, dummy, b_content = b_data.partition(b"\r\n\r\n") del b_data if PY3: @@ -721,21 +730,20 @@ def wait_for_complete(self, task_url): self.fail_json(msg="Upload of collection failed: {0}".format(response["json"]["error"]["description"])) else: time.sleep(1) - return - def upload(self, path, endpoint, wait=True, item_type="unknown"): + def upload(self, path, endpoint, wait=True, repository="staging", item_type="unknown"): if "://" in path: tmppath = fetch_file(self, path) - path = ".".join(tmppath.split(".")[:-2]) + ".tar.gz" + path = path.split("/")[-1] os.rename(tmppath, path) self.add_cleanup_file(path) - ct, body = self.prepare_multipart(path) + content_type, body = self.prepare_multipart(path) response = self.make_request( "POST", - endpoint, + "{0}/content/{1}/v3/{2}/".format(self.path_prefix, repository, endpoint), **{ "data": body, - "headers": {"Content-Type": str(ct)}, + "headers": {"Content-Type": str(content_type)}, "binary": True, "return_errors_on_404": True, } @@ -746,15 +754,14 @@ def upload(self, path, endpoint, wait=True, item_type="unknown"): if wait: self.wait_for_complete(response["json"]["task"]) return + if "json" in response and "__all__" in response["json"]: + self.fail_json(msg="Unable to create {0} from {1}: {2}".format(item_type, path, response["json"]["__all__"][0])) + elif "json" in response and "errors" in response["json"] and "detail" in response["json"]["errors"][0]: + self.fail_json(msg="Unable to create {0} from {1}: {2}".format(item_type, path, response["json"]["errors"][0]["detail"])) + elif "json" in response: + self.fail_json(msg="Unable to create {0} from {1}: {2}".format(item_type, path, response["json"])) else: - if "json" in response and "__all__" in response["json"]: - self.fail_json(msg="Unable to create {0} from {1}: {2}".format(item_type, path, response["json"]["__all__"][0])) - elif "json" in response and "errors" in response["json"] and "detail" in response["json"]["errors"][0]: - self.fail_json(msg="Unable to create {0} from {1}: {2}".format(item_type, path, response["json"]["errors"][0]["detail"])) - elif "json" in response: - self.fail_json(msg="Unable to create {0} from {1}: {2}".format(item_type, path, response["json"])) - else: - self.fail_json(msg="Unable to create {0} from {1}: {2}".format(item_type, path, response["status_code"])) + self.fail_json(msg="Unable to create {0} from {1}: {2}".format(item_type, path, response["status_code"])) def update_if_needed( self, @@ -923,6 +930,25 @@ def get_exactly_one(self, endpoint, name_or_id=None, **kwargs): def resolve_name_to_id(self, endpoint, name_or_id): return self.get_exactly_one(endpoint, name_or_id)["id"] + @staticmethod + def fields_could_be_same(old_field, new_field): + """Treating $encrypted$ as a wild card, + return False if the two values are KNOWN to be different + return True if the two values are the same, or could potentially be the same, + depending on the unknown $encrypted$ value or sub-values + """ + if isinstance(old_field, dict) and isinstance(new_field, dict): + if set(old_field.keys()) != set(new_field.keys()): + return False + for key in new_field.keys(): + if not AHModule.fields_could_be_same(old_field[key], new_field[key]): + return False + return True # all sub-fields are either equal or could be equal + else: + if old_field == AHModule.ENCRYPTED_STRING: + return True + return bool(new_field == old_field) + def objects_could_be_different(self, old, new, field_set=None, warning=False): if field_set is None: field_set = set(fd for fd in new.keys() if fd not in ("modified", "related", "summary_fields")) diff --git a/plugins/module_utils/ah_pulp_object.py b/plugins/module_utils/ah_pulp_object.py new file mode 100644 index 00000000..12040364 --- /dev/null +++ b/plugins/module_utils/ah_pulp_object.py @@ -0,0 +1,1344 @@ +# Copyright: (c) 2021, Herve Quatremain +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# You can consult the UI API documentation directly on a running private +# automation hub at https://hub.example.com/pulp/api/v3/docs/ +# +# Ansible Automation Hub UI project at https://github.com/ansible/ansible-hub-ui + +from __future__ import absolute_import, division, print_function +import time + +from .ah_api_module import AHAPIModuleError + +__metaclass__ = type + + +class AHPulpObject(object): + """Manage Pulp objects through the API. + + Create a subclass of :py:class:``AHPulpObject`` to represent a specific type + of object (namespace, ...) + + :param API_object: Module object to use to access the private automation hub API. + :type API_object: :py:class:``AHAPIModule`` + :param data: Initial data + :type data: dict + """ + + def __init__(self, API_object, data=None): + """Initialize the module.""" + # The API endpoint is the last component of the URL and allows access + # to the object API: + # https://hub.example.com/pulp/api/v3// + # Example: + # https://hub.example.com/pulp/api/v3/pulp_container/namespaces/ (endpoint=pulp_container/namespaces) + self.endpoint = None + + # Type of the objects managed by the class. This is only used in the + # messages returned to the user. + self.object_type = "object" + + # In the JSON API response message, self.name_field is the name of the + # attribute that stores the object name. This is also used as the query + # parameter in GET requests (https://..../pulp_container/namespaces/?name=aap20) + self.name_field = "name" + + # JSON data returned by the last API call. This typically stores the + # object details: + # { + # "name": "AAP20", + # "pulp_created": "2021-08-16T09:22:40.249903Z", + # "pulp_href": "/pulp/api/v3/pulp_container/namespaces/017bc08f-99f7-4fc6-859c-3cff0713e39b/" + # } + self.data = data if data else {} + + # Is the class instance has been initialized with a valid object? + self.exists = True if data else False + + # The AHAPIModule class object that is used to access the API + self.api = API_object + + @property + def href(self): + """Return the object URL path.""" + if "pulp_href" in self.data: + return self.data["pulp_href"] + return None + + @property + def name(self): + """Return the object name.""" + if self.name_field in self.data: + return self.data[self.name_field] + return None + + def get_object(self, name): + """Retrieve a single object from a GET API call. + + Upon completion, :py:attr:``self.exists`` is set to ``True`` if the + object exists or ``False`` if not. + :py:attr:``self.data`` contains the retrieved object (or ``{}`` if + the requested object does not exist) + + :param name: Name of the object to retrieve. + :type name: str + """ + query = {self.name_field: name} + url = self.api.build_pulp_url(self.endpoint, query_params=query) + try: + response = self.api.make_request("GET", url) + except AHAPIModuleError as e: + self.api.fail_json(msg="GET error: {error}".format(error=e)) + + if response["status_code"] != 200: + error_msg = self.api.extract_error_msg(response) + if error_msg: + fail_msg = "Unable to get {object_type} {name}: {code}: {error}".format( + object_type=self.object_type, + name=name, + code=response["status_code"], + error=error_msg, + ) + else: + fail_msg = "Unable to get {object_type} {name}: {code}".format( + object_type=self.object_type, + name=name, + code=response["status_code"], + ) + self.api.fail_json(msg=fail_msg) + + if "count" not in response["json"] or "results" not in response["json"]: + self.api.fail_json( + msg="Unable to get {object_type} {name}: the endpoint did not provide count and results".format(object_type=self.object_type, name=name) + ) + + if response["json"]["count"] == 0: + self.data = {} + self.exists = False + return + + if response["json"]["count"] > 1: + # Only one object should be returned. If more that one is returned, + # then look for the requested name in the returned list. + for asset in response["json"]["results"]: + if self.name_field in asset and asset[self.name_field] == name: + self.data = asset + self.exists = True + return + self.data = {} + self.exists = False + return + + self.data = response["json"]["results"][0] + # Make sure the object name is available in the response + if self.name_field not in self.data: + self.data[self.name_field] = name + self.exists = True + + def delete(self, auto_exit=True, exit_on_error=True): + """Perform a DELETE API call to delete the object. + + :param auto_exit: Exit the module when the API call is done. + :type auto_exit: bool + :param exit_on_error: If ``True`` (the default), exit the module on API + error. Otherwise, raise the + :py:class:``AHAPIModuleError`` exception. + :type exit_on_error: bool + + :raises AHAPIModuleError: An API error occurred. That exception is only + raised when ``exit_on_error`` is ``False``. + + :return: Do not return if ``auto_exit`` is ``True``. Otherwise, return + ``True`` if object has been deleted (change state) or ``False`` + if the object do not need updating (already removed). + :rtype: bool + """ + if not self.exists: + if auto_exit: + self.api.exit_json(changed=False) + return False + + if self.api.check_mode: + if auto_exit: + self.api.exit_json(changed=True) + self.exists = False + self.data = {} + return True + + url = self.api.host_url._replace(path=self.href) + try: + response = self.api.make_request("DELETE", url) + except AHAPIModuleError as e: + if exit_on_error: + self.api.fail_json(msg="Delete error: {error}".format(error=e)) + else: + raise + + if response["status_code"] in [202, 204]: + + self.api.json_output = { + "name": self.name, + "href": self.href, + "type": self.object_type, + "changed": True, + } + if auto_exit: + self.api.exit_json(**self.api.json_output) + self.exists = False + self.data = {} + return True + + error_msg = self.api.extract_error_msg(response) + if error_msg: + fail_msg = "Unable to delete {object_type} {name}: {error}".format(object_type=self.object_type, name=self.name, error=error_msg) + else: + fail_msg = "Unable to delete {object_type} {name}: {code}".format( + object_type=self.object_type, + name=self.name, + code=response["status_code"], + ) + if exit_on_error: + self.api.fail_json(msg=fail_msg) + else: + raise AHAPIModuleError(fail_msg) + + def create(self, new_item, auto_exit=True): + """Perform an POST API call to create a new object. + + :param new_item: The data to pass to the API call. This provides the + object details ({"name": "test123"} for example) + :type new_item: dict + :param auto_exit: Exit the module when the API call is done. + :type auto_exit: bool + + :return: Do not return if ``auto_exit`` is ``True``. Otherwise, return + ``True``. + :rtype: bool + """ + if self.api.check_mode: + self.data.update(new_item) + self.api.json_output = { + "name": self.name, + "type": self.object_type, + "changed": True, + } + self.api.json_output.update(self.data) + if auto_exit: + self.api.exit_json(**self.api.json_output) + return True + + url = self.api.build_pulp_url(self.endpoint) + try: + response = self.api.make_request("POST", url, data=new_item) + except AHAPIModuleError as e: + self.api.fail_json(msg="Create error: {error}, url: {url}".format(error=e, url=url.geturl())) + + if response["status_code"] in [200, 201, 202]: + self.exists = True + self.data = response["json"] + # Make sure the object name is available in the response + if self.name_field not in self.data: + self.data[self.name_field] = new_item[self.name_field] + self.api.json_output = { + "name": self.name, + "href": self.href, + "type": self.object_type, + "changed": True, + } + self.api.json_output.update(self.data) + if auto_exit: + self.api.exit_json(**self.api.json_output) + return True + + error_msg = self.api.extract_error_msg(response) + if error_msg: + self.api.fail_json(msg="Unable to create {object_type} {name}: {error}".format(object_type=self.object_type, name=self.name, error=error_msg)) + self.api.fail_json( + msg="Unable to create {object_type} {name}: {code}".format( + object_type=self.object_type, + name=self.name, + code=response["status_code"], + ) + ) + + def object_are_different(self, old, new): + for k in new.keys(): + if k not in old: + return True + new_item = new[k] + old_item = old[k] + if type(new_item) is not type(old_item) or new_item != old_item: + return True + return False + + def update(self, new_item, auto_exit=True): + """Update the existing object in private automation hub. + + :param new_item: The data to pass to the API call. This provides the + object details ({"description": "test"} for example) + :type new_item: dict + :param auto_exit: Exit the module when the API call is done. + :type auto_exit: bool + + :return: Do not return if ``auto_exit`` is ``True``. Otherwise, return + ``True`` if object has been updated (change state) or ``False`` + if the object do not need updating. + :rtype: bool + """ + # The "key" field ("name", "username", ...) is required for PUT + # requests. Making sure that it is present. + if self.name_field not in new_item: + new_item[self.name_field] = self.name + + # Check to see if anything within the item requires the item to be + # updated. + needs_patch = self.object_are_different(self.data, new_item) + + if not needs_patch: + self.api.json_output = { + "name": self.name, + "href": self.href, + "type": self.object_type, + "changed": False, + } + self.api.json_output.update(self.data) + if auto_exit: + self.api.exit_json(**self.api.json_output) + return False + + if self.api.check_mode: + self.data.update(new_item) + self.api.json_output = { + "name": self.name, + "href": self.href, + "type": self.object_type, + "changed": True, + } + self.api.json_output.update(self.data) + if auto_exit: + self.api.exit_json(**self.api.json_output) + return True + + url = self.api.host_url._replace(path=self.href) + try: + response = self.api.make_request("PUT", url, data=new_item) + except AHAPIModuleError as e: + self.api.fail_json(msg="Update error: {error}".format(error=e)) + + if response["status_code"] in [200, 202, 204]: + self.exists = True + self.data.update(new_item) + self.api.json_output = { + "name": self.name, + "href": self.href, + "type": self.object_type, + "changed": True, + } + self.api.json_output.update(self.data) + if auto_exit: + self.api.exit_json(**self.api.json_output) + return True + + error_msg = self.api.extract_error_msg(response) + if error_msg: + self.api.fail_json(msg="Unable to update {object_type} {name}: {error}".format(object_type=self.object_type, name=self.name, error=error_msg)) + self.api.fail_json( + msg="Unable to update {object_type} {name}: {code}".format( + object_type=self.object_type, + name=self.name, + code=response["status_code"], + ) + ) + + def create_or_update(self, new_item, auto_exit=True): + """Create or update the current object in private automation hub. + + :param new_item: The data to pass to the API call. This provides the + object details ({"username": "jdoe", ...} for example) + :type new_item: dict + :param auto_exit: Exit the module when the API call is done. + :type auto_exit: bool + + :return: Do not return if ``auto_exit`` is ``True``. Otherwise, return + ``True`` if object has been updated (change state) or ``False`` + if the object do not need updating. + :rtype: bool + """ + if self.exists: + return self.update(new_item, auto_exit) + return self.create(new_item, auto_exit) + + +class AHPulpRolePerm(AHPulpObject): + """Manage the roles that contain permissions with the Pulp API. + + The :py:class:``AHPulpRolePerm`` creates and deletes namespaces. + + Getting the details of a role: + ``GET /pulp/api/v3/roles/?name=`` :: + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "pulp_href": "/api/galaxy/pulp/api/v3/roles/2b43c4c2-a9ef-4828-8653-c3bb69a49709/", + "pulp_created": "2022-12-28T21:13:53.428816Z", + "name": "galaxy.stuff.mcsutffins", + "description": null, + "permissions": [ + "galaxy.add_containerregistryremote" + ], + "locked": false + } + ] + } + + Create a namespace: + ``POST /api/galaxy/pulp/api/v3/roles/`` + Update a namespace: + ``POST/PATCH /api/galaxy/pulp/api/v3/roles/dd54d0df-cd88-420b-922a-43a0725a20fc/`` + Delete a namespace: + ``DELETE /api/galaxy/pulp/api/v3/roles/dd54d0df-cd88-420b-922a-43a0725a20fc/`` + """ + + def __init__(self, API_object, data=None): + """Initialize the object.""" + super(AHPulpRolePerm, self).__init__(API_object, data) + self.endpoint = "roles" + self.object_type = "role" + self.name_field = "name" + self.perms = [] + + +class AHPulpEENamespace(AHPulpObject): + """Manage the execution environment namespace with the Pulp API. + + The :py:class:``AHPulpEENamespace`` creates and deletes namespaces. + See :py:class:``AHUIEENamespace`` to manage groups and permissions. + + Getting the details of a namespace: + ``GET /pulp/api/v3/pulp_container/namespaces/?name=`` :: + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "pulp_href": "/pulp/api/v3/pulp_container/namespaces/dd54d0df-cd88-420b-922a-43a0725a20fc/", + "pulp_created": "2021-08-17T14:19:29.217506Z", + "name": "namespace1" + } + ] + } + + Create a namespace: + ``POST /pulp/api/v3/pulp_container/namespaces/`` + + Delete a namespace: + ``DELETE /pulp/api/v3/pulp_container/namespaces/dd54d0df-cd88-420b-922a-43a0725a20fc/`` + """ + + def __init__(self, API_object, data=None): + """Initialize the object.""" + super(AHPulpEENamespace, self).__init__(API_object, data) + self.endpoint = "pulp_container/namespaces" + self.object_type = "namespace" + self.name_field = "name" + + +class AHPulpEERemote(AHPulpObject): + """Manage the execution environment repository with the Pulp API. + + A repository (or container for Pulp) represents a container image and is + stored inside a namespace. + + The :py:class:``AHPulpEERemote`` creates, deletes, and updates remotes. + Creating the repository with this class breaks the web UI, therefore we should + use the remote_ui functionality to create. The vision is that this class will mostly be used to rename a remote. + + Getting the details of a remote: + ``GET /pulp/api/v3/remotes/container/container/?name=`` :: + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "name": "ansible-automation-platform-20-early-access/ee-minimal-rhel8", + "base_path": "ansible-automation-platform-20-early-access/ee-minimal-rhel8", + "pulp_created": "2021-08-17T08:22:24.338660Z", + "pulp_href": "/pulp/api/v3/distributions/container/container/d610ec76-ec86-427e-89d4-4d28c37515e1/", + "pulp_labels": {}, + "content_guard": "/pulp/api/v3/contentguards/container/content_redirect/2406a920-5821-432c-9c86-3ed36f2c87ef/", + "repository_version": null, + "repository": "/pulp/api/v3/repositories/container/container-push/7f926cb2-1cc7-4043-b0f2-da6c5cd7caa0/", + "registry_path": "hub.lab.example.com/ansible-automation-platform-20-early-access/ee-minimal-rhel8", + "namespace": "/pulp/api/v3/pulp_container/namespaces/88c3275f-72be-405d-83e2-d4a49cb444d9/", + "private": false, + "description": null + } + ] + } + + Delete a remote: + ``DELETE /pulp/api/v3/remotes/container/container/d610ec76-ec86-427e-89d4-4d28c37515e1/`` + """ + + def __init__(self, API_object, data=None): + """Initialize the object.""" + super(AHPulpEERemote, self).__init__(API_object, data) + self.endpoint = "remotes/container/container" + self.object_type = "remote" + self.name_field = "name" + + +class AHPulpEERepository(AHPulpObject): + """Manage the execution environment repository with the Pulp API. + + A repository (or container for Pulp) represents a container image and is + stored inside a namespace. + + The :py:class:``AHPulpEENamespace`` creates, deletes, and set a description + to repositories. + Although the class can be used to create a repository, the recommended + method is to push an image, with C(podman push) for example. + Creating the repository with this class breaks the web UI, therefore the + module does not provide that functionality. + + See :py:class:``AHUIEERepository`` to manage the README file associated with the repository. + + Getting the details of a repository: + ``GET /pulp/api/v3/distributions/container/container/?name=`` :: + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "name": "ansible-automation-platform-20-early-access/ee-minimal-rhel8", + "base_path": "ansible-automation-platform-20-early-access/ee-minimal-rhel8", + "pulp_created": "2021-08-17T08:22:24.338660Z", + "pulp_href": "/pulp/api/v3/distributions/container/container/d610ec76-ec86-427e-89d4-4d28c37515e1/", + "pulp_labels": {}, + "content_guard": "/pulp/api/v3/contentguards/container/content_redirect/2406a920-5821-432c-9c86-3ed36f2c87ef/", + "repository_version": null, + "repository": "/pulp/api/v3/repositories/container/container-push/7f926cb2-1cc7-4043-b0f2-da6c5cd7caa0/", + "registry_path": "hub.lab.example.com/ansible-automation-platform-20-early-access/ee-minimal-rhel8", + "namespace": "/pulp/api/v3/pulp_container/namespaces/88c3275f-72be-405d-83e2-d4a49cb444d9/", + "private": false, + "description": null + } + ] + } + + Delete a repository: + ``DELETE /pulp/api/v3/distributions/container/container/d610ec76-ec86-427e-89d4-4d28c37515e1/`` + """ + + def __init__(self, API_object, data=None): + """Initialize the object.""" + super(AHPulpEERepository, self).__init__(API_object, data) + self.endpoint = "distributions/container/container" + self.object_type = "repository" + self.name_field = "name" + + @property + def repository_endpoint(self): + """Return the repository endpoint.""" + if self.exists and "repository" in self.data: + return self.data["repository"] + return "" + + @classmethod + def get_repositories_in_namespace(cls, API_object, namespace_name, exit_on_error=True): + """Return all the repositories in a namespace. + + :param API_object: A :py:class:``ah_api_module.AHAPIModule`` object. + :type API_object: :py:class:``ah_api_module.AHAPIModule`` + :param namespace_name: The name of the namespace to search. + :param exit_on_error: If ``True`` (the default), exit the module on API + error. Otherwise, raise the + :py:class:``AHAPIModuleError`` exception. + :type exit_on_error: bool + + :raises AHAPIModuleError: An API error occurred. That exception is only + raised when ``exit_on_error`` is ``False``. + + :return: A list of :py:class:``AHPulpEERepository`` objects. + """ + tmp_obj = cls(API_object) + + query = {"namespace__name": namespace_name} + url = tmp_obj.api.build_pulp_url(tmp_obj.endpoint, query_params=query) + try: + response = tmp_obj.api.make_request("GET", url) + except AHAPIModuleError as e: + if exit_on_error: + tmp_obj.api.fail_json(msg="GET error: {error}".format(error=e)) + else: + raise + + if response["status_code"] != 200: + error_msg = tmp_obj.api.extract_error_msg(response) + if error_msg: + fail_msg = "Unable to get repositories in namespace {name}: {code}: {error}".format( + name=namespace_name, code=response["status_code"], error=error_msg + ) + else: + fail_msg = "Unable to get repositories in namespace {name}: {code}".format(name=namespace_name, code=response["status_code"]) + if exit_on_error: + tmp_obj.api.fail_json(msg=fail_msg) + else: + raise AHAPIModuleError(fail_msg) + + if "count" not in response["json"] or "results" not in response["json"]: + fail_msg = "Unable to get repositories in namespace {name}: the endpoint did not provide count and results".format(name=namespace_name) + if exit_on_error: + tmp_obj.api.fail_json(msg=fail_msg) + else: + raise AHAPIModuleError(fail_msg) + + repo_list = [] + for repo in response["json"]["results"]: + repo_list.append(cls(API_object, repo)) + return repo_list + + def delete_image(self, digest, auto_exit=True): + """Perform a POST API call to delete the image with the given digest. + + :param digest: Digest (SHA256) of the image to delete. + :type digest: str + :param auto_exit: Exit the module when the API call is done. + :type auto_exit: bool + """ + if not self.exists: + if auto_exit: + self.api.json_output = {"digest": digest, "type": "image", "changed": False} + self.api.exit_json(changed=False) + return + + if self.api.check_mode: + self.api.json_output = { + "name": self.name, + "digest": digest, + "type": "image", + "changed": True, + } + if auto_exit: + self.api.exit_json(**self.api.json_output) + return + + url = self.api.host_url._replace(path="{endpoint}remove_image/".format(endpoint=self.repository_endpoint)) + try: + response = self.api.make_request("POST", url, data={"digest": digest}) + except AHAPIModuleError as e: + self.api.fail_json(msg="Delete error: {error}".format(error=e)) + + if response["status_code"] in [202, 204]: + + self.api.json_output = { + "name": self.name, + "digest": digest, + "type": "image", + "changed": True, + } + if auto_exit: + self.api.exit_json(**self.api.json_output) + return + + error_msg = self.api.extract_error_msg(response) + if error_msg: + self.api.fail_json( + msg="Unable to delete image from {object_type} {name}: {digest}: {error}".format( + object_type=self.object_type, + name=self.name, + digest=digest, + error=error_msg, + ) + ) + self.api.fail_json( + msg="Unable to delete image from {object_type} {name}: {digest}: {code}".format( + object_type=self.object_type, + name=self.name, + digest=digest, + code=response["status_code"], + ) + ) + + def delete_tag(self, digest, tag, auto_exit=True): + """Perform a POST API call to delete the tag for the image with the given digest. + + :param digest: Digest (SHA256) of the image to update. + :type digest: str + :param tag: Tag to remove from the image. + :type tag: str + :param auto_exit: Exit the module when the API call is done. + :type auto_exit: bool + + :return: Do not return if ``auto_exit`` is ``True``. Otherwise, return + ``True`` if object has been updated (change state) or ``False`` + if the object do not need updating. + """ + if not self.exists: + self.api.json_output = { + "digest": digest, + "tag": tag, + "type": "image", + "changed": False, + } + if auto_exit: + self.api.exit_json(**self.api.json_output) + return False + + if self.api.check_mode: + + self.api.json_output = { + "name": self.name, + "digest": digest, + "tag": tag, + "type": "image", + "changed": True, + } + if auto_exit: + self.api.exit_json(**self.api.json_output) + return True + + url = self.api.host_url._replace(path="{endpoint}untag/".format(endpoint=self.repository_endpoint)) + try: + response = self.api.make_request("POST", url, data={"tag": tag}) + except AHAPIModuleError as e: + self.api.fail_json(msg="Untag error: {error}".format(error=e)) + + if response["status_code"] in [202, 204]: + self.api.json_output = { + "name": self.name, + "digest": digest, + "tag": tag, + "type": "image", + "changed": True, + } + if auto_exit: + self.api.exit_json(**self.api.json_output) + return True + + if response["status_code"] >= 400: + self.api.json_output = { + "name": self.name, + "digest": digest, + "tag": tag, + "type": "image", + "changed": False, + } + if auto_exit: + self.api.exit_json(**self.api.json_output) + return False + + error_msg = self.api.extract_error_msg(response) + if error_msg: + self.api.fail_json( + msg="Unable to delete image tag {tag} from {object_type} {name}: {digest}: {error}".format( + tag=tag, + object_type=self.object_type, + name=self.name, + digest=digest, + error=error_msg, + ) + ) + self.api.fail_json( + msg="Unable to delete image tag {tag} from {object_type} {name}: {digest}: {code}".format( + tag=tag, + object_type=self.object_type, + name=self.name, + digest=digest, + code=response["status_code"], + ) + ) + + def create_tag(self, digest, tag, auto_exit=True): + """Perform a POST API call to add a tag to the image with the given digest. + + :param digest: Digest (SHA256) of the image to update. + :type digest: str + :param tag: Tag to add to the image. + :type tag: str + :param auto_exit: Exit the module when the API call is done. + :type auto_exit: bool + + :return: Do not return if ``auto_exit`` is ``True``. Otherwise, return + ``True`` if object has been updated (change state) or ``False`` + if the object do not need updating. + """ + if not self.exists: + self.api.json_output = { + "digest": digest, + "tag": tag, + "type": "image", + "changed": False, + } + if auto_exit: + self.api.exit_json(**self.api.json_output) + return False + + if self.api.check_mode: + self.api.json_output = { + "name": self.name, + "digest": digest, + "tag": tag, + "type": "image", + "changed": True, + } + if auto_exit: + self.api.exit_json(**self.api.json_output) + return True + + url = self.api.host_url._replace(path="{endpoint}tag/".format(endpoint=self.repository_endpoint)) + try: + response = self.api.make_request("POST", url, data={"digest": digest, "tag": tag}) + except AHAPIModuleError as e: + self.api.fail_json(msg="Tag error: {error}".format(error=e)) + + if response["status_code"] in [202, 204]: + self.api.json_output = { + "name": self.name, + "digest": digest, + "tag": tag, + "type": "image", + "changed": True, + } + if auto_exit: + self.api.exit_json(**self.api.json_output) + return True + + error_msg = self.api.extract_error_msg(response) + if error_msg: + self.api.fail_json( + msg="Unable to add image tag {tag} to {object_type} {name}: {digest}: {error}".format( + tag=tag, + object_type=self.object_type, + name=self.name, + digest=digest, + error=error_msg, + ) + ) + self.api.fail_json( + msg="Unable to add image tag {tag} to {object_type} {name}: {digest}: {code}".format( + tag=tag, + object_type=self.object_type, + name=self.name, + digest=digest, + code=response["status_code"], + ) + ) + + +class AHPulpTask(AHPulpObject): + """Manage a task with the Pulp API. + + The :py:class:``AHPulpTask`` get tasks. + + Getting the details of a namespace: + ``GET /pulp/api/v3/tasks/`` :: + { + "pulp_href": "/pulp/api/v3/tasks/947b4b75-c4ee-46e7-a1b4-a39f9b8968eb/", + "pulp_created": "2022-04-08T12:55:06.523919Z", + "state": "completed", + "name": "galaxy_ng.app.tasks.registry_sync.sync_all_repos_in_registry", + "logging_cid": "", + "started_at": "2022-04-08T12:55:06.571182Z", + "finished_at": "2022-04-08T12:55:07.332468Z", + "error": null, + "worker": "/pulp/api/v3/workers/c03535f2-6b6b-4918-ba24-dd28e1f12a90/", + "parent_task": null, + "child_tasks": [ + "/pulp/api/v3/tasks/49c3c7d8-6343-4f6b-a870-d1746918c2e3/", + "/pulp/api/v3/tasks/551afc88-99e8-4590-a54c-3274c3295261/", + "/pulp/api/v3/tasks/2d28ab54-615f-4eb1-a8e8-87defe4e8e9c/", + "/pulp/api/v3/tasks/e19cab33-3434-4288-8c08-96fabc928a0a/", + "/pulp/api/v3/tasks/75467e10-e5e0-452e-82f1-96df3e6aa3fc/", + "/pulp/api/v3/tasks/25641ab9-4404-4ceb-aacd-8de408a5ea87/" + ], + "task_group": null, + "progress_reports": [], + "created_resources": [], + "reserved_resources_record": [] + } + + """ + + def __init__(self, API_object, data=None): + """Initialize the object.""" + super(AHPulpTask, self).__init__(API_object, data) + self.endpoint = "tasks" + self.object_type = "task" + self.name_field = "name" + + def get_object(self, name): + url = self.api.build_pulp_url("{endpoint}/{task_id}".format(endpoint=self.endpoint, task_id=name.split("/")[-2])) + try: + response = self.api.make_request("GET", url) + except AHAPIModuleError as e: + self.api.fail_json(msg="GET error: {error}".format(error=e)) + + if response["status_code"] != 200: + error_msg = self.api.extract_error_msg(response) + if error_msg: + fail_msg = "Unable to get {object_type} {name}: {code}: {error}".format( + object_type=self.object_type, + name=self.href, + code=response["status_code"], + error=error_msg, + ) + else: + fail_msg = "Unable to get {object_type} {name}: {code}".format( + object_type=self.object_type, + name=self.href, + code=response["status_code"], + ) + self.api.fail_json(msg=fail_msg) + + self.data = response["json"] + self.exists = True + + def get_children(self, parent_task): + """Retrieve a single object from a GET API call. + + Upon completion, :py:attr:``self.exists`` is set to ``True`` if the + object exists or ``False`` if not. + :py:attr:``self.data`` contains the retrieved object (or ``{}`` if + the requested object does not exist) + + :param parent_task: ID of the parent task + :type parent_task: str + """ + query = {"parent_task": parent_task} + url = self.api.build_pulp_url(self.endpoint, query_params=query) + try: + response = self.api.make_request("GET", url) + except AHAPIModuleError as e: + self.api.fail_json(msg="GET error: {error}".format(error=e)) + + if response["status_code"] != 200: + error_msg = self.api.extract_error_msg(response) + if error_msg: + fail_msg = "Unable to get {object_type} {parent_task}: {code}: {error}".format( + object_type=self.object_type, + parent_task=parent_task, + code=response["status_code"], + error=error_msg, + ) + else: + fail_msg = "Unable to get {object_type} {pt}: {code}".format( + object_type=self.object_type, + pt=parent_task, + code=response["status_code"], + ) + self.api.fail_json(msg=fail_msg) + + return response["json"]["results"] + + def wait_for_children(self, parent_task, interval, timeout, task_status="Started"): + start = time.time() + elapsed = 0 + while task_status not in ["Complete", "Failed"]: + children = self.get_children(parent_task) + complete = True + for child_task in children: + if child_task["error"]: + task_status = "Complete" + error_output = child_task["error"]["description"].split(",") + if len(error_output) == 3: + self.api.fail_json( + status=error_output[0], + msg=error_output[1], + url=error_output[2], + traceback=child_task["error"]["traceback"], + ) + else: + self.api.fail_json(msg="Error in tasks. tasks: {children}".format(children=children)) + complete &= child_task["state"] == "completed" + if complete: + task_status = "Complete" + break + time.sleep(interval) + elapsed = time.time() - start + if timeout and elapsed > timeout: + self.api.fail_json(msg="Timed out awaiting task completion. tasks: {children}".format(children=children)) + return task_status + + +class AHPulpGroups(AHPulpObject): + """Manage Groups with the Pulp API. + + The :py:class:``AHPulpGroups`` creates, deletes, and add permissions to groups. + + Getting the details of a group: + ``GET /pulp/api/v3/groups/?name=`` :: + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "name": "santa", + "pulp_href": "/api/galaxy/pulp/api/v3/groups/3/", + "id": 3 + } + ] + } + + Create a collection remote: + ``POST /pulp/api/v3/groups/`` + + Delete a collection remote: + ``DELETE pulp/api/v3/groups/3/`` + """ + + def __init__(self, API_object, data=None): + """Initialize the object.""" + super(AHPulpGroups, self).__init__(API_object, data) + self.endpoint = "groups" + self.object_type = "group" + self.name_field = "name" + self.roles = [] + self.api.json_output = { + "changed": False, + } + + def get_perms(self, group): + """Return the permissions associated with the group. + + :return: The list of permission names. + :rtype: list + """ + + url = self.api.host_url._replace(path="{endpoint}roles/".format(endpoint=group['pulp_href'])) + # self.api.fail_json(msg="url: {error}".format(error=url)) + try: + response = self.api.make_request("GET", url, wait_for_task=False) + except AHAPIModuleError as e: + self.api.fail_json(msg="Error Retrieving Permissions: {error}".format(error=e)) + + return response["json"]["results"] + + def associate_permissions(self, group_data=None, new_perms=None, state='present'): + """Find the associations of the permissions. + + :return: The list of actions taken + :rtype: list + """ + # Find if the permission has been created, set definition + response = {} + response['removed'] = [] + response['added'] = [] + response['existing'] = [] + for new_permission in new_perms: + search_data = self.find_permissions(group_data=group_data, new_perm=new_permission) + if search_data['found']: + response['existing'].append( + { + "group": group_data['name'], + "group_href": group_data['pulp_href'], + "perms": new_permission + } + ) + group_data = search_data['group_data'] + if search_data['found'] and state == 'absent': + self.remove_permission(search_data['pulp_href']) + response['removed'].append( + { + "group": group_data['name'], + "perms": new_permission + } + ) + if not search_data['found'] and state in ('present', 'enforced'): + response['added'].append(self.add_permission(group=group_data, permission=new_permission)) + if state == 'enforced': + for enforced_perm in group_data['before_perms']: + if "found" not in enforced_perm: + self.remove_permission(enforced_perm['pulp_href']) + response['removed'].append( + { + "group": group_data['name'], + "perms": enforced_perm + } + ) + return response + + def find_permissions(self, group_data=None, new_perm=None): + """Check if the permission is associated with the group. + + :return: True/False if Found. + :rtype: list + """ + response = {'found': False} + + # Find if the permission has been created, set definition + for index, current_permission in enumerate(group_data['before_perms']): + if new_perm['role'] == current_permission['role'] and new_perm['content_object'] == current_permission['content_object']: + response['found'] = True + response['pulp_href'] = current_permission['pulp_href'] + group_data['before_perms'][index]['found'] = True + response['group_data'] = group_data + return response + + def add_permission(self, group=None, permission=None): + """Add listed permissions. + "group_perm_list": [ + { + "group": "santa", + "group_href": "/api/automation-hub/pulp/api/v3/groups/6/", + "perms": { + "content_object": "/api/automation-hub/pulp/api/v3/pulp_ansible/namespaces/1/", + "role": "galaxy.collection_namespace_owner" + } + } + :return: The list of permission sets. + :rtype: list + """ + url = self.api.host_url._replace(path="{endpoint}roles/".format(endpoint=group['pulp_href'])) + try: + response = self.api.make_request("POST", url, data=permission) + except AHAPIModuleError as e: + self.api.fail_json(msg="Start Sync error: {error}".format(error=e)) + self.api.json_output['changed'] = True + return response['json'] + + def remove_permission(self, permission_path): + """Remove listed permissions. + 'removal_list': [ + '/api/automation-hub/pulp/api/v3/groups/6/roles/018a099e-f8f8-7868-bfcd-68ed880e9296/' + ] + :return: The list of permission hrefs removed. + :rtype: list + """ + url = self.api.host_url._replace(path=permission_path) + try: + response = self.api.make_request("DELETE", url) + except AHAPIModuleError as e: + self.api.fail_json(msg="Start Sync error: {error}".format(error=e)) + self.api.json_output['changed'] = True + return response + + +class AHPulpAnsibleRemote(AHPulpObject): + """Manage the collection remote with the Pulp API. + + The :py:class:``AHPulpAnsibleRemote`` creates and deletes collection remotes. + + Getting the details of a collection remote: + ``GET /pulp/api/v3/remotes/ansible/collection/?name=`` :: + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "pulp_href": "/api/automation-hub/pulp/api/v3/remotes/ansible/collection/0189f01a-873f-7ce5-ac11-43dd498680f5/", + "pulp_created": "2023-08-13T18:13:37.727598Z", + "name": "galax", + } + ] + } + + Create a collection remote: + ``POST /pulp/api/v3/remotes/ansible/collection/`` + + Delete a collection remote: + ``DELETE pulp/api/v3/remotes/ansible/collection/0189f01a-873f-7ce5-ac11-43dd498680f5/`` + """ + + def __init__(self, API_object, data=None): + """Initialize the object.""" + super(AHPulpAnsibleRemote, self).__init__(API_object, data) + self.endpoint = "remotes/ansible/collection" + self.object_type = "collection remote" + self.name_field = "name" + + +class AHPulpAnsibleRepository(AHPulpObject): + """Manage the ansible repository with the Pulp API. + + TODO: add description + + Getting the details of a repository: + ``GET /pulp/api/v3/repositories/ansible/ansible/?name=`` :: + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "pulp_href": "/api/automation-hub/pulp/api/v3/repositories/ansible/ansible/018983f5-c249-7bd1-9e68-47a07b03d11b/", + "pulp_created": "2023-07-23T18:14:28.682316Z", + "versions_href": "/api/automation-hub/pulp/api/v3/repositories/ansible/ansible/018983f5-c249-7bd1-9e68-47a07b03d11b/versions/", + "pulp_labels": {}, + "latest_version_href": "/api/automation-hub/pulp/api/v3/repositories/ansible/ansible/018983f5-c249-7bd1-9e68-47a07b03d11b/versions/0/", + "name": "alpine", + "description": null, + "retain_repo_versions": 1, + "remote": null, + "last_synced_metadata_time": null, + "gpgkey": null, + "last_sync_task": null, + "private": false + } + ] + } + """ + + def __init__(self, API_object, data=None): + """Initialize the object.""" + super(AHPulpAnsibleRepository, self).__init__(API_object, data) + self.endpoint = "repositories/ansible/ansible" + self.object_type = "collection repository" + self.name_field = "name" + + def sync(self, wait, interval, timeout): + """Perform an POST API call to sync an object. + + :param wait: Whether to wait for the object to finish syncing + :type wait: bool + :param interval: How often to poll for a change in the sync status + :type interval: integer + :param timeout: How long to wait for the sync to complete in seconds + :type timeout: integer + :param auto_exit: Exit the module when the API call is done. + :type auto_exit: bool + + :return: Do not return if ``auto_exit`` is ``True``. Otherwise, return + ``True``. + :rtype: bool + """ + + url = self.api.host_url._replace(path="{endpoint}sync/".format(endpoint=self.href)) + # self.api.fail_json(msg="url: {error}".format(error=url)) + try: + response = self.api.make_request("POST", url, wait_for_task=False) + except AHAPIModuleError as e: + self.api.fail_json(msg="Start Sync error: {error}".format(error=e)) + + if response["status_code"] == 202: + sync_status = "Started" + if wait: + start = time.time() + task_href = response["json"]["task"] + task_pulp = AHPulpTask(self.api) + elapsed = 0 + while sync_status not in ["Complete", "Failed"]: + task_pulp.get_object(task_href) + if task_pulp.data["error"]: + sync_status = "Complete" + error_output = task_pulp.data["error"]["description"].split(",") + self.api.fail_json( + status=error_output[0], + msg=error_output[1], + url=error_output[2], + traceback=task_pulp.data["error"]["traceback"], + ) + if task_pulp.data["state"] == "completed": + sync_status = "Complete" + break + time.sleep(interval) + elapsed = time.time() - start + if timeout and elapsed > timeout: + self.api.fail_json(msg="Timed out awaiting sync") + + self.api.json_output = { + "name": self.name, + "changed": True, + "sync_status": sync_status, + "task": response["json"]["task"], + } + self.api.exit_json(**self.api.json_output) + return True + + error_msg = self.api.extract_error_msg(response) + if error_msg: + self.api.fail_json(msg="Unable to create {object_type} {name}: {error}".format(object_type=self.object_type, name=self.name, error=error_msg)) + self.api.fail_json( + msg="Unable to create {object_type} {name}: {code}".format( + object_type=self.object_type, + name=self.name, + code=response["status_code"], + ) + ) + + +class AHPulpAnsibleDistribution(AHPulpObject): + """Manage the ansible distribution with the Pulp API. + + TODO: add description + + Getting the details of a repository: + ``GET /pulp/api/v3/distributions/ansible/ansible/?name=`` :: + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "pulp_href": "/api/automation-hub/pulp/api/v3/distributions/ansible/ansible/018983f5-5afb-7272-9ce3-a825f11c1f7d/", + "pulp_created": "2023-07-23T18:14:02.236595Z", + "base_path": "alpine", + "content_guard": "/api/automation-hub/pulp/api/v3/contentguards/core/content_redirect/01898355-81e9-7ed6-9a49-2fcc84754196/", + "name": "alpine", + "repository": "/api/automation-hub/pulp/api/v3/repositories/ansible/ansible/018983f5-5986-7da9-b42c-a0534d7a9524/", + "repository_version": null, + "client_url": "http://localhost:5001/pulp_ansible/galaxy/alpine/", + "pulp_labels": {} + } + ] + } + """ + + def __init__(self, API_object, data=None): + """Initialize the object.""" + super(AHPulpAnsibleDistribution, self).__init__(API_object, data) + self.endpoint = "distributions/ansible/ansible" + self.object_type = "collection distribution" + self.name_field = "name" + + +class AHPulpAnsibleNamesace(AHPulpObject): + """Manage the ansible Namesace with the Pulp API. + + TODO: add description + Currently the below Get does not work. + Getting the details of a repository: + ``GET /pulp/api/v3/pulp_ansible/namespaces/?name=`` :: + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "pulp_href": "/api/automation-hub/pulp/api/v3/distributions/ansible/ansible/018983f5-5afb-7272-9ce3-a825f11c1f7d/", + "pulp_created": "2023-07-23T18:14:02.236595Z", + "base_path": "alpine", + "content_guard": "/api/automation-hub/pulp/api/v3/contentguards/core/content_redirect/01898355-81e9-7ed6-9a49-2fcc84754196/", + "name": "alpine", + "repository": "/api/automation-hub/pulp/api/v3/repositories/ansible/ansible/018983f5-5986-7da9-b42c-a0534d7a9524/", + "repository_version": null, + "client_url": "http://localhost:5001/pulp_ansible/galaxy/alpine/", + "pulp_labels": {} + } + ] + } + """ + + def __init__(self, API_object, data=None): + """Initialize the object.""" + super(AHPulpAnsibleNamesace, self).__init__(API_object, data) + self.endpoint = "pulp_ansible/namespaces" + self.object_type = "collection namespace" + self.name_field = "name" diff --git a/plugins/module_utils/ah_ui_object.py b/plugins/module_utils/ah_ui_object.py new file mode 100644 index 00000000..e5ba9fca --- /dev/null +++ b/plugins/module_utils/ah_ui_object.py @@ -0,0 +1,1626 @@ +# Copyright: (c) 2021, Herve Quatremain +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# You can consult the UI API documentation directly on a running private +# automation hub at https://hub.example.com/pulp/api/v3/docs/ +# +# Ansible Automation Hub UI project at https://github.com/ansible/ansible-hub-ui + +from __future__ import absolute_import, division, print_function +import time + +from .ah_api_module import AHAPIModuleError +from .ah_pulp_object import AHPulpTask + +__metaclass__ = type + + +class AHUIObject(object): + """Manage API objects. + + Create a subclass of :py:class:``AHUIObject`` to represent a specific type + of object (user, group, ...) + + :param API_object: Module object to use to access the private automation hub API. + :type API_object: :py:class:``AHAPIModule`` + :param data: Initial data + :type data: dict + """ + + def __init__(self, API_object, data=None): + """Initialize the module.""" + # The API endpoint is the last component of the URL and allows access + # to the object API: + # https://hub.example.com/api/galaxy/_ui/v1// + # Examples: + # https://hub.example.com/api/galaxy/_ui/v1/groups/ (endpoint=groups) + # https://hub.example.com/api/galaxy/_ui/v1/users/ (endpoint=users) + self.endpoint = None + + # Type of the objects managed by the class. This is only used in the + # messages returned to the user. + self.object_type = "object" + + # In the JSON API response message, self.name_field is the name of the + # attribute that stores the object name. This is also used as the query + # parameter in GET requests (https://..../groups/?name=operators) + self.name_field = "name" + + # In the JSON API response message, self.id_field is the name of the + # attribute that stores the object ID. That ID is used with DELETE, PUT, + # and POST requests (https://.../users//) + self.id_field = "id" + + # API attributes that store a password. This is used to display a + # warning message to the user when they update a password (changed + # status always True because we don't get the previous password to + # compare) + self.password_fields = ["password"] + + # JSON data returned by the last API call. This typically stores the + # object details ({ "username": "jdoe", "last_name": "Doe", ...}) + self.data = data if data else {} + + # Is the class instance has been initialized with a valid object? + self.exists = bool(data) + + # The AHAPIModule class object that is used to access the API + self.api = API_object + + @property + def id(self): + """Return the object ID.""" + if self.id_field in self.data: + return self.data[self.id_field] + return None + + @property + def name(self): + """Return the object name.""" + if self.name_field in self.data: + return self.data[self.name_field] + return None + + @property + def id_endpoint(self): + """Return the object's endpoint.""" + id = self.id + if id is None: + return self.endpoint + return "{endpoint}/{id}".format(endpoint=self.endpoint, id=id) + + def _isequal_list(self, old, new, key="id"): + """Compare two lists and tell is they are equal or not. + + If the items in the lists are dictionaries, then the attribute provided + by the `key` parameters is used for comparing the items. + + :param old: The first list. + :type old: list + :param new: The second list. + :type new: list + :param key: When the items in the lists are dictionaries, use that key + for comparing the items. + :type key: str + + :return: True is the two lists are identical and False otherwise. + :rtype: bool + """ + if len(new) != len(old): + return False + if len(new) == 0: + return True + if isinstance(new[0], dict): + new_items = set([k[key] for k in new if key in k]) + old_items = set([k[key] for k in old if key in k]) + else: + new_items = set(new) + old_items = set(old) + return new_items == old_items + + def objects_could_be_different(self, old, new, warning=True): + """Tell if the new dictionary is a subset of the old one. + + This method is used to decide if the object must be updated (PUT) or + not. If no new attribute, or no attribute change, then no need to + call the API. + + :param old: The old object parameters + :type old: dict + :param new: The new object parameters. + :type new: dict + + :return: True is the new dictionary contains items not in the old one. + False otherwise. + :rtype: bool + """ + for k in new.keys(): + if k in self.password_fields: + if warning: + self.api.warn( + "The field {k} of {object_type} {name} has encrypted data and may inaccurately report task is changed.".format( + k=k, object_type=self.object_type, name=self.name + ) + ) + return True + + if k not in old: + return True + new_field = new[k] + old_field = old[k] + if isinstance(new_field, list) and isinstance(old_field, list): + if not self._isequal_list(old_field, new_field): + return True + continue + + if old_field != new_field: + return True + return False + + def get_object(self, name, vers, exit_on_error=True): + """Retrieve a single object from a GET API call. + + Upon completion, :py:attr:``self.exists`` is set to ``True`` if the + object exists or ``False`` if not. + :py:attr:``self.data`` contains the retrieved object (or ``{}`` if + the requested object does not exist) + + :param name: Name of the object to retrieve. + :type name: str + :param exit_on_error: If ``True`` (the default), exit the module on API + error. Otherwise, raise the + :py:class:``AHAPIModuleError`` exception. + :type exit_on_error: bool + + :raises AHAPIModuleError: An API error occurred. That exception is only + raised when ``exit_on_error`` is ``False``. + """ + query = {self.name_field: name, "limit": "1000"} + url = self.api.build_ui_url(self.endpoint, query_params=query) + try: + response = self.api.make_request("GET", url) + except AHAPIModuleError as e: + if exit_on_error: + self.api.fail_json(msg="GET error: {error}".format(error=e)) + else: + raise + + if response["status_code"] != 200: + error_msg = self.api.extract_error_msg(response) + if error_msg: + fail_msg = "Unable to get {object_type} {name}: {code}: {error}".format( + object_type=self.object_type, + name=name, + code=response["status_code"], + error=error_msg, + ) + else: + fail_msg = "Unable to get {object_type} {name}: {code}".format( + object_type=self.object_type, + name=name, + code=response["status_code"], + ) + if exit_on_error: + self.api.fail_json(msg=fail_msg) + else: + raise AHAPIModuleError(fail_msg) + + if "meta" not in response["json"] or "count" not in response["json"]["meta"] or "data" not in response["json"]: + fail_msg = "Unable to get {object_type} {name}: the endpoint did not provide count and results".format(object_type=self.object_type, name=name) + if exit_on_error: + self.api.fail_json(msg=fail_msg) + else: + raise AHAPIModuleError(fail_msg) + + if response["json"]["meta"]["count"] == 0: + self.data = {} + self.exists = False + return + + if response["json"]["meta"]["count"] > 1: + # Only one object should be returned. If more that one is returned, + # then look for the requested name in the returned list. + for asset in response["json"]["data"]: + if self.name_field in asset and asset[self.name_field] == name: + self.data = asset + self.exists = True + return + self.data = {} + self.exists = False + return + + self.data = response["json"]["data"][0] + # Make sure the object name is available in the response + if self.name_field not in self.data: + self.data[self.name_field] = name + self.exists = True + + def delete(self, auto_exit=True): + """Perform a DELETE API call to delete the object. + + :param auto_exit: Exit the module when the API call is done. + :type auto_exit: bool + """ + if not self.exists: + if auto_exit: + self.api.exit_json(changed=False) + return + + if self.api.check_mode: + if auto_exit: + self.api.exit_json(changed=True) + self.exists = False + self.data = {} + return + + url = self.api.build_ui_url(self.id_endpoint) + try: + response = self.api.make_request("DELETE", url) + except AHAPIModuleError as e: + self.api.fail_json(msg="Delete error: {error}".format(error=e)) + + if response["status_code"] in [202, 204]: + if auto_exit: + json_output = { + "name": self.name, + "id": self.id, + "type": self.object_type, + "changed": True, + } + self.api.exit_json(**json_output) + self.exists = False + self.data = {} + return + + error_msg = self.api.extract_error_msg(response) + if error_msg: + self.api.fail_json(msg="Unable to delete {object_type} {name}: {error}".format(object_type=self.object_type, name=self.name, error=error_msg)) + self.api.fail_json( + msg="Unable to delete {object_type} {name}: {code}".format( + object_type=self.object_type, + name=self.name, + code=response["status_code"], + ) + ) + + def create(self, new_item, auto_exit=True): + """Perform an POST API call to create a new object. + + :param new_item: Tha data to pass to the API call. This provides the + object details ({"username": "jdoe", ...} for example) + :type new_item: dict + :param auto_exit: Exit the module when the API call is done. + :type auto_exit: bool + + :return: Do not return if ``auto_exit`` is ``True``. Otherwise, return + ``True``. + :rtype: bool + """ + if self.api.check_mode: + self.data.update(new_item) + if auto_exit: + json_output = { + "name": self.name, + "id": self.id, + "type": self.object_type, + "changed": True, + } + self.api.exit_json(**json_output) + return True + + url = self.api.build_ui_url(self.endpoint) + try: + response = self.api.make_request("POST", url, data=new_item) + except AHAPIModuleError as e: + self.api.fail_json(msg="Create error: {error}, url: {url}, data: {data}".format(error=e, url=url, data=new_item)) + + if response["status_code"] in [200, 201]: + self.exists = True + self.data = response["json"] + # Make sure the object name is available in the response + if self.name_field not in self.data: + self.data[self.name_field] = new_item[self.name_field] + if auto_exit: + json_output = { + "name": self.name, + "id": self.id, + "type": self.object_type, + "changed": True, + } + self.api.exit_json(**json_output) + return True + + error_msg = self.api.extract_error_msg(response) + if error_msg: + self.api.fail_json(msg="Unable to create {object_type} {name}: {error}".format(object_type=self.object_type, name=self.name, error=error_msg)) + self.api.fail_json( + msg="Unable to create {object_type} {name}: {code}".format( + object_type=self.object_type, + name=self.name, + code=response["status_code"], + ) + ) + + def update(self, new_item, auto_exit=True): + """Update the existing object in private automation hub. + + :param new_item: The data to pass to the API call. This provides the + object details ({"username": "jdoe", ...} for example) + :type new_item: dict + :param auto_exit: Exit the module when the API call is done. + :type auto_exit: bool + + :return: Do not return if ``auto_exit`` is ``True``. Otherwise, return + ``True`` if object has been updated (change state) or ``False`` + if the object do not need updating. + :rtype: bool + """ + # The "key" field ("name", "username", ...) is required for PUT + # requests. Making sure that it is present. + if self.name_field not in new_item: + new_item[self.name_field] = self.name + + # Check to see if anything within the item requires the item to be + # updated. + needs_patch = self.objects_could_be_different(self.data, new_item) + + if not needs_patch: + if auto_exit: + json_output = { + "name": self.name, + "id": self.id, + "type": self.object_type, + "changed": False, + } + self.api.exit_json(**json_output) + return False + + if self.api.check_mode: + self.data.update(new_item) + if auto_exit: + json_output = { + "name": self.name, + "id": self.id, + "type": self.object_type, + "changed": True, + } + self.api.exit_json(**json_output) + return True + url = self.api.build_ui_url(self.id_endpoint) + try: + response = self.api.make_request("PUT", url, data=new_item) + except AHAPIModuleError as e: + self.api.fail_json(msg="Update error: {error}, url: {url}, data: {data}".format(error=e, url=url, data=new_item)) + + if response["status_code"] == 200: + self.exists = True + self.data = response["json"] + # Make sure the object name is available in the response + if self.name_field not in self.data: + self.data[self.name_field] = new_item[self.name_field] + if auto_exit: + json_output = { + "name": self.name, + "id": self.id, + "type": self.object_type, + "changed": True, + } + self.api.exit_json(**json_output) + return True + + error_msg = self.api.extract_error_msg(response) + if error_msg: + self.api.fail_json(msg="Unable to update {object_type} {name}: {error}".format(object_type=self.object_type, name=self.name, error=error_msg)) + self.api.fail_json( + msg="Unable to update {object_type} {name}: {code}".format( + object_type=self.object_type, + name=self.name, + code=response["status_code"], + ) + ) + + def create_or_update(self, new_item, auto_exit=True): + """Create or update the current object in private automation hub. + + :param new_item: The data to pass to the API call. This provides the + object details ({"username": "jdoe", ...} for example) + :type new_item: dict + :param auto_exit: Exit the module when the API call is done. + :type auto_exit: bool + + :return: Do not return if ``auto_exit`` is ``True``. Otherwise, return + ``True`` if object has been updated (change state) or ``False`` + if the object do not need updating. + :rtype: bool + """ + if self.exists: + return self.update(new_item, auto_exit) + return self.create(new_item, auto_exit) + + +class AHUIUser(AHUIObject): + """Manage the Ansible automation hub UI user API. + + Listing users: + ``GET /api/galaxy/_ui/v1/users/`` + + Getting the details of a user: + ``GET /api/galaxy/_ui/v1/users//`` :: + + { + "id": 19, + "username": "admin1", + "first_name": "Jean", + "last_name": "Vasquez", + "email": "lvasquez@example.com", + "groups": [ + { + "id": 22, + "name": "operators" + }, + { + "id": 23, + "name": "administrators" + } + ], + "date_joined": "2021-08-12T09:45:21.516810Z", + "is_superuser": false + } + + Searching for users: + ``GET /api/galaxy/_ui/v1/users/?username=admin1`` :: + + { + "meta": { + "count": 1 + }, + "links": { + "first": "/api/galaxy/_ui/v1/users/?limit=10&offset=0&username=admin1", + "previous": null, + "next": null, + "last": "/api/galaxy/_ui/v1/users/?limit=10&offset=0&username=admin1" + }, + "data": [ + { + "id": 19, + "username": "admin1", + "first_name": "Jean", + "last_name": "Vasquez", + "email": "lvasquez@example.com", + "groups": [ + { + "id": 22, + "name": "operators" + }, + { + "id": 23, + "name": "administrators" + } + ], + "date_joined": "2021-08-12T09:45:21.516810Z", + "is_superuser": false + } + ] + } + + Deleting a user: + ``DELETE /api/galaxy/_ui/v1/users//`` + + Creating a user: + ``POST /api/galaxy/_ui/v1/users/`` + + Updating a user: + ``PUT /api/galaxy/_ui/v1/users/`` + """ + + def __init__(self, API_object, data=None): + """Initialize the object.""" + super(AHUIUser, self).__init__(API_object, data) + self.endpoint = "users" + self.object_type = "user" + self.name_field = "username" + + @property + def superuser(self): + """Tell if the user is a super user or not.""" + return self.api.boolean(self.data.get("is_superuser", False)) + + @property + def groups(self): + """Return the groups for which the user is a member.""" + return self.data.get("groups", []) + + +class AHUIGroup(AHUIObject): + """Manage the Ansible Automation Hub UI group API. + + Listing groups: + ``GET /api/galaxy/_ui/v1/groups/`` :: + + { + "meta": { + "count": 3 + }, + "links": { + "first": "/api/galaxy/_ui/v1/groups/?limit=10&offset=0", + "previous": null, + "next": null, + "last": "/api/galaxy/_ui/v1/groups/?limit=10&offset=0" + }, + "data": [ + { + "name": "operators", + "pulp_href": "/pulp/api/v3/groups/22/", + "id": 22 + }, + { + "name": "administrators", + "pulp_href": "/pulp/api/v3/groups/23/", + "id": 23 + }, + { + "name": "managers", + "pulp_href": "/pulp/api/v3/groups/24/", + "id": 24 + } + ] + } + + Getting the details of a group: + ``GET /api/galaxy/_ui/v1/groups//`` :: + + { + "name": "operators", + "pulp_href": "/pulp/api/v3/groups/22/", + "id": 22 + } + + Searching for groups: + ``GET /api/galaxy/_ui/v1/groups/?name=operators`` :: + + { + "meta": { + "count": 1 + }, + "links": { + "first": "/api/galaxy/_ui/v1/groups/?limit=10&name=operators&offset=0", + "previous": null, + "next": null, + "last": "/api/galaxy/_ui/v1/groups/?limit=10&name=operators&offset=0" + }, + "data": [ + { + "name": "operators", + "pulp_href": "/pulp/api/v3/groups/22/", + "id": 22 + } + ] + } + + + Deleting a group: + ``DELETE /api/galaxy/_ui/v1/groups//`` + + Creating a group: + ``POST /api/galaxy/_ui/v1/groups/`` + + Updating a group: + The API does not allow changing the group name. + """ + + def __init__(self, API_object, data=None): + """Initialize the object.""" + super(AHUIGroup, self).__init__(API_object, data) + self.endpoint = "groups" + self.object_type = "group" + self.name_field = "name" + self.perms = [] + + def load_perms(self): + """Retrieve the group permissions.""" + if not self.exists: + self.perms = [] + return + + url = self.api.build_ui_url(AHUIGroupPerm.perm_endpoint(self.id), query_params={"limit": 100}) + try: + response = self.api.make_request("GET", url) + except AHAPIModuleError as e: + self.api.fail_json(msg="Getting permissions error: {error}".format(error=e)) + + if response["status_code"] != 200: + error_msg = self.api.extract_error_msg(response) + if error_msg: + fail_msg = "Unable to get permissions for {object_type} {name}: {code}: {error}".format( + object_type=self.object_type, + name=self.name, + code=response["status_code"], + error=error_msg, + ) + else: + fail_msg = "Unable to get permissions for {object_type} {name}: {code}".format( + object_type=self.object_type, + name=self.name, + code=response["status_code"], + ) + self.api.fail_json(msg=fail_msg) + + if "meta" not in response["json"] or "count" not in response["json"]["meta"] or "data" not in response["json"]: + self.api.fail_json( + msg="Unable to get permissions for {object_type} {name}: the endpoint did not provide count and results".format( + object_type=self.object_type, name=self.name + ) + ) + + self.perms = [] + if response["json"]["meta"]["count"] == 0: + return + for r in response["json"]["data"]: + self.perms.append(AHUIGroupPerm(self.api, self.id, r)) + + def get_perms(self): + """Return the permissions associated with the group. + + :return: The list of permission names. + :rtype: list + """ + return [p.name for p in self.perms] + + def delete_perms(self, perms_to_delete): + """Remove permissions from the group. + + The method exits the module. + + :param perms_to_delete: List of the permission names to remove from the + group. + :type perms_to_delete: list + """ + if perms_to_delete is not None and len(perms_to_delete) > 0: + for perm_name in perms_to_delete: + for perm in self.perms: + if perm.is_perm(perm_name): + perm.delete(auto_exit=False) + break + self.api.exit_json(changed=True) + self.api.exit_json(changed=False) + + def create_perms(self, perms_to_create): + """Add a permission to the group. + + The method exits the module. + + :param perms_to_create: List of the permission names to add to the + group. + :type perms_to_delete: list + """ + if perms_to_create is not None and len(perms_to_create) > 0: + for perm_name in perms_to_create: + perm = AHUIGroupPerm(self.api, self.id) + perm.create({"permission": perm_name}, auto_exit=False) + self.api.exit_json(changed=True) + self.api.exit_json(changed=False) + + +class AHUIGroupPerm(AHUIObject): + """Manage the Ansible Automation Hub UI group permissions API. + + Listing permissions for the group ID 8: + ``GET /api/galaxy/_ui/v1/groups/8/model-permissions/?limit=100`` :: + + { + "meta": { + "count": 2 + }, + "links": { + "first": "/api/galaxy/_ui/v1/groups/8/model-permissions/?limit=100&offset=0", + "previous": null, + "next": null, + "last": "/api/galaxy/_ui/v1/groups/8/model-permissions/?limit=100&offset=0" + }, + "data": [ + { + "pulp_href": "/pulp/api/v3/groups/8/model_permissions/37/", + "id": 37, + "permission": "ansible.modify_ansible_repo_content", + "obj": null + }, + { + "pulp_href": "/pulp/api/v3/groups/8/model_permissions/6/", + "id": 6, + "permission": "ansible.change_collectionremote", + "obj": null + } + ] + } + + Removing a permission from a group: + ``DELETE /api/galaxy/_ui/v1/groups/8/model-permissions/37/`` + + Adding a permission to a group: + ``POST /api/galaxy/_ui/v1/groups/8/model-permissions/`` + """ + + @staticmethod + def perm_endpoint(group_id): + """Return the endpoint for permissions for the given group.""" + return "groups/{id}/model-permissions".format(id=group_id) + + def __init__(self, API_object, group_id, data=None): + """Initialize the object.""" + super(AHUIGroupPerm, self).__init__(API_object, data) + self.endpoint = AHUIGroupPerm.perm_endpoint(group_id) + self.object_type = "permissions" + self.name_field = "permission" + + @property + def id_endpoint(self): + """Return the object's endpoint.""" + id = self.id + if id is None: + return self.endpoint + return "{endpoint}/{id}".format(endpoint=self.endpoint, id=id) + + def is_perm(self, perm_name): + """Tell if the given permission name is the name of the current permission.""" + return self.name == perm_name + + +class AHUIEENamespace(AHUIObject): + """Manage the private automation hub execution environment namespaces. + + Execution Environment namespaces are managed through the Pulp API for + creation and deletion, and through the UI API for assigning groups and + permission. + Although the UI API and the web UI cannot create nor delete namespaces, the + module provides that feature. + Normally, a namespace is automatically created when an image is pushed by + using ``podman push``. + + The :py:class:``AHUIEENamespace`` manages the namespace through the UI API. + It is used to manage groups and permissions associated with namespaces. + See :py:class:``AHPulpEENamespace`` to create and delete namespaces. + + Getting the details of a namespace: + ``GET /api/galaxy/_ui/v1/execution-environments/namespaces//`` :: + + { + "name": "ansible-automation-platform-20-early-access", + "my_permissions": [ + "container.add_containernamespace", + "container.change_containernamespace", + "container.delete_containernamespace", + "container.namespace_add_containerdistribution", + "container.namespace_change_containerdistribution", + "container.namespace_delete_containerdistribution", + "container.namespace_modify_content_containerpushrepository", + "container.namespace_pull_containerdistribution", + "container.namespace_push_containerdistribution", + "container.namespace_view_containerdistribution", + "container.namespace_view_containerpushrepository", + "container.view_containernamespace" + ], + "owners": [ + "admin" + ], + "groups": [ + { + "id": 50, + "name": "operators", + "object_roles": [ + "namespace_add_containerdistribution" + ] + } + ] + } + + Updating the groups and permissions: + ``PUT /api/galaxy/_ui/v1/execution-environments/namespaces//`` :: + + data: + { + "groups":[ + { + "id":50, + "name":"operators", + "object_roles": [ + "namespace_add_containerdistribution" + ] + } + ] + } + """ + + def __init__(self, API_object, data=None): + """Initialize the object.""" + super(AHUIEENamespace, self).__init__(API_object, data) + self.endpoint = "execution-environments/namespaces" + self.object_type = "namespace" + self.name_field = "name" + + @property + def id_endpoint(self): + """Return the object's endpoint.""" + name = self.name + if name is None: + return self.endpoint + return "{endpoint}/{name}".format(endpoint=self.endpoint, name=name) + + def groups_are_different(self, old, new): + """Compare two dictionaries. + + :param old: The current groups and permissions. + :type old: dict + :param new: The new groups and permissions. + :type new: dict + + :return: ``True`` if the two sets are different, ``False`` otherwise. + :rtype: bool + """ + for n in new: + for o in old: + if o["id"] == n["id"]: + if ("object_roles" in o and set(o["object_roles"]) != set(n["object_roles"])) or \ + ("object_permissions" in o and set(o["object_permissions"]) != set(n["object_permissions"])): + return True + break + else: + if ("object_roles" in n and len(n["object_roles"])) or ("object_permissions" in n and len(n["object_permissions"])): + return True + return False + + def update_groups(self, new_item, auto_exit=True, exit_on_error=True): + """Update the existing object in private automation hub. + + :param new_item: The data to pass to the API call. This provides the + object details (``{"groups": ...}``) + :type new_item: dict + :param auto_exit: Exit the module when the API call is done. + :type auto_exit: bool + :param exit_on_error: If ``True`` (the default), exit the module on API + error. Otherwise, raise the + :py:class:``AHAPIModuleError`` exception. + :type exit_on_error: bool + + :raises AHAPIModuleError: An API error occurred. That exception is only + raised when ``exit_on_error`` is ``False``. + + :return: Do not return if ``auto_exit`` is ``True``. Otherwise, return + ``True`` if object has been updated (change state) or ``False`` + if the object do not need updating. + :rtype: bool + """ + + # Check to see if anything within the item requires the item to be + # updated. + needs_patch = self.groups_are_different(self.data["groups"], new_item["groups"]) + + if not needs_patch: + if auto_exit: + json_output = { + "name": self.name, + "type": self.object_type, + "changed": False, + } + self.api.exit_json(**json_output) + return False + + if self.api.check_mode: + self.data["groups"] = new_item + if auto_exit: + json_output = { + "name": self.name, + "type": self.object_type, + "changed": True, + } + self.api.exit_json(**json_output) + return True + + url = self.api.build_ui_url(self.id_endpoint) + try: + response = self.api.make_request("PUT", url, data=new_item) + except AHAPIModuleError as e: + if exit_on_error: + self.api.fail_json(msg="Updating groups error: {error}".format(error=e)) + else: + raise + + if response["status_code"] == 200: + self.exists = True + self.data = response["json"] + # Make sure the object name is available in the response + if self.name_field not in self.data: + self.data[self.name_field] = new_item[self.name_field] + if auto_exit: + json_output = { + "name": self.name, + "type": self.object_type, + "changed": True, + } + self.api.exit_json(**json_output) + return True + + error_msg = self.api.extract_error_msg(response) + if error_msg: + fail_msg = "Unable to update {object_type} {name}: {error}".format(object_type=self.object_type, name=self.name, error=error_msg) + else: + fail_msg = "Unable to update {object_type} {name}: {code}".format( + object_type=self.object_type, + name=self.name, + code=response["status_code"], + ) + if exit_on_error: + self.api.fail_json(msg=fail_msg) + else: + raise AHAPIModuleError(fail_msg) + + +class AHUIEERemote(AHUIObject): + def __init__(self, API_object, data=None): + """Initialize the object.""" + super(AHUIEERemote, self).__init__(API_object, data) + self.endpoint = "execution-environments/remotes" + self.object_type = "remote" + self.name_field = "pulp_id" + self.id_field = "pulp_id" + + @property + def id_endpoint(self): + """Return the object's endpoint.""" + name = self.name + if name is None: + return self.endpoint + return "{endpoint}/{name}".format(endpoint=self.endpoint, name=name) + + +class AHUIEERepository(AHUIObject): + """Manage the README file of execution environment repositories. + + A repository (or container for Pulp) represents a container image and is + stored inside a namespace. + + You manage execution environment repositories through two APIs: + + * The Pulp API can create and delete repositories, and can update the + repository description text. + See the :py:class:``AHPulpEERepository`` class. + * The UI API can update the README file associated with the repository. + That current class manages that API. + + Getting the details of a repository: + ``GET /api/galaxy/_ui/v1/execution-environments/repositories//`` :: + + { + "meta": { + "count": 1 + }, + "links": { + "first": "/api/galaxy/_ui/v1/execution-environments/repositories/?name=ansible-automation-platform-20-early-access%2Fee-supported-rhel8", + "previous": null, + "next": null, + "last": "/api/galaxy/_ui/v1/execution-environments/repositories/?name=ansible-automation-platform-20-early-access%2Fee-supported-rhel8" + }, + "data": [ + { + "id": "61b6c7de-a19b-4976-89c2-15d665781e20", + "name": "ansible-automation-platform-20-early-access/ee-supported-rhel8", + "pulp": { + "repository": { + "pulp_id": "8be51c84-e14c-4b84-8db1-8db4737ca9ff", + "pulp_type": "container.container-push", + "version": 7, + "name": "ansible-automation-platform-20-early-access/ee-supported-rhel8", + "description": null + }, + "distribution": { + "pulp_id": "61b6c7de-a19b-4976-89c2-15d665781e20", + "name": "ansible-automation-platform-20-early-access/ee-supported-rhel8", + "base_path": "ansible-automation-platform-20-early-access/ee-supported-rhel8" + } + }, + "namespace": { + "name": "ansible-automation-platform-20-early-access", + "my_permissions": [ + "container.add_containernamespace", + ... + "container.view_containernamespace" + ], + "owners": [ + "admin" + ] + }, + "description": null, + "created": "2021-08-17T08:21:01.751907Z", + "updated": "2021-08-17T08:21:37.194078Z" + } + ] + } + + Getting the README file contents: + ``GET /api/galaxy/_ui/v1/execution-environments/repositories//_content/readme/`` + + Setting the README file contents: + ``PUT /api/galaxy/_ui/v1/execution-environments/repositories//_content/readme/`` + """ + + def __init__(self, API_object, data=None): + """Initialize the object.""" + super(AHUIEERepository, self).__init__(API_object, data) + self.endpoint = "execution-environments/repositories" + self.object_type = "repository" + self.name_field = "name" + + def get_object(self, name, vers, exit_on_error=True): + """Retrieve a single object from a GET API call. + + Upon completion, :py:attr:``self.exists`` is set to ``True`` if the + object exists or ``False`` if not. + :py:attr:``self.data`` contains the retrieved object (or ``{}`` if + the requested object does not exist) + + :param name: Name of the object to retrieve. + :type name: str + :param exit_on_error: If ``True`` (the default), exit the module on API + error. Otherwise, raise the + :py:class:``AHAPIModuleError`` exception. + :type exit_on_error: bool + + :raises AHAPIModuleError: An API error occurred. That exception is only + raised when ``exit_on_error`` is ``False``. + """ + query = {self.name_field: name, "limit": "1000"} + self.vers = vers + if vers < "4.7": + url = self.api.build_ui_url(self.endpoint, query_params=query) + else: + url = self.api.build_plugin_url(self.endpoint, query_params=query) + + try: + response = self.api.make_request("GET", url) + except AHAPIModuleError as e: + if exit_on_error: + self.api.fail_json(msg="GET error: {error}, url: {url}".format(error=e, url=url)) + else: + raise + + if response["status_code"] != 200: + error_msg = self.api.extract_error_msg(response) + if error_msg: + fail_msg = "Unable to get {object_type} {name}: {code}: {error}".format( + object_type=self.object_type, + name=name, + code=response["status_code"], + error=error_msg, + ) + else: + fail_msg = "Unable to get {object_type} {name}: {code}".format( + object_type=self.object_type, + name=name, + code=response["status_code"], + ) + if exit_on_error: + self.api.fail_json(msg=fail_msg) + else: + raise AHAPIModuleError(fail_msg) + + if "meta" not in response["json"] or "count" not in response["json"]["meta"] or "data" not in response["json"]: + fail_msg = "Unable to get {object_type} {name}: the endpoint did not provide count and results".format(object_type=self.object_type, name=name) + if exit_on_error: + self.api.fail_json(msg=fail_msg) + else: + raise AHAPIModuleError(fail_msg) + + if response["json"]["meta"]["count"] == 0: + self.data = {} + self.exists = False + return + + if response["json"]["meta"]["count"] > 1: + # Only one object should be returned. If more that one is returned, + # then look for the requested name in the returned list. + for asset in response["json"]["data"]: + if self.name_field in asset and asset[self.name_field] == name: + self.data = asset + self.exists = True + return + self.data = {} + self.exists = False + return + + self.data = response["json"]["data"][0] + # Make sure the object name is available in the response + if self.name_field not in self.data: + self.data[self.name_field] = name + self.exists = True + + @property + def id_endpoint(self): + """Return the object's endpoint.""" + name = self.name + if name is None: + return self.endpoint + return "{endpoint}/{name}".format(endpoint=self.endpoint, name=name) + + def sync(self, wait, interval, timeout): + """Perform an POST API call to sync an object. + + :param wait: Whether to wait for the object to finish syncing + :type wait: bool + :param interval: How often to poll for a change in the sync status + :type interval: integer + :param timeout: How long to wait for the sync to complete in seconds + :type timeout: integer + :param auto_exit: Exit the module when the API call is done. + :type auto_exit: bool + + :return: Do not return if ``auto_exit`` is ``True``. Otherwise, return + ``True``. + :rtype: bool + """ + + if self.vers < "4.7": + url = self.api.build_ui_url("{endpoint}/_content/sync".format(endpoint=self.id_endpoint)) + else: + url = self.api.build_plugin_url("{endpoint}/_content/sync".format(endpoint=self.id_endpoint)) + try: + response = self.api.make_request("POST", url, wait_for_task=False) + except AHAPIModuleError as e: + self.api.fail_json(msg="Start Sync error: {error}".format(error=e)) + + if response["status_code"] == 202: + sync_status = "Started" + if wait: + start = time.time() + task_href = response["json"]["task"] + task_pulp = AHPulpTask(self.api) + elapsed = 0 + while sync_status not in ["Complete", "Failed"]: + task_pulp.get_object(task_href) + if task_pulp.data["error"]: + sync_status = "Complete" + error_output = task_pulp.data["error"]["description"].split(",") + if len(error_output) == 3: + self.api.fail_json( + status=error_output[0], + msg=error_output[1], + url=error_output[2], + traceback=task_pulp.data["error"]["traceback"], + ) + else: + self.api.fail_json( + status="", + msg="", + url="", + traceback=task_pulp.data["error"]["traceback"], + ) + if task_pulp.data["state"] == "completed": + sync_status = "Complete" + break + time.sleep(interval) + elapsed = time.time() - start + if timeout and elapsed > timeout: + self.api.fail_json(msg="Timed out awaiting sync") + + json_output = { + "name": self.name, + "changed": True, + "sync_status": sync_status, + "task": response["json"]["task"], + } + self.api.exit_json(**json_output) + return True + + error_msg = self.api.extract_error_msg(response) + if error_msg: + self.api.fail_json(msg="Unable to create {object_type} {name}: {error}".format(object_type=self.object_type, name=self.name, error=error_msg)) + self.api.fail_json( + msg="Unable to create {object_type} {name}: {code}".format( + object_type=self.object_type, + name=self.name, + code=response["status_code"], + ) + ) + + def get_readme(self): + """Retrieve and return the README file associated with the repository. + + ``GET /api/galaxy/_ui/v1/execution-environments/repositories//_content/readme/ :: + + { + "updated":"2021-08-17T12:54:37.250390Z", + "created":"2021-08-17T12:54:37.250369Z", + "text":"" + } + + :return: The README file contents. + :rtype: str + """ + if not self.exists: + return "" + + if self.vers < "4.7": + url = self.api.build_ui_url("{endpoint}/_content/readme".format(endpoint=self.id_endpoint)) + else: + url = self.api.build_plugin_url("{endpoint}/_content/readme".format(endpoint=self.id_endpoint)) + try: + response = self.api.make_request("GET", url) + except AHAPIModuleError as e: + self.api.fail_json(msg="Error while getting the README: {error}".format(error=e)) + + if response["status_code"] == 200: + return response["json"]["text"] if "text" in response["json"] else "" + error_msg = self.api.extract_error_msg(response) + if error_msg: + self.api.fail_json( + msg="Unable to retrieve the README for {object_type} {name}: {error}".format(object_type=self.object_type, name=self.name, error=error_msg) + ) + self.api.fail_json( + msg="Unable to retrieve the README for {object_type} {name}: {code}".format( + object_type=self.object_type, + name=self.name, + code=response["status_code"], + ) + ) + + def update_readme(self, readme, auto_exit=True): + """Update the repository README in private automation hub. + + :param readme: The README file contents. + :type readme: str + :param auto_exit: Exit the module when the API call is done. + :type auto_exit: bool + + :return: Do not return if ``auto_exit`` is ``True``. Otherwise, return + ``True`` if object has been updated (change state) or ``False`` + if the object do not need updating. + :rtype: bool + """ + # Verify if the README needs updating + old_readme = self.get_readme() + if old_readme.strip() == readme.strip(): + if auto_exit: + json_output = { + "name": self.name, + "type": self.object_type, + "changed": False, + } + self.api.exit_json(**json_output) + return False + + if self.api.check_mode: + if auto_exit: + json_output = { + "name": self.name, + "type": self.object_type, + "changed": True, + } + self.api.exit_json(**json_output) + return True + + if self.vers < "4.7": + url = self.api.build_ui_url("{endpoint}/_content/readme".format(endpoint=self.id_endpoint)) + else: + url = self.api.build_plugin_url("{endpoint}/_content/readme".format(endpoint=self.id_endpoint)) + try: + response = self.api.make_request("PUT", url, data={"text": readme}) + except AHAPIModuleError as e: + self.api.fail_json(msg="Unable to update the README: {error}".format(error=e)) + + if response["status_code"] == 200: + if auto_exit: + json_output = { + "name": self.name, + "type": self.object_type, + "changed": True, + } + self.api.exit_json(**json_output) + return True + + error_msg = self.api.extract_error_msg(response) + if error_msg: + self.api.fail_json( + msg="Unable to update the README for {object_type} {name}: {error}".format(object_type=self.object_type, name=self.name, error=error_msg) + ) + self.api.fail_json( + msg="Unable to update the README for {object_type} {name}: {code}".format( + object_type=self.object_type, + name=self.name, + code=response["status_code"], + ) + ) + + +class AHUIEERegistry(AHUIObject): + """Manage execution environment registries. + + A registry is a remote resource which can be synced to pull down repositories (container images) + + Getting the info for a registry: + ``GET /api/galaxy/_ui/v1/execution-environments/registries/`` :: + + { + "pk": "70acd9b2-ca24-48bb-b62f-87099022d69c", + "name": "test3", + "url": "https://quay.io/mytest", + "policy": "immediate", + "created_at": "2022-04-05T17:21:41.556808Z", + "updated_at": "2022-04-05T17:21:41.556829Z", + "tls_validation": false, + "client_cert": null, + "ca_cert": null, + "last_sync_task": {}, + "download_concurrency": null, + "proxy_url": null, + "write_only_fields": [ + { + "name": "client_key", + "is_set": false + }, + { + "name": "username", + "is_set": false + }, + { + "name": "password", + "is_set": false + }, + { + "name": "client_key", + "is_set": false + }, + { + "name": "proxy_username", + "is_set": false + }, + { + "name": "proxy_password", + "is_set": false + } + ], + "rate_limit": null, + "is_indexable": false + } + """ + + def __init__(self, API_object, data=None): + """Initialize the object.""" + super(AHUIEERegistry, self).__init__(API_object, data) + self.endpoint = "execution-environments/registries" + self.object_type = "registries" + self.name_field = "name" + self.id_field = "pk" + + def sync(self, wait, interval, timeout, auto_exit=True): + """Perform an POST API call to sync an object. + + :param wait: Whether to wait for the object to finish syncing + :type wait: bool + :param interval: How often to poll for a change in the sync status + :type interval: integer + :param timeout: How long to wait for the sync to complete in seconds + :type timeout: integer + :param auto_exit: Exit the module when the API call is done. + :type auto_exit: bool + + :return: Do not return if ``auto_exit`` is ``True``. Otherwise, return + ``True``. + :rtype: bool + """ + + url = self.api.build_ui_url("{endpoint}/sync".format(endpoint=self.id_endpoint)) + try: + response = self.api.make_request("POST", url) + except AHAPIModuleError as e: + self.api.fail_json(msg="Start Sync error: {error}".format(error=e)) + + if response["status_code"] == 202: + task_status = "Started" + if wait: + parent_task = response["json"]["task"] + task_pulp = AHPulpTask(self.api) + task_status = task_pulp.wait_for_children(parent_task, interval, timeout) + + if auto_exit: + json_output = { + "name": self.name, + "changed": True, + "task_status": task_status, + "task": response["json"]["task"], + } + self.api.exit_json(**json_output) + return True + + error_msg = self.api.extract_error_msg(response) + if error_msg: + self.api.fail_json(msg="Unable to create {object_type} {name}: {error}".format(object_type=self.object_type, name=self.name, error=error_msg)) + self.api.fail_json( + msg="Unable to create {object_type} {name}: {code}".format( + object_type=self.object_type, + name=self.name, + code=response["status_code"], + ) + ) + + def index(self, wait, interval, timeout, auto_exit=True): + """Perform an POST API call to index an object. + + :param wait: Whether to wait for the object to finish syncing + :type wait: bool + :param interval: How often to poll for a change in the sync status + :type interval: integer + :param timeout: How long to wait for the sync to complete in seconds + :type timeout: integer + :param auto_exit: Exit the module when the API call is done. + :type auto_exit: bool + + :return: Do not return if ``auto_exit`` is ``True``. Otherwise, return + ``True``. + :rtype: bool + """ + url = self.api.build_ui_url("{endpoint}/index".format(endpoint=self.id_endpoint)) + try: + response = self.api.make_request("POST", url) + except AHAPIModuleError as e: + self.api.fail_json(msg="Start Sync error: {error}".format(error=e)) + + if response["status_code"] == 202: + task_status = "Started" + if wait: + parent_task = response["json"]["task"] + task_pulp = AHPulpTask(self.api) + task_status = task_pulp.wait_for_children(parent_task, interval, timeout) + + if auto_exit: + json_output = { + "name": self.name, + "changed": True, + "task_status": task_status, + "task": response["json"]["task"], + } + self.api.exit_json(**json_output) + return True + + error_msg = self.api.extract_error_msg(response) + if error_msg: + self.api.fail_json(msg="Unable to create {object_type} {name}: {error}".format(object_type=self.object_type, name=self.name, error=error_msg)) + self.api.fail_json( + msg="Unable to create {object_type} {name}: {code}".format( + object_type=self.object_type, + name=self.name, + code=response["status_code"], + ) + ) + + +class AHUIEEImage(AHUIObject): + """Manage execution environment images. + + A repository (or container for Pulp) represents a container image and is + stored inside a namespace. + + You manage execution environment images through two APIs: + + * The Pulp API can delete images, and add or remove tags. + See the :py:class:``AHPulpEERepository`` class. + * The UI API can retrieve the SHA256 digests for the tagging operations. + That current class manages that API. + + Getting the list of images for a repository: + ``GET /api/galaxy/_ui/v1/execution-environments/repositories//_content/images/`` :: + + { + "meta": { + "count": 2 + }, + "links": { + ... + }, + "data": [ + { + "pulp_id": "a7d13a94-cd12-4fee-9d7f-665f0b083382", + "digest": "sha256:a4ebd33ba78252a3c17bf951ff82ac39a6e1020d25031 eb42a8c0d2a0f673c9e", + "schema_version": 2, + "media_type": "application/vnd.docker.distribution.manifest.v2+json", + "config_blob": { + "digest": "sha256:54993dbce43b6d346b7840cde5ab44c3001d4751deaf8ac4a9592d56638e062f", + "media_type": "application/vnd.docker.image.rootfs.diff.tar.gzip" + }, + "tags": [ + "test123", + "2.0.0-10" + ], + "pulp_created": "2021-08-16T09:22:45.136729Z", + "layers": [ + ... + ] + }, + { + "pulp_id": "1fcf4d3e-15ef-44c3-87f0-2037aaefd249", + "digest": "sha256:902643fa5de3ce478dccd7a7182e6b91469a8a9539043e788d315ecc557d792a", + "schema_version": 2, + "media_type": "application/vnd.docker.distribution.manifest.v2+json", + "config_blob": { + "digest": "sha256:078c7d4aca51b39cf0dc6dfdf8efb9953216cb1502a9ec935d5973b7afdfbdb7", + "media_type": "application/vnd.docker.image.rootfs.diff.tar.gzip" + }, + "tags": [ + "2.0.0-15" + ], + "pulp_created": "2021-08-18T08:31:38.919872Z", + "layers": [ + ... + ] + } + ] + } + """ + + def __init__(self, API_object, data=None): + """Initialize the object.""" + super(AHUIEEImage, self).__init__(API_object, data) + self.endpoint = "execution-environments/repositories" + self.object_type = "image" + self.name_field = "name" + + @property + def id_endpoint(self): + """Return the object's endpoint.""" + name = self.image_name + if name is None: + return self.endpoint + return "{endpoint}/{name}/_content/images/".format(endpoint=self.endpoint, name=name) + + def get_tag(self, name, tag, vers): + """Retrieve the image associated with the given repository and tag. + + Upon completion, if the object exists, then :py:attr:``self.digest`` is + set to the SHA256 image digest and :py:attr:``self.tags`` is set to the + list of tags. + If the object does not exist, then :py:attr:``self.digest`` is set to + ``None``. + + :param name: Name of the image (or repository name). + :type name: str + :param tag: Name of the tag to retrieve. + :type tag: str + """ + self.image_name = name + self.tag = tag + self.vers = vers + if vers < "4.7": + url = self.api.build_ui_url(self.id_endpoint, query_params={"limit": 1000}) + else: + url = self.api.build_plugin_url(self.id_endpoint, query_params={"limit": 1000}) + try: + response = self.api.make_request("GET", url) + except AHAPIModuleError as e: + self.api.fail_json(msg="Unable to get tag: {error}".format(error=e)) + + if response["status_code"] != 200: + error_msg = self.api.extract_error_msg(response) + if error_msg: + fail_msg = "Unable to get {object_type} {name}: {code}: {error}".format( + object_type=self.object_type, + name=name, + code=response["status_code"], + error=error_msg, + ) + else: + fail_msg = "Unable to get {object_type} {name}: {code}".format( + object_type=self.object_type, + name=name, + code=response["status_code"], + ) + self.api.fail_json(msg=fail_msg) + + if "meta" not in response["json"] or "count" not in response["json"]["meta"] or "data" not in response["json"]: + self.api.fail_json( + msg="Unable to get {object_type} {name}: the endpoint did not provide count and results".format(object_type=self.object_type, name=name) + ) + + self.digest = None + self.tags = [] + for asset in response["json"]["data"]: + if "tags" in asset and tag in asset["tags"] and "digest" in asset: + self.digest = asset["digest"] + self.tags = asset["tags"] + break diff --git a/roles/collection/README.md b/roles/collection/README.md index ad9b1635..c7a5042e 100644 --- a/roles/collection/README.md +++ b/roles/collection/README.md @@ -18,6 +18,7 @@ An Ansible Role to update, or destroy Automation Hub Collections. |`ah_collections`|`see below`|yes|Data structure describing your collections, described below.|| These are the sub options for the vars `ah_collections` which are dictionaries with the options you want. See examples for details. + |Variable Name|Default Value|Required|Description|Example| |:---:|:---:|:---:|:---:|:---:| |`namespace`|""|yes|Namespace name. Must be lower case containing only alphanumeric characters and underscores.|"awx"|