Probe the RPM filename nfpm actually publishes #175
@@ -8,4 +8,9 @@ dev = [
|
|||||||
"pytest>=8",
|
"pytest>=8",
|
||||||
"jsonschema>=4",
|
"jsonschema>=4",
|
||||||
"pyyaml>=6",
|
"pyyaml>=6",
|
||||||
|
# tools/build's own script dependencies, so tests can import it
|
||||||
|
"typer",
|
||||||
|
"requests",
|
||||||
|
"hvac",
|
||||||
|
"cerberus",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Tests for the RPM filename tools/build reconstructs when probing artifactapi.
|
||||||
|
|
||||||
|
The expected values below were captured from the real nfpm; they are the names
|
||||||
|
the publish step actually uploads, so the existence probe has to ask for
|
||||||
|
exactly these or it 404s and rebuilds forever.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib.machinery
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).parent.parent
|
||||||
|
|
||||||
|
_loader = importlib.machinery.SourceFileLoader(
|
||||||
|
"rpmbuilder_build", str(REPO_ROOT / "tools" / "build")
|
||||||
|
)
|
||||||
|
_spec = importlib.util.spec_from_loader(_loader.name, _loader)
|
||||||
|
build = importlib.util.module_from_spec(_spec)
|
||||||
|
_loader.exec_module(build)
|
||||||
|
|
||||||
|
|
||||||
|
# nfpm output for `version: <input>` with release 1, arch amd64
|
||||||
|
NFPM_VERSIONS = [
|
||||||
|
("2025.08.03", "2025.8.3"),
|
||||||
|
("2025.07.13", "2025.7.13"),
|
||||||
|
("1.05.0", "1.5.0"),
|
||||||
|
("10.02.01", "10.2.1"),
|
||||||
|
("0.0.09", "0.0.9"),
|
||||||
|
("0.6.1", "0.6.1"),
|
||||||
|
("1.2.3", "1.2.3"),
|
||||||
|
("1.05", "1.5.0"),
|
||||||
|
("08", "8.0.0"),
|
||||||
|
("1.0.0-rc1", "1.0.0~rc1"),
|
||||||
|
("1.05.0-rc1", "1.5.0~rc1"),
|
||||||
|
("1.0.0-rc.1", "1.0.0~rc.1"),
|
||||||
|
# not semver: nfpm leaves these verbatim, so we must too
|
||||||
|
("1.02.3.4", "1.02.3.4"),
|
||||||
|
("2025.08.03.01", "2025.08.03.01"),
|
||||||
|
("1.2.3.4", "1.2.3.4"),
|
||||||
|
("1.0.0-rc.01", "1.0.0-rc.01"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("version,expected", NFPM_VERSIONS)
|
||||||
|
def test_nfpm_rpm_version(version, expected):
|
||||||
|
assert build.nfpm_rpm_version(version) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_rpm_file_name_matches_nfpm_output():
|
||||||
|
assert (
|
||||||
|
build.rpm_file_name("nzbget_exporter", "2025.08.03", "1.el9", "amd64")
|
||||||
|
== "nzbget_exporter-2025.8.3-1.el9.x86_64.rpm"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_package_exists_probes_published_filename(monkeypatch):
|
||||||
|
"""A zero-padded version must not probe a filename nfpm can never produce."""
|
||||||
|
probed = []
|
||||||
|
|
||||||
|
class _Session:
|
||||||
|
def get(self, url, timeout=None):
|
||||||
|
probed.append(url)
|
||||||
|
return type("R", (), {"status_code": 404})()
|
||||||
|
|
||||||
|
monkeypatch.setattr(build, "_artifactapi_session", _Session())
|
||||||
|
|
||||||
|
assert not build.check_package_exists(
|
||||||
|
"nzbget_exporter", "2025.08.03", "1.el9", "almalinux/el9", "amd64"
|
||||||
|
)
|
||||||
|
expected = (
|
||||||
|
"https://artifactapi.k8s.syd1.au.unkin.net/api/v2/remotes/rpm-vendor-el9"
|
||||||
|
"/files/Packages/nzbget_exporter-2025.8.3-1.el9.x86_64.rpm"
|
||||||
|
)
|
||||||
|
assert probed == [expected]
|
||||||
+33
-39
@@ -517,37 +517,38 @@ def get_github_token() -> str:
|
|||||||
|
|
||||||
# ==================== GITEA API FUNCTIONS ====================
|
# ==================== GITEA API FUNCTIONS ====================
|
||||||
|
|
||||||
def normalize_version(version: str) -> str:
|
def nfpm_rpm_version(version: str) -> str:
|
||||||
"""
|
"""
|
||||||
Normalize version string by removing leading zeros from numeric components.
|
Render a metadata version the way nfpm renders it into an RPM filename.
|
||||||
Gitea automatically does this normalization.
|
|
||||||
|
nfpm re-renders any semver-parseable version: leading zeros are dropped,
|
||||||
|
missing components padded to three, and the prerelease joined with '~'.
|
||||||
|
Anything semver cannot parse (four or more components, non-numeric
|
||||||
|
components, a numeric prerelease identifier with a leading zero) is used
|
||||||
|
verbatim.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
"2025.08.03" -> "2025.8.3"
|
"2025.08.03" -> "2025.8.3"
|
||||||
"1.05.0" -> "1.5.0"
|
"1.05" -> "1.5.0"
|
||||||
"0.6.1" -> "0.6.1" (no change needed)
|
"1.2.3-rc1" -> "1.2.3~rc1"
|
||||||
|
"1.02.3.4" -> "1.02.3.4" (not semver, left alone)
|
||||||
Args:
|
|
||||||
version: Original version string
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Normalized version string
|
|
||||||
"""
|
"""
|
||||||
import re
|
core, sep, prerelease = version.partition('-')
|
||||||
|
if sep and not prerelease:
|
||||||
|
return version
|
||||||
|
|
||||||
# Split by common separators and normalize each numeric part
|
components = core.split('.')
|
||||||
parts = re.split(r'([.\-_])', version)
|
if len(components) > 3 or not all(c.isdigit() for c in components):
|
||||||
normalized_parts = []
|
return version
|
||||||
|
|
||||||
for part in parts:
|
# semver rejects numeric prerelease identifiers with a leading zero
|
||||||
# If this part is purely numeric and has leading zeros, remove them
|
if any(i.isdigit() and len(i) > 1 and i.startswith('0')
|
||||||
if part.isdigit() and len(part) > 1 and part.startswith('0'):
|
for i in prerelease.split('.')):
|
||||||
# Remove leading zeros but keep at least one digit
|
return version
|
||||||
normalized_parts.append(str(int(part)))
|
|
||||||
else:
|
|
||||||
normalized_parts.append(part)
|
|
||||||
|
|
||||||
return ''.join(normalized_parts)
|
components += ['0'] * (3 - len(components))
|
||||||
|
rendered = '.'.join(str(int(c)) for c in components)
|
||||||
|
return f"{rendered}~{prerelease}" if prerelease else rendered
|
||||||
|
|
||||||
|
|
||||||
def get_rpm_dist_tag(distro: str) -> str:
|
def get_rpm_dist_tag(distro: str) -> str:
|
||||||
@@ -598,6 +599,14 @@ def get_rpm_arch(arch: str) -> str:
|
|||||||
return {'amd64': 'x86_64', 'arm64': 'aarch64'}.get(arch, arch)
|
return {'amd64': 'x86_64', 'arm64': 'aarch64'}.get(arch, arch)
|
||||||
|
|
||||||
|
|
||||||
|
def rpm_file_name(package_name: str, version: str, release: str, arch: str) -> str:
|
||||||
|
"""Filename nfpm produces for this package, and therefore the published name."""
|
||||||
|
return (
|
||||||
|
f"{package_name}-{nfpm_rpm_version(version)}-{release}"
|
||||||
|
f".{get_rpm_arch(arch)}.rpm"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def check_package_exists(
|
def check_package_exists(
|
||||||
package_name: str,
|
package_name: str,
|
||||||
version: str,
|
version: str,
|
||||||
@@ -627,7 +636,7 @@ def check_package_exists(
|
|||||||
|
|
||||||
base_url = os.getenv('ARTIFACTAPI_URL', 'https://artifactapi.k8s.syd1.au.unkin.net')
|
base_url = os.getenv('ARTIFACTAPI_URL', 'https://artifactapi.k8s.syd1.au.unkin.net')
|
||||||
repo = get_vendor_repo(distro)
|
repo = get_vendor_repo(distro)
|
||||||
rpm_file = f"{package_name}-{version}-{release}.{get_rpm_arch(arch)}.rpm"
|
rpm_file = rpm_file_name(package_name, version, release, arch)
|
||||||
url = f"{base_url}/api/v2/remotes/{repo}/files/Packages/{rpm_file}"
|
url = f"{base_url}/api/v2/remotes/{repo}/files/Packages/{rpm_file}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -651,21 +660,6 @@ def check_package_exists(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def get_package_full_name(package_name: str, version: str, release: str) -> str:
|
|
||||||
"""
|
|
||||||
Generate the full package name as used in the registry.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
package_name: Package name
|
|
||||||
version: Version string
|
|
||||||
release: Release number
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Full package name string
|
|
||||||
"""
|
|
||||||
return f"{package_name}-{version}-{release}"
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== DOCKER FUNCTIONS ====================
|
# ==================== DOCKER FUNCTIONS ====================
|
||||||
|
|
||||||
def check_docker_available() -> bool:
|
def check_docker_available() -> bool:
|
||||||
|
|||||||
Reference in New Issue
Block a user