Skip to content

Commit ee08213

Browse files
authored
feat(RELEASE-2499): rewrite make-repo-public logic in Python (konflux-ci#802)
Replace the inline bash script in the make-repo-public Tekton task with a standalone Python module. The new implementation uses the requests library and shared helpers (http_client, file, logger) for cleaner error handling, CA bundle setup, and Quay registry detection. Unit tests cover all code paths including caching, failure modes, and multi-registry validation. The Dockerfile PYTHONPATH and pyproject.toml pythonpath are extended to include scripts/python/tasks/managed/ so the new module is importable both at runtime and in pytest. Assisted-by: Cursor Signed-off-by: Lubomir Gallovic <lgallovi@redhat.com>
1 parent ca7e5b1 commit ee08213

4 files changed

Lines changed: 748 additions & 1 deletion

File tree

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ ENV PATH="$PATH:/home/publish-to-cgw-wrapper"
160160
# Flat imports: helpers and task scripts must be importable.
161161
# Tests use the same layout via pyproject [tool.pytest.ini_options] pythonpath.
162162
# Keep /home for other modules (e.g. pyxis, sbom) that expect it.
163-
ENV PYTHONPATH="/home:/home/utils:/home/scripts/python/helpers:/home/scripts/python/tasks/internal:/home/pubtools-pulp-wrapper:/home/publish-to-cgw-wrapper"
163+
ENV PYTHONPATH="/home:/home/utils:/home/scripts/python/helpers:/home/scripts/python/tasks/internal:/home/scripts/python/tasks/managed:/home/pubtools-pulp-wrapper:/home/publish-to-cgw-wrapper"
164164

165165
# uv installs newer requests and certifi which don't use the system CA like the one installed via
166166
# dnf. So we need to point requests to the system CA bundle explicitly.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ pythonpath = [
3535
"integration-tests/lib",
3636
"scripts/python/helpers",
3737
"scripts/python/tasks/internal",
38+
"scripts/python/tasks/managed",
3839
"utils",
3940
"pubtools-pulp-wrapper",
4041
"publish-to-cgw-wrapper",
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
#!/usr/bin/env python3
2+
"""Make Quay repositories public using the Quay API."""
3+
4+
from __future__ import annotations
5+
6+
import json
7+
import os
8+
import sys
9+
from pathlib import Path
10+
from typing import Any
11+
12+
import requests
13+
14+
import file
15+
import http_client
16+
from logger import logger
17+
18+
PROG = "make_repo_public.py"
19+
20+
SYSTEM_CA_BUNDLE = "/etc/pki/tls/certs/ca-bundle.crt"
21+
22+
23+
def setup_ca_bundle(ca_cert_path: Path) -> None:
24+
"""Combine system and custom CA bundles if the custom cert exists."""
25+
if not ca_cert_path.is_file():
26+
return
27+
28+
system_bundle = Path(SYSTEM_CA_BUNDLE)
29+
parts: list[bytes] = []
30+
if system_bundle.is_file():
31+
parts.append(system_bundle.read_bytes())
32+
parts.append(ca_cert_path.read_bytes())
33+
combined = file.make_tempfile_path(prefix="combined-ca-bundle-", data=b"\n".join(parts))
34+
35+
os.environ["SSL_CERT_FILE"] = str(combined)
36+
os.environ["CURL_CA_BUNDLE"] = str(combined)
37+
os.environ["REQUESTS_CA_BUNDLE"] = str(combined)
38+
39+
40+
def is_quay_registry(
41+
registry: str,
42+
session: requests.Session,
43+
cache: dict[str, bool],
44+
) -> bool:
45+
"""Return True if ``registry`` exposes a Quay-compatible discovery endpoint.
46+
47+
Issue a GET to ``https://{registry}/api/v1/discovery``; HTTP 200 means
48+
Quay, any other status means not Quay. Results are cached in ``cache``
49+
so each registry is probed only once.
50+
"""
51+
if registry in cache:
52+
return cache[registry]
53+
54+
url = f"https://{registry}/api/v1/discovery"
55+
try:
56+
resp = session.get(url, timeout=30)
57+
result = resp.status_code == 200
58+
except requests.RequestException as exc:
59+
logger.warning(
60+
"Failed to probe discovery endpoint for %s: %s",
61+
registry,
62+
exc,
63+
)
64+
result = False
65+
66+
cache[registry] = result
67+
return result
68+
69+
70+
def make_repo_public(
71+
registry: str,
72+
repo_path: str,
73+
token: str,
74+
session: requests.Session,
75+
) -> None:
76+
"""POST to the Quay API to change repository visibility to public.
77+
78+
Raises ``RuntimeError`` on non-2xx responses. When ``REGISTRY_SECRET_NAME``
79+
is set in the environment, the error message includes a hint about the
80+
expected secret key and permissions.
81+
"""
82+
url = f"https://{registry}/api/v1/repository/{repo_path}/changevisibility"
83+
try:
84+
resp = session.post(
85+
url,
86+
headers={
87+
"Authorization": f"Bearer {token}",
88+
"Content-Type": "application/json",
89+
},
90+
json={"visibility": "public"},
91+
timeout=30,
92+
)
93+
except requests.RequestException as exc:
94+
raise RuntimeError(
95+
f"Failed to connect to {registry} to make {repo_path} public: {exc}"
96+
) from exc
97+
if resp.ok:
98+
logger.info("Repository %s/%s is now public", registry, repo_path)
99+
return
100+
101+
msg = (
102+
f"Failed to make repo {registry}/{repo_path} public"
103+
f" (HTTP {resp.status_code}: {resp.text})."
104+
)
105+
secret_name = os.environ.get("REGISTRY_SECRET_NAME", "").strip()
106+
if secret_name:
107+
msg += (
108+
f" Make sure the secret {secret_name} contains"
109+
' the "token" key with token that has permission to'
110+
" Administer Repositories."
111+
)
112+
raise RuntimeError(msg)
113+
114+
115+
def run(
116+
data_file: Path,
117+
snapshot_file: Path,
118+
secret_path: Path,
119+
ca_cert_path: Path,
120+
) -> None:
121+
"""Orchestrate making repositories public based on data and snapshot files.
122+
123+
Reads the merged data JSON and snapshot JSON, then for each component with
124+
``public: true`` calls the Quay API to change visibility. Only a single
125+
Quay registry is supported per invocation; encountering repos on two
126+
different Quay registries raises ``RuntimeError``.
127+
"""
128+
if not data_file.is_file():
129+
raise RuntimeError("No valid data file was provided.")
130+
if not snapshot_file.is_file():
131+
raise RuntimeError("No valid snapshot file was provided.")
132+
133+
setup_ca_bundle(ca_cert_path)
134+
135+
token_path = secret_path / "token"
136+
if not token_path.is_file():
137+
raise RuntimeError(f"Registry secret token file not found at {token_path}")
138+
token = token_path.read_text(encoding="utf-8").strip()
139+
140+
try:
141+
data: dict[str, Any] = json.loads(data_file.read_text(encoding="utf-8"))
142+
except json.JSONDecodeError as exc:
143+
raise RuntimeError(f"Invalid JSON in data file {data_file}: {exc}") from exc
144+
try:
145+
snapshot: dict[str, Any] = json.loads(snapshot_file.read_text(encoding="utf-8"))
146+
except json.JSONDecodeError as exc:
147+
raise RuntimeError(f"Invalid JSON in snapshot file {snapshot_file}: {exc}") from exc
148+
149+
default_public = data.get("mapping", {}).get("defaults", {}).get("public", False)
150+
151+
session = http_client.get_session()
152+
quay_cache: dict[str, bool] = {}
153+
154+
target_registry: str | None = None
155+
156+
components = snapshot.get("components", [])
157+
for component in components:
158+
public = component.get("public", default_public)
159+
if str(public).lower() != "true":
160+
continue
161+
162+
repositories = component.get("repositories", [])
163+
for repo_entry in repositories:
164+
repo_url = repo_entry.get("url", "")
165+
if not repo_url:
166+
raise RuntimeError(
167+
f"Repository entry in component {component.get('name', '?')}"
168+
" is missing the 'url' field"
169+
)
170+
171+
logger.info("Making repository %s public...", repo_url)
172+
173+
registry = repo_url.split("/")[0]
174+
repo_path = "/".join(repo_url.split("/")[1:]).rstrip("/")
175+
176+
if not is_quay_registry(registry, session, quay_cache):
177+
logger.warning("Registry %s is not a Quay instance. Skipping.", registry)
178+
continue
179+
180+
if target_registry is None:
181+
target_registry = registry
182+
elif target_registry != registry:
183+
raise RuntimeError(
184+
f"Multiple Quay registries found ({target_registry} and"
185+
f" {registry}). Only a single Quay registry is supported"
186+
" because the registrySecret contains a token for one"
187+
" registry."
188+
)
189+
190+
make_repo_public(registry, repo_path, token, session)
191+
192+
193+
def main() -> int:
194+
"""Read environment variables and call ``run()``.
195+
196+
Return 0 on success, 1 on missing env vars or ``RuntimeError`` from
197+
``run()``.
198+
"""
199+
data_file_str = os.environ.get("DATA_FILE", "").strip()
200+
snapshot_file_str = os.environ.get("SNAPSHOT_FILE", "").strip()
201+
202+
if not data_file_str:
203+
print(f"{PROG}: DATA_FILE must be set", file=sys.stderr)
204+
return 1
205+
if not snapshot_file_str:
206+
print(f"{PROG}: SNAPSHOT_FILE must be set", file=sys.stderr)
207+
return 1
208+
209+
secret_path = file.path_from_env_variable("REGISTRY_SECRET_PATH", "/etc/secrets")
210+
ca_cert_path = file.path_from_env_variable("CA_CERT_PATH", "/mnt/trusted-ca/ca-bundle.crt")
211+
212+
try:
213+
run(
214+
Path(data_file_str),
215+
Path(snapshot_file_str),
216+
secret_path,
217+
ca_cert_path,
218+
)
219+
except RuntimeError as e:
220+
print(f"{PROG}: {e}", file=sys.stderr)
221+
return 1
222+
223+
logger.info("make-repo-public completed successfully")
224+
return 0
225+
226+
227+
if __name__ == "__main__":
228+
raise SystemExit(main())

0 commit comments

Comments
 (0)