Gemeldet wurde ein Abbruch der Einrichtung mit "Der Dienst meldet sich nicht als betriebsbereit". Der Dienst lief einwandfrei — das Protokoll im Bericht zeigt einen vollstaendigen Start, und genau fuenfzehn Sekunden spaeter beendet ihn der Rueckbau. Fuenfzehn Sekunden sind genau fuenfzehn Pruefversuche. Die Ursache stand ebenfalls im Bericht, eine Zeile weiter oben: ## Gesundheit curl ist nicht vorhanden. setup.sh prueft die Betriebsbereitschaft ausschliesslich mit curl. Auf einem schlanken Serverabbild ist der nicht installiert; das ist der Normalfall und nicht die Ausnahme. Die Pruefung kam nicht an den Dienst heran, hielt das fuer einen gescheiterten Start und baute eine funktionierende Anlage zurueck. Ein Einrichtungsskript darf nicht voraussetzen, was es nicht selbst mitbringt. Jetzt drei Wege: curl, sonst wget, sonst /dev/tcp der Bash — letzteres gehoert zur Shell selbst und ist damit ueberall vorhanden. Betrifft setup.sh, update.sh und diagnose.sh; der Diagnosebericht nennt zusaetzlich, womit er gemessen hat. Real nachgewiesen auf einem System ohne curl UND ohne wget: rc3 bricht ab, die korrigierte Fassung laeuft durch. Regressionstest vorhanden und als fangend geprueft. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
334 lines
13 KiB
Go
334 lines
13 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", "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")
|
|
}
|
|
}
|
|
|
|
// TestScriptsDoNotDependOnCurl ist der Regressionstest zu Issue #2.
|
|
//
|
|
// Gemeldet wurde ein Abbruch der Einrichtung mit "Der Dienst meldet sich nicht
|
|
// als betriebsbereit". Der Dienst lief einwandfrei — Protokoll und Zeitstempel
|
|
// belegen es: Er startete, und genau fuenfzehn Sekunden spaeter beendete ihn der
|
|
// Rueckbau, also nach genau fuenfzehn Pruefversuchen.
|
|
//
|
|
// Die Ursache stand im Diagnosebericht: "curl ist nicht vorhanden". Auf einem
|
|
// schlanken Serverabbild ist das der Normalfall und nicht die Ausnahme.
|
|
//
|
|
// Ein Einrichtungsskript darf nicht voraussetzen, was es nicht selbst mitbringt.
|
|
func TestScriptsDoNotDependOnCurl(testInstance *testing.T) {
|
|
for _, scriptName := range []string{"setup.sh", "update.sh", "diagnose.sh"} {
|
|
scriptContent, readError := os.ReadFile(filepath.Join(repositoryRoot, "scripts", scriptName))
|
|
if readError != nil {
|
|
testInstance.Fatalf("%s ließ sich nicht lesen: %v", scriptName, readError)
|
|
}
|
|
|
|
scriptText := string(scriptContent)
|
|
|
|
// Der Notweg ueber /dev/tcp gehoert zur Bash selbst und ist damit
|
|
// ueberall vorhanden.
|
|
if !strings.Contains(scriptText, "/dev/tcp/") {
|
|
testInstance.Errorf("%s kennt keinen Weg ohne curl und wget", scriptName)
|
|
}
|
|
|
|
if !strings.Contains(scriptText, "command -v wget") {
|
|
testInstance.Errorf("%s weicht nicht auf wget aus", scriptName)
|
|
}
|
|
}
|
|
|
|
// Die Bereitschaftspruefung selbst darf curl nicht mehr unmittelbar
|
|
// aufrufen: Genau dort brach die Einrichtung ab.
|
|
for _, scriptName := range []string{"setup.sh", "update.sh"} {
|
|
scriptContent, _ := os.ReadFile(filepath.Join(repositoryRoot, "scripts", scriptName))
|
|
|
|
for _, currentLine := range strings.Split(string(scriptContent), "\n") {
|
|
trimmedLine := strings.TrimSpace(currentLine)
|
|
|
|
// Beispielaufrufe in Hinweistexten sind erlaubt; ein "if curl"
|
|
// in der Ablauflogik nicht.
|
|
if strings.HasPrefix(trimmedLine, "if curl ") {
|
|
testInstance.Errorf("%s prüft die Betriebsbereitschaft unmittelbar mit curl: %s",
|
|
scriptName, trimmedLine)
|
|
}
|
|
}
|
|
}
|
|
}
|