Gemeldet wurde ein Abbruch der Einrichtung mit "Der Dienst meldet sich nicht als
betriebsbereit". Der Diagnosebericht enthielt die Ursache woertlich:
syncova-api.service: Failed to set up mount namespacing:
/tmp/syncova-repository: No such file or directory
status=226/NAMESPACE
Die Diensteinheit setzt PrivateTmp=yes. Der Dienst bekommt damit ein eigenes
/tmp, und der in ReadWritePaths genannte Ablageort existiert in seiner Sicht
nicht — systemd bricht ab, bevor das Programm ueberhaupt laeuft.
Der technische Fehler ist der kleinere. Der groessere ist, dass setup.sh einen
Ablageort unter /tmp ueberhaupt angenommen hat: systemd-tmpfiles raeumt dort
regelmaessig 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.
Deshalb:
- Fluechtige Ablageorte werden abgelehnt: /tmp, /var/tmp, /dev/shm, /run und
jedes tmpfs oder ramfs. Geprueft wird VOR der Datenbankeinrichtung — ein
unbeaufsichtigter Lauf scheitert damit in Sekunden statt nach Minuten und
einem Rueckbau.
- SYNCOVA_SETUP_ALLOW_VOLATILE_REPOSITORY=ja laesst es ausdruecklich zu; dann
wird PrivateTmp abgeschaltet, sonst startet der Dienst nie. Real geprueft:
Einheit traegt PrivateTmp=no, Dienst laeuft.
- Kommt der Dienst nicht hoch, liefert setup.sh die letzten Journalzeilen gleich
mit und erklaert 226/NAMESPACE. Der Verweis auf journalctl allein war
wertlos: Beim Rueckbau ist der Dienst weg, und der Meldende brauchte einen
zweiten Anlauf, um ueberhaupt zu erfahren, was los war.
Regressionstest vorhanden und als fangend geprueft.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
285 lines
11 KiB
Go
285 lines
11 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")
|
|
}
|
|
}
|