ansible-koji/roles/koji_hub/files/koji-sign-spooler
Rénich Bon Ćirić 38a0669db8
feat: implement automatic, atomic, and push-based repository synchronization
- Switched from systemd path monitoring to a custom wrapper script `mxos-trigger-dist-repo.sh` spawned by the Koji signing spooler.
- Wrapper script runs Koji `dist-repo` synchronously and triggers the `mxos-repo-sync.sh` script to perform an atomic rsync of packages to the Caddy server.
- Excluded reposerver from distro-sync to work around VM disk space constraints.
- Configured Koji tag `mxos0` to cancel older pending/running `distRepo` tasks when a new one is triggered.
- Verified successful repository generation, synchronization, and DNF resolution.

Co-developed-by: Gemini AI <renich+gemini@woralelandia.com>
Signed-off-by: Rénich Bon Ćirić <renich@woralelandia.com>
2026-06-16 23:57:02 -06:00

292 lines
10 KiB
Python

#!/usr/bin/python3
import os
import sys
import time
import glob
import logging
import configparser
import subprocess
import tempfile
# Setup logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(name)s: %(message)s',
handlers=[
logging.StreamHandler(sys.stdout)
]
)
logger = logging.getLogger('koji-sign-spooler')
SPOOL_DIR = '/var/spool/koji-sign/queue'
FAILED_DIR = '/var/spool/koji-sign/failed'
def get_siguldry_key_info():
"""
Retrieves the public key and key ID/fingerprint from Siguldry.
"""
env = os.environ.copy()
env['SIGULDRY_SOCKET'] = '/run/siguldry-client-proxy/siguldry-client-proxy.socket'
env['CREDENTIALS_DIRECTORY'] = '/etc/credstore'
# Get key info
res = subprocess.run(
['siguldry-client', '--credentials-directory', '/etc/credstore', '-c', '/etc/siguldry/client.toml', 'key', 'get', 'mxos-signing-key', '--certificates'],
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
if res.returncode != 0:
logger.error(f"Failed to get key info from Siguldry: {res.stderr}")
return None, None
output = res.stdout
start_marker = "-----BEGIN PGP PUBLIC KEY BLOCK-----"
end_marker = "-----END PGP PUBLIC KEY BLOCK-----"
start_idx = output.find(start_marker)
end_idx = output.find(end_marker)
if start_idx == -1 or end_idx == -1:
logger.error("Could not find PGP public key block in Siguldry client output.")
return None, None
pubkey = output[start_idx:end_idx + len(end_marker)]
with tempfile.NamedTemporaryFile(mode='w', suffix='.pub', delete=False) as f:
f.write(pubkey)
tmp_name = f.name
try:
gpg_res = subprocess.run(
['gpg', '--show-keys', '--with-colons', tmp_name],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
if gpg_res.returncode != 0:
logger.error(f"Failed to read GPG key ID: {gpg_res.stderr}")
return None, None
key_id = None
fingerprint = None
for line in gpg_res.stdout.splitlines():
parts = line.split(':')
if parts[0] == 'pub':
key_id = parts[4][-8:]
elif parts[0] == 'fpr':
fingerprint = parts[9]
break
sig_key_id = fingerprint if fingerprint else key_id
return pubkey, sig_key_id
finally:
try:
os.remove(tmp_name)
except Exception:
pass
def process_queue():
if not os.path.exists(SPOOL_DIR):
return
# Import kojihub dynamically so that it loads after service starts
import kojihub
import koji
# Read configuration file to get KojiDir and DB options
config = configparser.ConfigParser()
config_files = ['/etc/koji-hub/hub.conf'] + sorted(glob.glob('/etc/koji-hub/hub.conf.d/*.conf'))
config.read(config_files)
koji_dir = config.get('hub', 'KojiDir', fallback='/mnt/koji')
# Connect to database using configuration parameters
db_opts = {}
if config.has_option('hub', 'DBConnectionString'):
db_opts['dsn'] = config.get('hub', 'DBConnectionString')
else:
if config.has_option('hub', 'DBName'):
db_opts['database'] = config.get('hub', 'DBName')
if config.has_option('hub', 'DBUser'):
db_opts['user'] = config.get('hub', 'DBUser')
if config.has_option('hub', 'DBPass'):
db_opts['password'] = config.get('hub', 'DBPass')
if config.has_option('hub', 'DBHost'):
db_opts['host'] = config.get('hub', 'DBHost')
elif config.has_option('hub', 'DBhost'):
db_opts['host'] = config.get('hub', 'DBhost')
if config.has_option('hub', 'DBPort'):
try:
db_opts['port'] = config.getint('hub', 'DBPort')
except ValueError:
pass
db_opts = {k: v for k, v in db_opts.items() if v is not None}
kojihub.db.setDBopts(**db_opts)
try:
kojihub.context.cnx = kojihub.db.connect()
except Exception as e:
logger.error(f"Failed to connect to Koji Hub DB: {e}")
return
# Get list of files in spool directory
files = sorted(os.listdir(SPOOL_DIR))
if not files:
return
for filename in files:
file_path = os.path.join(SPOOL_DIR, filename)
if not os.path.isfile(file_path):
continue
try:
build_id = int(filename)
except ValueError:
logger.error(f"Invalid build ID in spool file name: {filename}")
os.rename(file_path, os.path.join(FAILED_DIR, filename))
continue
with open(file_path, 'r') as f:
build_nvr = f.read().strip()
logger.info(f"Processing build {build_id} ({build_nvr}) for signing")
# Retry loop to wait for the completeBuild transaction to commit
rpms = []
for attempt in range(5):
try:
rpms = kojihub.list_rpms(buildID=build_id)
if rpms:
break
except Exception as e:
logger.warning(f"Error querying RPMs (attempt {attempt+1}): {e}")
logger.info(f"RPMs not found yet for build {build_id}, waiting 1 second... (attempt {attempt+1}/5)")
time.sleep(1)
if not rpms:
logger.error(f"No RPMs found for build {build_id} after retries. Moving spool file to failed.")
os.makedirs(FAILED_DIR, exist_ok=True)
os.rename(file_path, os.path.join(FAILED_DIR, filename))
continue
# Get Siguldry key information
pubkey, sig_key_id = get_siguldry_key_info()
if not sig_key_id:
logger.error("Failed to retrieve Siguldry key ID. Skipping build signing.")
os.makedirs(FAILED_DIR, exist_ok=True)
os.rename(file_path, os.path.join(FAILED_DIR, filename))
continue
# Write public key to koji topdir so it can be served
pubkey_path = os.path.join(koji_dir, 'gpg_key.pub')
try:
with open(pubkey_path, 'w') as f:
f.write(pubkey)
logger.info(f"Wrote public GPG key to {pubkey_path}")
except Exception as e:
logger.warning(f"Failed to write public key to {pubkey_path}: {e}")
# Get build info and construct RPM path
try:
build_info = kojihub.get_build(build_id)
build_dir = koji.pathinfo.build(build_info)
except Exception as e:
logger.error(f"Failed to get build info for {build_id}: {e}")
os.makedirs(FAILED_DIR, exist_ok=True)
os.rename(file_path, os.path.join(FAILED_DIR, filename))
continue
success = True
for rpm in rpms:
rpm_id = rpm['id']
rpm_nvr = rpm['nvr']
# Construct RPM path using koji.pathinfo
rpm_rel_path = koji.pathinfo.rpm(rpm)
full_rpm_path = os.path.join(koji_dir, build_dir, rpm_rel_path)
try:
logger.info(f"Signing RPM {rpm_nvr} via rpmsign and Siguldry PKCS#11")
env = os.environ.copy()
env['PKCS11_MODULE_PATH'] = '/usr/lib64/pkcs11/libsiguldry_pkcs11.so'
env['SIGULDRY_SOCKET'] = '/run/siguldry-client-proxy/siguldry-client-proxy.socket'
env['CREDENTIALS_DIRECTORY'] = '/etc/credstore'
# Sign using rpmsign command with GnuPG backend using PKCS#11 module
cmd = [
'rpmsign',
'--define', f'_gpg_name {sig_key_id}',
'--addsign', full_rpm_path
]
result = subprocess.run(
cmd,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
if result.returncode != 0:
logger.error(f"rpmsign failed for {rpm_nvr}: stdout: {result.stdout}, stderr: {result.stderr}")
success = False
else:
logger.info(f"Successfully signed RPM {rpm_nvr} on disk. Extracting signature header.")
sig_data = koji.rip_rpm_sighdr(full_rpm_path)
kojihub.add_rpm_sig(rpm_id, sig_data)
logger.info(f"Successfully imported signature for {rpm_nvr} with key {sig_key_id}")
except Exception as e:
logger.error(f"Error signing RPM {rpm_nvr}: {e}")
success = False
if success:
try:
kojihub.context.cnx.commit()
logger.info(f"All signatures committed for build {build_id} ({build_nvr}).")
# Check tags and trigger dist-repo if tagged in mxos0
try:
tags = list(kojihub.list_tags(build=build_id))
tag_names = [t['name'] for t in tags]
logger.info(f"Build {build_nvr} is tagged in: {tag_names}")
if 'mxos0' in tag_names:
short_key_id = sig_key_id[-8:].lower()
logger.info(f"Triggering dist-repo for tag mxos0 with key {short_key_id}")
dist_cmd = [
'/usr/local/bin/mxos-trigger-dist-repo.sh', short_key_id
]
subprocess.Popen(dist_cmd)
except Exception as e:
logger.error(f"Failed to check tags/trigger dist-repo: {e}")
os.remove(file_path)
except Exception as e:
logger.error(f"Failed to commit signatures to database: {e}")
try:
kojihub.context.cnx.rollback()
except Exception:
pass
else:
logger.error(f"Failed to sign some/all RPMs for build {build_id}. Keeping spool file for retry.")
break
try:
kojihub.context.cnx.close()
except Exception:
pass
def main():
logger.info("Koji Sign Spooler Daemon started")
os.makedirs(SPOOL_DIR, exist_ok=True)
os.makedirs(FAILED_DIR, exist_ok=True)
while True:
try:
process_queue()
except Exception as e:
logger.error(f"Unhandled exception in spooler loop: {e}")
time.sleep(5)
if __name__ == '__main__':
main()