Wirklich bequem wird die Softwareinstallation erst dann, wenn der Paketmanager die Programme selbst findet und herunterlädt. Deshalb erweitern wir unser Build-System um ein Repository.
Im letzten Beitrag dieser Serie entstand ein Bot, der die von unserem Build-System gebauten Pakete signiert. So können Benutzer leicht feststellen, ob ein Paket nach dem Bau manipuliert wurde. Vorher müssen sie die Pakete aber überhaupt erst einmal herunterladen können. Dazu erstellen wir ein Paket-Repository, das es erlaubt, Pakete direkt via Apt zu installieren.
Struktur
Wer schon einmal versucht hat, auf einem Debian-artigen System Pakete zu installieren, die nicht direkt von der Distribution kommen, wird mit der Konfiguration der Paketquellen in »/etc/apt/sources.list« vertraut sein. Diese Konfiguration enthält mehrere Zeilen wie die aus Listing 1, von denen jede ein Repository definiert, das Apt nutzen kann. Neben dem Schlüsselwort »deb« und der URL des Repositorys finden sich in der Zeile der Codename der Distribution sowie die Komponenten »main«, »contrib« und »non-free«.
Listing 1
Paketquellen
deb http://ftp.de.debian.org/debian bookworm main contrib non-free
Die Bedeutung der Codenamen und Komponenten wird klar, wenn man sich den Aufbau eines Repositorys ansieht: In einem Debian-Repository finden sich die Pakete verschiedener Debian-Versionen wie »bookworm« und »buster« (gegenwärtig auch »stable« beziehungsweise »oldstable« genannt). Die Pakete der einzelnen Versionen gliedern sich in freie und unfreie Packages sowie solche, die von unfreien Paketen abhängen. Für diese drei Kategorien verwendet Debian die Namen »free«, »non-free« und »contrib« (Abbildung 1).
Durch Entfernen der letzten beiden Schlüsselwörter aus der Konfiguration lässt sich verhindern, dass man unfreie Software installiert. Da sich meist auch Firmware für Netzwerk- und Grafikkarten in diesen Komponenten befindet, ist dabei allerdings Vorsicht angesagt. Die nächste stabile Debian-Version “Bullseye” wird unfreie Firmware deshalb als neue Komponente »non-free-firmware« führen.
Zum Verwalten von Repositories gibt es gleich mehrere Tools, von denen sich aus Sicht des Autors »reprepro« als das benutzerfreundlichste Werkzeug hervorgetan hat. Mit ihm wollen wir nun zwei Funktionen implementieren, die wir zum Verwalten des Repositorys brauchen. Die erste erstellt eine neue Paketquelle, die zweite fügt ein Paket in ein existierendes Repository ein.
Repository erstellen
Bevor wir mit Reprepro ein Repository initialisieren, müssen wir eine Konfigurationsdatei dafür erstellen. Sie enthält mehrere Abschnitte, die jeweils eine Distribution in dem Repository beschreiben. Jeder dieser Abschnitte definiert neben dem Namen und der Beschreibung des Repositorys die Architekturen und Komponenten der Distribution sowie den zum Signieren verwendeten GPG-Schlüssel.
Listing 2 zeigt die Konfiguration für ein fiktives Repository namens »deb.example.com«, in dem sich eine Distribution mit dem Namen »stable« befindet. Sie stellt eine Komponente »main« mit Paketen für die Architekturen »amd64« und »i386« bereit.
Listing 2
Reprepro-Konfiguration
Origin: deb.example.com Label: deb.example.com Codename: stable Architectures: amd64 i386 Components: main Description: APT-Repository von example.com SignWith: 4775742067656D61636874203A44
In einem ersten Schritt schreiben wir also eine Funktion »make_release_config()«, die einen Konfigurationsabschnitt für eine Distribution erstellt. Die Funktion fügt lediglich alle ihr übergebenen Argumente in einen Here-Text ein, den sie auf der Standardausgabe ausgibt. Die einzelnen Abschnitte in der Konfiguration müssen durch eine Leerzeile getrennt werden, weswegen die Ausgabe der Funktion mit einer Leerzeile endet (Listing 3). Eine weitere Funktion »make_repo_config()« iteriert über die Codenamen im Repository und ruft obige Funktion auf (Listing 4). Somit wird die gesamte Konfigurationsdatei auf der Standardausgabe ausgegeben.
Listing 3
Konfigurationsabschnitt
make_release_config() {
local name="$1"
local codename="$2"
local architectures="$3"
local gpgkey="$4"
local description="$5"
local components="$6"
cat <<EOF
Origin: $name
Label: $name
Codename: $codename
Architectures: $architectures
Components: $components
Description: $description
SignWith: $gpgkey
EOF
}
Listing 4
Repo-Konfiguration
make_repo_config() {
local name="$1"
local arch="$2"
local gpgkey="$3"
local description="$4"
local components="$5"
local codenames=("${@:6}")
local codename
for codename in "${codenames[@]}"; do
make_release_config "$name" "$codename" "$arch" "$gpgkey" "$description" "$components"
done
}
Mithilfe dieser beiden Funktionen und einiger weiterer Befehle können wir nun eine Funktion implementieren, die ein komplettes Repository initialisiert. Die Funktion »repo_init()« bekommt den Pfad des Repositorys sowie die anderen Eigenschaften als Parameter übergeben und prüft zunächst, ob in dem Pfad bereits eine Konfiguration existiert. Falls ja, gehen wir davon aus, dass das Repository bereits existiert, und überspringen die nächsten Schritte.
Existiert keine Konfiguration, so erstellen wir ein neues Repository, indem wir dafür ein Verzeichnis samt Unterverzeichnis »conf/« für die Konfigurationsdatei erstellen. Anschließend verwenden wir die oben implementierte Funktion, um die Konfigurationsdatei zu generieren. Zum Schluss erledigt der Befehl »reprepro export« die eigentliche Initialisierung. Er liest die Konfiguration ein und erstellt alle nötigen Dateien, damit Apt mit dem Repository etwas anfangen kann (Listing 5).
Listing 5
Repo-Initialisierung
repo_init() {
local repo="$1"
local domain="$2"
local archs="$3"
local gpgkey="$4"
local description="$5"
local components="$6"
local codenames=("${@:7}")
if [ -f "$repo/conf/distributions" ]; then
log_info "Repo $repo existiert. Ueberspringe Initialisierung."
return 0
elif ! mkdir -p "$repo/conf"; then
log_error "Konnte $repo/conf nicht erstellen"
return 1
elif ! make_repo_config "$domain" "$archs" "$gpgkey" "$description" "$components" "${codenames[@]}" > "$repo/conf/distributions"; then
log_error "Konnte Konfiguration nicht schreiben"
return 1
elif ! reprepro --basedir "$repo" export; then
log_error "Konnte Repository nicht initialisieren"
return 1
fi
return 0
}
Wareneingang
Ein mit »repo_init()« erstelltes Repository können wir nun direkt mit Apt verwenden. Da das Repository aber nur so viel Freude bereitet, wie es Pakete enthält, schreiben wir eine Funktion, die ein Paket in ein Repository einsortiert. Diese Funktion nennen wir »repo_add_package()« und implementieren sie durch einen Aufruf von »reprepro includedeb«. Neben den Pfaden des Repositorys und des Pakets bekommt der Befehl auch den Codenamen der Distribution übergeben, der das Paket hinzugefügt werden soll. Das Ganze sieht dann so aus wie in Listing 6.
Listing 6
Paket hinzufügen
repo_add_package() {
local repository="$1"
local package="$2"
local codename="$3"
reprepro --basedir "$repository" includedeb "$codename" "$package"
return "$?"
}
Damit haben wir jetzt alle Zutaten für den neuen Bot zusammen. Bei seiner Implementierung gehen wir ganz ähnlich vor wie in den bisherigen Beiträgen dieser Serie: In der Funktion »main()« initialisieren wir als Erstes das Repository. Anschließend rufen wir mit »inst_start()« eine Instanz der Funktion »init_bot()« auf. Sie bereitet einen IPC-Endpunkt vor und übergibt ihn an die Funktion »dispatch_messages()«.
Sie implementiert die Haupt-Schleife des Bots. Darin nehmen wir Nachrichten per »ipc_endpoint_recv()« entgegen und reichen sie, sofern sie nicht ungültig sind, an »handle_message()« weiter. Ist die empfangene Nachricht nicht vom Typ »sign« oder enthält keinen gültigen Kontext, werfen wir sie weg. Gültige Sign-Nachrichten geben wir zusammen mit dem Kontext an die Funktion »handle_sign_message()« weiter.
Bis hierhin unterscheidet sich der neue Bot nicht nennenswert von seinen Geschwistern, weshalb wir bei »handle_sign_message()« anfangen, den Code genauer zu beleuchten.
Einlasskontrolle
Signbot lässt die anderen Bots mit einer Sign-Nachricht wissen, dass neue Pakete signiert wurden und bereit sind, an Benutzer verteilt zu werden. Wenn Distbot eine solche Nachricht erhält, hat er die Aufgabe, die signierten Pakete in sein APT-Repository einzusortieren.
Distbot fungiert aber nicht nur als Hausmeister des Repositorys, sondern auch als dessen – recht strenger – Türsteher. Trifft eine Sign-Nachricht ein, sortiert er die darin genannten Pakete nicht blind ins Repository ein, sondern prüft zuerst, ob die Pakete wirklich signiert sind und ob die Signatur von einem vertrauten Schlüssel stammt. Die Prüfung und das Einsortieren der Pakete übernehmen die zwei Funktionen »validate_artifacts()« und »add_artifacts_to_repo()«, die »handle_sign_message()« dazu aufruft (Listing 7).
Listing 7
Sign-Nachrichten behandeln
handle_sign_message() {
local endpoint="$1"
local repopath="$2"
local context="$3"
local signmsg="$4"
if ! validate_artifacts "$endpoint" "$context" "$signmsg";
then
return 1
fi
if ! add_artifacts_to_repo "$endpoint" "$context" "$signmsg" "$repopath"; then
return 1
fi
return 0
}
Die Funktion »validate_artifacts()« iteriert über die Liste der Pakete, die sie mithilfe von Jq aus der Nachricht zieht. Sie prüft die Signatur jedes Pakets einzeln und sortiert gültige und ungültige Pakete in die Arrays »valid« beziehungsweise »invalid« ein. Ist letzteres Array nicht leer, enthält die Sign-Nachricht mindestens ein ungültiges Paket. In diesem Fall verwerfen wir die Nachricht aber nicht einfach, sondern senden auch eine Fehlernachricht. Das erledigen wir in einer Hilfsfunktion namens »send_invalid_artifact_error()«.
Bleibt »invalid« leer, heißt das aber nicht automatisch, dass mit den Paketen alles in Ordnung ist. Wir testen, ob möglicherweise auch das Array »valid« leersteht, und senden bei Bedarf mit »send_no_valid_artifacts_error()« eine entsprechende Fehlernachricht. Nur wenn »invalid« leer und »valid« nicht leer ist, meldet »validate_artifacts()« dem Aufrufer einen erfolgreichen Abschluss (Listing 8). Hinter der Funktion »artifact_is_valid()« verbirgt sich ein Aufruf von »dpkg-sig –verify«, das wir im letzten Beitrag dieser Reihe kennengelernt haben.
Listing 8
Pakete validieren
validate_artifacts() {
local endpoint="$1"
local context="$2"
local signmsg="$3"
local contextdir
local artifact
local -a valid
local -a invalid
contextdir="/var/lib/dbs/contexts/$context"
while read -r artifact; do
local artifact_path
artifact_path="$contextdir/$artifact"
if ! artifact_is_valid "$artifact_path"; then
invalid+=("$artifact")
else
valid+=("$artifact")
fi
done < <(jq -e -r '.artifacts[]' <<< "$signmsg")
if (( ${#invalid[@]} > 0 )); then
send_invalid_artifact_error "$endpoint" "$context" "${invalid[@]}"
return 1
fi
if (( ${#valid[@]} == 0 )); then
send_no_valid_artifacts_error "$endpoint" "$context"
return 1
fi
return 0
}
Hat Distbot alle Pakete in der Nachricht geprüft und als gut befunden, fügt er die Pakete mit »add_artifacts_to_repo()« in sein Repository ein. Wie schon in der vorherigen Funktion iterieren wir über alle Pakete und versuchen, jedes einzelne mit der oben geschriebenen Funktion »repo_add_package()« in das Repository einzufügen. Da ein Repository aber Pakete für mehrere Distributionen führt, benötigen wir hier auch den Codenamen der Distribution, in die das Paket einsortiert werden soll. Wir übergeben dem Bot daher ein globales assoziatives Array »codename_map«, mit dem wir jedem Branch einen Codenamen zuordnen.
Da die Sign-Nachricht den Namen des Zweigs enthält, von dem die Pakete gebaut wurden, können wir so mit einem Ausdruck wie »${codename_map[$branch]}« herausfinden, in welche Distribution das Paket einsortiert werden soll. Wurde einem Branch kein Codename zugeordnet, verwenden wir den Codenamen mit dem besonderen Index »”*”«. Mit dem Ausdruck »”${codename_map[$branch]-${codename_map[‘*’]}”« können wir bequem entweder das eine oder das andere aus dem Array ziehen. Die Anführungszeichen im zweiten Teil dürfen wir nicht vergessen, da »${codename_map[*]}« zu einem String mit allen Elementen des Arrays expandiert.
Wurde ein Paket erfolgreich hinzugefügt, fügen wir seinen Namen in das Array »succeeded« ein; trat dagegen ein Problem auf, landet der Name im Array »failed«. Wenn wir alle Pakete abgearbeitet haben und »failed« nicht leer ist, senden wir mit der Hilfsfunktion »send_publish_error()« eine Fehlernachricht. Sie lässt die anderen Teilnehmer des Build-Systems wissen, dass einige Pakete nicht veröffentlicht werden konnten. Ist das Array »succeeded« nicht leer, haben wir einige Pakete erfolgreich veröffentlicht. Mit der Hilfsfunktion »send_dist_message()« senden wir dann eine Nachricht vom Typ »dist«. Sie informiert alle interessierten Prozesse darüber, dass das Repository neue Pakete enthält (Listing 9).
Listing 9
Pakete hinzufügen
add_artifacts_to_repo() {
local endpoint="$1"
local context="$2"
local signmsg="$3"
local repopath="$4"
local contextdir
local artifact
local -a failed
local -a succeeded
local branch
local codename
branch=$(jq -r -e '.branch' <<< "$signmsg")
codename="${codename_map[$branch]-${codename_map['*']}}"
contextdir="/var/lib/dbs/contexts/$context"
while read -r artifact; do
local package
package="$contextdir/$artifact"
if ! repo_add_package "$repopath" "$package" "$codename"; then
failed+=("$artifact")
else
succeeded+=("$artifact")
fi
done < <(jq -e -r '.artifacts[]' <<< "$signmsg")
if (( ${#failed[@]} > 0 )); then
send_publish_error "$endpoint" "$context" "$repopath" "${failed[@]}"
return 1
fi
if (( ${#succeeded[@]} > 0 )); then
send_dist_message "$endpoint" "$context" "$repopath" "${succeeded[@]}"
fi
return 0
}
Die Funktionen zum Versenden von Nachrichten konstruieren jeweils ein JSON-Objekt aus den übergebenen Argumenten und schicken es mittels »ipc_endpoint_publish()« an die anderen Prozesse. Fehlermeldungen veröffentlichen wir wie gehabt unter dem Topic »errors«; Dist-Nachrichten gehen an »dists« beziehungsweise an das Topic, das der Benutzer mit »–publish-to« auf der Kommandozeile definiert hat.
Zuletzt verlieren wir noch ein Wort über das assoziative Array »codename_map«, mit dem wir Codenamen Branch-Namen zuordnen. Haben Sie diese Serie bis hierhin verfolgt, ahnen Sie sicher schon, dass wir das Array befüllen, indem wir eine Option »–codename« mit einem Callback definieren. Der dieser Option übergebene Wert soll entweder die Form »branch:codename« oder »codename« haben. Die erste Form definiert ein direktes Mapping, das wir mit »codename_map[“$branch”]=”$codename”« in das Array einfügen. Die zweite legt ein Mapping für alle Zweige fest, die nicht explizit definiert wurden. In diesem Fall wird der Codename unter dem besonderen Index »”*”« gespeichert. Den Code hierzu können Sie dem Begleitmaterial zu diesem Artikel entnehmen.
Startklar
Den neuen Bot speichern wir nun unter »/usr/local/share/dbs/distbot.sh« und erstellen einen symbolischen Link »/usr/local/bin/distbot« auf das Skript. Dieses Mal wollen wir zwei verschiedene Schlüssel für Signbot und Distbot benutzen. Daher generieren wir mit »gpg –generate-key« zwei Schlüssel, denen wir die E-Mail-Adressen »signbot@example.com« und »distbot@example.com« geben, um sie später unterscheiden zu können. Da die Bots keine passwortgeschützten Schlüssel benutzen können, sehen wir vom Passwortschutz ab. Die Schlüssel-IDs (jeweils eine längere hexadezimale Zahl) aus der Ausgabe notieren wir für später oder schlagen sie bei Bedarf mit »gpg -k« nach.
Der neue Bot soll ein Repository für die Distribution »stable« mit der Komponente »main« verwalten. Pakete, die vom Branch »stable« gebaut wurden, sollen in die gleichnamige Distribution wandern. Obwohl – oder gerade weil – wir bei diesem Test keine architekturabhängigen Pakete bauen, soll das Repository die Architekturen »amd64« und »i386« unterstützen. Mit einem Befehl wie dem aus Listing 10 weisen wir Distbot an, das Repository unter »/tmp/deb.example.com« zu erstellen und zu verwalten. An die Option »–gpg-key« hängen wir die Schlüssel-ID für den Schlüssel »distbot@example.com« an.
Listing 10
Repo für Stable
$ distbot --gpg-key Distbot-Schlüssel --codename "stable" \ --component "main" --name "deb.example.com" \ --output "/tmp/deb.example.com" --arch "amd64" --arch "i386" \ --description "deb.example.com Paket-Repository"
Anschließend konfigurieren wir Apt, sodass wir Pakete direkt aus dem Repository installieren können. Dazu fügen wir die folgende Zeile an einer beliebigen Stelle in »/etc/apt/sources.list« ein:
deb file:///tmp/deb.example.com stable main
Wenn wir nun »apt-get update« ausführen, bekommen wir eine Fehlermeldung, da Apt dem Schlüssel von Distbot nicht vertraut. Wir müssen den Schlüssel erst exportieren und unter »/etc/apt/trusted.gpg.d« speichern (Listing 11, erste zwei Zeilen). Ein erneuter Aufruf von »apt-get update« sollte nun keine Fehler mehr anzeigen.
Listing 11
Komponenten starten
$ gpg --output deb.example.com.gpg --export "distbot@example.com" $ sudo mv deb.example.com.gpg /etc/apt/trusted.gpg.d/. $ git clone https://github.com/m10k/toolbox -b stable /tmp/toolbox $ watchbot --interval 15 --repository "/tmp/toolbox#stable" $ debbot --auto-versioning --build-branch "stable" $ signbot --gpg-key Signbot-Schlüssel $ ./listener.sh --topic errors --topic commits --topic builds --topic signs --topic dists
Nach Abschluss der Repository-Konfiguration starten wir die verbleibenden Komponenten des Build-Systems (Listing 11, Zeile 3 bis 6). Dabei gehen wir genauso vor wie im letzten Beitrag. Beim Start von Signbot übergeben wir mit »–gpg-key« den Schlüssel von Signbot. Mit dem Skript »listener.sh«, das wir im letzten Beitrag entwickelt haben, überwachen wir nun alle Topics des Build-Systems (letzte Zeile). So bekommen wir eine Idee davon, was im Build-System abläuft.
Um das Build-System in Bewegung zu versetzen, verändern wir jetzt das Git-Repository in »/tmp/toolbox« minimal und fügen mit »git commit -a -m “Distbot Test”« ein Commit an seine Historie an. Nun sollte »listener.sh« nach und nach Nachrichten der verschiedenen Bots ausgeben. Hat alles geklappt, erscheint am Schluss eine Nachricht vom Typ »dist« (Abbildung 2).

Abbildung 2: Die abschließende Nachricht vom Typ »dist« bestätigt den erfolgreichen Ablauf aller Schritte.
Richten wir jetzt mit dem Befehl »sudo apt-get install toolbox« das gebaute Paket ein, können wir beobachten, wie es aus dem lokalen Repository heruntergeladen und installiert wird. Erstellen wir das Repository statt in »/tmp« im Webroot eines HTTP-Servers, lässt es sich auch von anderen Computern aus verwenden.
Fazit
Unser Build-System kann jetzt komplett autonom Debian-Pakete aus unseren Git-Repositorys bauen und zur Installation mit Apt bereitstellen. Da das System komplett selbstständig arbeitet, ist es nicht immer offensichtlich, was im Inneren vor sich geht. Zum Abschluss entwickeln wir deshalb beim nächsten Mal einen Bot, der uns mit Benachrichtigungen auf dem Laufenden hält. (jcb)






