Fehler #1: Repository unter /tmp macht den Dienst startunfaehig
Some checks failed
CI / Backend (Go) (push) Failing after 32s
CI / Frontend (React/TypeScript) (push) Successful in 35s
CI / Sicherheitsprüfungen (push) Successful in 24s

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>
This commit is contained in:
Jerrit Fritzsche 2026-08-17 16:14:29 +02:00
parent 0b5ce96c51
commit a37631c501
5 changed files with 215 additions and 3 deletions

View File

@ -1,5 +1,22 @@
# Änderungen # Änderungen
## V1 — Release Candidate 3, 17. August 2026
Behebt [Issue #1](https://git.jfritzsche.de/jf/syncova-backup/issues/1): Die
Einrichtung brach ab, wenn das Repository unter `/tmp` liegen sollte.
- **Ein flüchtiger Ablageort wird abgelehnt** — `/tmp`, `/var/tmp`, `/dev/shm`,
`/run` und jedes tmpfs. `systemd-tmpfiles` räumt dort auf, ein tmpfs ist nach
einem Neustart leer: Die Sicherungen verschwänden von selbst, ohne Meldung,
bis jemand sie braucht. Geprüft wird **vor** der Datenbankeinrichtung, damit
ein unbeaufsichtigter Lauf in Sekunden scheitert statt nach Minuten.
Für Wegwerf-Umgebungen: `SYNCOVA_SETUP_ALLOW_VOLATILE_REPOSITORY=ja` — dann
weicht `PrivateTmp`, sonst könnte der Dienst nicht starten.
- **Der Abbruch zeigt jetzt den Grund.** Kommt der Dienst nicht hoch, liefert
`setup.sh` die letzten Journalzeilen gleich mit und erklärt `226/NAMESPACE`.
Vorher verwies er nur auf `journalctl` — und beim Rückbau war der Dienst dann
schon weg.
## V1 — Release Candidate 2, 17. August 2026 ## V1 — Release Candidate 2, 17. August 2026
Ergänzt gegenüber `rc1`: Ergänzt gegenüber `rc1`:

View File

@ -17,6 +17,12 @@ wiederherstellt. Sie beschreibt den Serverteil; die Agenten stehen in
Datenträgerausfall nähme sonst Original und Sicherung gemeinsam mit. Das ist Datenträgerausfall nähme sonst Original und Sicherung gemeinsam mit. Das ist
keine Feinheit der Einrichtung, sondern der Zweck der Übung. keine Feinheit der Einrichtung, sondern der Zweck der Übung.
**Und nicht nach `/tmp`, `/var/tmp`, `/run` oder auf ein tmpfs.**
`systemd-tmpfiles` räumt die ersten beiden regelmäßig auf, ein tmpfs liegt im
Arbeitsspeicher. Die Sicherungen verschwänden dort von selbst — ohne Meldung,
bis jemand sie braucht. `setup.sh` lehnt solche Orte ab; die Diensteinheit
könnte mit `PrivateTmp=yes` ohnehin nicht starten.
## Der kurze Weg: setup.sh ## Der kurze Weg: setup.sh
Das Paket bringt ein Einrichtungsskript mit. Es geht genau die Schritte dieser Das Paket bringt ein Einrichtungsskript mit. Es geht genau die Schritte dieser

View File

@ -57,6 +57,26 @@ statt im Klartext zu lauschen.
**Nichts im Protokoll, Prozess weg** — meist die Datenbank. Der Start wartet 30 **Nichts im Protokoll, Prozess weg** — meist die Datenbank. Der Start wartet 30
Sekunden auf sie und endet dann mit einer Meldung. Sekunden auf sie und endet dann mit einer Meldung.
**`status=226/NAMESPACE`, „Failed to set up mount namespacing"** — ein Pfad in
`ReadWritePaths` der Diensteinheit existiert **aus Sicht des Dienstes** nicht.
Der häufigste Fall ist ein Repository unterhalb von `/tmp`: Die Einheit setzt
`PrivateTmp=yes`, der Dienst bekommt damit ein eigenes `/tmp`, und der
Ablageort ist dort nicht vorhanden.
```text
syncova-api.service: Failed to set up mount namespacing: /tmp/… : No such file or directory
syncova-api.service: Failed at step NAMESPACE spawning /opt/syncova/bin/syncova-api
```
**Ein Repository gehört ohnehin nicht nach `/tmp`.** `systemd-tmpfiles` räumt
dort regelmäßig auf, und auf vielen Systemen ist es ein tmpfs — nach einem
Neustart leer. Ein Backupsystem, das jede Nacht Erfolg meldet und keine Daten
hat, ist schlimmer als gar keines. `setup.sh` lehnt solche Orte seit `rc3` ab.
Abhilfe: Repository auf ein dauerhaftes Verzeichnis legen (etwa
`/srv/syncova-repository`) und `ReadWritePaths` in
`/etc/systemd/system/syncova-api.service` anpassen.
--- ---
## Anmeldung schlägt fehl ## Anmeldung schlägt fehl

View File

@ -225,3 +225,60 @@ func TestOperationScriptsAreShipped(testInstance *testing.T) {
} }
} }
} }
// 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")
}
}

View File

@ -301,6 +301,75 @@ queryAsPostgres() {
su - postgres -c "psql -tAc \"$1\"" 2>/dev/null || true su - postgres -c "psql -tAc \"$1\"" 2>/dev/null || true
} }
# volatileRepositoryOverride laesst einen fluechtigen Ort ausdruecklich zu.
readonly volatileRepositoryOverride="${SYNCOVA_SETUP_ALLOW_VOLATILE_REPOSITORY:-nein}"
# repositoryIsUnderPrivateTmp meldet einen Ort unterhalb von /tmp.
#
# Der Dienst laeuft mit PrivateTmp=yes und bekommt damit ein **eigenes** /tmp.
# Ein Repository unter /tmp existiert in seiner Sicht nicht; systemd bricht den
# Start mit 226/NAMESPACE ab, noch bevor das Programm laeuft.
repositoryIsUnderPrivateTmp() {
[[ "$1" == /tmp/* || "$1" == "/tmp" ]]
}
# validateRepositoryPath weist einen fluechtigen Ablageort zurueck.
#
# **Der wichtigste Punkt dieser ganzen Datei.** Ein Backup-Repository unter /tmp,
# /var/tmp oder auf einem tmpfs ist nicht unbequem, sondern falsch:
#
# - systemd-tmpfiles raeumt /tmp und /var/tmp regelmaessig auf. Die Sicherungen
# verschwinden dann von selbst, ohne Meldung und ohne dass jemand es merkt —
# bis er sie braucht.
# - Ein tmpfs liegt im Arbeitsspeicher. Nach einem Neustart ist es leer.
#
# Fuer ein Programm, dessen einziger Zweck die Aufbewahrung von Daten ist, waere
# das die schlimmste denkbare Voreinstellung: Es meldete jede Nacht erfolgreiche
# Sicherungen und haette keine.
validateRepositoryPath() {
local candidatePath="$1" refusalReason=""
case "${candidatePath}" in
/tmp|/tmp/*) refusalReason="/tmp wird von systemd-tmpfiles regelmaessig aufgeraeumt" ;;
/var/tmp|/var/tmp/*) refusalReason="/var/tmp wird von systemd-tmpfiles regelmaessig aufgeraeumt" ;;
/dev/shm|/dev/shm/*) refusalReason="/dev/shm liegt im Arbeitsspeicher" ;;
/run|/run/*) refusalReason="/run liegt im Arbeitsspeicher" ;;
esac
# Der allgemeine Fall: Was auf einem tmpfs liegt, ueberlebt keinen Neustart —
# gleich wie das Verzeichnis heisst.
if [[ -z "${refusalReason}" ]] && [[ -d "${candidatePath}" ]]; then
local filesystemType
filesystemType="$(stat -f -c '%T' "${candidatePath}" 2>/dev/null || echo unbekannt)"
case "${filesystemType}" in
tmpfs|ramfs) refusalReason="der Ort liegt auf einem ${filesystemType} (Arbeitsspeicher)" ;;
esac
fi
[[ -z "${refusalReason}" ]] && return 0
if [[ "${volatileRepositoryOverride}" == "ja" ]]; then
writeWarning "Fluechtiger Ablageort ausdruecklich zugelassen: ${refusalReason}."
writeDetail "Die Sicherungen dort ueberleben weder Aufraeumlaeufe noch einen Neustart."
writeDetail "Das ist ausschliesslich fuer Wegwerf-Umgebungen vertretbar."
return 0
fi
abortWithMessage "Der Ort ${candidatePath} taugt nicht als Repository:
${refusalReason}.
Die Sicherungen wuerden dort verschwinden, ohne dass es jemandem
auffaellt — und ein Backupsystem, das jede Nacht Erfolg meldet und
keine Daten hat, ist schlimmer als gar keines.
Nehmen Sie ein dauerhaftes Verzeichnis, etwa /srv/syncova-repository,
und moeglichst einen anderen Datentraeger als den der Quelldaten.
Nur fuer Wegwerf-Umgebungen: SYNCOVA_SETUP_ALLOW_VOLATILE_REPOSITORY=ja"
}
# detectPackageManager ermittelt das Paketwerkzeug der Distribution. # detectPackageManager ermittelt das Paketwerkzeug der Distribution.
detectPackageManager() { detectPackageManager() {
if command -v apt-get >/dev/null 2>&1; then echo "apt"; return; fi if command -v apt-get >/dev/null 2>&1; then echo "apt"; return; fi
@ -358,6 +427,11 @@ fi
packageVersion="$("${packageDirectory}/bin/syncova-api" --version 2>/dev/null | awk '{print $2}')" packageVersion="$("${packageDirectory}/bin/syncova-api" --version 2>/dev/null | awk '{print $2}')"
writeSuccess "Paket gefunden: ${packageVersion}" writeSuccess "Paket gefunden: ${packageVersion}"
# Den vorgesehenen Ablageort sofort pruefen. Im unbeaufsichtigten Lauf soll das
# in Sekunden scheitern und nicht erst, nachdem PostgreSQL installiert, das
# Schema angelegt und ein Administrator eingerichtet wurde.
validateRepositoryPath "${repositoryPath}"
# Pruefsummen kontrollieren, sofern das Paket welche mitbringt. # Pruefsummen kontrollieren, sofern das Paket welche mitbringt.
if [[ -f "${packageDirectory}/SHA256SUMS" ]] && command -v sha256sum >/dev/null 2>&1; then if [[ -f "${packageDirectory}/SHA256SUMS" ]] && command -v sha256sum >/dev/null 2>&1; then
if ( cd "${packageDirectory}" && sha256sum --quiet -c SHA256SUMS >/dev/null 2>&1 ); then if ( cd "${packageDirectory}" && sha256sum --quiet -c SHA256SUMS >/dev/null 2>&1 ); then
@ -708,6 +782,8 @@ if [[ "${unattendedMode}" != "ja" ]]; then
askQuestion "Ort des Repositorys" "${repositoryPath}" repositoryPath askQuestion "Ort des Repositorys" "${repositoryPath}" repositoryPath
fi fi
validateRepositoryPath "${repositoryPath}"
if [[ -e "${repositoryPath}/format/repository.json" ]]; then if [[ -e "${repositoryPath}/format/repository.json" ]]; then
writeDetail "Unter ${repositoryPath} liegt bereits ein Repository; es wird verwendet." writeDetail "Unter ${repositoryPath} liegt bereits ein Repository; es wird verwendet."
else else
@ -733,6 +809,20 @@ fi
printf '\n' printf '\n'
writeStep "Dienst" writeStep "Dienst"
# PrivateTmp und ein Repository unter /tmp schliessen einander aus.
#
# Mit PrivateTmp=yes bekommt der Dienst ein eigenes /tmp; der Ablageort
# existiert in seiner Sicht dann nicht, und systemd bricht den Start mit
# 226/NAMESPACE ab — bevor das Programm ueberhaupt laeuft. Der Fall tritt nur
# noch mit ausdruecklicher Freigabe auf; dann muss die Haertung an dieser einen
# Stelle weichen, sonst startet der Dienst nie.
privateTmpSetting="yes"
if repositoryIsUnderPrivateTmp "${repositoryPath}"; then
privateTmpSetting="no"
writeWarning "PrivateTmp wird abgeschaltet, weil das Repository unter /tmp liegt."
fi
cat > "${serviceUnitFile}" <<UNIT cat > "${serviceUnitFile}" <<UNIT
[Unit] [Unit]
Description=Syncova Control Plane Description=Syncova Control Plane
@ -751,7 +841,7 @@ RestartSec=5
# Haertung: Der Dienst braucht Netz und sein Repository, sonst nichts. # Haertung: Der Dienst braucht Netz und sein Repository, sonst nichts.
NoNewPrivileges=yes NoNewPrivileges=yes
PrivateTmp=yes PrivateTmp=${privateTmpSetting}
ProtectSystem=strict ProtectSystem=strict
ProtectHome=yes ProtectHome=yes
ReadWritePaths=${repositoryPath} ${backupDirectory} ReadWritePaths=${repositoryPath} ${backupDirectory}
@ -789,8 +879,30 @@ done
if [[ "${serviceIsReady}" != "ja" ]]; then if [[ "${serviceIsReady}" != "ja" ]]; then
printf '\n' printf '\n'
writeWarning "Der Dienst meldet sich nicht als betriebsbereit." writeWarning "Der Dienst meldet sich nicht als betriebsbereit."
writeDetail " systemctl status ${serviceName}"
writeDetail " journalctl -u ${serviceName} -n 50 --no-pager" # Die Zeilen gleich mitliefern statt auf sie zu verweisen.
#
# Ein Abbruch, der nur sagt "sehen Sie ins Journal", verlangt einen zweiten
# Anlauf, um ueberhaupt zu erfahren, was los war — und beim Rueckbau ist der
# Dienst dann schon weg.
if command -v journalctl >/dev/null 2>&1; then
printf '\n'
writeDetail "Die letzten Zeilen des Dienstes:"
printf '\n'
journalctl -u "${serviceName}" -n 20 --no-pager 2>/dev/null | sed 's/^/ /' >&2
printf '\n'
# 226/NAMESPACE ist der haeufigste Fall und ohne Erklaerung nicht zu
# deuten: Er bedeutet fast immer einen Pfad in ReadWritePaths, den der
# Dienst in seiner eigenen Namensraumsicht nicht sieht.
if journalctl -u "${serviceName}" -n 20 --no-pager 2>/dev/null | grep -q "226/NAMESPACE"; then
writeDetail "226/NAMESPACE bedeutet: Ein Pfad der Diensteinheit ist aus Sicht"
writeDetail "des Dienstes nicht vorhanden. Pruefen Sie ReadWritePaths in"
writeDetail "${serviceUnitFile} — insbesondere den Ort des Repositorys."
printf '\n'
fi
fi
abortWithMessage "Die Einrichtung wird zurueckgebaut." abortWithMessage "Die Einrichtung wird zurueckgebaut."
fi fi