// 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", "diagnose.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", "diagnose.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) } } } // TestSetupRefusesVolatileRepositoryPaths ist der Regressionstest zu Issue #1. // // Gemeldet wurde ein Abbruch der Einrichtung mit "Der Dienst meldet sich nicht // als betriebsbereit". Die Ursache stand im Diagnosebericht: Der Ablageort lag // unter /tmp, die Diensteinheit setzt PrivateTmp=yes, und damit existiert der // Pfad aus Sicht des Dienstes nicht — systemd bricht mit 226/NAMESPACE ab. // // Der technische Fehler ist der kleinere. Der groessere ist, dass ein // Backup-Repository unter /tmp ueberhaupt angenommen wurde: systemd-tmpfiles // raeumt dort auf, und auf einem tmpfs ist nach einem Neustart nichts mehr da. // Ein Backupsystem, das jede Nacht Erfolg meldet und keine Daten hat, ist // schlimmer als gar keines. func TestSetupRefusesVolatileRepositoryPaths(testInstance *testing.T) { setupScript, readError := os.ReadFile(filepath.Join(repositoryRoot, "scripts/setup.sh")) if readError != nil { testInstance.Fatalf("setup.sh ließ sich nicht lesen: %v", readError) } scriptText := string(setupScript) // Die Orte, die abgelehnt werden muessen. for _, volatilePath := range []string{"/tmp", "/var/tmp", "/dev/shm", "/run"} { if !strings.Contains(scriptText, volatilePath+"|"+volatilePath+"/*") { testInstance.Errorf("setup.sh prüft %s nicht als flüchtigen Ablageort", volatilePath) } } // Der allgemeine Fall: Was auf einem tmpfs liegt, überlebt keinen Neustart — // gleich wie das Verzeichnis heißt. for _, filesystemName := range []string{"tmpfs", "ramfs"} { if !strings.Contains(scriptText, filesystemName) { testInstance.Errorf("setup.sh erkennt %s nicht als flüchtiges Dateisystem", filesystemName) } } // Die Prüfung muss **vor** der Datenbankeinrichtung greifen. Sonst steht am // Ende eine installierte PostgreSQL und ein Rückbau, der zwar funktioniert, // aber Minuten gekostet hat. firstCheckPosition := strings.Index(scriptText, `validateRepositoryPath "${repositoryPath}"`) databaseSectionPosition := strings.Index(scriptText, "PostgreSQL wird installiert") if firstCheckPosition < 0 || databaseSectionPosition < 0 { testInstance.Fatal("die Prüfung oder der Datenbankabschnitt wurde nicht gefunden") } if firstCheckPosition > databaseSectionPosition { testInstance.Error("der Ablageort wird erst nach der Datenbankeinrichtung geprüft; " + "ein unbeaufsichtigter Lauf soll in Sekunden scheitern, nicht nach Minuten") } // Und wenn jemand die Ablehnung ausdrücklich übergeht, muss PrivateTmp // weichen — sonst startet der Dienst nie. if !strings.Contains(scriptText, "privateTmpSetting") { testInstance.Error("PrivateTmp wird nicht abgeschaltet, wenn das Repository unter /tmp liegt") } }