fix/distro-aware-package-check (#164)
ci/woodpecker/push/deploy-fedora43 Pipeline was successful
ci/woodpecker/push/deploy-fedora44 Pipeline was successful
ci/woodpecker/push/deploy-fedora42 Pipeline was successful
ci/woodpecker/push/deploy-almalinux8 Pipeline was successful
ci/woodpecker/push/deploy-almalinux9 Pipeline was successful

Reviewed-on: #164
This commit was merged in pull request #164.
This commit is contained in:
2026-05-17 23:43:35 +10:00
parent 539a63e0a1
commit 8a3a585f8c
73 changed files with 471 additions and 318 deletions
+93 -20
View File
@@ -23,6 +23,8 @@ import sys
import logging
import subprocess
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from pathlib import Path
from typing import List, Optional
from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -33,6 +35,36 @@ import yaml
from cerberus import Validator
# ==================== HTTP SESSIONS ====================
def _make_session(retries: int = 3, backoff_factor: float = 0.5) -> requests.Session:
"""
Build a requests Session that retries on transient failures.
Retries up to `retries` times on connection errors, read timeouts, and
5xx / 429 responses, with exponential backoff (0s, 0.5s, 1s, 2s …).
Only GET requests are retried to stay safe.
"""
session = requests.Session()
retry = Retry(
total=retries,
backoff_factor=backoff_factor,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET"],
raise_on_status=False,
)
adapter = HTTPAdapter(max_retries=retry)
session.mount("https://", adapter)
session.mount("http://", adapter)
return session
# Shared sessions — one per upstream so connections are pooled and reused
# across all package checks (including parallel builds in ThreadPoolExecutor).
# requests.Session is thread-safe for concurrent requests.
_gitea_session = _make_session()
_github_session = _make_session()
# ==================== VALIDATION SCHEMA ====================
# Cerberus schema for metadata.yaml validation based on PackageMetadata and Build dataclasses
@@ -81,6 +113,11 @@ METADATA_SCHEMA = {
'required': False,
'empty': False
},
'dist_tag': {
'type': 'boolean',
'required': False,
'default': False
},
'builds': {
'type': 'list',
'required': True,
@@ -94,7 +131,7 @@ METADATA_SCHEMA = {
'minlength': 1,
'schema': {
'type': 'string',
'allowed': ['almalinux/el8', 'almalinux/el9'],
'allowed': ['almalinux/el8', 'almalinux/el9', 'fedora/42', 'fedora/43', 'fedora/44'],
'empty': False
}
},
@@ -146,6 +183,7 @@ class PackageMetadata:
maintainer: str = ""
homepage: str = ""
license: str = ""
dist_tag: bool = False
builds: List[Build] = None
def __post_init__(self):
@@ -274,15 +312,14 @@ def get_github_latest_release(repo: str) -> Optional[dict]:
try:
github_token = get_github_token()
_github_session.headers.update({
'Authorization': f'token {github_token}',
'Accept': 'application/vnd.github.v3+json',
})
url = f"https://api.github.com/repos/{repo}/releases/latest"
headers = {
'Authorization': f'token {github_token}',
'Accept': 'application/vnd.github.v3+json'
}
logger.debug(f"Checking GitHub releases: {url}")
response = requests.get(url, headers=headers, timeout=30)
response = _github_session.get(url, timeout=15)
if response.status_code == 200:
release = response.json()
@@ -389,15 +426,14 @@ def get_github_releases_by_pattern(repo: str, pattern: str) -> Optional[dict]:
try:
github_token = get_github_token()
_github_session.headers.update({
'Authorization': f'token {github_token}',
'Accept': 'application/vnd.github.v3+json',
})
url = f"https://api.github.com/repos/{repo}/releases"
headers = {
'Authorization': f'token {github_token}',
'Accept': 'application/vnd.github.v3+json'
}
logger.debug(f"Checking GitHub releases with pattern '{pattern}': {url}")
response = requests.get(url, headers=headers, timeout=30)
response = _github_session.get(url, timeout=15)
if response.status_code == 200:
releases = response.json()
@@ -504,14 +540,45 @@ def normalize_version(version: str) -> str:
return ''.join(normalized_parts)
def get_rpm_dist_tag(distro: str) -> str:
"""
Map a distro path to its RPM dist tag string.
Examples:
'almalinux/el9' -> 'el9'
'fedora/43' -> 'fc43'
"""
if distro.startswith('almalinux/'):
return distro.split('/', 1)[1]
if distro.startswith('fedora/'):
return 'fc' + distro.split('/', 1)[1]
return ''
def effective_release(base_release: str, distro: str, use_dist_tag: bool) -> str:
"""Return the release string to use for building and registry checks.
When use_dist_tag is True the dist tag is appended so different distros
produce distinct entries in the package registry (e.g. '1.el9', '1.fc43').
"""
if not use_dist_tag:
return base_release
tag = get_rpm_dist_tag(distro)
return f"{base_release}.{tag}" if tag else base_release
def check_package_exists(package_name: str, version: str, release: str) -> bool:
"""
Check if a package version exists in the Gitea package registry.
Distro disambiguation is handled by the caller via the release string:
when dist_tag is enabled the caller appends the dist tag to release
(e.g. '1.el9', '1.fc43') so each distro maps to a unique version entry.
Args:
package_name: Name of the package
version: Version string
release: Release number
release: Effective release (may include dist tag suffix)
Returns:
True if package exists, False otherwise
@@ -536,17 +603,17 @@ def check_package_exists(package_name: str, version: str, release: str) -> bool:
f"{package_type}/{package_name}/{full_version}"
)
headers = {'Authorization': f'token {gitea_token}'}
_gitea_session.headers.update({'Authorization': f'token {gitea_token}'})
logger.debug(f"Checking package existence: {url}")
response = requests.get(url, headers=headers, timeout=30)
response = _gitea_session.get(url, timeout=10)
if response.status_code == 200:
package_info = response.json()
# Package exists if we get package info back
exists = bool(package_info.get('id'))
logger.debug(f"Package {package_name}:{full_version} {'exists' if exists else 'not found'}")
return exists
elif response.status_code == 404:
logger.debug(f"Package {package_name}:{full_version} not found (404)")
return False
@@ -1241,6 +1308,7 @@ class Builder:
package_name = metadata.get('name', package_dir.name)
build_configs = metadata.get('builds', [])
use_dist_tag = metadata.get('dist_tag', False)
if not build_configs:
self.logger.warning(f"No builds in metadata.yaml for {package_name}")
@@ -1259,7 +1327,8 @@ class Builder:
if repositories and base_image and version and release:
# Use the first repository as the distro identifier
build_distro = repositories[0] if repositories else 'unknown'
packages.append(PackageInfo(package_name, version, str(release), package_dir, build_distro, base_image))
rel = effective_release(str(release), build_distro, use_dist_tag)
packages.append(PackageInfo(package_name, version, rel, package_dir, build_distro, base_image))
else:
# Build for specific distro
for build_config in build_configs:
@@ -1271,7 +1340,8 @@ class Builder:
# Check if the target distro matches any repository
if distro in repositories and base_image and version and release:
packages.append(PackageInfo(package_name, version, str(release), package_dir, distro, base_image))
rel = effective_release(str(release), distro, use_dist_tag)
packages.append(PackageInfo(package_name, version, rel, package_dir, distro, base_image))
break
else:
# If no matching distro found, log a warning
@@ -1324,6 +1394,8 @@ class Builder:
with open(metadata_file, 'r') as f:
metadata = yaml.safe_load(f)
use_dist_tag = metadata.get('dist_tag', False)
# Find base image and validate version/release for the specified distro
build_configs = metadata.get('builds', [])
base_image = None
@@ -1367,7 +1439,8 @@ class Builder:
self.logger.error(f"Error reading metadata.yaml for {package}: {e}")
return False
package_info = PackageInfo(package, version, release, package_dir, distro, base_image)
rel = effective_release(release, distro, use_dist_tag)
package_info = PackageInfo(package, version, rel, package_dir, distro, base_image)
return self._build_package(package_info, dry_run, force, native, buildah)
def build_all(self, dry_run: bool = False, force: bool = False, parallel: int = 4, distro: str = 'el/9', native: bool = False, buildah: bool = False) -> bool: