Initial commit

This commit is contained in:
2026-09-07 11:55:21 +02:00
commit d83087cbbd
20 changed files with 1963 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
FROM almalinux:10
# dnf-plugins-core -> dnf download, repoquery, config-manager
# createrepo_c -> Repo-Metadaten bauen
# rpm-sign, gnupg2 -> Pakete umsignieren
# git, openssh-clients -> packages.yaml vor jedem Sync aus dem gemeinsamen
# Freigabe-Git-Repo ziehen
# python3-pyyaml, jq -> packages.yaml (YAML) nach JSON wandeln und auswerten
# cpio -> Pakete fuer die AppStream-Erzeugung teilweise entpacken
# rsync -> privaten Paketstand pflegen + alle Fremdrepos zu
# einem gemeinsamen Repo zusammenfuehren
# util-linux-core -> stellt setpriv (root -> mirror-User droppen) sicher
RUN dnf -y install epel-release \
&& dnf -y install \
dnf-plugins-core \
createrepo_c \
rpm-sign \
gnupg2 \
git \
openssh-clients \
python3-pyyaml \
util-linux-core \
findutils \
cpio \
rsync \
procps-ng \
jq \
&& dnf clean all
# AppStream-Metadaten fuer Discover/GNOME Software werden mit `appstreamcli`
# gebaut. Dessen "compose"-Kommando steckt NICHT im Basispaket `appstream`,
# sondern im separaten Subpaket `appstream-compose` (gleiches AlmaLinux-10-
# AppStream-Repo). Ohne dieses Subpaket bricht `appstreamcli compose` zur
# Laufzeit mit "AppStream Compose binary ... was not found" ab.
RUN dnf -y install appstream appstream-compose \
&& dnf clean all
RUN useradd -r -m -d /var/lib/mirror mirror
COPY gpg-import.sh /usr/local/lib/gpg-import.sh
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
COPY run-as-mirror.sh /usr/local/bin/run-as-mirror.sh
COPY sync.sh /usr/local/bin/sync.sh
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/run-as-mirror.sh /usr/local/bin/sync.sh
RUN mkdir -p /srv/dnf /var/cache/mirror /etc/mirror-gpg \
&& chown -R mirror:mirror /srv/dnf /var/cache/mirror /etc/mirror-gpg
ENV HOME=/var/lib/mirror
# Gemeinsamer Signing-Key: entrypoint.sh kopiert ihn beim Start aus dem
# read-only gemounteten /run/secrets/gpg hierher - derselbe Key, mit dem der
# flatpak-mirror-Container signiert.
ENV GNUPGHOME=/etc/mirror-gpg
# Bleibt root: entrypoint.sh uebernimmt den gemounteten Key und die
# Verzeichnisrechte und wechselt danach per setpriv fest zu "mirror". Alles
# Weitere (Sync-Loop, git, dnf download, gpg) laeuft unprivilegiert.
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
@@ -0,0 +1,44 @@
# OPTIONAL. Standardmaessig fragt sync.sh fuer die "was kann Alma sowieso
# schon aufloesen?"-Pruefung einfach die im Mirror-Image bereits vorhandenen
# System-Repos ab (das Image ist selbst AlmaLinux, hat also baseos/appstream/
# extras/crb + epel schon konfiguriert) - dafuer ist diese Datei NICHT noetig.
#
# Setze die Env-Variable ALMA_BASELINE_REPOSDIR=/etc/mirror/alma-baseline.repo.d,
# wenn du stattdessen explizit gegen andere/eigene Repo-Definitionen pruefen
# willst (z.B. anderer Releasever, eigener interner Alma-Spiegel). Es wird
# daraus nie etwas gespiegelt, nur `dnf repoquery` dagegen ausgefuehrt.
[baseos]
name=AlmaLinux $releasever - BaseOS
mirrorlist=https://mirrors.almalinux.org/mirrorlist/$releasever/baseos
gpgcheck=1
gpgkey=https://repo.almalinux.org/almalinux/RPM-GPG-KEY-AlmaLinux-10
enabled=1
[appstream]
name=AlmaLinux $releasever - AppStream
mirrorlist=https://mirrors.almalinux.org/mirrorlist/$releasever/appstream
gpgcheck=1
gpgkey=https://repo.almalinux.org/almalinux/RPM-GPG-KEY-AlmaLinux-10
enabled=1
[extras]
name=AlmaLinux $releasever - Extras
mirrorlist=https://mirrors.almalinux.org/mirrorlist/$releasever/extras
gpgcheck=1
gpgkey=https://repo.almalinux.org/almalinux/RPM-GPG-KEY-AlmaLinux-10
enabled=1
[crb]
name=AlmaLinux $releasever - CRB
mirrorlist=https://mirrors.almalinux.org/mirrorlist/$releasever/crb
gpgcheck=1
gpgkey=https://repo.almalinux.org/almalinux/RPM-GPG-KEY-AlmaLinux-10
enabled=1
[epel]
name=Extra Packages for Enterprise Linux $releasever - $basearch
metalink=https://mirrors.fedoraproject.org/metalink?repo=epel-$releasever&arch=$basearch&infra=$infra&content=$contentdir
gpgcheck=1
gpgkey=https://dl.fedoraproject.org/pub/epel/RPM-GPG-KEY-EPEL-10
enabled=1
+27
View File
@@ -0,0 +1,27 @@
#!/bin/bash
# Startet als root, uebernimmt den gemeinsamen, EXTERN erzeugten Signing-Key
# aus dem read-only gemounteten /run/secrets/gpg (auf dem Host: ./gpg neben
# der docker-compose.yml) in ein containerlokales GNUPGHOME, das dem
# unprivilegierten "mirror"-User gehoert - und wechselt dann per setpriv
# dauerhaft zu diesem User (ersetzt den Prozess direkt, kein Wrapper-
# Kindprozess, damit Signale/PID 1 sauber bleiben).
#
# Bewusst eine Kopie statt direkt auf dem Mount zu arbeiten: gpg schreibt in
# seinem Homedir (trustdb, random_seed, Lockfiles), und derselbe Ordner ist
# gleichzeitig im flatpak-mirror-Container gemountet. Die Kopie haelt das
# Original unveraendert und vermeidet, dass sich beide Container beim
# Signieren gegenseitig ins Gehege kommen.
set -euo pipefail
GPG_SOURCE=/run/secrets/gpg
export GNUPGHOME="${GNUPGHOME:-/etc/mirror-gpg}"
# shellcheck source=/dev/null
. /usr/local/lib/gpg-import.sh
import_shared_gpg
mkdir -p /srv/dnf /var/cache/mirror
chown -R mirror:mirror "$GNUPGHOME" /srv/dnf /var/cache/mirror
exec setpriv --reuid=mirror --regid=mirror --init-groups /usr/local/bin/run-as-mirror.sh
+95
View File
@@ -0,0 +1,95 @@
#!/bin/bash
# Gemeinsame Hilfsfunktionen beider Spiegel-Container: den EXTERN erzeugten
# Signing-Key aus dem read-only gemounteten $GPG_SOURCE uebernehmen und
# pruefen, ob damit unbeaufsichtigt signiert werden kann.
#
# ACHTUNG: identische Kopie in dnf-mirror/gpg-import.sh - Aenderungen bitte in
# beiden pflegen. (Getrennte Build-Kontexte je Image; ein gemeinsamer Kontext
# wuerde das Schluesselverzeichnis ./gpg in den Build-Kontext ziehen.)
# Die Dateien, die ein GnuPG-Homedir ausmachen. Bewusst eine feste Liste statt
# "cp -a .": im Quellverzeichnis liegen oft noch die Agent-Sockets
# (S.gpg-agent & Co.) und Lock-Dateien eines gpg-agent, der auf dem HOST
# lief/laeuft. Die haben im Container nichts verloren.
GPG_HOME_ENTRIES=(pubring.kbx pubring.gpg secring.gpg trustdb.gpg \
private-keys-v1.d openpgp-revocs.d public-keys.d)
import_shared_gpg() {
if [ ! -d "$GPG_SOURCE" ]; then
echo "[gpg] FEHLER: $GPG_SOURCE ist nicht gemountet - fehlt der Ordner ./gpg?" >&2
return 1
fi
rm -rf "$GNUPGHOME"
mkdir -p "$GNUPGHOME"
chmod 700 "$GNUPGHOME"
local entry copied=0
for entry in "${GPG_HOME_ENTRIES[@]}"; do
[ -e "$GPG_SOURCE/$entry" ] || continue
# Kein "|| true": schlaegt das Kopieren des Schluesselmaterials fehl,
# soll das laut und sofort auffallen statt spaeter als "Key nicht
# gefunden".
cp -a "$GPG_SOURCE/$entry" "$GNUPGHOME/"
copied=1
done
if [ "$copied" -eq 0 ]; then
echo "[gpg] FEHLER: in $GPG_SOURCE liegt kein GnuPG-Schluesselbund." >&2
echo "[gpg] Erwartet wird der INHALT eines GnuPG-Homedirs (pubring.kbx," >&2
echo "[gpg] private-keys-v1.d, ...), nicht z.B. eine exportierte .asc-Datei." >&2
echo "[gpg] Tatsaechlicher Inhalt:" >&2
ls -la "$GPG_SOURCE" >&2
return 1
fi
chmod -R go-rwx "$GNUPGHOME"
# Signiert wird unbeaufsichtigt, also nie ein Pinentry-Fenster erwarten.
echo "allow-loopback-pinentry" > "$GNUPGHOME/gpg-agent.conf"
echo "pinentry-mode loopback" > "$GNUPGHOME/gpg.conf"
}
# Listet die im Homedir tatsaechlich vorhandenen geheimen Schluessel als
# "<fingerprint> <uid>" - genau das, was als GPG_KEY_ID in die .env gehoert.
list_available_secret_keys() {
gpg --batch --list-secret-keys --with-colons 2>/dev/null | awk -F: '
$1 == "sec" { want = 1; fpr = ""; next }
$1 == "fpr" && want { fpr = $10; want = 0; next }
$1 == "uid" && fpr != "" { print " " fpr " " $10; fpr = ""; next }
'
}
# Prueft nicht nur, OB der Key da ist, sondern ob damit auch wirklich
# unbeaufsichtigt signiert werden kann - ein passphrasegeschuetzter Key faellt
# sonst erst mitten im ersten Sync-Lauf auf.
require_usable_signing_key() {
if ! gpg --batch --list-secret-keys "$GPG_KEY_ID" >/dev/null 2>&1; then
echo "[gpg] FEHLER: kein geheimer Schluessel '$GPG_KEY_ID' in ./gpg gefunden." >&2
local available
available="$(list_available_secret_keys)"
if [ -n "$available" ]; then
echo "[gpg] Vorhanden sind:" >&2
echo "$available" >&2
echo "[gpg] Einen dieser Fingerprints als GPG_KEY_ID in die .env eintragen." >&2
else
echo "[gpg] Der Schluesselbund enthaelt ueberhaupt keinen GEHEIMEN Schluessel" >&2
echo "[gpg] (nur oeffentliche?). private-keys-v1.d/ pruefen - dort muss" >&2
echo "[gpg] mindestens eine .key-Datei liegen." >&2
fi
return 1
fi
local err
err="$(printf 'signing-selftest' \
| gpg --batch --yes --local-user "$GPG_KEY_ID" --detach-sign --output /dev/null - 2>&1)" && {
echo "[gpg] Gemeinsamen Signing-Key '$GPG_KEY_ID' uebernommen, Testsignatur ok."
return 0
}
echo "[gpg] FEHLER: Schluessel '$GPG_KEY_ID' ist da, aber Signieren schlaegt fehl:" >&2
echo "$err" >&2
echo "[gpg] Haeufigste Ursache: der Key hat eine Passphrase. Unbeaufsichtigtes" >&2
echo "[gpg] Signieren braucht einen Key OHNE Passphrase (siehe README)." >&2
return 1
}
+33
View File
@@ -0,0 +1,33 @@
#!/bin/bash
# Laeuft als unprivilegierter User "mirror" (siehe entrypoint.sh, der als root
# den gemeinsamen Signing-Key nach $GNUPGHOME kopiert und dann per setpriv
# hierher wechselt).
set -euo pipefail
: "${GPG_KEY_ID:?GPG_KEY_ID muss gesetzt sein (gemeinsamer Signing-Key, siehe .env)}"
: "${MIRROR_REPO_ID:=company-mirror}"
: "${SYNC_INTERVAL_SECONDS:=21600}"
# Der Key wird NICHT vom Container erzeugt: beide Spiegel benutzen denselben,
# extern erzeugten Key aus ./gpg (den entrypoint.sh als root hierher kopiert
# hat). Ist er unbrauchbar, ist der ganze Sync sinnlos - lieber sofort und mit
# einer Meldung abbrechen, die sagt, was stattdessen im Schluesselbund liegt.
# shellcheck source=/dev/null
. /usr/local/lib/gpg-import.sh
require_usable_signing_key
mkdir -p /srv/dnf
gpg --batch --yes --armor --export "$GPG_KEY_ID" > /srv/dnf/RPM-GPG-KEY-mirror
echo "[entrypoint] Public Key nach /srv/dnf/RPM-GPG-KEY-mirror exportiert."
term_handler() {
echo "[entrypoint] Beende nach aktuellem Sync-Lauf ..."
exit 0
}
trap term_handler SIGTERM SIGINT
while true; do
/usr/local/bin/sync.sh || echo "[entrypoint] sync.sh ist mit Fehler beendet, naechster Versuch in ${SYNC_INTERVAL_SECONDS}s"
sleep "$SYNC_INTERVAL_SECONDS" &
wait $!
done
+661
View File
@@ -0,0 +1,661 @@
#!/bin/bash
# Ein Sync-Durchlauf:
# 0. packages.yaml frisch aus Git ziehen (Fremdrepos + Paketnamen je Repo)
# 1. je Fremdrepo genau die dort gelisteten Pakete per `dnf download` holen,
# filtern (Alma-Baseline) und in einen privaten, pro Fremdrepo getrennten
# Paket-Cache signieren
# 2. ALLE Fremdrepos zu EINEM gemeinsamen, oeffentlichen Ausgabe-Repo
# zusammenfuehren (ein repodata, ein GPG-Key, eine Client-.repo-Datei) -
# Clients binden nur dieses eine Repo ein, unabhaengig davon, aus wie
# vielen Fremdrepos die Pakete urspruenglich stammen
# 3. Repo-Metadaten (createrepo_c) + AppStream-Metadaten (fuer Discover) bauen
# 4. repomd.xml signieren, Client-.repo-Datei schreiben
set -uo pipefail
CACHE=/var/cache/mirror
OUT=/srv/dnf
SOURCES_GIT_CHECKOUT="$CACHE/sources-git"
PACKAGES_JSON="$CACHE/packages.json"
SIGNED_ROOT="$CACHE/signed"
: "${GPG_KEY_ID:?GPG_KEY_ID muss gesetzt sein (gemeinsamer Signing-Key, siehe .env)}"
: "${MIRROR_DOMAIN:?}"
: "${SOURCES_GIT_URL:?SOURCES_GIT_URL muss auf das Git-Repo mit packages.yaml zeigen}"
: "${SOURCES_GIT_REF:=main}"
: "${MIRROR_REPO_ID:=company-mirror}"
: "${ALMA_RELEASEVER:=10}"
: "${ALMA_BASEARCH:=x86_64}"
: "${ALMA_BASELINE_REPOIDS:=baseos,appstream,extras,crb,epel}"
mkdir -p "$CACHE/stage" "$CACHE/tmp-repos" "$SIGNED_ROOT" "$OUT"
log() { echo "[sync $(date '+%F %T')] $*"; }
# Zieht packages.yaml vor JEDEM Sync-Lauf frisch aus einem Git-Repo (statt aus
# lokal gemounteten Dateien), damit Aenderungen an der Paketliste ohne
# Container-Neubau/-Neustart wirksam werden.
fetch_packages_yaml() {
# HTTPS mit Token: ueber GIT_CONFIG_*-Umgebungsvariablen (git >= 2.31)
# statt "https://user:token@host/..." - so steht das Token weder in der
# URL (die in Logs auftauchen koennte) noch als Kommandozeilenargument
# (das per "ps aux" auf dem Host sichtbar waere).
unset GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0
if [ -n "${SOURCES_GIT_USERNAME:-}" ] && [ -n "${SOURCES_GIT_TOKEN:-}" ]; then
export GIT_CONFIG_COUNT=1
export GIT_CONFIG_KEY_0="http.extraHeader"
export GIT_CONFIG_VALUE_0="Authorization: Basic $(printf '%s' "${SOURCES_GIT_USERNAME}:${SOURCES_GIT_TOKEN}" | base64 | tr -d '\n')"
fi
if [ -n "${SOURCES_GIT_SSH_KEY:-}" ] && [ -f "$SOURCES_GIT_SSH_KEY" ]; then
export GIT_SSH_COMMAND="ssh -i ${SOURCES_GIT_SSH_KEY} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=${CACHE}/known_hosts"
fi
if [ -d "$SOURCES_GIT_CHECKOUT/.git" ]; then
log "Aktualisiere packages.yaml aus Git ($SOURCES_GIT_URL @ $SOURCES_GIT_REF) ..."
if git -C "$SOURCES_GIT_CHECKOUT" fetch --depth 1 origin "$SOURCES_GIT_REF" >>"$CACHE/git-sources.log" 2>&1 \
&& git -C "$SOURCES_GIT_CHECKOUT" reset --hard FETCH_HEAD >>"$CACHE/git-sources.log" 2>&1 \
&& git -C "$SOURCES_GIT_CHECKOUT" clean -fdx >>"$CACHE/git-sources.log" 2>&1; then
:
else
log "WARNUNG: git fetch fehlgeschlagen, siehe $CACHE/git-sources.log - verwende letzten bekannten Stand von packages.yaml weiter."
fi
else
log "Klone packages.yaml aus Git ($SOURCES_GIT_URL @ $SOURCES_GIT_REF) ..."
rm -rf "$SOURCES_GIT_CHECKOUT"
if ! git clone --branch "$SOURCES_GIT_REF" --single-branch --depth 1 \
"$SOURCES_GIT_URL" "$SOURCES_GIT_CHECKOUT" >>"$CACHE/git-sources.log" 2>&1; then
log "FEHLER: Erstklon von packages.yaml aus Git fehlgeschlagen, siehe $CACHE/git-sources.log"
return 1
fi
fi
local yaml_file="$SOURCES_GIT_CHECKOUT/packages.yaml"
if [ ! -f "$yaml_file" ]; then
log "FEHLER: $yaml_file existiert nicht im Git-Repo."
return 1
fi
if ! python3 -c '
import sys, json, yaml
with open(sys.argv[1]) as f:
data = yaml.safe_load(f) or {}
data.setdefault("repos", [])
data.setdefault("force_include", [])
data.setdefault("console_apps", [])
json.dump(data, sys.stdout)
' "$yaml_file" > "$PACKAGES_JSON.tmp" 2>"$CACHE/packages-yaml-parse.log"; then
log "FEHLER: packages.yaml ist kein gueltiges YAML, siehe $CACHE/packages-yaml-parse.log"
rm -f "$PACKAGES_JSON.tmp"
return 1
fi
mv "$PACKAGES_JSON.tmp" "$PACKAGES_JSON"
}
refresh_alma_names() {
log "Ermittle Paketnamen, die AlmaLinux selbst bereitstellt ..."
local -a enable_args=()
local id
IFS=',' read -ra ids <<< "$ALMA_BASELINE_REPOIDS"
for id in "${ids[@]}"; do
enable_args+=(--enablerepo="$id")
done
local -a reposdir_args=()
if [ -n "${ALMA_BASELINE_REPOSDIR:-}" ] && ls "${ALMA_BASELINE_REPOSDIR}"/*.repo >/dev/null 2>&1; then
reposdir_args=(--setopt="reposdir=${ALMA_BASELINE_REPOSDIR}")
fi
if ! dnf repoquery -y --quiet -a --qf '%{name}' \
--disablerepo='*' "${enable_args[@]}" \
--releasever="$ALMA_RELEASEVER" --forcearch="$ALMA_BASEARCH" \
"${reposdir_args[@]}" 2>>"$CACHE/repoquery.log" | sort -u > "$CACHE/alma-names.txt.tmp"; then
log "WARNUNG: repoquery gegen AlmaLinux-Baseline fehlgeschlagen, siehe $CACHE/repoquery.log"
log "Verwende ggf. vorherigen Stand von alma-names.txt weiter."
rm -f "$CACHE/alma-names.txt.tmp"
return
fi
mv "$CACHE/alma-names.txt.tmp" "$CACHE/alma-names.txt"
log "$(wc -l < "$CACHE/alma-names.txt") Paketnamen als 'ueber Alma aufloesbar' erfasst."
}
is_force_included() {
local name="$1" f
for f in "${FORCE_INCLUDE_NAMES[@]}"; do
[ "$f" = "$name" ] && return 0
done
return 1
}
# Steht das Paket in console_apps: der packages.yaml? Dann bekommt es einen
# Katalogeintrag, auch wenn es (wie fast alle reinen CLI-Pakete) weder eine
# .desktop- noch eine .metainfo.xml-Datei mitbringt.
is_console_app() {
local name="$1" f
for f in "${CONSOLE_APP_NAMES[@]}"; do
[ "$f" = "$name" ] && return 0
done
return 1
}
# Liest einen einzelnen Schluessel aus dem [Desktop Entry]-Abschnitt einer
# .desktop-Datei (ignoriert lokalisierte Varianten wie Name[de]= und andere
# Abschnitte wie [Desktop Action ...]).
desktop_get() {
local file="$1" key="$2"
awk -v key="$key" '
/^\[Desktop Entry\]/ { insec=1; next }
/^\[/ { insec=0 }
insec && index($0, key "=") == 1 { sub("^" key "=", ""); print; exit }
' "$file"
}
# ACHTUNG: Ab bash 5.2 (AlmaLinux 10 liefert 5.2.26) steht ein unmaskiertes
# "&" im ERSETZUNGS-Teil von ${var//muster/ersatz} - wie bei sed - fuer den
# gefundenen Text. "${s//</&lt;}" wuerde also "<lt;" ergeben statt "&lt;".
# Die Ampersands muessen hier deshalb mit \& maskiert werden.
xml_escape() {
local s="$1"
s="${s//&/\&amp;}"
s="${s//</\&lt;}"
s="${s//>/\&gt;}"
s="${s//\"/\&quot;}"
s="${s//\'/\&apos;}"
printf '%s' "$s"
}
# Erzeugt fuer ein Paket, das in console_apps: der packages.yaml steht, eine
# console-application-Metainfo aus dem RPM-Header. Reine CLI-Pakete (dotnet-sdk,
# terraform, kubectl, ...) bringen praktisch nie eine eigene .metainfo.xml oder
# .desktop-Datei mit und tauchen ohne Katalogeintrag in Discover ueberhaupt
# nicht auf - auch nicht bei exakter Suche nach dem Paketnamen, denn Discovers
# PackageKit-Backend durchsucht ausschliesslich den AppStream-Pool.
#
# BEWUSST nur opt-in ueber packages.yaml: wuerde man das fuer jedes Paket ohne
# .desktop-Datei tun, landeten auch saemtliche Runtime-, Bibliotheks- und
# Debug-Unterpakete als "Anwendungen" im Katalog.
#
# Args: rpm-datei pkgname composeroot dateiliste-des-pakets
generate_console_metainfo() {
local rpm="$1" pkgname="$2" composeroot="$3" filelist="$4"
local metadir="$composeroot/usr/share/metainfo"
local target="$metadir/${pkgname}.metainfo.xml"
local summary description url license binary
mkdir -p "$metadir"
[ -e "$target" ] && return
summary=$(rpm -qp --qf '%{SUMMARY}' "$rpm" 2>/dev/null)
description=$(rpm -qp --qf '%{DESCRIPTION}' "$rpm" 2>/dev/null)
url=$(rpm -qp --qf '%{URL}' "$rpm" 2>/dev/null)
license=$(rpm -qp --qf '%{LICENSE}' "$rpm" 2>/dev/null)
[ -z "$summary" ] || [ "$summary" = "(none)" ] && summary="$pkgname"
[ -z "$description" ] || [ "$description" = "(none)" ] && description="$summary"
[ "$url" = "(none)" ] && url=""
[ -z "$license" ] || [ "$license" = "(none)" ] && license="LicenseRef-proprietary"
{
echo '<?xml version="1.0" encoding="UTF-8"?>'
echo '<component type="console-application">'
echo " <id>$(xml_escape "$pkgname")</id>"
echo " <pkgname>$(xml_escape "$pkgname")</pkgname>"
echo ' <metadata_license>CC0-1.0</metadata_license>'
echo " <project_license>$(xml_escape "$license")</project_license>"
echo " <name>$(xml_escape "$pkgname")</name>"
echo " <summary>$(xml_escape "$summary")</summary>"
echo ' <description>'
# %{DESCRIPTION} ist Fliesstext mit Leerzeilen als Absatztrennern -
# AppStream verlangt hier <p>-Elemente.
printf '%s\n' "$description" | awk '
NF == 0 { if (para != "") { print para; para = "" }; next }
{ para = (para == "" ? $0 : para " " $0) }
END { if (para != "") print para }
' | while IFS= read -r para; do
echo " <p>$(xml_escape "$para")</p>"
done
echo ' </description>'
[ -n "$url" ] && echo " <url type=\"homepage\">$(xml_escape "$url")</url>"
# <provides><binary> laesst Discover/appstreamcli die Komponente auch
# ueber den Befehlsnamen finden, nicht nur ueber den Paketnamen.
local -a binaries=()
while IFS= read -r binary; do
[ -n "$binary" ] && binaries+=("$binary")
done < <(grep -E '^\./usr/(local/)?s?bin/[^/]+$' "$filelist" 2>/dev/null | sed 's|.*/||' | sort -u)
if [ "${#binaries[@]}" -gt 0 ]; then
echo ' <provides>'
for binary in "${binaries[@]}"; do
echo " <binary>$(xml_escape "$binary")</binary>"
done
echo ' </provides>'
fi
echo '</component>'
} > "$target"
}
# appstreamcli compose (AppStream 1.0+) ignoriert .desktop-Dateien komplett,
# wenn keine begleitende .metainfo.xml existiert - anders als das aeltere
# appstream-builder, das genau das frueher automatisch synthetisiert hat.
# Die meisten unabhaengigen Drittanbieter-RPMs (z.B. jpackage-basierte Apps
# wie XPipe) liefern aber nur eine .desktop-Datei mit. Deshalb hier selbst
# eine minimale Ersatz-Metainfo aus den Desktop-Datei-Feldern erzeugen, damit
# die Anwendung ueberhaupt einen Katalogeintrag in Discover bekommt. Bringt
# ein Paket schon eine echte .metainfo.xml mit (die auf dasselbe Desktop-File
# verweist), hat diese immer Vorrang - hier wird dann nichts generiert.
generate_fallback_metainfo() {
local composeroot="$1" pkgmap="$2"
local appdir="$composeroot/usr/share/applications"
local metadir="$composeroot/usr/share/metainfo"
local desktop_file base name summary categories cat pkgname
[ -d "$appdir" ] || return
mkdir -p "$metadir"
for desktop_file in "$appdir"/*.desktop; do
[ -e "$desktop_file" ] || continue
base=$(basename "$desktop_file")
if grep -rlq "type=\"desktop-id\">${base}<" "$metadir" 2>/dev/null; then
continue
fi
pkgname=""
[ -f "$pkgmap" ] && pkgname=$(awk -F'\t' -v b="$base" '$1 == b {print $2; exit}' "$pkgmap")
[ -z "$pkgname" ] && pkgname="${base%.desktop}"
name=$(desktop_get "$desktop_file" "Name")
summary=$(desktop_get "$desktop_file" "Comment")
categories=$(desktop_get "$desktop_file" "Categories")
[ -z "$name" ] && name="${base%.desktop}"
[ -z "$summary" ] && summary="$name"
{
echo '<?xml version="1.0" encoding="UTF-8"?>'
echo '<component type="desktop-application">'
echo " <id>${base}</id>"
echo " <pkgname>$(xml_escape "$pkgname")</pkgname>"
echo ' <metadata_license>CC0-1.0</metadata_license>'
echo ' <project_license>LicenseRef-proprietary</project_license>'
echo " <name>$(xml_escape "$name")</name>"
echo " <summary>$(xml_escape "$summary")</summary>"
echo " <description>"
echo " <p>$(xml_escape "$summary")</p>"
echo " </description>"
echo " <launchable type=\"desktop-id\">${base}</launchable>"
if [ -n "$categories" ]; then
echo ' <categories>'
local IFS=';'
for cat in $categories; do
[ -z "$cat" ] && continue
echo " <category>$(xml_escape "$cat")</category>"
done
echo ' </categories>'
fi
echo '</component>'
} > "$metadir/${base%.desktop}.metainfo.xml"
done
}
# Baut den AppStream-Katalog mit `appstreamcli compose` (Paket
# appstream-compose). Das Kommando arbeitet nicht auf .rpm-Dateien, sondern auf
# einem Verzeichnisbaum wie einem Dateisystem-Root (/usr/share/applications,
# /usr/share/metainfo, ...). Wir entpacken deshalb erst die dafuer relevanten
# Pfade aus allen Paketen in so einen Baum und rufen dann `appstreamcli compose`
# darauf auf.
generate_appstream_via_compose() {
local id="$1" out="$2"
local composeroot="$CACHE/appstream-root/$id"
local asout="$CACHE/appstream/$id"
local pkgmap="$CACHE/appstream-pkgmap-$id.txt"
local rpm
rm -rf "$composeroot"
mkdir -p "$composeroot/usr/share/applications" "$composeroot/usr/share/metainfo"
: > "$pkgmap"
for rpm in "$out"/*.rpm; do
[ -e "$rpm" ] || continue
local pkgname
pkgname=$(rpm -qp --qf '%{NAME}' "$rpm" 2>/dev/null)
# Icons/Pixmaps liegen so gut wie immer am Standard-FHS-Pfad - dafuer
# reicht ein blinder Glob-Extract, eine Paketzuordnung brauchen wir
# dafuer nicht.
(cd "$composeroot" && rpm2cpio "$rpm" | cpio -idm --quiet \
'./usr/share/icons/*' \
'./usr/share/pixmaps/*') >>"$CACHE/appstream-$id.log" 2>&1
# .desktop-/AppStream-Dateien koennen dagegen auch ausserhalb der
# Standardpfade liegen (u.a. alle jpackage-basierten Java/JavaFX-Apps
# wie z.B. XPipe legen sie direkt im eigenen Installationsverzeichnis
# ab, z.B. /opt/<app>/<app>.desktop, und verschieben sie erst per
# %post-Skript - das sehen wir beim reinen Lesen des Pakets nicht).
# Deshalb IMMER das ganze Paket nur AUFLISTEN (kein teurer Voll-
# Extract, bei z.T. >100 MB grossen JRE-Bundles) und alle
# .desktop/.metainfo.xml/.appdata.xml-Dateien gezielt herausgreifen -
# dabei merken wir uns, aus welchem RPM-Paket jede .desktop-Datei
# stammt, fuer <pkgname> in der (Fallback-)Metainfo weiter unten.
local filelist matches
filelist=$(mktemp)
rpm2cpio "$rpm" 2>/dev/null | cpio -t --quiet > "$filelist" 2>/dev/null
mapfile -t matches < <(grep -E '\.desktop$|\.metainfo\.xml$|\.appdata\.xml$' "$filelist")
# Reines CLI-Paket, das in console_apps: der packages.yaml steht: aus
# dem RPM-Header eine console-application-Metainfo bauen. Bringt das
# Paket wider Erwarten doch eigene AppStream-/Desktop-Dateien mit,
# haben die Vorrang und wir erzeugen nichts.
if [ "${#matches[@]}" -eq 0 ] && is_console_app "$pkgname"; then
log "$pkgname: erzeuge Konsolen-Katalogeintrag (kein .desktop/.metainfo.xml im Paket)."
generate_console_metainfo "$rpm" "$pkgname" "$composeroot" "$filelist"
fi
if [ "${#matches[@]}" -gt 0 ]; then
local extractroot f base destdir
extractroot=$(mktemp -d)
(cd "$extractroot" && rpm2cpio "$rpm" | cpio -idm --quiet "${matches[@]}") >>"$CACHE/appstream-$id.log" 2>&1
for f in "${matches[@]}"; do
[ -f "$extractroot/$f" ] || continue
base=$(basename "$f")
case "$base" in
*.desktop)
destdir="$composeroot/usr/share/applications"
printf '%s\t%s\n' "$base" "$pkgname" >> "$pkgmap"
;;
*)
destdir="$composeroot/usr/share/metainfo"
;;
esac
# Nicht ueberschreiben, falls bereits ein anderes Paket schon
# eine gleichnamige Datei mitgebracht hat.
if [ ! -e "$destdir/$base" ]; then
cp "$extractroot/$f" "$destdir/$base"
# Vom Hersteller mitgelieferte .metainfo.xml/.appdata.xml
# haben so gut wie nie ein <pkgname> (das kennt nur die
# Distro/das Repo, nicht der Upstream-Entwickler) - ohne
# das bleibt die Komponente in Discover trotz gueltiger
# Metainfo nicht installierbar. Deshalb hier direkt
# ergaenzen, falls es fehlt.
if [ "$base" != "${base%.desktop}" ]; then
: # ist eine .desktop-Datei, braucht selbst kein <pkgname>
elif ! grep -q '<pkgname>' "$destdir/$base" 2>/dev/null; then
sed -i "s#</component># <pkgname>$(xml_escape "$pkgname")</pkgname>\n</component>#" "$destdir/$base"
fi
fi
done
rm -rf "$extractroot"
fi
rm -f "$filelist"
done
if [ -z "$(find "$composeroot" -type f 2>/dev/null)" ]; then
log "Keine Desktop-/AppStream-Dateien in den Paketen gefunden und kein Paket in console_apps: - ueberspringe Katalog-Generierung."
return
fi
generate_fallback_metainfo "$composeroot" "$pkgmap"
rm -rf "$asout"
mkdir -p "$asout"
# Exit-Code NICHT sofort als fatal behandeln: appstreamcli compose liefert
# bei einzelnen fehlerhaften Komponenten ("Run failed, some data was
# ignored") einen Fehler-Exitcode, schreibt aber trotzdem einen Katalog
# mit allen UEBRIGEN, gueltigen Komponenten. Nur wenn am Ende wirklich
# gar kein Katalog entstanden ist, brechen wir ab.
if ! appstreamcli compose --prefix=/usr --origin="$id" --result-root="$asout" "$composeroot" \
>>"$CACHE/appstream-$id.log" 2>&1; then
log "WARNUNG: appstreamcli compose meldet Fehler bei einzelnen Komponenten, siehe $CACHE/appstream-$id.log - verwende ggf. trotzdem erzeugten Teil-Katalog."
fi
local catalog
catalog=$(find "$asout" -type f \( -iname '*.xml.gz' -o -iname '*.yml.gz' \) | head -n1)
if [ -z "$catalog" ]; then
log "WARNUNG: appstreamcli compose hat keinen Katalog erzeugt, siehe $CACHE/appstream-$id.log"
return
fi
# `appstreamcli compose` schreibt den Origin nur in Datei- und
# Verzeichnisnamen, NICHT als origin=-Attribut in den Katalog. libappstream
# (AsPool) liest den Origin einer Katalogdatei aber genau aus diesem
# Attribut und findet ohne ihn die zugehoerigen Icons unter
# /var/cache/swcatalog/icons/<origin>/ nicht - die App erscheint in
# Discover dann ohne Symbol. Deshalb hier nachtragen.
if ! zcat "$catalog" | head -c 400 | grep -q 'origin='; then
if zcat "$catalog" | sed "0,/<components /s//<components origin=\"$id\" /" \
| gzip -9 > "$catalog.tmp"; then
mv "$catalog.tmp" "$catalog"
else
log "WARNUNG: konnte origin=\"$id\" nicht in $catalog eintragen - Icons fehlen ggf. in Discover."
rm -f "$catalog.tmp"
fi
fi
# --compress-type=gz ist PFLICHT: modifyrepo_c komprimiert die uebergebene
# Datei neu und nutzt auf AlmaLinux 10 per Default zstd. Das dort
# installierte AppStream 1.0.2 kennt in as_metadata_file_guess_style() aber
# nur .xml/.xml.gz/.yml/.yml.gz (.xml.zst erst ab AppStream 1.0.5). Bei
# einer .xml.zst lehnt as_utils_install_metadata_file() die Datei mit
# "Can not process files of this type" ab, PackageKit bricht daraufhin den
# gesamten Repo-Refresh ab und in /var/cache/swcatalog/ landet nie etwas -
# Discover und appstreamcli sehen das Repo dann gar nicht.
modifyrepo_c --compress-type=gz --mdtype=appstream "$catalog" "$out/repodata"
# Icons: as_utils_install_metadata_file() akzeptiert nur *.tar.gz UND
# verlangt eine der bekannten Groessen (48x48, 64x64, 128x128, jeweils
# optional @2) IM DATEINAMEN - fehlt sie, bricht es mit "Unable to find
# valid icon size in icon tarball name" ab und reisst wieder den ganzen
# Refresh mit. Entpackt wird flach nach
# /var/cache/swcatalog/icons/<repo-id>/<groesse>/, der Tarball darf also
# nur die PNGs auf oberster Ebene enthalten (nicht <origin>/<groesse>/...).
# PackageKit holt genau EINEN appstream-icons-Eintrag, wir liefern deshalb
# genau eine Groesse aus (64x64 ist das, was Discover/GNOME Software in der
# Listenansicht nutzen).
local icons_root icons_size icons_src tarball
icons_root=$(find "$asout" -type d -name 'icons' | head -n1)
for icons_size in 64x64 128x128 48x48; do
if [ -n "$icons_root" ] && [ -d "$icons_root/$id/$icons_size" ] \
&& [ -n "$(find "$icons_root/$id/$icons_size" -type f 2>/dev/null)" ]; then
icons_src="$icons_root/$id/$icons_size"
break
fi
done
if [ -n "${icons_src:-}" ]; then
tarball="$asout/$id-icons-$icons_size.tar.gz"
rm -f "$tarball"
if tar -C "$icons_src" -czf "$tarball" . ; then
modifyrepo_c --compress-type=gz --mdtype=appstream-icons "$tarball" "$out/repodata"
else
log "WARNUNG: Icon-Tarball fuer $id konnte nicht gepackt werden - Apps erscheinen in Discover ohne Symbol."
fi
else
log "WARNUNG: appstreamcli compose hat keine Icons erzeugt - Apps erscheinen in Discover ohne Symbol."
fi
}
# Holt + filtert + signiert die Pakete EINES Fremdrepos in einen privaten,
# nur intern genutzten Cache ($SIGNED_ROOT/$id) - noch NICHT im oeffentlichen
# Ausgabeverzeichnis. Der eigentliche Merge zu einem gemeinsamen Repo passiert
# erst danach, einmalig fuer alle Fremdrepos zusammen (siehe unten).
# Args: id baseurl gpgkey gpgcheck(0/1) paket1 [paket2 ...]
sync_one_repo() {
local id="$1" baseurl="$2" gpgkey="$3" gpgcheck="$4"
shift 4
local -a packages=("$@")
local stage="$CACHE/stage/$id"
local signed="$SIGNED_ROOT/$id"
local tmp_repofile="$CACHE/tmp-repos/$id.repo"
log "== $id: hole ${#packages[@]} Paket(e) aus $baseurl =="
rm -f "$CACHE/tmp-repos"/*.repo
{
echo "[$id]"
echo "name=$id"
echo "baseurl=$baseurl"
echo "enabled=1"
echo "gpgcheck=$gpgcheck"
[ -n "$gpgkey" ] && echo "gpgkey=$gpgkey"
} > "$tmp_repofile"
rm -rf "$stage"
mkdir -p "$stage"
if ! dnf --setopt="reposdir=$CACHE/tmp-repos" \
--disablerepo='*' --enablerepo="$id" \
--forcearch="$ALMA_BASEARCH" \
download --destdir="$stage" "${packages[@]}" \
>>"$CACHE/download-$id.log" 2>&1; then
log "FEHLER: dnf download fuer $id fehlgeschlagen, siehe $CACHE/download-$id.log - ueberspringe (bisheriger Stand von $id bleibt im gemeinsamen Repo erhalten)."
return 1
fi
log "== $id: filtere Pakete, die Alma bereits bereitstellt =="
local removed=0 kept=0
while IFS= read -r -d '' rpm; do
local name
name=$(rpm -qp --qf '%{NAME}' "$rpm" 2>/dev/null)
if [ -z "$name" ]; then
continue
fi
if grep -qxF "$name" "$CACHE/alma-names.txt" 2>/dev/null && ! is_force_included "$name"; then
rm -f "$rpm"
removed=$((removed + 1))
else
kept=$((kept + 1))
fi
done < <(find "$stage" -maxdepth 1 -name '*.rpm' -print0)
log "$id: $kept Pakete werden gespiegelt, $removed uebersprungen (bereits ueber Alma verfuegbar)."
log "== $id: aktualisiere internen Paketstand und signiere neue/geaenderte Pakete =="
mkdir -p "$signed"
local changed_list rsync_out
changed_list=$(mktemp)
rsync_out=$(mktemp)
if ! rsync -ai --delete --include='*.rpm' --exclude='*' "$stage"/ "$signed"/ > "$rsync_out" 2>&1; then
log "FEHLER: rsync (interner Paketstand) fuer $id fehlgeschlagen: $(cat "$rsync_out") - ueberspringe $id fuer diesen Lauf."
rm -f "$changed_list" "$rsync_out"
return 1
fi
awk '/^[><]f/{print $2}' "$rsync_out" > "$changed_list"
rm -f "$rsync_out"
local resign_failed=0
while IFS= read -r f; do
[ -z "$f" ] && continue
if ! rpmsign --resign \
--define "_gpg_name $GPG_KEY_ID" \
--define "_gpg_path $GNUPGHOME" \
"$signed/$f" >>"$CACHE/rpmsign-$id.log" 2>&1; then
log "FEHLER: Signieren von $signed/$f fehlgeschlagen, siehe $CACHE/rpmsign-$id.log"
resign_failed=1
fi
done < "$changed_list"
local changed_count
changed_count=$(wc -l < "$changed_list")
rm -f "$changed_list"
log "$id: $changed_count Pakete neu signiert."
if [ "$resign_failed" -ne 0 ]; then
log "FEHLER: mind. ein Paket in $id konnte nicht signiert werden - $id wird bei der Zusammenfuehrung uebersprungen."
return 1
fi
log "== $id: fertig =="
}
if ! fetch_packages_yaml; then
log "FEHLER: keine packages.yaml verfuegbar, breche Sync-Lauf komplett ab."
exit 1
fi
mapfile -t FORCE_INCLUDE_NAMES < <(jq -r '.force_include[]? // empty' "$PACKAGES_JSON")
mapfile -t CONSOLE_APP_NAMES < <(jq -r '.console_apps[]? // empty' "$PACKAGES_JSON")
refresh_alma_names
if [ ! -s "$CACHE/alma-names.txt" ]; then
log "FEHLER: keine Alma-Baseline-Namen verfuegbar, breche Sync-Lauf ab (Sicherheitsnetz gegen versehentliches Doppel-Spiegeln)."
exit 1
fi
overall_status=0
all_ids=()
repo_count=$(jq '.repos | length' "$PACKAGES_JSON")
for ((i = 0; i < repo_count; i++)); do
repo_json=$(jq -c ".repos[$i]" "$PACKAGES_JSON")
id=$(jq -r '.id // empty' <<<"$repo_json")
baseurl=$(jq -r '.baseurl // empty' <<<"$repo_json")
gpgkey=$(jq -r '.gpgkey // empty' <<<"$repo_json")
gpgcheck=$(jq -r 'if .gpgcheck == false then "0" else "1" end' <<<"$repo_json")
mapfile -t packages < <(jq -r '.packages[]? // empty' <<<"$repo_json")
if [ -z "$id" ] || [ -z "$baseurl" ] || [ "${#packages[@]}" -eq 0 ]; then
log "WARNUNG: Eintrag Nr. $((i + 1)) in packages.yaml unvollstaendig (id/baseurl/packages fehlt), ueberspringe."
overall_status=1
continue
fi
all_ids+=("$id")
sync_one_repo "$id" "$baseurl" "$gpgkey" "$gpgcheck" "${packages[@]}" || overall_status=1
done
# Internen Paketstand von Fremdrepos aufraeumen, die nicht mehr in
# packages.yaml stehen - sonst wuerden deren alte Pakete beim Merge unten
# unnoetig im Cache liegen bleiben (sie werden ja schon jetzt nicht mehr in
# die Merge-Quellenliste aufgenommen, siehe unten - das hier ist nur Hygiene).
if [ -d "$SIGNED_ROOT" ]; then
for d in "$SIGNED_ROOT"/*/; do
[ -d "$d" ] || continue
existing_id=$(basename "$d")
keep=0
for id in "${all_ids[@]}"; do
[ "$id" = "$existing_id" ] && keep=1 && break
done
if [ "$keep" -eq 0 ]; then
log "Entferne internen Paketstand des nicht mehr in packages.yaml gelisteten Fremdrepos '$existing_id' ..."
rm -rf "$d"
fi
done
fi
log "== Fuehre alle Fremdrepos zu EINEM gemeinsamen Ausgabe-Repo zusammen =="
merge_sources=()
for id in "${all_ids[@]}"; do
[ -d "$SIGNED_ROOT/$id" ] && merge_sources+=("$SIGNED_ROOT/$id/")
done
if [ "${#merge_sources[@]}" -eq 0 ]; then
log "FEHLER: kein Fremdrepo hat je erfolgreich Pakete geliefert - gemeinsames Repo bleibt unveraendert/leer."
exit 1
fi
if ! rsync -ai --delete --include='*.rpm' --exclude='*' "${merge_sources[@]}" "$OUT"/ >>"$CACHE/merge.log" 2>&1; then
log "FEHLER: Zusammenfuehren der Fremdrepos zum gemeinsamen Repo fehlgeschlagen, siehe $CACHE/merge.log"
exit 1
fi
log "== baue Repo-Metadaten fuer das gemeinsame Repo =="
if ! createrepo_c --update "$OUT" >>"$CACHE/createrepo.log" 2>&1; then
log "FEHLER: createrepo_c fehlgeschlagen, siehe $CACHE/createrepo.log"
exit 1
fi
log "== baue AppStream-Metadaten (fuer Discover/GNOME Software) =="
if command -v appstreamcli >/dev/null 2>&1; then
generate_appstream_via_compose "$MIRROR_REPO_ID" "$OUT"
else
log "WARNUNG: appstreamcli nicht installiert - Pakete erscheinen ohne App-Katalogeintrag in Discover."
fi
log "== signiere repomd.xml =="
gpg --batch --yes --detach-sign --armor --local-user "$GPG_KEY_ID" \
--output "$OUT/repodata/repomd.xml.asc" "$OUT/repodata/repomd.xml"
cat > "$OUT/$MIRROR_REPO_ID.repo" <<EOF
[$MIRROR_REPO_ID]
name=$MIRROR_REPO_ID (Mirror)
baseurl=https://$MIRROR_DOMAIN/dnf/
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=https://$MIRROR_DOMAIN/dnf/RPM-GPG-KEY-mirror
EOF
package_count=$(find "$OUT" -maxdepth 1 -name '*.rpm' | wc -l)
log "== fertig: $package_count Paket(e) im gemeinsamen Repo =="
exit "$overall_status"