Skip to content

Commit 25df439

Browse files
authored
feat(RELEASE-2533): add update_infra_deployments.py script (konflux-ci#803)
This commit adds a python script to replicate the functionality of the inline bash script in the update-infra-deployments task in the catalog repo. The task will be updated to use this python module instead. Assisted-By: Cursor Signed-off-by: Johnny Bieren <jbieren@redhat.com>
1 parent ee08213 commit 25df439

15 files changed

Lines changed: 2370 additions & 139 deletions

scripts/python/helpers/file.py

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,21 @@
33
from __future__ import annotations
44

55
import hashlib
6+
import json
67
import os
78
import tempfile
89
from pathlib import Path
10+
from typing import Any
11+
12+
13+
def load_json_dict(path: Path) -> dict[str, Any]:
14+
"""Load a JSON file whose root value must be an object."""
15+
with open(path, encoding="utf-8") as handle:
16+
data = json.load(handle)
17+
if not isinstance(data, dict):
18+
msg = f"JSON root must be an object: {path}"
19+
raise TypeError(msg)
20+
return data
921

1022

1123
def sha256(path: Path) -> str:
@@ -21,14 +33,14 @@ def path_from_env_variable(
2133
name: str,
2234
default: str | Path,
2335
) -> Path:
24-
"""Return a filesystem path from the named environment variable, or *default*.
36+
"""Return a filesystem path from an environment variable, or a default.
2537
26-
The value of *name* in ``os.environ`` (if set and non-blank after
27-
``str.strip``) is interpreted as a path; this function does not open or
28-
stat paths.
38+
The value of name in `os.environ` (if set and not blank after
39+
`str.strip`) is interpreted as a path; it is not a path to a file whose
40+
contents you read, and this function does not open or stat paths.
2941
30-
If the variable is missing or only whitespace, *default* is returned (a
31-
str or an existing ``Path``).
42+
If the variable is missing or only whitespace, default is returned (a str
43+
or an existing `Path`).
3244
3345
Typical use: a Tekton or pod env var that holds a mount directory path, with
3446
tests setting the same variable to a temp directory. Existence of the path
@@ -46,13 +58,13 @@ def make_tempfile_path(
4658
) -> Path:
4759
"""Create a secure private temp file and return a pathlib.Path to it.
4860
49-
Uses the standard library ``tempfile.mkstemp``, which creates a new file and
50-
returns a file handle (a safe pattern). We never use the old ``mktemp`` API
51-
(unsafe under concurrency; deprecated in Python 3.12). If ``data`` is given,
61+
Uses the standard library `tempfile.mkstemp`, which creates a new file and
62+
returns a file handle (a safe pattern). We never use the old `mktemp` API
63+
(unsafe under concurrency; deprecated in Python 3.12). If `data` is given,
5264
those bytes are written into the new file; otherwise the file is empty. The
5365
file is closed before returning; the caller is responsible for deleting the
54-
path when done. ``prefix`` is the filename prefix in the system temp
55-
directory, same as the ``prefix`` argument to ``mkstemp``.
66+
path when done. `prefix` is the filename prefix in the system temp
67+
directory, same as the `prefix` argument to `mkstemp`.
5668
"""
5769
fd, name = tempfile.mkstemp(prefix=prefix)
5870
try:

scripts/python/helpers/image_ref.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,56 @@
22

33
from __future__ import annotations
44

5+
import json
6+
import re
57

6-
def pyxis_url_for_pull_spec(pyxis_url: str, pull_spec: str) -> str:
8+
import http_client
9+
import requests
10+
11+
_QUAY_SHA_TAG = re.compile(r"^[0-9a-f]{40}$")
12+
_MAX_QUAY_TAG_PAGES = 50
13+
14+
15+
def resolve_quay_digest_to_git_sha(digest: str, container_image: str) -> str | None:
16+
"""Resolve an image digest to a git commit SHA via the Quay public API.
17+
18+
Returns `None` when resolution fails (non-quay image, no matching tag, etc).
719
"""
8-
Build the Pyxis repository/tag API URL for `pull_spec`.
20+
try:
21+
repo_url = container_image.split("@", 1)[0]
22+
if not repo_url.startswith("quay.io/"):
23+
print("Not a quay.io image, skipping digest resolution")
24+
return None
25+
repo_path = repo_url.removeprefix("quay.io/")
26+
page = 1
27+
while page <= _MAX_QUAY_TAG_PAGES:
28+
url = (
29+
f"https://quay.io/api/v1/repository/{repo_path}/tag/" f"?limit=100&page={page}"
30+
)
31+
try:
32+
body = http_client.get_text(url, timeout=10)
33+
except requests.HTTPError as exc:
34+
code = exc.response.status_code if exc.response is not None else "?"
35+
print(f"Quay API returned {code}, skipping digest resolution")
36+
return None
37+
data = json.loads(body)
38+
for tag in data.get("tags", []):
39+
name = tag.get("name", "")
40+
if tag.get("manifest_digest") == digest and _QUAY_SHA_TAG.fullmatch(name):
41+
print(f"Resolved {digest[:19]}... to git SHA {name}")
42+
return str(name)
43+
if not data.get("has_additional", False):
44+
break
45+
page += 1
46+
print(f"No git SHA tag found for digest {digest[:19]}...")
47+
return None
48+
except Exception as exc:
49+
print(f"Failed to resolve digest to git SHA: {exc}")
50+
return None
51+
52+
53+
def pyxis_url_for_pull_spec(pyxis_url: str, pull_spec: str) -> str:
54+
"""Build the Pyxis repository/tag API URL for `pull_spec`.
955
1056
`registry.redhat.io` is rewritten to `registry.access.redhat.com` to
1157
match Pyxis lookups.

scripts/python/helpers/snapshot.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Helpers for reading Konflux snapshot JSON documents."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
import file
8+
9+
10+
def first_component(snapshot_path: Path) -> dict[str, str]:
11+
"""Return fields from the first snapshot `components` entry.
12+
13+
Keys: `revision`, `origin_repo` (`source.git.url` without `.git`),
14+
and `container_image`.
15+
"""
16+
snapshot = file.load_json_dict(snapshot_path)
17+
components = snapshot.get("components")
18+
if not isinstance(components, list) or not components:
19+
msg = f"snapshot has no components: {snapshot_path}"
20+
raise ValueError(msg)
21+
first = components[0]
22+
if not isinstance(first, dict):
23+
msg = f"snapshot component[0] must be an object: {snapshot_path}"
24+
raise TypeError(msg)
25+
source = first.get("source")
26+
if not isinstance(source, dict):
27+
msg = f"snapshot component[0].source must be an object: {snapshot_path}"
28+
raise TypeError(msg)
29+
git_info = source.get("git")
30+
if not isinstance(git_info, dict):
31+
msg = f"snapshot component[0].source.git must be an object: {snapshot_path}"
32+
raise TypeError(msg)
33+
revision = str(git_info.get("revision", "")).strip()
34+
origin_repo = str(git_info.get("url", "")).strip().removesuffix(".git")
35+
container_image = str(first.get("containerImage", "")).strip()
36+
return {
37+
"revision": revision,
38+
"origin_repo": origin_repo,
39+
"container_image": container_image,
40+
}

scripts/python/helpers/test_file.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
"""Tests for the ``file`` helper module."""
1+
"""Tests for the `file` helper module."""
22

33
from __future__ import annotations
44

5+
import json
56
from pathlib import Path
67

78
import file
@@ -11,7 +12,7 @@
1112
def test_path_from_env_variable_uses_set_value(
1213
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1314
) -> None:
14-
"""A non-empty env value (after trim) is returned as a ``Path``."""
15+
"""A non-empty env value (after trim) is returned as a `Path`."""
1516
p = tmp_path / "m"
1617
monkeypatch.setenv("MOUNT", str(p))
1718
assert file.path_from_env_variable("MOUNT", "/d/e/f") == p
@@ -29,7 +30,7 @@ def test_path_from_env_variable_strips_value(
2930
def test_path_from_env_variable_uses_default_when_unset(
3031
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
3132
) -> None:
32-
"""Unset or all-whitespace *name* yields *default* (``Path`` or str)."""
33+
"""Unset or all-whitespace *name* yields *default* (`Path` or str)."""
3334
default = str(tmp_path / "default")
3435
monkeypatch.delenv("MOUNT", raising=False)
3536
assert file.path_from_env_variable("MOUNT", default) == tmp_path / "default"
@@ -40,14 +41,29 @@ def test_path_from_env_variable_uses_default_when_unset(
4041
def test_path_from_env_variable_path_default(
4142
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
4243
) -> None:
43-
"""*default* may be a ``Path`` object, returned unchanged when the env is unset."""
44+
"""*default* may be a `Path` object, returned unchanged when the env is unset."""
4445
d = tmp_path / "d"
4546
monkeypatch.delenv("MOUNTX", raising=False)
4647
assert file.path_from_env_variable("MOUNTX", d) == d
4748

4849

50+
def test_load_json_dict(tmp_path: Path) -> None:
51+
"""A JSON object file is parsed and returned as a dict."""
52+
path = tmp_path / "data.json"
53+
path.write_text(json.dumps({"a": 1}), encoding="utf-8")
54+
assert file.load_json_dict(path) == {"a": 1}
55+
56+
57+
def test_load_json_dict_rejects_non_object(tmp_path: Path) -> None:
58+
"""A JSON array (non-object root) raises `TypeError`."""
59+
path = tmp_path / "data.json"
60+
path.write_text("[1]", encoding="utf-8")
61+
with pytest.raises(TypeError, match="object"):
62+
file.load_json_dict(path)
63+
64+
4965
def test_make_tempfile_path_empty_file() -> None:
50-
"""A ``None`` payload leaves the created file with zero length."""
66+
"""A `None` payload leaves the created file with zero length."""
5167
p = file.make_tempfile_path("t-", None)
5268
try:
5369
assert p.read_bytes() == b""
@@ -56,7 +72,7 @@ def test_make_tempfile_path_empty_file() -> None:
5672

5773

5874
def test_make_tempfile_path_with_bytes() -> None:
59-
"""If ``data`` is set, the file on disk has exactly those bytes."""
75+
"""If `data` is set, the file on disk has exactly those bytes."""
6076
p = file.make_tempfile_path("t-", b"hello")
6177
try:
6278
assert p.read_bytes() == b"hello"

scripts/python/helpers/test_image_ref.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@
22

33
from __future__ import annotations
44

5+
import json
6+
from unittest import mock
7+
58
import image_ref
69
import pytest
10+
import requests
711

812

913
def test_pyxis_url_for_pull_spec_with_tag_and_registry_rewrite() -> None:
@@ -28,3 +32,103 @@ def test_pyxis_url_for_pull_spec_invalid() -> None:
2832
"""Invalid pull specs raise `ValueError`."""
2933
with pytest.raises(ValueError, match="invalid pull spec"):
3034
image_ref.pyxis_url_for_pull_spec("https://pyxis/v1", "not/a-pullspec")
35+
36+
37+
def test_resolve_quay_digest_skips_non_quay() -> None:
38+
"""Non-quay.io images return `None` without calling the Quay API."""
39+
assert (
40+
image_ref.resolve_quay_digest_to_git_sha(
41+
"sha256:abc",
42+
"registry.io/org/repo@sha256:abc",
43+
)
44+
is None
45+
)
46+
47+
48+
def test_resolve_quay_digest_finds_sha_tag() -> None:
49+
"""A 40-char hex tag matching the digest is returned from the first API page."""
50+
digest = "sha256:" + "a" * 64
51+
sha = "b" * 40
52+
payload = json.dumps(
53+
{
54+
"tags": [{"name": sha, "manifest_digest": digest}],
55+
"has_additional": False,
56+
}
57+
)
58+
with mock.patch("image_ref.http_client.get_text", return_value=payload):
59+
out = image_ref.resolve_quay_digest_to_git_sha(
60+
digest,
61+
f"quay.io/org/repo@{digest}",
62+
)
63+
assert out == sha
64+
65+
66+
def test_resolve_quay_digest_non_200_response() -> None:
67+
"""Quay API errors return `None` instead of raising."""
68+
digest = "sha256:" + "a" * 64
69+
response = mock.MagicMock(status_code=503)
70+
with mock.patch(
71+
"image_ref.http_client.get_text",
72+
side_effect=requests.HTTPError(response=response),
73+
):
74+
out = image_ref.resolve_quay_digest_to_git_sha(
75+
digest,
76+
f"quay.io/org/repo@{digest}",
77+
)
78+
assert out is None
79+
80+
81+
def test_resolve_quay_digest_paginates() -> None:
82+
"""Resolution follows `has_additional` across multiple tag-list pages."""
83+
digest = "sha256:" + "a" * 64
84+
sha = "c" * 40
85+
page_one = json.dumps({"tags": [], "has_additional": True})
86+
page_two = json.dumps(
87+
{
88+
"tags": [{"name": sha, "manifest_digest": digest}],
89+
"has_additional": False,
90+
}
91+
)
92+
with mock.patch(
93+
"image_ref.http_client.get_text",
94+
side_effect=[page_one, page_two],
95+
) as get_text:
96+
out = image_ref.resolve_quay_digest_to_git_sha(
97+
digest,
98+
f"quay.io/org/repo@{digest}",
99+
)
100+
assert out == sha
101+
assert get_text.call_count == 2
102+
103+
104+
def test_resolve_quay_digest_no_matching_tag() -> None:
105+
"""Return `None` when no tag has both the digest and a 40-char hex name."""
106+
digest = "sha256:" + "a" * 64
107+
payload = json.dumps(
108+
{
109+
"tags": [
110+
{"name": "not-a-sha", "manifest_digest": digest},
111+
{"name": "b" * 40, "manifest_digest": "sha256:other"},
112+
],
113+
"has_additional": False,
114+
}
115+
)
116+
with mock.patch("image_ref.http_client.get_text", return_value=payload):
117+
out = image_ref.resolve_quay_digest_to_git_sha(
118+
digest,
119+
f"quay.io/org/repo@{digest}",
120+
)
121+
assert out is None
122+
123+
124+
def test_resolve_quay_digest_handles_exception() -> None:
125+
"""Unexpected failures are swallowed and return `None`."""
126+
with mock.patch(
127+
"image_ref.http_client.get_text",
128+
side_effect=RuntimeError("network down"),
129+
):
130+
out = image_ref.resolve_quay_digest_to_git_sha(
131+
"sha256:abc",
132+
"quay.io/org/repo@sha256:abc",
133+
)
134+
assert out is None

0 commit comments

Comments
 (0)