Bisher endete setup.sh mit einer laufenden API auf 127.0.0.1:8080 und der Aufgabe, einen Webserver von Hand davorzusetzen. Das war der haeufigste Punkt, an dem eine Einrichtung liegen blieb. setup.sh richtet jetzt nginx ein und stellt ein selbst signiertes Zertifikat aus. Es gilt fuer Rechnernamen, vollstaendigen Namen und jede globale IPv4-Adresse (subjectAltName — moderne Browser lesen den CN nicht mehr), 3650 Tage; der SHA-256-Fingerabdruck wird genannt. Drei Entscheidungen: - Die API bleibt an 127.0.0.1:8080 gebunden. Sie auf alle Schnittstellen zu legen waere der kuerzere Weg und der falsche: Die Verschluesselung liesse sich dann umgehen, indem man Port 8080 direkt anspricht. - Die Firewall wird gemeldet, nicht geaendert. Eine Einrichtung, die selbsttaetig einen Port ins Netz oeffnet, hebelt genau die Entscheidung aus, fuer die jemand die Firewall aufgesetzt hat. - Scheitert die Oberflaeche, scheitert nicht die Einrichtung. Geprueft wird mit nginx -t, bevor die Konfiguration uebernommen wird; haelt sie nicht, wird sie entfernt und der Nachholweg gezeigt. Zwei Funde beim Erproben: - http2 on; gibt es erst ab nginx 1.25.1. Debian 12 liefert 1.22, wo HTTP/2 ein Parameter von listen ist — die neue Schreibweise ergibt dort "unknown directive http2", und nginx startet nicht. Die Fassung wird jetzt gelesen. - setup.sh kopierte nur diagnose.sh neben die Programme. Der eigene Hinweis "Spaeter nachholen: /opt/syncova/setup.sh --weboberflaeche" verwies damit auf eine Datei, die es nicht gab; schwerer wiegt uninstall.sh — wer das ausgepackte Paket aufraeumte, haette die Anlage nie wieder entfernen koennen. Jetzt kommen alle vier Skripte mit. Nachgewiesen im Container gegen Debian 12 mit nginx 1.22: Neuinstallation von Grund auf, Oberflaeche und /api/ von aussen ueber HTTPS erreichbar (200), SPA-Fallback traegt, Anmeldung und Repository-Anlage durch nginx hindurch, Durchsetzungsstufe gemessen, HTTP leitet mit 301 auf HTTPS, Fingerabdruck stimmt mit dem genannten ueberein, Neuausstellung des Zertifikats geprueft. Regressionstests fuer beide Funde, beide durch Mutation als fangend bestaetigt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
401 lines
16 KiB
Go
401 lines
16 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|