Skip to content

Commit 33619b0

Browse files
authored
Robot account federation (#17)
1 parent 5a97e57 commit 33619b0

File tree

26 files changed

+703
-191
lines changed

26 files changed

+703
-191
lines changed

CHANGELOG.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,19 @@ Quay Container Registry Collection Release Notes
44

55
.. contents:: Topics
66

7+
v2.5.0
8+
======
9+
10+
Release Summary
11+
---------------
12+
13+
Support configuring keyless authentications with robot accounts.
14+
15+
Minor Changes
16+
-------------
17+
18+
- 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)
19+
720
v2.4.0
821
======
922

changelogs/changelog.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,3 +273,13 @@ releases:
273273
name: quay_repository_prune
274274
namespace: ''
275275
release_date: '2024-11-23'
276+
2.5.0:
277+
changes:
278+
minor_changes:
279+
- Add the ``federations`` option to the ``infra.quay_configuration.quay_robot``
280+
module. With this option, you can configure keyless authentications with robot
281+
accounts (Quay 3.13 and later)
282+
release_summary: Support configuring keyless authentications with robot accounts.
283+
fragments:
284+
- 17-v2.5.0-summary.yml
285+
release_date: '2024-11-26'

galaxy.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
namespace: infra
33
name: quay_configuration
4-
version: 2.4.0
4+
version: 2.5.0
55
readme: README.md
66
authors:
77
- Hervé Quatremain <herve.quatremain@redhat.com>

plugins/module_utils/api_module.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1214,6 +1214,72 @@ def get_namespace(self, namespace, exit_on_error=True):
12141214
return user_details
12151215
return None
12161216

1217+
def split_name(self, parameter_name, value, state, separator="/"):
1218+
"""Split the namespace and the base name from a full name.
1219+
1220+
:param parameter_name: The name of the parameter being parsed. Used
1221+
only to display in the error message.
1222+
:type parameter_name: str
1223+
:param value: The value to split. Usually a namespace and a repository
1224+
(``production/smallimage`` for example), or a robot
1225+
account (``production+myrobot`` for example)
1226+
:type value: str
1227+
:param state: Whether it is a create/update (``present``) operation, or
1228+
a delete (``absent``) operation.
1229+
:type state: str
1230+
:param separator: The separator character between the namespace and the
1231+
object.
1232+
:type separator: str
1233+
1234+
:return: A list. The first item is the namespace, which can be a
1235+
personal namespace. The second item in the object name in the
1236+
namespace (usually a repository name or a robot account name).
1237+
The last item is a Boolean that indicates if the namespace is
1238+
an organization (``True``), or a personal namespace
1239+
(``False``).
1240+
:rtype: list
1241+
"""
1242+
# Extract namespace and name from the parameter
1243+
my_name = self.who_am_i()
1244+
try:
1245+
namespace, shortname = value.split(separator, 1)
1246+
except ValueError:
1247+
# No namespace part in the name. Therefore, use the user's personal
1248+
# namespace
1249+
if my_name:
1250+
namespace = my_name
1251+
shortname = value
1252+
else:
1253+
self.fail_json(
1254+
msg=(
1255+
"The `{param}' parameter must include the"
1256+
" organization: <organization>{sep}{name}."
1257+
).format(param=parameter_name, sep=separator, name=value)
1258+
)
1259+
1260+
# Check whether namespace exists (organization or user account)
1261+
namespace_details = self.get_namespace(namespace)
1262+
if not namespace_details:
1263+
if state == "absent":
1264+
self.exit_json(changed=False)
1265+
self.fail_json(
1266+
msg="The {namespace} namespace does not exist.".format(namespace=namespace)
1267+
)
1268+
# Make sure that the current user is the owner of that namespace
1269+
if (
1270+
not namespace_details.get("is_organization")
1271+
and namespace_details.get("name") != my_name
1272+
):
1273+
if my_name:
1274+
msg = "You ({user}) are not the owner of {namespace}'s namespace.".format(
1275+
user=my_name, namespace=namespace
1276+
)
1277+
else:
1278+
msg = "You cannot access {namespace}'s namespace.".format(namespace=namespace)
1279+
self.fail_json(msg=msg)
1280+
1281+
return (namespace, shortname, namespace_details.get("is_organization", False))
1282+
12171283
def get_tags(self, namespace, repository, tag=None, digest=None, only_active_tags=True):
12181284
"""Return the list of tags for the given repository.
12191285
@@ -1419,6 +1485,51 @@ def process_prune_parameters(
14191485
)
14201486
return data
14211487

1488+
def str_period_to_second(self, parameter_name, value):
1489+
"""Convert a period string into seconds.
1490+
1491+
:param parameter_name: The name of the parameter being parsed. Used
1492+
only to display in the error message.
1493+
:type parameter_name: str
1494+
:param value: The value to convert into seconds. The value accepts
1495+
the ``s``, ``m``, ``h``, ``d``, and ``w`` suffixes, or no
1496+
suffix, and can contain spaces.
1497+
Parsing is case-insensitive.
1498+
:type value: str
1499+
1500+
:return: The session token.
1501+
:rtype: int
1502+
"""
1503+
try:
1504+
return int(value)
1505+
except ValueError:
1506+
# Second
1507+
m = re.match(r"\s*(\d+)\s*s", value, re.IGNORECASE)
1508+
if m:
1509+
return int(m.group(1))
1510+
# Minute
1511+
m = re.match(r"\s*(\d+)\s*m", value, re.IGNORECASE)
1512+
if m:
1513+
return int(m.group(1)) * 60
1514+
# Hour
1515+
m = re.match(r"\s*(\d+)\s*h", value, re.IGNORECASE)
1516+
if m:
1517+
return int(m.group(1)) * 60 * 60
1518+
# Day
1519+
m = re.match(r"\s*(\d+)\s*d", value, re.IGNORECASE)
1520+
if m:
1521+
return int(m.group(1)) * 60 * 60 * 24
1522+
# Week
1523+
m = re.match(r"\s*(\d+)\s*w", value, re.IGNORECASE)
1524+
if m:
1525+
return int(m.group(1)) * 60 * 60 * 24 * 7
1526+
self.fail_json(
1527+
msg=(
1528+
"Wrong format for the `{param}' parameter: {value} is not an"
1529+
" integer followed by the s, m, h, d, or w suffix."
1530+
).format(param=parameter_name, value=value)
1531+
)
1532+
14221533

14231534
class APIModuleNoAuth(APIModule):
14241535
AUTH_ARGSPEC = dict(

plugins/modules/quay_notification.py

Lines changed: 5 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,12 @@
3434
description:
3535
- Name of the repository which contains the notifications to manage. The
3636
format for the name is C(namespace)/C(shortname). The namespace can be
37-
an organization or a personal namespace.
37+
an organization or your personal namespace.
3838
- If you omit the namespace part in the name, then the module looks for
3939
the repository in your personal namespace.
40+
- You can manage notifications for repositories in your personal
41+
namespace, but not in the personal namespace of other users. The token
42+
you use in O(quay_token) determines the user account you are using.
4043
required: true
4144
type: str
4245
title:
@@ -427,33 +430,7 @@ def main():
427430
vulnerability_level = module.params.get("vulnerability_level")
428431
image_expiry_days = module.params.get("image_expiry_days")
429432

430-
# Extract namespace and repository from the repository parameter
431-
my_name = module.who_am_i()
432-
try:
433-
namespace, repo_shortname = repository.split("/", 1)
434-
except ValueError:
435-
# No namespace part in the repository name. Therefore, the repository
436-
# is in the user's personal namespace
437-
if my_name:
438-
namespace = my_name
439-
repo_shortname = repository
440-
else:
441-
module.fail_json(
442-
msg=(
443-
"The `repository' parameter must include the"
444-
" organization: <organization>/{name}."
445-
).format(name=repository)
446-
)
447-
448-
# Check whether namespace exists (organization or user account)
449-
namespace_details = module.get_namespace(namespace)
450-
if not namespace_details:
451-
if state == "absent":
452-
module.exit_json(changed=False)
453-
module.fail_json(
454-
msg="The {namespace} namespace does not exist.".format(namespace=namespace)
455-
)
456-
433+
namespace, repo_shortname, _not_used = module.split_name("repository", repository, state)
457434
full_repo_name = "{namespace}/{repository}".format(
458435
namespace=namespace, repository=repo_shortname
459436
)

plugins/modules/quay_proxy_cache.py

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,16 @@
5858
description:
5959
- Whether to allow insecure connections to the remote registry.
6060
- If V(true), then the module does not validate SSL certificates.
61+
- V(false) by default.
6162
type: bool
62-
default: false
6363
expiration:
6464
description:
6565
- Tag expiration in seconds for cached images.
66+
- The O(expiration) parameter accepts a time unit as a suffix;
67+
C(s) for seconds, C(m) for minutes, C(h) for hours, C(d) for days, and
68+
C(w) for weeks. For example, C(8h) for eight hours.
6669
- 86400 (one day) by default.
67-
type: int
68-
default: 86400
70+
type: str
6971
state:
7072
description:
7173
- If V(absent), then the module removes the proxy cache configuration.
@@ -107,7 +109,7 @@
107109
registry: quay.io/prodimgs
108110
username: cwade
109111
password: My53cr3Tpa55
110-
expiration: 172800
112+
expiration: 48h
111113
state: present
112114
quay_host: https://quay.example.com
113115
quay_token: vgfH9zH5q6eV16Con7SvDQYSr0KPYQimMHVehZv7
@@ -131,8 +133,8 @@ def main():
131133
registry=dict(default="quay.io"),
132134
username=dict(),
133135
password=dict(no_log=True),
134-
insecure=dict(type="bool", default=False),
135-
expiration=dict(type="int", default=86400),
136+
insecure=dict(type="bool"),
137+
expiration=dict(type="str"),
136138
state=dict(choices=["present", "absent"], default="present"),
137139
)
138140

@@ -148,6 +150,13 @@ def main():
148150
expiration = module.params.get("expiration")
149151
state = module.params.get("state")
150152

153+
# Verify that the expiration is valid and convert it to an integer (seconds)
154+
s_expiration = (
155+
module.str_period_to_second("expiration", expiration)
156+
if expiration is not None
157+
else 86400
158+
)
159+
151160
# Get the organization details from the given name.
152161
#
153162
# GET /api/v1/organization/{orgname}
@@ -239,10 +248,31 @@ def main():
239248
"organization/{orgname}/proxycache", orgname=organization
240249
)
241250

251+
if state == "absent":
252+
if not cache_details or not cache_details.get("upstream_registry"):
253+
module.exit_json(changed=False)
254+
module.delete(
255+
cache_details,
256+
"proxy cache",
257+
organization,
258+
"organization/{orgname}/proxycache",
259+
orgname=organization,
260+
)
261+
262+
if (
263+
cache_details
264+
and username is None
265+
and password is None
266+
and registry == cache_details.get("upstream_registry")
267+
and (insecure is None or insecure == cache_details.get("insecure"))
268+
and (expiration is None or s_expiration == cache_details.get("expiration_s"))
269+
):
270+
module.exit_json(changed=False)
271+
242272
# Always remove the proxy cache configuration, because the configuration
243273
# cannot be updated (an error is received if you try to set a configuration
244274
# when one already exists)
245-
upd = module.delete(
275+
module.delete(
246276
cache_details,
247277
"proxy cache",
248278
organization,
@@ -251,14 +281,11 @@ def main():
251281
orgname=organization,
252282
)
253283

254-
if state == "absent":
255-
module.exit_json(changed=upd)
256-
257284
# Prepare the data that gets set for create
258285
new_fields = {
259286
"org_name": organization,
260-
"expiration_s": int(expiration),
261-
"insecure": insecure,
287+
"expiration_s": s_expiration,
288+
"insecure": insecure if insecure is not None else False,
262289
"upstream_registry": registry,
263290
"upstream_registry_username": username if username else None,
264291
"upstream_registry_password": password if password else None,

plugins/modules/quay_repository.py

Lines changed: 6 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,13 @@
3434
description:
3535
- Name of the repository to create, remove, or modify. The format for the
3636
name is C(namespace)/C(shortname). The namespace can be an organization
37-
or a personal namespace.
37+
or your personal namespace.
3838
- The name must be in lowercase and must not contain white spaces.
3939
- If you omit the namespace part in the name, then the module uses your
4040
personal namespace.
41+
- You can manage repositories in your personal namespace,
42+
but not in the personal namespace of other users. The token you use in
43+
O(quay_token) determines the user account you are using.
4144
required: true
4245
type: str
4346
visibility:
@@ -309,23 +312,7 @@ def main():
309312
)
310313
auto_prune_value = value
311314

312-
my_name = module.who_am_i()
313-
try:
314-
namespace, repo_shortname = name.split("/", 1)
315-
except ValueError:
316-
# No namespace part in the repository name. Therefore, the repository
317-
# is in the user's personal namespace
318-
if my_name:
319-
namespace = my_name
320-
repo_shortname = name
321-
else:
322-
module.fail_json(
323-
msg=(
324-
"The `name' parameter must include the"
325-
" organization: <organization>/{name}."
326-
).format(name=name)
327-
)
328-
315+
namespace, repo_shortname, _not_used = module.split_name("name", name, state)
329316
full_repo_name = "{namespace}/{repository}".format(
330317
namespace=namespace, repository=repo_shortname
331318
)
@@ -351,9 +338,7 @@ def main():
351338
# "can_admin": true
352339
# }
353340
repo_details = module.get_object_path(
354-
"repository/{full_repo_name}",
355-
ok_error_codes=[404, 403],
356-
full_repo_name=full_repo_name,
341+
"repository/{full_repo_name}", full_repo_name=full_repo_name
357342
)
358343

359344
# Remove the repository
@@ -366,13 +351,6 @@ def main():
366351
full_repo_name=full_repo_name,
367352
)
368353

369-
# Check whether namespace exists (organization or user account)
370-
namespace_details = module.get_namespace(namespace)
371-
if not namespace_details:
372-
module.fail_json(
373-
msg="The {namespace} namespace does not exist.".format(namespace=namespace)
374-
)
375-
376354
changed = False
377355
if not repo_details:
378356
# Create the repository

0 commit comments

Comments
 (0)