// 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) } } } } // TestSetupEmitsVersionAwareHTTP2Directive haelt fest, dass die // HTTP/2-Schreibweise von der vorgefundenen nginx-Fassung abhaengt. // // Die Anweisung "http2 on;" gibt es erst ab 1.25.1. Davor war HTTP/2 ein // Parameter von "listen". Wer die neue Schreibweise fest verdrahtet, erzeugt // auf Debian 12 (nginx 1.22) eine Konfiguration, die nginx ablehnt — die // Oberflaeche fehlt dann vollstaendig, waehrend die Anlage selbst laeuft. func TestSetupEmitsVersionAwareHTTP2Directive(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) } scriptContent := string(setupScript) // Die Fallunterscheidung muss ueberhaupt stattfinden. if !strings.Contains(scriptContent, "http2DirectiveFor") { testInstance.Fatal("setup.sh unterscheidet die nginx-Fassungen nicht; " + "eine fest verdrahtete HTTP/2-Schreibweise laesst nginx auf " + "Debian 12 nicht starten") } // Und sie muss beide Schreibweisen kennen. for _, expectedForm := range []string{"listen-parameter", "eigene-anweisung"} { if !strings.Contains(scriptContent, expectedForm) { testInstance.Errorf("setup.sh kennt die Schreibweise %q nicht", expectedForm) } } // Die neue Anweisung darf nicht ungeschuetzt in der Vorlage stehen. Sie // gehoert in die Zuweisung an http2Line, nicht in das Here-Document. if strings.Contains(scriptContent, "\n http2 on;\n") { testInstance.Error("setup.sh schreibt 'http2 on;' unbedingt in die " + "nginx-Vorlage; auf nginx < 1.25.1 ergibt das 'unknown directive'") } } // TestSetupInstallsEveryOperationScript haelt fest, dass alle vier // Betriebsskripte neben den Programmen landen. // // Beim Erproben verwies setup.sh nach einer misslungenen Einrichtung der // Oberflaeche auf "/opt/syncova/setup.sh --weboberflaeche" — eine Datei, die es // dort nicht gab. Kopiert wurde nur diagnose.sh. // // Der schwerere Fall ist uninstall.sh: Wer das ausgepackte Paket aufraeumt, // haette die Anlage nie wieder entfernen koennen. func TestSetupInstallsEveryOperationScript(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) } scriptContent := string(setupScript) for _, operationScript := range []string{ "setup.sh", "update.sh", "uninstall.sh", "diagnose.sh", } { // Das Skript muss in der Kopierschleife genannt sein. if !strings.Contains(scriptContent, "for operationScript in") || !strings.Contains(scriptContent, operationScript) { testInstance.Errorf("setup.sh legt %s nicht neben die Programme; "+ "ein Hinweis auf /opt/syncova/%s liefe dann ins Leere", operationScript, operationScript) } } }