From 33619b05546abb75520f4d5a44a1b93e7bd04d1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Herv=C3=A9=20Quatremain?= Date: Tue, 26 Nov 2024 08:07:48 +0100 Subject: [PATCH] Robot account federation (#17) --- CHANGELOG.rst | 13 ++ changelogs/changelog.yaml | 10 ++ galaxy.yml | 2 +- plugins/module_utils/api_module.py | 111 +++++++++++++ plugins/modules/quay_notification.py | 33 +--- plugins/modules/quay_proxy_cache.py | 51 ++++-- plugins/modules/quay_repository.py | 34 +--- plugins/modules/quay_repository_mirror.py | 54 +++---- plugins/modules/quay_repository_prune.py | 33 +--- plugins/modules/quay_robot.py | 146 ++++++++++++----- plugins/modules/quay_team_ldap.py | 4 +- plugins/modules/quay_team_oidc.py | 4 +- roles/quay_org/README.md | 2 +- roles/quay_org/meta/argument_specs.yml | 23 ++- roles/quay_org/tasks/organization_prune.yml | 4 +- roles/quay_org/tasks/repositories.yml | 4 +- roles/quay_org/tasks/robots.yml | 2 + roles/quay_org/tests/test.yml | 2 +- .../targets/check_mode/tasks/main.yml | 66 +++++++- .../targets/quay_notification/tasks/main.yml | 21 +++ .../targets/quay_proxy_cache/tasks/main.yml | 24 ++- .../targets/quay_repository/tasks/main.yml | 30 ++++ .../quay_repository_mirror/tasks/main.yml | 49 +++++- .../quay_repository_prune/tasks/main.yml | 18 +++ .../targets/quay_robot/tasks/main.yml | 149 ++++++++++++++++++ .../targets/role_quay_org/tasks/main.yml | 5 +- 26 files changed, 703 insertions(+), 191 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 608b1a7..cd9a32e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,6 +4,19 @@ Quay Container Registry Collection Release Notes .. contents:: Topics +v2.5.0 +====== + +Release Summary +--------------- + +Support configuring keyless authentications with robot accounts. + +Minor Changes +------------- + +- Add the ``federations`` option to the ``infra.quay_configuration.quay_robot`` module. With this option, you can configure keyless authentications with robot accounts (Quay 3.13 and later) + v2.4.0 ====== diff --git a/changelogs/changelog.yaml b/changelogs/changelog.yaml index adeb8d1..ee89334 100644 --- a/changelogs/changelog.yaml +++ b/changelogs/changelog.yaml @@ -273,3 +273,13 @@ releases: name: quay_repository_prune namespace: '' release_date: '2024-11-23' + 2.5.0: + changes: + minor_changes: + - Add the ``federations`` option to the ``infra.quay_configuration.quay_robot`` + module. With this option, you can configure keyless authentications with robot + accounts (Quay 3.13 and later) + release_summary: Support configuring keyless authentications with robot accounts. + fragments: + - 17-v2.5.0-summary.yml + release_date: '2024-11-26' diff --git a/galaxy.yml b/galaxy.yml index 4923d25..cc0ae87 100644 --- a/galaxy.yml +++ b/galaxy.yml @@ -1,7 +1,7 @@ --- namespace: infra name: quay_configuration -version: 2.4.0 +version: 2.5.0 readme: README.md authors: - Hervé Quatremain diff --git a/plugins/module_utils/api_module.py b/plugins/module_utils/api_module.py index b275d25..f27e7f4 100644 --- a/plugins/module_utils/api_module.py +++ b/plugins/module_utils/api_module.py @@ -1214,6 +1214,72 @@ def get_namespace(self, namespace, exit_on_error=True): return user_details return None + def split_name(self, parameter_name, value, state, separator="/"): + """Split the namespace and the base name from a full name. + + :param parameter_name: The name of the parameter being parsed. Used + only to display in the error message. + :type parameter_name: str + :param value: The value to split. Usually a namespace and a repository + (``production/smallimage`` for example), or a robot + account (``production+myrobot`` for example) + :type value: str + :param state: Whether it is a create/update (``present``) operation, or + a delete (``absent``) operation. + :type state: str + :param separator: The separator character between the namespace and the + object. + :type separator: str + + :return: A list. The first item is the namespace, which can be a + personal namespace. The second item in the object name in the + namespace (usually a repository name or a robot account name). + The last item is a Boolean that indicates if the namespace is + an organization (``True``), or a personal namespace + (``False``). + :rtype: list + """ + # Extract namespace and name from the parameter + my_name = self.who_am_i() + try: + namespace, shortname = value.split(separator, 1) + except ValueError: + # No namespace part in the name. Therefore, use the user's personal + # namespace + if my_name: + namespace = my_name + shortname = value + else: + self.fail_json( + msg=( + "The `{param}' parameter must include the" + " organization: {sep}{name}." + ).format(param=parameter_name, sep=separator, name=value) + ) + + # Check whether namespace exists (organization or user account) + namespace_details = self.get_namespace(namespace) + if not namespace_details: + if state == "absent": + self.exit_json(changed=False) + self.fail_json( + msg="The {namespace} namespace does not exist.".format(namespace=namespace) + ) + # Make sure that the current user is the owner of that namespace + if ( + not namespace_details.get("is_organization") + and namespace_details.get("name") != my_name + ): + if my_name: + msg = "You ({user}) are not the owner of {namespace}'s namespace.".format( + user=my_name, namespace=namespace + ) + else: + msg = "You cannot access {namespace}'s namespace.".format(namespace=namespace) + self.fail_json(msg=msg) + + return (namespace, shortname, namespace_details.get("is_organization", False)) + def get_tags(self, namespace, repository, tag=None, digest=None, only_active_tags=True): """Return the list of tags for the given repository. @@ -1419,6 +1485,51 @@ def process_prune_parameters( ) return data + def str_period_to_second(self, parameter_name, value): + """Convert a period string into seconds. + + :param parameter_name: The name of the parameter being parsed. Used + only to display in the error message. + :type parameter_name: str + :param value: The value to convert into seconds. The value accepts + the ``s``, ``m``, ``h``, ``d``, and ``w`` suffixes, or no + suffix, and can contain spaces. + Parsing is case-insensitive. + :type value: str + + :return: The session token. + :rtype: int + """ + try: + return int(value) + except ValueError: + # Second + m = re.match(r"\s*(\d+)\s*s", value, re.IGNORECASE) + if m: + return int(m.group(1)) + # Minute + m = re.match(r"\s*(\d+)\s*m", value, re.IGNORECASE) + if m: + return int(m.group(1)) * 60 + # Hour + m = re.match(r"\s*(\d+)\s*h", value, re.IGNORECASE) + if m: + return int(m.group(1)) * 60 * 60 + # Day + m = re.match(r"\s*(\d+)\s*d", value, re.IGNORECASE) + if m: + return int(m.group(1)) * 60 * 60 * 24 + # Week + m = re.match(r"\s*(\d+)\s*w", value, re.IGNORECASE) + if m: + return int(m.group(1)) * 60 * 60 * 24 * 7 + self.fail_json( + msg=( + "Wrong format for the `{param}' parameter: {value} is not an" + " integer followed by the s, m, h, d, or w suffix." + ).format(param=parameter_name, value=value) + ) + class APIModuleNoAuth(APIModule): AUTH_ARGSPEC = dict( diff --git a/plugins/modules/quay_notification.py b/plugins/modules/quay_notification.py index 4450fa3..8de0611 100644 --- a/plugins/modules/quay_notification.py +++ b/plugins/modules/quay_notification.py @@ -34,9 +34,12 @@ description: - Name of the repository which contains the notifications to manage. The format for the name is C(namespace)/C(shortname). The namespace can be - an organization or a personal namespace. + an organization or your personal namespace. - If you omit the namespace part in the name, then the module looks for the repository in your personal namespace. + - You can manage notifications for repositories in your personal + namespace, but not in the personal namespace of other users. The token + you use in O(quay_token) determines the user account you are using. required: true type: str title: @@ -427,33 +430,7 @@ def main(): vulnerability_level = module.params.get("vulnerability_level") image_expiry_days = module.params.get("image_expiry_days") - # Extract namespace and repository from the repository parameter - my_name = module.who_am_i() - try: - namespace, repo_shortname = repository.split("/", 1) - except ValueError: - # No namespace part in the repository name. Therefore, the repository - # is in the user's personal namespace - if my_name: - namespace = my_name - repo_shortname = repository - else: - module.fail_json( - msg=( - "The `repository' parameter must include the" - " organization: /{name}." - ).format(name=repository) - ) - - # Check whether namespace exists (organization or user account) - namespace_details = module.get_namespace(namespace) - if not namespace_details: - if state == "absent": - module.exit_json(changed=False) - module.fail_json( - msg="The {namespace} namespace does not exist.".format(namespace=namespace) - ) - + namespace, repo_shortname, _not_used = module.split_name("repository", repository, state) full_repo_name = "{namespace}/{repository}".format( namespace=namespace, repository=repo_shortname ) diff --git a/plugins/modules/quay_proxy_cache.py b/plugins/modules/quay_proxy_cache.py index 4f3abc6..4c0a548 100644 --- a/plugins/modules/quay_proxy_cache.py +++ b/plugins/modules/quay_proxy_cache.py @@ -58,14 +58,16 @@ description: - Whether to allow insecure connections to the remote registry. - If V(true), then the module does not validate SSL certificates. + - V(false) by default. type: bool - default: false expiration: description: - Tag expiration in seconds for cached images. + - The O(expiration) parameter accepts a time unit as a suffix; + C(s) for seconds, C(m) for minutes, C(h) for hours, C(d) for days, and + C(w) for weeks. For example, C(8h) for eight hours. - 86400 (one day) by default. - type: int - default: 86400 + type: str state: description: - If V(absent), then the module removes the proxy cache configuration. @@ -107,7 +109,7 @@ registry: quay.io/prodimgs username: cwade password: My53cr3Tpa55 - expiration: 172800 + expiration: 48h state: present quay_host: https://quay.example.com quay_token: vgfH9zH5q6eV16Con7SvDQYSr0KPYQimMHVehZv7 @@ -131,8 +133,8 @@ def main(): registry=dict(default="quay.io"), username=dict(), password=dict(no_log=True), - insecure=dict(type="bool", default=False), - expiration=dict(type="int", default=86400), + insecure=dict(type="bool"), + expiration=dict(type="str"), state=dict(choices=["present", "absent"], default="present"), ) @@ -148,6 +150,13 @@ def main(): expiration = module.params.get("expiration") state = module.params.get("state") + # Verify that the expiration is valid and convert it to an integer (seconds) + s_expiration = ( + module.str_period_to_second("expiration", expiration) + if expiration is not None + else 86400 + ) + # Get the organization details from the given name. # # GET /api/v1/organization/{orgname} @@ -239,10 +248,31 @@ def main(): "organization/{orgname}/proxycache", orgname=organization ) + if state == "absent": + if not cache_details or not cache_details.get("upstream_registry"): + module.exit_json(changed=False) + module.delete( + cache_details, + "proxy cache", + organization, + "organization/{orgname}/proxycache", + orgname=organization, + ) + + if ( + cache_details + and username is None + and password is None + and registry == cache_details.get("upstream_registry") + and (insecure is None or insecure == cache_details.get("insecure")) + and (expiration is None or s_expiration == cache_details.get("expiration_s")) + ): + module.exit_json(changed=False) + # Always remove the proxy cache configuration, because the configuration # cannot be updated (an error is received if you try to set a configuration # when one already exists) - upd = module.delete( + module.delete( cache_details, "proxy cache", organization, @@ -251,14 +281,11 @@ def main(): orgname=organization, ) - if state == "absent": - module.exit_json(changed=upd) - # Prepare the data that gets set for create new_fields = { "org_name": organization, - "expiration_s": int(expiration), - "insecure": insecure, + "expiration_s": s_expiration, + "insecure": insecure if insecure is not None else False, "upstream_registry": registry, "upstream_registry_username": username if username else None, "upstream_registry_password": password if password else None, diff --git a/plugins/modules/quay_repository.py b/plugins/modules/quay_repository.py index e6d2454..0941a8e 100644 --- a/plugins/modules/quay_repository.py +++ b/plugins/modules/quay_repository.py @@ -34,10 +34,13 @@ description: - Name of the repository to create, remove, or modify. The format for the name is C(namespace)/C(shortname). The namespace can be an organization - or a personal namespace. + or your personal namespace. - The name must be in lowercase and must not contain white spaces. - If you omit the namespace part in the name, then the module uses your personal namespace. + - You can manage repositories in your personal namespace, + but not in the personal namespace of other users. The token you use in + O(quay_token) determines the user account you are using. required: true type: str visibility: @@ -309,23 +312,7 @@ def main(): ) auto_prune_value = value - my_name = module.who_am_i() - try: - namespace, repo_shortname = name.split("/", 1) - except ValueError: - # No namespace part in the repository name. Therefore, the repository - # is in the user's personal namespace - if my_name: - namespace = my_name - repo_shortname = name - else: - module.fail_json( - msg=( - "The `name' parameter must include the" - " organization: /{name}." - ).format(name=name) - ) - + namespace, repo_shortname, _not_used = module.split_name("name", name, state) full_repo_name = "{namespace}/{repository}".format( namespace=namespace, repository=repo_shortname ) @@ -351,9 +338,7 @@ def main(): # "can_admin": true # } repo_details = module.get_object_path( - "repository/{full_repo_name}", - ok_error_codes=[404, 403], - full_repo_name=full_repo_name, + "repository/{full_repo_name}", full_repo_name=full_repo_name ) # Remove the repository @@ -366,13 +351,6 @@ def main(): full_repo_name=full_repo_name, ) - # Check whether namespace exists (organization or user account) - namespace_details = module.get_namespace(namespace) - if not namespace_details: - module.fail_json( - msg="The {namespace} namespace does not exist.".format(namespace=namespace) - ) - changed = False if not repo_details: # Create the repository diff --git a/plugins/modules/quay_repository_mirror.py b/plugins/modules/quay_repository_mirror.py index a1e240e..8567f18 100644 --- a/plugins/modules/quay_repository_mirror.py +++ b/plugins/modules/quay_repository_mirror.py @@ -33,8 +33,13 @@ name: description: - Name of the existing repository for which the mirror parameters are - configured. The format for the name is C(namespace)/C(shortname). The - namespace can only be an organization namespace. + configured. The format for the name is C(namespace)/C(shortname).The + namespace can be an organization or your personal namespace. + - If you omit the namespace part in the name, then the module looks for + the repository in your personal namespace. + - You can manage mirrors for repositories in your personal + namespace, but not in the personal namespace of other users. The token + you use in O(quay_token) determines the user account you are using. required: true type: str is_enabled: @@ -46,7 +51,7 @@ description: - Path to the remote container repository to synchronize, such as quay.io/projectquay/quay for example. - - That parameter is required when creating the mirroring configuration. + - This parameter is required when creating the mirroring configuration. type: str external_registry_username: description: @@ -59,8 +64,11 @@ sync_interval: description: - Synchronization interval for this repository mirror in seconds. + - The O(sync_interval) parameter accepts a time unit as a suffix; + C(s) for seconds, C(m) for minutes, C(h) for hours, C(d) for days, and + C(w) for weeks. For example, C(8h) for eight hours. - 86400 (one day) by default. - type: int + type: str sync_start_date: description: - The date and time at which the first synchronization should be @@ -75,7 +83,7 @@ robot_username: description: - Username of the robot account that is used for synchronization. - - That parameter is required when creating the mirroring configuration. + - This parameter is required when creating the mirroring configuration. type: str image_tags: description: @@ -186,7 +194,7 @@ def main(): external_registry_password=dict(no_log=True), verify_tls=dict(type="bool"), image_tags=dict(type="list", elements="str"), - sync_interval=dict(type="int"), + sync_interval=dict(type="str"), sync_start_date=dict(), http_proxy=dict(), https_proxy=dict(), @@ -212,30 +220,14 @@ def main(): https_proxy = module.params.get("https_proxy") no_proxy = module.params.get("no_proxy") - my_name = module.who_am_i() - try: - namespace, repo_shortname = name.split("/", 1) - except ValueError: - # No namespace part in the repository name. Therefore, the repository - # is in the user's personal namespace - if my_name: - namespace = my_name - repo_shortname = name - else: - module.fail_json( - msg=( - "The `name' parameter must include the" - " organization: /{name}." - ).format(name=name) - ) - - # Check whether namespace exists (organization or user account) - namespace_details = module.get_namespace(namespace) - if not namespace_details: - module.fail_json( - msg="The {namespace} namespace does not exist.".format(namespace=namespace) - ) + # Verify that the interval is valid and convert it to an integer (seconds) + s_interval = ( + module.str_period_to_second("sync_interval", sync_interval) + if sync_interval is not None + else 86400 + ) + namespace, repo_shortname, _not_used = module.split_name("name", name, "present") full_repo_name = "{namespace}/{repository}".format( namespace=namespace, repository=repo_shortname ) @@ -299,7 +291,7 @@ def main(): "robot_username": robot_username, "external_reference": external_reference, "root_rule": {"rule_kind": "tag_glob_csv", "rule_value": image_tags}, - "sync_interval": int(sync_interval) if sync_interval is not None else 86400, + "sync_interval": s_interval, "sync_start_date": ( sync_start_date if sync_start_date @@ -350,7 +342,7 @@ def main(): if sync_start_date is not None: new_fields["sync_start_date"] = sync_start_date if sync_interval is not None: - new_fields["sync_interval"] = int(sync_interval) + new_fields["sync_interval"] = s_interval if robot_username is not None: new_fields["robot_username"] = robot_username if external_reference is not None: diff --git a/plugins/modules/quay_repository_prune.py b/plugins/modules/quay_repository_prune.py index 4bacd73..84189ea 100644 --- a/plugins/modules/quay_repository_prune.py +++ b/plugins/modules/quay_repository_prune.py @@ -34,10 +34,13 @@ repository: description: - Name of the existing repository to configure. The format for the name is - C(namespace)/C(shortname). The namespace can be an organization or a + C(namespace)/C(shortname). The namespace can be an organization or your personal namespace. - If you omit the namespace part in the name, then the module looks for the repository in your personal namespace. + - You can manage auto-pruning policies for repositories in your personal + namespace, but not in the personal namespace of other users. The token + you use in O(quay_token) determines the user account you are using. required: true type: str append: @@ -167,33 +170,7 @@ def main(): # Convert the parameters to a dictionary that can be used with the API data = module.process_prune_parameters(method, value, tag_pattern, tag_pattern_matches) - # Extract namespace and repository from the repository parameter - my_name = module.who_am_i() - try: - namespace, repo_shortname = repository.split("/", 1) - except ValueError: - # No namespace part in the repository name. Therefore, the repository - # is in the user's personal namespace - if my_name: - namespace = my_name - repo_shortname = repository - else: - module.fail_json( - msg=( - "The `repository' parameter must include the" - " organization: /{name}." - ).format(name=repository) - ) - - # Check whether namespace exists (organization or user account) - namespace_details = module.get_namespace(namespace) - if not namespace_details: - if state == "absent": - module.exit_json(changed=False) - module.fail_json( - msg="The {namespace} namespace does not exist.".format(namespace=namespace) - ) - + namespace, repo_shortname, _not_used = module.split_name("repository", repository, state) full_repo_name = "{namespace}/{repository}".format( namespace=namespace, repository=repo_shortname ) diff --git a/plugins/modules/quay_robot.py b/plugins/modules/quay_robot.py index d86d40b..8db757c 100644 --- a/plugins/modules/quay_robot.py +++ b/plugins/modules/quay_robot.py @@ -38,7 +38,7 @@ name: description: - Name of the robot account to create or remove, in the format - C(namespace)+C(shortname). The namespace can be an organization or a + C(namespace)+C(shortname). The namespace can be an organization or your personal namespace. - The short name (the part after the C(+) sign) must be in lowercase, must not contain white spaces, must not start by a digit, and must be @@ -55,6 +55,34 @@ - Description of the robot account. You cannot update the description of existing robot accounts. type: str + federations: + description: + - Federation configurations, which enable keyless authentication with + robot accounts. + - Robot account federations require Quay version 3.13 or later. + type: list + elements: dict + suboptions: + issuer: + description: + - OpenID Connect (OIDC) issuer URL. + required: true + type: str + subject: + description: + - OpenID Connect (OIDC) subject. + required: true + type: str + append: + description: + - If V(true), then add the robot account federation configurations + defined in O(federations). + - If V(false), then the module sets the federation configurations + specified in O(federations), removing all others federation + configurations. + - Robot account federations require Quay version 3.13 or later. + type: bool + default: true state: description: - If V(absent), then the module deletes the robot account. @@ -68,6 +96,8 @@ notes: - The token that you provide in O(quay_token) must have the "Administer Organization" and "Administer User" permissions. + - The O(federations) and O(append) parameters require Quay version 3.13 or + later. attributes: check_mode: support: full @@ -113,6 +143,18 @@ state: absent quay_host: https://quay.example.com quay_token: vgfH9zH5q6eV16Con7SvDQYSr0KPYQimMHVehZv7 + +# Robot account federations require Quay version 3.13 or later +- name: Ensure the robot account production+robotprod2 exists, with federation + infra.quay_configuration.quay_robot: + name: production+robotprod2 + description: Second robot account for production + federations: + - issuer: https://keycloak-auth-realm.quayadmin.org/realms/quayrealm + subject: 449e14f8-9eb5-4d59-a63e-b7a77c75f770 + state: present + quay_host: https://quay.example.com + quay_token: vgfH9zH5q6eV16Con7SvDQYSr0KPYQimMHVehZv7 """ RETURN = r""" @@ -132,6 +174,8 @@ sample: IWG3K5EW92KZLPP42PMOKM5CJ2DEAQMSCU33A35NR7MNL21004NKVP3BECOWSQP2 """ +import json + from ..module_utils.api_module import APIModule @@ -158,6 +202,15 @@ def main(): argument_spec = dict( name=dict(required=True), description=dict(), + federations=dict( + type="list", + elements="dict", + options=dict( + issuer=dict(required=True), + subject=dict(required=True), + ), + ), + append=dict(type="bool", default=True), state=dict(choices=["present", "absent"], default="present"), ) @@ -167,54 +220,27 @@ def main(): # Extract our parameters name = module.params.get("name") description = module.params.get("description") + federations = module.params.get("federations") + append = module.params.get("append") state = module.params.get("state") - my_name = module.who_am_i() - try: - namespace, robot_shortname = name.split("+", 1) - except ValueError: - # No namespace part in the robot account name. Therefore, the robot - # account is in the user's personal namespace - if my_name: - namespace = my_name - robot_shortname = name - else: - module.fail_json( - msg=( - "The `name' parameter must include the" - " organization: +{name}." - ).format(name=name) - ) - - # Check whether namespace exists (organization or user account) - namespace_details = module.get_namespace(namespace) - if not namespace_details: - if state == "absent": - module.exit_json(changed=False) - module.fail_json( - msg="The {namespace} namespace does not exist.".format(namespace=namespace) - ) - # Make sure that the current user is the owner of that namespace - if ( - not namespace_details.get("is_organization") - and namespace_details.get("name") != my_name - ): - if my_name: - msg = "You ({user}) are not the owner of {namespace}'s namespace.".format( - user=my_name, namespace=namespace - ) - else: - msg = "You cannot access {namespace}'s namespace.".format(namespace=namespace) - module.fail_json(msg=msg) + namespace, robot_shortname, is_org = module.split_name("name", name, state, separator="+") # Build the API URL to access the robot object. - if namespace_details.get("is_organization"): + if is_org: path_url = "organization/{orgname}/robots/{robot_shortname}".format( orgname=namespace, robot_shortname=robot_shortname ) else: path_url = "user/robots/{robot_shortname}".format(robot_shortname=robot_shortname) + fed_url = "{url}/federation".format(url=path_url) + fed_req_set = ( + set([(f.get("issuer"), f.get("subject")) for f in federations]) + if federations + else set() + ) + # Get the robot account details. # # For robot accounts in organizations: @@ -247,15 +273,51 @@ def main(): module.delete(robot_details, "robot account", name, path_url) if robot_details: - exit_module(module, False, robot_details) + # GET /api/v1/organization/{orgname}/robots/{robot_shortname}/federation + # [ + # { + # "issuer": "https://keycloak-realm.quayadmin.org/realms/quayrealm", + # "subject": "449e14f8-9eb5-4d59-a63e-b7a77c75f770" + # } + # ] + fed_details = module.get_object_path(fed_url) + fed_curr_set = ( + set([(f.get("issuer"), f.get("subject")) for f in fed_details]) + if fed_details + else set() + ) + fed_to_add = fed_req_set - fed_curr_set + + if federations is None or fed_req_set == fed_curr_set or (append and not fed_to_add): + exit_module(module, False, robot_details) + + if append: + new_fields = [ + {"issuer": f[0], "subject": f[1], "isExpanded": False} + for f in fed_req_set | fed_curr_set + ] + else: + new_fields = [ + {"issuer": f[0], "subject": f[1], "isExpanded": False} for f in fed_req_set + ] + data = json.dumps(new_fields).encode() + module.create("robot account federation", name, fed_url, data, auto_exit=False) + exit_module(module, True, robot_details) # Prepare the data that gets set for create new_fields = {} if description: new_fields["description"] = description - data = module.unconditional_update("robot account", name, path_url, new_fields) - exit_module(module, True, data) + robot_data = module.unconditional_update("robot account", name, path_url, new_fields) + if federations: + new_fields = [ + {"issuer": f.get("issuer"), "subject": f.get("subject"), "isExpanded": False} + for f in federations + ] + data = json.dumps(new_fields).encode() + module.create("robot account federation", name, fed_url, data, auto_exit=False) + exit_module(module, True, robot_data) if __name__ == "__main__": diff --git a/plugins/modules/quay_team_ldap.py b/plugins/modules/quay_team_ldap.py index 6f18eee..0fb031d 100644 --- a/plugins/modules/quay_team_ldap.py +++ b/plugins/modules/quay_team_ldap.py @@ -33,8 +33,8 @@ name: description: - Name of the team to synchronize or unsynchronize with an LDAP group. - That team must exist (see the M(infra.quay_configuration.quay_team) module to - create it). + This team must exist (see the M(infra.quay_configuration.quay_team) + module to create it). required: true type: str organization: diff --git a/plugins/modules/quay_team_oidc.py b/plugins/modules/quay_team_oidc.py index 68608cc..f7a5b14 100644 --- a/plugins/modules/quay_team_oidc.py +++ b/plugins/modules/quay_team_oidc.py @@ -33,8 +33,8 @@ name: description: - Name of the team to synchronize or unsynchronize with an OIDC group. - That team must exist (see the M(infra.quay_configuration.quay_team) module to - create it). + This team must exist (see the M(infra.quay_configuration.quay_team) + module to create it). required: true type: str organization: diff --git a/roles/quay_org/README.md b/roles/quay_org/README.md index 179bdd3..a0b7f59 100644 --- a/roles/quay_org/README.md +++ b/roles/quay_org/README.md @@ -88,7 +88,7 @@ Example Playbook quay_org_email: production@example.com # Proxy cache quay_org_cache_registry: quay.io/sclorg - quay_org_cache_expiration: 259200 + quay_org_cache_expiration: 3d # Quota quay_org_quota: 1.5 TiB quay_org_warning_pct: 90 diff --git a/roles/quay_org/meta/argument_specs.yml b/roles/quay_org/meta/argument_specs.yml index 0a8d684..7330e09 100644 --- a/roles/quay_org/meta/argument_specs.yml +++ b/roles/quay_org/meta/argument_specs.yml @@ -127,8 +127,11 @@ argument_specs: quay_org_cache_expiration: description: - Tag expiration in seconds for cached images. + - The O(quay_org_cache_expiration) parameter accepts a time unit as a + suffix; C(s) for seconds, C(m) for minutes, C(h) for hours, C(d) + for days, and C(w) for weeks. For example, C(8h) for eight hours. - 86400 (one day) by default. - type: int + type: str default: 86400 quay_org_quota: description: @@ -239,6 +242,24 @@ argument_specs: - Description of the robot account. You cannot update the description of existing robot accounts. type: str + federations: + description: + - Federation configurations, which enable keyless authentication + with robot accounts. + - Robot account federations require Quay version 3.13 or later. + type: list + elements: dict + options: + issuer: + description: + - OpenID Connect (OIDC) issuer URL. + required: true + type: str + subject: + description: + - OpenID Connect (OIDC) subject. + required: true + type: str quay_org_teams: description: - List of teams to create in the organization. diff --git a/roles/quay_org/tasks/organization_prune.yml b/roles/quay_org/tasks/organization_prune.yml index 8ba27dc..06f233f 100644 --- a/roles/quay_org/tasks/organization_prune.yml +++ b/roles/quay_org/tasks/organization_prune.yml @@ -4,8 +4,8 @@ namespace: "{{ quay_org_name }}" method: "{{ item['method'] }}" value: "{{ item['value'] }}" - tag_pattern: "{{ item['tag_pattern'] | default(omit) }}" - tag_pattern_matches: "{{ item['tag_pattern_matches'] | default(omit) }}" + tag_pattern: "{{ item['tag_pattern'] | default(omit) }}" + tag_pattern_matches: "{{ item['tag_pattern_matches'] | default(omit) }}" state: present quay_token: "{{ quay_org_token | default(omit) }}" quay_username: "{{ quay_org_username | default(omit) }}" diff --git a/roles/quay_org/tasks/repositories.yml b/roles/quay_org/tasks/repositories.yml index a3ddcac..90a3236 100644 --- a/roles/quay_org/tasks/repositories.yml +++ b/roles/quay_org/tasks/repositories.yml @@ -22,8 +22,8 @@ repository: "{{ quay_org_name }}/{{ item[0]['name'] }}" method: "{{ item[1]['method'] }}" value: "{{ item[1]['value'] }}" - tag_pattern: "{{ item[1]['tag_pattern'] | default(omit) }}" - tag_pattern_matches: "{{ item[1]['tag_pattern_matches'] | default(omit) }}" + tag_pattern: "{{ item[1]['tag_pattern'] | default(omit) }}" + tag_pattern_matches: "{{ item[1]['tag_pattern_matches'] | default(omit) }}" state: present quay_token: "{{ quay_org_token | default(omit) }}" quay_username: "{{ quay_org_username | default(omit) }}" diff --git a/roles/quay_org/tasks/robots.yml b/roles/quay_org/tasks/robots.yml index 8693b3c..c19ff7c 100644 --- a/roles/quay_org/tasks/robots.yml +++ b/roles/quay_org/tasks/robots.yml @@ -4,6 +4,8 @@ name: "{{ item['name'] if '+' in item['name'] else quay_org_name + '+' + item['name'] }}" description: "{{ item['description'] | default(omit) }}" + federations: "{{ item['federations'] | default(omit) }}" + append: false state: present quay_token: "{{ quay_org_token | default(omit) }}" quay_username: "{{ quay_org_username | default(omit) }}" diff --git a/roles/quay_org/tests/test.yml b/roles/quay_org/tests/test.yml index 64440d1..562a9cb 100644 --- a/roles/quay_org/tests/test.yml +++ b/roles/quay_org/tests/test.yml @@ -14,7 +14,7 @@ quay_org_name: production quay_org_email: production@example.com quay_org_cache_registry: public.ecr.aws/nginx - quay_org_cache_expiration: 345600 + quay_org_cache_expiration: 4d quay_org_quota: 500 GiB quay_org_warning_pct: 80 quay_org_reject_pct: 90 diff --git a/tests/integration/targets/check_mode/tasks/main.yml b/tests/integration/targets/check_mode/tasks/main.yml index b1d252e..a20b747 100644 --- a/tests/integration/targets/check_mode/tasks/main.yml +++ b/tests/integration/targets/check_mode/tasks/main.yml @@ -114,7 +114,7 @@ that: result['changed'] fail_msg: The preceding task should have deleted the user account -- name: Ensure user lvasquez does not exist (check mode) +- name: Ensure user lvasquez does not exist infra.quay_configuration.quay_user: username: lvasquez email: newemail@example.com @@ -128,4 +128,68 @@ ansible.builtin.assert: that: result['changed'] fail_msg: The preceding task should have deleted the user account + +# Expected errors +- name: ERROR EXPECTED Ensure the user exists (host does not exist) + infra.quay_configuration.quay_user: + username: lvasquez + email: newemail@example.com + state: present + quay_host: http://doesnotexists.local + quay_token: "{{ quay_token }}" + validate_certs: false + ignore_errors: true + register: result + +- name: Ensure that the task failed (host does not exist) + ansible.builtin.assert: + that: result['failed'] + fail_msg: The preceding task should have failed (host does not exist) + +- name: ERROR EXPECTED Ensure the user exists (cannot connect) + infra.quay_configuration.quay_user: + username: lvasquez + email: newemail@example.com + state: present + quay_host: https://locahost:12345 + quay_token: "{{ quay_token }}" + validate_certs: false + ignore_errors: true + register: result + +- name: Ensure that the task failed (cannot connect to the API) + ansible.builtin.assert: + that: result['failed'] + fail_msg: The preceding task should have failed (cannot connect) + +- name: ERROR EXPECTED Ensure the user exists (SSL validation) + infra.quay_configuration.quay_user: + username: lvasquez + email: newemail@example.com + state: present + quay_host: "{{ quay_host }}" + quay_token: "{{ quay_token }}" + ignore_errors: true + register: result + +- name: Ensure that the task failed (SSL validation) + ansible.builtin.assert: + that: result['failed'] + fail_msg: The preceding task should have failed (SSL validation) + +- name: ERROR EXPECTED Ensure the user exists (credentials) + infra.quay_configuration.quay_user: + username: lvasquez + email: newemail@example.com + state: present + quay_host: "{{ quay_host }}" + quay_token: "AABBCCDDEEFFGGHH" + validate_certs: false + ignore_errors: true + register: result + +- name: Ensure that the task failed (wrong credentials) + ansible.builtin.assert: + that: result['failed'] + fail_msg: The preceding task should have failed (wrong credentials) ... diff --git a/tests/integration/targets/quay_notification/tasks/main.yml b/tests/integration/targets/quay_notification/tasks/main.yml index 8f7bfcb..2bff8da 100644 --- a/tests/integration/targets/quay_notification/tasks/main.yml +++ b/tests/integration/targets/quay_notification/tasks/main.yml @@ -72,6 +72,27 @@ that: result['failed'] fail_msg: The preceding task should have failed (missing parameters) +- name: ERROR EXPECTED Access to another user namespace + infra.quay_configuration.quay_notification: + repository: ansibletestuser1/ansibletestrepo + title: Test Quay Notification + event: repo_push + method: quay_notification + config: + name: ansibletestteam1 + type: team + state: present + quay_host: "{{ quay_url }}" + quay_token: "{{ quay_token }}" + validate_certs: false + ignore_errors: true + register: result + +- name: Ensure that the task failed + ansible.builtin.assert: + that: result['failed'] + fail_msg: The preceding task should have failed (not allowed) + - name: Ensure notification of type Quay Notification exists infra.quay_configuration.quay_notification: repository: ansibletestorg/ansibletestrepo diff --git a/tests/integration/targets/quay_proxy_cache/tasks/main.yml b/tests/integration/targets/quay_proxy_cache/tasks/main.yml index 2a10445..7d9054a 100644 --- a/tests/integration/targets/quay_proxy_cache/tasks/main.yml +++ b/tests/integration/targets/quay_proxy_cache/tasks/main.yml @@ -17,9 +17,9 @@ infra.quay_configuration.quay_proxy_cache: organization: ansibletestorg registry: quay.io/projectquay - expiration: 172800 + expiration: 2d insecure: true - # Non-existing fake user + # Nonexisting fake user username: cwade16324 password: My53cr3Tpa55 state: present @@ -33,13 +33,29 @@ that: result['changed'] fail_msg: The preceding task should have changed something +- name: Ensure proxy cache configuration exists (no change) + infra.quay_configuration.quay_proxy_cache: + organization: ansibletestorg + registry: quay.io/projectquay + expiration: 48h + state: present + quay_host: "{{ quay_url }}" + quay_token: "{{ quay_token }}" + validate_certs: false + register: result + +- name: Ensure that the task did not change anything + ansible.builtin.assert: + that: not result['changed'] + fail_msg: The preceding task should not have changed anything + - name: Ensure proxy cache configuration exists (2) infra.quay_configuration.quay_proxy_cache: organization: ansibletestorg registry: quay.io/projectquay - expiration: 172800 + expiration: 48h insecure: false - # Non-existing fake user + # Nonexisting fake user username: cwade16324 password: My53cr3Tpa55 state: present diff --git a/tests/integration/targets/quay_repository/tasks/main.yml b/tests/integration/targets/quay_repository/tasks/main.yml index 4103d1b..0d3566d 100644 --- a/tests/integration/targets/quay_repository/tasks/main.yml +++ b/tests/integration/targets/quay_repository/tasks/main.yml @@ -1,4 +1,34 @@ --- +- name: ERROR EXPECTED Access to another user namespace + infra.quay_configuration.quay_repository: + name: ansibletestuser1/ansibletestrepo1 + state: present + quay_host: "{{ quay_url }}" + quay_token: "{{ quay_token }}" + validate_certs: false + ignore_errors: true + register: result + +- name: Ensure that the task failed + ansible.builtin.assert: + that: result['failed'] + fail_msg: The preceding task should have failed (not allowed) + +- name: ERROR EXPECTED Nonexisting namespace + infra.quay_configuration.quay_repository: + name: doesnotexist/ansibletestrepo1 + state: present + quay_host: "{{ quay_url }}" + quay_token: "{{ quay_token }}" + validate_certs: false + ignore_errors: true + register: result + +- name: Ensure that the task failed + ansible.builtin.assert: + that: result['failed'] + fail_msg: The preceding task should have failed (nonexisting namespace) + - name: Ensure repository ansibletestrepo1 exists infra.quay_configuration.quay_repository: name: ansibletestorg/ansibletestrepo1 diff --git a/tests/integration/targets/quay_repository_mirror/tasks/main.yml b/tests/integration/targets/quay_repository_mirror/tasks/main.yml index 6b074ea..a2b6af9 100644 --- a/tests/integration/targets/quay_repository_mirror/tasks/main.yml +++ b/tests/integration/targets/quay_repository_mirror/tasks/main.yml @@ -29,6 +29,26 @@ that: result['failed'] fail_msg: The preceding task should have failed (missing parameters) +- name: ERROR EXPECTED Access to another user namespace + infra.quay_configuration.quay_repository_mirror: + name: ansibletestuser1/ansibletestrepo1 + external_reference: docker.io/library/hello-world + robot_username: ansibletestorg+ansibletestrobot1 + image_tags: + - latest + sync_interval: 43200 + sync_start_date: "2021-01-01T12:00:00Z" + quay_host: "{{ quay_url }}" + quay_token: "{{ quay_token }}" + validate_certs: false + ignore_errors: true + register: result + +- name: Ensure that the task failed + ansible.builtin.assert: + that: result['failed'] + fail_msg: The preceding task should have failed (not allowed) + - name: Ensure repository mirror configuration for ansibletestrepo1 exists infra.quay_configuration.quay_repository_mirror: name: ansibletestorg/ansibletestrepo1 @@ -48,6 +68,7 @@ external_reference: docker.io/library/hello-world http_proxy: "" verify_tls: true + sync_interval: 12h image_tags: - latest quay_host: "{{ quay_url }}" @@ -82,7 +103,7 @@ http_proxy: "" no_proxy: quay.io verify_tls: false - sync_interval: 21600 + sync_interval: 360m image_tags: - linux quay_host: "{{ quay_url }}" @@ -110,7 +131,7 @@ external_registry_username: jziglar external_registry_password: vs9mrD55NP verify_tls: false - sync_interval: 21600 + sync_interval: 21600s sync_start_date: "2021-11-02T21:42:00Z" image_tags: - linux @@ -134,6 +155,26 @@ quay_token: "{{ quay_token }}" validate_certs: false +- name: ERROR EXPECTED Wrong sync interval + infra.quay_configuration.quay_repository_mirror: + name: ansibletestorg/ansibletestrepo1 + external_reference: docker.io/library/hello-world + robot_username: ansibletestorg+ansibletestrobot1 + image_tags: + - latest + sync_interval: notaninterval + sync_start_date: "2021-01-01T12:00:00Z" + quay_host: "{{ quay_url }}" + quay_token: "{{ quay_token }}" + validate_certs: false + ignore_errors: true + register: result + +- name: Ensure that the task failed + ansible.builtin.assert: + that: result['failed'] + fail_msg: The preceding task should have failed (wrong sync interval) + - name: ERROR EXPECTED Non-existing repository (1) infra.quay_configuration.quay_repository_mirror: name: nonexisting/ansibletestrepo1 @@ -141,7 +182,7 @@ robot_username: ansibletestorg+ansibletestrobot1 image_tags: - latest - sync_interval: 43200 + sync_interval: 4d sync_start_date: "2021-01-01T12:00:00Z" quay_host: "{{ quay_url }}" quay_token: "{{ quay_token }}" @@ -161,7 +202,7 @@ robot_username: ansibletestorg+ansibletestrobot1 image_tags: - latest - sync_interval: 43200 + sync_interval: 4w sync_start_date: "2021-01-01T12:00:00Z" quay_host: "{{ quay_url }}" quay_token: "{{ quay_token }}" diff --git a/tests/integration/targets/quay_repository_prune/tasks/main.yml b/tests/integration/targets/quay_repository_prune/tasks/main.yml index 95989a5..2840a08 100644 --- a/tests/integration/targets/quay_repository_prune/tasks/main.yml +++ b/tests/integration/targets/quay_repository_prune/tasks/main.yml @@ -63,6 +63,24 @@ that: result['failed'] fail_msg: The preceding task should have failed (wrong date) +- name: ERROR EXPECTED Access to another user namespace + infra.quay_configuration.quay_repository_prune: + repository: ansibletestuser1/ansibletestrepo + method: tags + value: 5 + tag_pattern: "unstable" + tag_pattern_matches: false + quay_host: "{{ quay_url }}" + quay_token: "{{ quay_token }}" + validate_certs: false + ignore_errors: true + register: result + +- name: Ensure that the task failed + ansible.builtin.assert: + that: result['failed'] + fail_msg: The preceding task should have failed (not allowed) + - name: Ensure a policy does not exist in a non-existing repository (no change) infra.quay_configuration.quay_repository_prune: repository: ansibletestorg/nonexisting diff --git a/tests/integration/targets/quay_robot/tasks/main.yml b/tests/integration/targets/quay_robot/tasks/main.yml index d70c319..834a857 100644 --- a/tests/integration/targets/quay_robot/tasks/main.yml +++ b/tests/integration/targets/quay_robot/tasks/main.yml @@ -7,6 +7,12 @@ quay_host: "{{ quay_url }}" quay_token: "{{ quay_token }}" validate_certs: false + register: result + +- name: Ensure that the task did change something + ansible.builtin.assert: + that: result['changed'] + fail_msg: The preceding task should have changed something - name: Ensure robot account testrobot1 exists (no change) infra.quay_configuration.quay_robot: @@ -29,6 +35,12 @@ quay_host: "{{ quay_url }}" quay_token: "{{ quay_token }}" validate_certs: false + register: result + +- name: Ensure that the task did change something + ansible.builtin.assert: + that: result['changed'] + fail_msg: The preceding task should have changed something - name: Ensure robot account testrobot3 does not exist (no change) infra.quay_configuration.quay_robot: @@ -52,6 +64,12 @@ quay_host: "{{ quay_url }}" quay_token: "{{ quay_token }}" validate_certs: false + register: result + +- name: Ensure that the task did change something + ansible.builtin.assert: + that: result['changed'] + fail_msg: The preceding task should have changed something - name: Ensure robot account testrobot4 is removed from my namespace infra.quay_configuration.quay_robot: @@ -61,6 +79,12 @@ quay_host: "{{ quay_url }}" quay_token: "{{ quay_token }}" validate_certs: false + register: result + +- name: Ensure that the task did change something + ansible.builtin.assert: + that: result['changed'] + fail_msg: The preceding task should have changed something - name: Non-existing organization and state=absent (no change) infra.quay_configuration.quay_robot: @@ -76,6 +100,130 @@ that: not result['changed'] fail_msg: The preceding task should not have changed anything +# Federation +- name: Ensure robot account testrobot3 exists with federation + infra.quay_configuration.quay_robot: + name: ansibletestorg+testrobot3 + federations: + - issuer: https://keycloak-realm.quayadmin.org/realms/quayrealm + subject: 449e14f8-9eb5-4d59-a63e-b7a77c75f770 + - issuer: https://keycloak-realm.quayadmin.org/realms/quayrealm2 + subject: 449e14f8-9eb5-4d59-a63e-b7a77c75f771 + state: present + quay_host: "{{ quay_url }}" + quay_token: "{{ quay_token }}" + validate_certs: false + register: result + +- name: Ensure that the task did change something + ansible.builtin.assert: + that: result['changed'] + fail_msg: The preceding task should have changed something + +- name: Ensure robot account testrobot3 exists (no change) 1 + infra.quay_configuration.quay_robot: + name: ansibletestorg+testrobot3 + federations: + - subject: 449e14f8-9eb5-4d59-a63e-b7a77c75f771 + issuer: https://keycloak-realm.quayadmin.org/realms/quayrealm2 + - issuer: https://keycloak-realm.quayadmin.org/realms/quayrealm + subject: 449e14f8-9eb5-4d59-a63e-b7a77c75f770 + append: false + state: present + quay_host: "{{ quay_url }}" + quay_token: "{{ quay_token }}" + validate_certs: false + register: result + +- name: Ensure that the task did not change anything + ansible.builtin.assert: + that: not result['changed'] + fail_msg: The preceding task should not have changed anything + +- name: Ensure robot account testrobot3 exists (no change) 2 + infra.quay_configuration.quay_robot: + name: ansibletestorg+testrobot3 + append: false + state: present + quay_host: "{{ quay_url }}" + quay_token: "{{ quay_token }}" + validate_certs: false + register: result + +- name: Ensure that the task did not change anything + ansible.builtin.assert: + that: not result['changed'] + fail_msg: The preceding task should not have changed anything + +- name: Ensure robot account testrobot3 exists (no change) 3 + infra.quay_configuration.quay_robot: + name: ansibletestorg+testrobot3 + federations: [] + append: true + state: present + quay_host: "{{ quay_url }}" + quay_token: "{{ quay_token }}" + validate_certs: false + register: result + +- name: Ensure that the task did not change anything + ansible.builtin.assert: + that: not result['changed'] + fail_msg: The preceding task should not have changed anything + +- name: Ensure robot account testrobot3 exists with no federation + infra.quay_configuration.quay_robot: + name: ansibletestorg+testrobot3 + federations: [] + append: false + state: present + quay_host: "{{ quay_url }}" + quay_token: "{{ quay_token }}" + validate_certs: false + register: result + +- name: Ensure that the task did change something + ansible.builtin.assert: + that: result['changed'] + fail_msg: The preceding task should have changed something + +- name: Ensure robot account testrobot3 is updated 1 + infra.quay_configuration.quay_robot: + name: ansibletestorg+testrobot3 + federations: + - issuer: https://keycloak-realm.quayadmin.org/realms/quayrealm + subject: 449e14f8-9eb5-4d59-a63e-b7a77c75f770 + append: false + state: present + quay_host: "{{ quay_url }}" + quay_token: "{{ quay_token }}" + validate_certs: false + register: result + +- name: Ensure that the task did change something + ansible.builtin.assert: + that: result['changed'] + fail_msg: The preceding task should have changed something + +- name: Ensure robot account testrobot3 is updated 2 + infra.quay_configuration.quay_robot: + name: ansibletestorg+testrobot3 + federations: + - issuer: https://keycloak-realm.quayadmin.org/realms/quayrealm4 + subject: 449e14f8-9eb5-4d59-a63e-b7a77c75f774 + append: false + state: present + quay_host: "{{ quay_url }}" + quay_token: "{{ quay_token }}" + validate_certs: false + register: result + +- name: Ensure that the task did change something + ansible.builtin.assert: + that: result['changed'] + fail_msg: The preceding task should have changed something + +# Errors - name: ERROR EXPECTED Non-existing organization infra.quay_configuration.quay_robot: name: nonexisting+testrobot5 @@ -144,4 +292,5 @@ loop: - ansibletestorg+testrobot1 - ansibletestorg+testrobot2 + - ansibletestorg+testrobot3 ... diff --git a/tests/integration/targets/role_quay_org/tasks/main.yml b/tests/integration/targets/role_quay_org/tasks/main.yml index 833be42..0d2e4cb 100644 --- a/tests/integration/targets/role_quay_org/tasks/main.yml +++ b/tests/integration/targets/role_quay_org/tasks/main.yml @@ -9,7 +9,7 @@ quay_org_name: testorg quay_org_email: testorg@example.com quay_org_cache_registry: public.ecr.aws/nginx - quay_org_cache_expiration: 345600 + quay_org_cache_expiration: 4d quay_org_cache_insecure: true quay_org_quota: 1.5 TiB quay_org_warning_pct: 90 @@ -31,6 +31,9 @@ quay_org_robots: - name: testrobot1 description: Test robot 1 + federations: + - issuer: https://keycloak-realm.quayadmin.org/realms/quayrealm + subject: 449e14f8-9eb5-4d59-a63e-b7a77c75f770 quay_org_teams: - name: testteam1 description: Test team 1