Eine Vorlage allein bringt wenig — sie stellt Fragen, die der Meldende meist nicht beantworten kann. Deshalb zwei Teile: diagnose.sh sammelt in einem Zug, was zur Analyse gebraucht wird: Fassungen aller acht Programme, Betriebssystem, Container ja/nein, Dateisystem des Repositorys, PostgreSQL-Fassung, Schemastand, Dienstzustand, Gesundheitsbericht (der auch bei 503 den vollstaendigen Bericht traegt), Bestand, die letzten nicht erfolgreichen Laeufe mit Fehlercode UND Fehlerklasse, die gemessene Durchsetzungsstufe und die letzten Fehlerzeilen. Es liest nur. Geheimnisse kommen nicht hinein, und der Weg dahin ist umgekehrt: Es gibt eine Liste der Werte, die gezeigt werden duerfen. Eine Sperrliste vergaesse den naechsten neuen Wert. Zusaetzlich werden die tatsaechlichen Geheimnisse gelesen und aus JEDER Ausgabe entfernt — auch aus Protokollzeilen, in die sie auf einem unvorhergesehenen Weg geraten sind. Real geprueft: weder Datenbankpasswort noch Schluessel noch Administratorpasswort stehen im Bericht. Die Vorlage beginnt mit sieben Faellen, die wie ein Fehler aussehen und gewolltes Verhalten sind — "advisory" statt "filesystem", ein Teilfehler, "geloescht aber nichts frei", 503 mit vollstaendigem Bericht. Das ist keine Abwehr, sondern spart beiden Seiten einen halben Tag. Pflichtfelder sind Beobachtung, Erwartung, Schritte, Bereich, Datenrisiko, Haeufigkeit und der Diagnosebericht; Fehlercode und request_id stehen eigens da, weil sie die beiden wertvollsten Angaben sind. Beim Erproben zwei Funde: - Die Installationsanleitung verlangte PostgreSQL 17. setup.sh installiert auf Debian 12 aber 15 — und alles lief, bis hin zu einem echten Sicherungslauf. Die Anforderung lautet jetzt 15 (geprueft gegen 17 in CI und Entwicklung, gegen 15 auf Debian 12), und setup.sh lehnt aeltere Fassungen ab statt sie stillschweigend zu nehmen. - Ein Repository auf der Platte, das nicht in der Control Plane eingetragen ist, faellt niemandem auf: Die Sicherung laeuft nie, weil der Server das Ziel nicht kennt. Der Bericht benennt diesen Fall jetzt ausdruecklich. Gegen das echte v1.0.0-rc1-Paket gefahren: Installation, erzeugte Stoerung (ALL_SOURCES_FAILED / source), Bericht zeigt Code, Klasse, "overlayfs" und "nie gemessen". 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", "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)
|
|
}
|
|
}
|
|
}
|