Skip to content

Commit

Permalink
Robot account federation (#17)
Browse files Browse the repository at this point in the history
  • Loading branch information
herve4m authored Nov 26, 2024
1 parent 5a97e57 commit 33619b0
Show file tree
Hide file tree
Showing 26 changed files with 703 additions and 191 deletions.
13 changes: 13 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
======

Expand Down
10 changes: 10 additions & 0 deletions changelogs/changelog.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
2 changes: 1 addition & 1 deletion galaxy.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
namespace: infra
name: quay_configuration
version: 2.4.0
version: 2.5.0
readme: README.md
authors:
- Hervé Quatremain <[email protected]>
Expand Down
111 changes: 111 additions & 0 deletions plugins/module_utils/api_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: <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.
Expand Down Expand Up @@ -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(
Expand Down
33 changes: 5 additions & 28 deletions plugins/modules/quay_notification.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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: <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
)
Expand Down
51 changes: 39 additions & 12 deletions plugins/modules/quay_proxy_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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"),
)

Expand All @@ -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}
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
34 changes: 6 additions & 28 deletions plugins/modules/quay_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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: <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
)
Expand All @@ -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
Expand All @@ -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
Expand Down
Loading

0 comments on commit 33619b0

Please sign in to comment.