Skip to content

Commit df70910

Browse files
committed
feat(RELEASE-2509): add python scripts for publish-to-nrrc task
add python scripts for publish-to-nrrc task Signed-off-by: Elena German <elgerman@redhat.com> Assisted-by: Claude
1 parent c376ee8 commit df70910

10 files changed

Lines changed: 1226 additions & 6 deletions

Dockerfile

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
FROM quay.io/konflux-ci/oras:latest@sha256:6cea0b9e142c2e18429f5cd30d716715d932047cbf1631334c5c31f7e47c3a19 as oras
22

3+
FROM quay.io/konflux-ci/charon@sha256:5b19341da3b441172a290831908c8783ea82225771030a6fdca16697f83f3143 as charon
4+
35
FROM registry.redhat.io/rhtas/cosign-rhel9:1.3.3-1773309431 as cosign
46

57
FROM registry.redhat.io/advanced-cluster-security/rhacs-roxctl-rhel8:4.10.3-1 as roxctl
@@ -36,6 +38,10 @@ RUN dnf install -y https://dl.fedoraproject.org/pub/epel/epel-release-latest-10.
3638
COPY --from=oras /usr/bin/oras /usr/bin/oras
3739
COPY --from=oras /usr/local/bin/select-oci-auth /usr/local/bin/select-oci-auth
3840
COPY --from=oras /usr/local/bin/get-reference-base /usr/local/bin/get-reference-base
41+
COPY --from=charon /usr/local/bin/charon /usr/local/bin/charon
42+
COPY --from=charon /usr/local/lib/python3.12/site-packages/charon /usr/local/lib/python3.12/site-packages/charon
43+
COPY --from=charon /usr/local/lib/python3.12/site-packages/subresource_integrity.py /usr/local/lib/python3.12/site-packages/subresource_integrity.py
44+
COPY --from=charon /usr/local/lib/python3.12/site-packages/semantic_version /usr/local/lib/python3.12/site-packages/semantic_version
3945
COPY --from=cosign /usr/local/bin/cosign-linux-*.gz /tmp/
4046
RUN ARCH=$(uname -m) && \
4147
if [ "$ARCH" = "x86_64" ]; then \
@@ -160,7 +166,7 @@ ENV PATH="$PATH:/home/publish-to-cgw-wrapper"
160166
# Flat imports: helpers and task scripts must be importable.
161167
# Tests use the same layout via pyproject [tool.pytest.ini_options] pythonpath.
162168
# 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"
169+
ENV PYTHONPATH="/home:/home/utils:/home/scripts/python/helpers:/home/scripts/python/tasks/internal:/home/scripts/python/tasks/managed"
164170

165171
# uv installs newer requests and certifi which don't use the system CA like the one installed via
166172
# 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
]
4041

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""Parse charon parameter files used by NRRC/MRRC publish tasks."""
2+
3+
from __future__ import annotations
4+
5+
import re
6+
import shutil
7+
from pathlib import Path
8+
9+
_REGISTRY_SPLIT = re.compile(r"%")
10+
11+
12+
def load_charon_env(path: Path) -> dict[str, str]:
13+
"""Parse a charon ``.env`` file containing ``export KEY=value`` lines."""
14+
if not path.is_file():
15+
raise FileNotFoundError(f"charon env file not found: {path}")
16+
env: dict[str, str] = {}
17+
for raw in path.read_text(encoding="utf-8", errors="replace").splitlines():
18+
line = raw.strip()
19+
if not line or line.startswith("#"):
20+
continue
21+
if line.startswith("export "):
22+
line = line[len("export ") :]
23+
if "=" not in line:
24+
continue
25+
key, _, value = line.partition("=")
26+
key = key.strip()
27+
value = value.strip()
28+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
29+
value = value[1:-1]
30+
env[key] = value
31+
return env
32+
33+
34+
def split_oci_registries(value: str) -> list[str]:
35+
"""Split ``CHARON_OCI_REGISTRY`` on ``%`` into non-empty registry references."""
36+
return [part.strip() for part in _REGISTRY_SPLIT.split(value) if part.strip()]
37+
38+
39+
def short_sha256_prefix(registry: str) -> str:
40+
"""Return the first six characters of the digest in *registry*."""
41+
marker = "@sha256:"
42+
if marker not in registry:
43+
raise ValueError(f"registry reference missing @sha256: digest: {registry!r}")
44+
return registry.split(marker, 1)[1][:6]
45+
46+
47+
def source_repo(registry: str) -> str:
48+
"""Return the repository part of an OCI reference (before ``@sha256:``)."""
49+
return registry.split("@sha256:", 1)[0]
50+
51+
52+
def require_env_keys(env: dict[str, str], *keys: str) -> None:
53+
"""Raise ValueError when any *keys* are missing from *env*."""
54+
for key in keys:
55+
if key not in env:
56+
raise ValueError(f"missing required charon env variable: {key}")
57+
58+
59+
def require_oci_registries(env: dict[str, str]) -> list[str]:
60+
"""Return non-empty ``CHARON_OCI_REGISTRY`` entries from *env*."""
61+
try:
62+
value = env["CHARON_OCI_REGISTRY"]
63+
except KeyError as e:
64+
raise ValueError("CHARON_OCI_REGISTRY is required in charon env file") from e
65+
registries = split_oci_registries(value)
66+
if not registries:
67+
raise ValueError("CHARON_OCI_REGISTRY must list at least one registry reference")
68+
return registries
69+
70+
71+
def charon_config_path(*, home: Path | None = None) -> Path:
72+
"""Return the default charon configuration file path under *home* or ``Path.home()``."""
73+
root = home if home is not None else Path.home()
74+
return root / ".charon" / "charon.yaml"
75+
76+
77+
def install_charon_config(config_source: Path, *, home: Path | None = None) -> Path:
78+
"""Copy the charon config into ``$HOME/.charon/charon.yaml``."""
79+
if not config_source.is_file():
80+
raise FileNotFoundError(f"charon config file not found: {config_source}")
81+
dest = charon_config_path(home=home)
82+
dest.parent.mkdir(parents=True, exist_ok=True)
83+
shutil.copy2(config_source, dest)
84+
return dest

scripts/python/helpers/file.py

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,20 @@
33
from __future__ import annotations
44

55
import os
6+
import re
7+
import subprocess
68
import tempfile
9+
from collections.abc import Callable, Sequence
710
from pathlib import Path
811

12+
_ARCHIVE_TYPE = re.compile(r"(gzip compressed data|POSIX tar archive)")
13+
914

1015
def path_from_env_variable(
1116
name: str,
1217
default: str | Path,
1318
) -> Path:
14-
"""
15-
Return a filesystem path from the string value of an environment variable, or
16-
a default.
19+
"""Return a path from an environment variable string value, or a default.
1720
1821
The value of name in ``os.environ`` (if set and not blank after
1922
``str.strip``) is interpreted as a path; it is not a path to a file whose
@@ -32,12 +35,38 @@ def path_from_env_variable(
3235
return default if isinstance(default, Path) else Path(default)
3336

3437

38+
def resolve_path_under_base(base: Path, relative: str | Path) -> Path:
39+
"""Resolve *relative* under *base* and ensure the result stays inside *base*.
40+
41+
Rejects absolute paths and ``..`` traversal after resolution. Typical use:
42+
Tekton passes a path relative to a data directory (e.g. charon env/config files).
43+
"""
44+
text = str(relative).strip()
45+
if not text:
46+
raise ValueError(f"path must be relative to {base}: {relative!r}")
47+
rel = Path(text)
48+
if rel.is_absolute():
49+
raise ValueError(f"path must be relative to {base}: {relative!r}")
50+
root = base.resolve()
51+
candidate = (root / rel).resolve()
52+
if not candidate.is_relative_to(root):
53+
raise ValueError(f"path must stay under {base}: {relative!r}")
54+
return candidate
55+
56+
57+
NRRC_WORK_DIR_DEFAULT = Path("/var/workdir/nrrc")
58+
59+
60+
def nrrc_work_dir() -> Path:
61+
"""Return the NRRC staging directory from ``WORK_DIR`` or ``/var/workdir/nrrc``."""
62+
return path_from_env_variable("WORK_DIR", NRRC_WORK_DIR_DEFAULT)
63+
64+
3565
def make_tempfile_path(
3666
prefix: str,
3767
data: bytes | None = None,
3868
) -> Path:
39-
"""
40-
Create a secure private temp file and return a pathlib.Path to it.
69+
"""Create a secure private temp file and return a pathlib.Path to it.
4170
4271
Uses the standard library ``tempfile.mkstemp``, which creates a new file and
4372
returns a file handle (a safe pattern). We never use the old ``mktemp`` API
@@ -54,3 +83,13 @@ def make_tempfile_path(
5483
finally:
5584
os.close(fd)
5685
return Path(name)
86+
87+
88+
def is_gzip_or_tar_archive(
89+
path: Path,
90+
*,
91+
file_cmd: Callable[[Sequence[str | Path]], subprocess.CompletedProcess[str]],
92+
) -> bool:
93+
"""Return True when ``file -b`` reports gzip or tar content for *path*."""
94+
result = file_cmd(["file", "-b", str(path)])
95+
return _ARCHIVE_TYPE.search(result.stdout) is not None
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"""Tests for ``charon_env``."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
import charon_env
8+
import pytest
9+
10+
11+
def test_load_charon_env_parses_export_lines(tmp_path: Path) -> None:
12+
"""Export lines are parsed into a key/value mapping."""
13+
env_file = tmp_path / "charon.env"
14+
env_file.write_text(
15+
"\nexport CHARON_OCI_REGISTRY=repo@sha256:abcdef0123456789\n"
16+
"export CHARON_TARGET=dev-npm-ga\n"
17+
'export CHARON_PRODUCT_NAME="Test Product"\n',
18+
encoding="utf-8",
19+
)
20+
env = charon_env.load_charon_env(env_file)
21+
assert env["CHARON_OCI_REGISTRY"] == "repo@sha256:abcdef0123456789"
22+
assert env["CHARON_TARGET"] == "dev-npm-ga"
23+
assert env["CHARON_PRODUCT_NAME"] == "Test Product"
24+
25+
26+
def test_load_charon_env_missing_file(tmp_path: Path) -> None:
27+
"""Missing env files raise FileNotFoundError."""
28+
with pytest.raises(FileNotFoundError):
29+
charon_env.load_charon_env(tmp_path / "missing.env")
30+
31+
32+
def test_split_oci_registries() -> None:
33+
"""Registry references are split on percent signs."""
34+
value = "quay.io/a@sha256:111111%quay.io/b@sha256:222222"
35+
assert charon_env.split_oci_registries(value) == [
36+
"quay.io/a@sha256:111111",
37+
"quay.io/b@sha256:222222",
38+
]
39+
40+
41+
def test_short_sha256_prefix() -> None:
42+
"""Short hash uses the first six digest characters."""
43+
assert charon_env.short_sha256_prefix("repo@sha256:0b15aad24f1b847") == "0b15aa"
44+
45+
46+
def test_source_repo() -> None:
47+
"""Source repo strips the digest suffix."""
48+
assert charon_env.source_repo("quay.io/org/app@sha256:abc") == "quay.io/org/app"
49+
50+
51+
def test_load_charon_env_skips_malformed_lines(tmp_path: Path) -> None:
52+
"""Lines without ``=`` are ignored."""
53+
env_file = tmp_path / "charon.env"
54+
env_file.write_text("export CHARON_TARGET=dev\nnot-a-variable\n", encoding="utf-8")
55+
env = charon_env.load_charon_env(env_file)
56+
assert env == {"CHARON_TARGET": "dev"}
57+
58+
59+
def test_short_sha256_prefix_requires_digest() -> None:
60+
"""Registry references without a digest raise ValueError."""
61+
with pytest.raises(ValueError, match="@sha256:"):
62+
charon_env.short_sha256_prefix("quay.io/org/app:latest")
63+
64+
65+
def test_split_oci_registries_ignores_empty_segments() -> None:
66+
"""Trailing or duplicate ``%`` separators yield no empty entries."""
67+
assert charon_env.split_oci_registries("quay.io/a@sha256:111111%") == [
68+
"quay.io/a@sha256:111111",
69+
]
70+
assert charon_env.split_oci_registries("%quay.io/a@sha256:111111%") == [
71+
"quay.io/a@sha256:111111",
72+
]
73+
74+
75+
def test_load_charon_env_skips_comments_and_blank_lines(tmp_path: Path) -> None:
76+
"""Comments and blank lines are ignored."""
77+
env_file = tmp_path / "charon.env"
78+
env_file.write_text(
79+
"# comment\n\nexport CHARON_TARGET=dev\n# export CHARON_FOO=bar\n",
80+
encoding="utf-8",
81+
)
82+
assert charon_env.load_charon_env(env_file) == {"CHARON_TARGET": "dev"}
83+
84+
85+
def test_load_charon_env_strips_single_quotes(tmp_path: Path) -> None:
86+
"""Single-quoted values are unquoted."""
87+
env_file = tmp_path / "charon.env"
88+
env_file.write_text("export CHARON_PRODUCT_NAME='Test Product'\n", encoding="utf-8")
89+
assert charon_env.load_charon_env(env_file)["CHARON_PRODUCT_NAME"] == "Test Product"
90+
91+
92+
def test_require_env_keys_raises_for_missing_key() -> None:
93+
"""Missing required keys raise ValueError."""
94+
with pytest.raises(ValueError, match="missing required charon env variable"):
95+
charon_env.require_env_keys({"CHARON_TARGET": "dev"}, "CHARON_PRODUCT_NAME")
96+
97+
98+
def test_require_oci_registries(tmp_path: Path) -> None:
99+
"""Non-empty registry lists are returned from parsed env data."""
100+
env_file = tmp_path / "charon.env"
101+
env_file.write_text(
102+
"export CHARON_OCI_REGISTRY=quay.io/a@sha256:111111%quay.io/b@sha256:222222\n",
103+
encoding="utf-8",
104+
)
105+
env = charon_env.load_charon_env(env_file)
106+
assert charon_env.require_oci_registries(env) == [
107+
"quay.io/a@sha256:111111",
108+
"quay.io/b@sha256:222222",
109+
]
110+
111+
112+
def test_require_oci_registries_missing_key() -> None:
113+
"""Missing ``CHARON_OCI_REGISTRY`` raises ValueError."""
114+
with pytest.raises(ValueError, match="CHARON_OCI_REGISTRY is required"):
115+
charon_env.require_oci_registries({})
116+
117+
118+
def test_require_oci_registries_empty_value() -> None:
119+
"""Blank ``CHARON_OCI_REGISTRY`` values raise ValueError."""
120+
with pytest.raises(ValueError, match="at least one registry"):
121+
charon_env.require_oci_registries({"CHARON_OCI_REGISTRY": ""})
122+
123+
124+
def test_charon_config_path_uses_explicit_home(tmp_path: Path) -> None:
125+
"""An explicit *home* overrides ``Path.home()``."""
126+
assert charon_env.charon_config_path(home=tmp_path) == tmp_path / ".charon" / "charon.yaml"
127+
128+
129+
def test_install_charon_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
130+
"""Charon config is copied into ``$HOME/.charon/charon.yaml``."""
131+
monkeypatch.setenv("HOME", str(tmp_path))
132+
config_path = charon_env.charon_config_path()
133+
source = tmp_path / "input.yaml"
134+
source.write_text("charon-config\n", encoding="utf-8")
135+
dest = charon_env.install_charon_config(source)
136+
assert dest == config_path
137+
assert config_path.read_text(encoding="utf-8") == "charon-config\n"
138+
139+
140+
def test_install_charon_config_missing_source(tmp_path: Path) -> None:
141+
"""Missing config sources raise FileNotFoundError."""
142+
with pytest.raises(FileNotFoundError, match="charon config file not found"):
143+
charon_env.install_charon_config(tmp_path / "missing.yaml")

0 commit comments

Comments
 (0)