Drei Betriebsskripte fuer Linux, im Auslieferungspaket neben den Programmen. setup.sh richtet eine Anlage vollstaendig ein: PostgreSQL auf Wunsch mit (apt/dnf/yum/zypper/pacman), Dienstkonto, Verschluesselungsschluessel, Schema, erster Administrator, gehaertetes Repository, gehaertete systemd-Einheit. Jeder Schritt vermerkt, was er angelegt hat; bricht der Lauf ab, wird genau das zurueckgebaut und nichts sonst. Eine bestehende Installation wird nicht ueberschrieben — dafuer gibt es update.sh, und der Unterschied ist, dass ein Update vorher sichert. update.sh haelt die Reihenfolge ein, um die es geht: sichern, anhalten, tauschen, migrieren, starten, pruefen. Kommt der Dienst danach nicht hoch, holt es die vorige Fassung zurueck. Ohne pg_dump wird gar nicht erst begonnen — ohne Sicherung gibt es nach einer misslungenen Migration keinen Weg zurueck. Repository und Verschluesselungsschluessel bleiben unberuehrt. uninstall.sh entfernt standardmaessig NUR Dienst und Programme. Datenbank, Repository und Konfiguration bleiben liegen; jede dieser Loeschungen verlangt ein woertlich getipptes Bestaetigungswort an einem Terminal. Ein Deinstallationsskript, das nebenbei die Backups mitnimmt, vernichtet genau das, wofuer jemand jahrelang Speicher bezahlt hat. Gegen Debian 12 im Container gefahren — Installation, Anmeldung, Repository eingetragen, Loeschschutz gemessen (advisory auf overlayfs, richtig), echter Sicherungslauf, Update mit unveraendertem Bestand, beide Abbauarten. Drei Fehler dabei gefunden und behoben, alle derselben Art: - "tr </dev/urandom | head -c 32" und "psql | grep -q": Der frueh geschlossene Pipe schickt dem Schreiber SIGPIPE, und mit "set -o pipefail" bricht das Skript mitten in der Einrichtung ab, ohne erkennbaren Grund. - update.sh las die laufende Fassung mit "grep -o" aus der Antwort von /health/ready. Die enthaelt gar kein Versionsfeld; grep endet mit 1, und das Skript nahm ein GELUNGENES Update wieder zurueck — wegen einer Zeile, die nur der Ausgabe dient. Dazu: SYNCOVA_ADMIN_PASSWORD stand in der Doku und existiert nicht — das Passwort kommt ueber die Standardeingabe. Und "syncova-repo break-lock" fehlte zwar nicht mehr, aber der Test auf die Uebereinstimmung der drei Skripte ist neu: Drei Skripte, die sich ueber den Installationsort uneinig sind, ergeben eine Anlage, die sich nicht mehr entfernen laesst. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
228 lines
8.3 KiB
Go
228 lines
8.3 KiB
Go
// Package scripts prüft die Vollständigkeit der Auslieferung (Phase 23).
|
|
//
|
|
// Der Test läuft im gewöhnlichen Testlauf mit und braucht kein gebautes Paket:
|
|
// Er prüft die **Bestandteile**, aus denen es entsteht. Ein Paket zu bauen,
|
|
// nur um es zu prüfen, dauerte Minuten und liefe deshalb bald nicht mehr mit.
|
|
package scripts
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// repositoryRoot ist der Pfad zum Wurzelverzeichnis.
|
|
const repositoryRoot = ".."
|
|
|
|
// TestReleaseDocumentationIsComplete prueft die Liste aus §25.
|
|
//
|
|
// Der Umsetzungsplan nennt zwoelf Bestandteile, die ein Auslieferungspaket
|
|
// enthalten muss. Fuenf davon sind Dokumente, und die schreibt man einmal —
|
|
// danach faellt es niemandem auf, wenn eines beim Umbenennen verlorengeht.
|
|
// Genau deshalb steht die Liste hier und nicht nur in einer Aufzaehlung.
|
|
func TestReleaseDocumentationIsComplete(testInstance *testing.T) {
|
|
requiredDocuments := map[string]string{
|
|
"docs/installation.md": "Installationsanleitung",
|
|
"docs/recovery-runbook.md": "Wiederherstellungsdokumentation",
|
|
"docs/security-guide.md": "Sicherheitsleitfaden",
|
|
"docs/api.md": "API-Dokumentation",
|
|
"docs/troubleshooting.md": "Stoerungsleitfaden",
|
|
"docs/release-howto.md": "Anleitung zum Veroeffentlichen und Testen",
|
|
"docs/agent-installation.md": "Installationsanleitung der Agenten",
|
|
"CHANGELOG.md": "Aenderungsliste",
|
|
"README.md": "Ueberblick",
|
|
}
|
|
|
|
for documentPath, documentPurpose := range requiredDocuments {
|
|
fileInformation, statError := os.Stat(filepath.Join(repositoryRoot, documentPath))
|
|
if statError != nil {
|
|
testInstance.Errorf("%s fehlt (%s): %v", documentPath, documentPurpose, statError)
|
|
|
|
continue
|
|
}
|
|
|
|
// Eine leere Datei erfüllt die Liste formal und hilft niemandem. Die
|
|
// Grenze ist bewusst niedrig — sie fängt die versehentlich angelegte
|
|
// Hülle, nicht ein knappes Dokument.
|
|
if fileInformation.Size() < 500 {
|
|
testInstance.Errorf("%s ist mit %d Byte zu klein für %s",
|
|
documentPath, fileInformation.Size(), documentPurpose)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestReleaseScriptCoversEveryCommand prueft, dass kein Programm fehlt.
|
|
//
|
|
// Ein Programm, das gebaut, aber nicht ausgeliefert wird, faellt erst beim
|
|
// Kunden auf — und dann fehlt genau das Werkzeug, das er gerade braucht.
|
|
func TestReleaseScriptCoversEveryCommand(testInstance *testing.T) {
|
|
buildScript := readReleaseScript(testInstance)
|
|
|
|
commandDirectories := []string{"apps/api/cmd", "apps/agent/cmd"}
|
|
|
|
for _, commandDirectory := range commandDirectories {
|
|
directoryEntries, readError := os.ReadDir(filepath.Join(repositoryRoot, commandDirectory))
|
|
if readError != nil {
|
|
testInstance.Fatalf("%s ließ sich nicht lesen: %v", commandDirectory, readError)
|
|
}
|
|
|
|
for _, singleEntry := range directoryEntries {
|
|
if !singleEntry.IsDir() {
|
|
continue
|
|
}
|
|
|
|
commandPath := commandDirectory + "/" + singleEntry.Name()
|
|
|
|
if !strings.Contains(buildScript, commandPath) {
|
|
testInstance.Errorf("das Programm %s wird gebaut, aber nicht ausgeliefert.\n"+
|
|
" Entweder in scripts/build-release.sh aufnehmen oder begründen, "+
|
|
"warum es nicht ins Paket gehört.", singleEntry.Name())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestReleaseScriptShipsEveryTargetPlatform prueft die Zielplattformen.
|
|
//
|
|
// Dieselben drei, die `make cross-build` bei jedem Lauf uebersetzt. Eine
|
|
// Plattform, die uebersetzt, aber nicht ausgeliefert wird, ist eine Zusage ohne
|
|
// Gegenstueck.
|
|
func TestReleaseScriptShipsEveryTargetPlatform(testInstance *testing.T) {
|
|
buildScript := readReleaseScript(testInstance)
|
|
|
|
requiredPlatforms := []string{"linux/amd64", "linux/arm64", "windows/amd64"}
|
|
|
|
for _, platformName := range requiredPlatforms {
|
|
if !strings.Contains(buildScript, platformName) {
|
|
testInstance.Errorf("die Zielplattform %s wird nicht ausgeliefert", platformName)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestReleaseScriptIncludesMigrationsAndInterface prueft die uebrigen Bestandteile.
|
|
func TestReleaseScriptIncludesMigrationsAndInterface(testInstance *testing.T) {
|
|
buildScript := readReleaseScript(testInstance)
|
|
|
|
requiredParts := map[string]string{
|
|
"migrations/*.sql": "Migrationsdateien",
|
|
"migrations/checksums.txt": "Prüfsummen der Migrationen",
|
|
"apps/web/dist": "Weboberfläche",
|
|
"docs/": "Dokumentation",
|
|
"CHANGELOG.md": "Änderungsliste",
|
|
"SHA256SUMS": "Prüfsummen des Pakets",
|
|
}
|
|
|
|
for partPattern, partPurpose := range requiredParts {
|
|
if !strings.Contains(buildScript, partPattern) {
|
|
testInstance.Errorf("im Auslieferungsskript fehlt %s (%s)", partPattern, partPurpose)
|
|
}
|
|
}
|
|
|
|
// Ohne diesen Schalter bindet Go gegen die libc des Bausystems. Der Start
|
|
// scheitert dann auf einer älteren Distribution mit einer Meldung über
|
|
// GLIBC — die niemand einem Backupprogramm zuordnet.
|
|
if !strings.Contains(buildScript, "CGO_ENABLED=0") {
|
|
testInstance.Error("die Programme werden nicht statisch gebunden (CGO_ENABLED=0 fehlt)")
|
|
}
|
|
}
|
|
|
|
// readReleaseScript liest das Auslieferungsskript.
|
|
func readReleaseScript(testInstance *testing.T) string {
|
|
testInstance.Helper()
|
|
|
|
scriptContent, readError := os.ReadFile(filepath.Join(repositoryRoot, "scripts/build-release.sh"))
|
|
if readError != nil {
|
|
testInstance.Fatalf("das Auslieferungsskript ließ sich nicht lesen: %v", readError)
|
|
}
|
|
|
|
return string(scriptContent)
|
|
}
|
|
|
|
// TestOperationScriptsAgreeOnPaths haelt die drei Betriebsskripte deckungsgleich.
|
|
//
|
|
// setup.sh, update.sh und uninstall.sh teilen die Orte, an denen die Anlage
|
|
// liegt. Waeren sie sich uneinig, entstuende der schlimmste Fall dieser drei
|
|
// Skripte: eine Installation, die sich nicht mehr entfernen laesst, weil das
|
|
// Deinstallationsskript woanders sucht — oder ein Update, das neben die
|
|
// vorhandene Fassung installiert.
|
|
//
|
|
// Geprueft wird die **Schnittmenge**: Wo zwei Skripte dieselbe Konstante
|
|
// deklarieren, muessen die Werte uebereinstimmen. Eine Konstante nur deshalb
|
|
// mitzufuehren, damit dieser Test etwas zu vergleichen hat, waere verkehrt.
|
|
func TestOperationScriptsAgreeOnPaths(testInstance *testing.T) {
|
|
scriptNames := []string{"setup.sh", "update.sh", "uninstall.sh"}
|
|
|
|
// valuesByConstant sammelt je Konstante die Werte samt Herkunft.
|
|
valuesByConstant := make(map[string]map[string]string)
|
|
|
|
constantPattern := regexp.MustCompile(`^readonly\s+(\w+)="([^"]*)"`)
|
|
|
|
for _, scriptName := range scriptNames {
|
|
scriptContent, readError := os.ReadFile(filepath.Join(repositoryRoot, "scripts", scriptName))
|
|
if readError != nil {
|
|
testInstance.Fatalf("%s ließ sich nicht lesen: %v", scriptName, readError)
|
|
}
|
|
|
|
for _, currentLine := range strings.Split(string(scriptContent), "\n") {
|
|
matchedGroups := constantPattern.FindStringSubmatch(strings.TrimSpace(currentLine))
|
|
if matchedGroups == nil {
|
|
continue
|
|
}
|
|
|
|
constantName, constantValue := matchedGroups[1], matchedGroups[2]
|
|
|
|
// Farben und dergleichen sind Ausgabe, kein Ort.
|
|
if strings.HasPrefix(constantName, "color") {
|
|
continue
|
|
}
|
|
|
|
if valuesByConstant[constantName] == nil {
|
|
valuesByConstant[constantName] = make(map[string]string)
|
|
}
|
|
|
|
valuesByConstant[constantName][scriptName] = constantValue
|
|
}
|
|
}
|
|
|
|
if len(valuesByConstant) == 0 {
|
|
testInstance.Fatal("in den Betriebsskripten wurde keine einzige Konstante gefunden")
|
|
}
|
|
|
|
for constantName, valuesByScript := range valuesByConstant {
|
|
if len(valuesByScript) < 2 {
|
|
continue
|
|
}
|
|
|
|
var referenceValue, referenceScript string
|
|
|
|
for scriptName, constantValue := range valuesByScript {
|
|
if referenceScript == "" {
|
|
referenceValue, referenceScript = constantValue, scriptName
|
|
|
|
continue
|
|
}
|
|
|
|
if constantValue != referenceValue {
|
|
testInstance.Errorf("%s ist uneinheitlich:\n %s: %q\n %s: %q\n"+
|
|
" Drei Skripte, die sich über den Installationsort uneinig sind, ergeben eine\n"+
|
|
" Anlage, die sich nicht mehr entfernen lässt.",
|
|
constantName, referenceScript, referenceValue, scriptName, constantValue)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestOperationScriptsAreShipped prueft, dass die Skripte im Paket landen.
|
|
func TestOperationScriptsAreShipped(testInstance *testing.T) {
|
|
buildScript := readReleaseScript(testInstance)
|
|
|
|
for _, scriptName := range []string{"setup.sh", "update.sh", "uninstall.sh"} {
|
|
if !strings.Contains(buildScript, scriptName) {
|
|
testInstance.Errorf("%s wird nicht ausgeliefert — dann steht ein Betreiber "+
|
|
"mit einem Paket da und ohne den Weg hinein", scriptName)
|
|
}
|
|
}
|
|
}
|