Fehler #2: Bereitschaftspruefung haengt an curl
Some checks failed
CI / Backend (Go) (push) Failing after 31s
CI / Frontend (React/TypeScript) (push) Successful in 33s
CI / Sicherheitsprüfungen (push) Successful in 23s

Gemeldet wurde ein Abbruch der Einrichtung mit "Der Dienst meldet sich nicht als
betriebsbereit". Der Dienst lief einwandfrei — das Protokoll im Bericht zeigt
einen vollstaendigen Start, und genau fuenfzehn Sekunden spaeter beendet ihn der
Rueckbau. Fuenfzehn Sekunden sind genau fuenfzehn Pruefversuche.

Die Ursache stand ebenfalls im Bericht, eine Zeile weiter oben:

  ## Gesundheit
  curl ist nicht vorhanden.

setup.sh prueft die Betriebsbereitschaft ausschliesslich mit curl. Auf einem
schlanken Serverabbild ist der nicht installiert; das ist der Normalfall und
nicht die Ausnahme. Die Pruefung kam nicht an den Dienst heran, hielt das fuer
einen gescheiterten Start und baute eine funktionierende Anlage zurueck.

Ein Einrichtungsskript darf nicht voraussetzen, was es nicht selbst mitbringt.

Jetzt drei Wege: curl, sonst wget, sonst /dev/tcp der Bash — letzteres gehoert
zur Shell selbst und ist damit ueberall vorhanden. Betrifft setup.sh, update.sh
und diagnose.sh; der Diagnosebericht nennt zusaetzlich, womit er gemessen hat.

Real nachgewiesen auf einem System ohne curl UND ohne wget: rc3 bricht ab, die
korrigierte Fassung laeuft durch. Regressionstest vorhanden und als fangend
geprueft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jerrit Fritzsche 2026-08-18 08:22:47 +02:00
parent a37631c501
commit 22763f927f
6 changed files with 292 additions and 11 deletions

View File

@ -1,5 +1,19 @@
# Änderungen # Änderungen
## V1 — Release Candidate 4, 18. August 2026
Behebt [Issue #2](https://git.jfritzsche.de/jf/syncova-backup/issues/2): Die
Einrichtung brach mit „Der Dienst meldet sich nicht als betriebsbereit" ab,
obwohl der Dienst einwandfrei lief.
- **Die Bereitschaftsprüfung hängt nicht mehr an `curl`.** Sie weicht auf `wget`
aus und zuletzt auf die Bash selbst (`/dev/tcp`), die überall vorhanden ist.
Auf einem schlanken Serverabbild ist `curl` nicht installiert — das ist der
Normalfall, nicht die Ausnahme. Ein Einrichtungsskript darf nicht
voraussetzen, was es nicht selbst mitbringt.
- Betrifft `setup.sh`, `update.sh` und `diagnose.sh`. Der Diagnosebericht nennt
jetzt zusätzlich, **womit** er gemessen hat.
## V1 — Release Candidate 3, 17. August 2026 ## V1 — Release Candidate 3, 17. August 2026
Behebt [Issue #1](https://git.jfritzsche.de/jf/syncova-backup/issues/1): Die Behebt [Issue #1](https://git.jfritzsche.de/jf/syncova-backup/issues/1): Die

View File

@ -57,6 +57,17 @@ 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.
**„Der Dienst meldet sich nicht als betriebsbereit", aber das Protokoll zeigt
einen sauberen Start** — dann kam die Prüfung nicht an den Dienst heran, nicht
umgekehrt. Bis `rc3` prüfte `setup.sh` ausschließlich mit `curl`; auf einem
schlanken Serverabbild ist der nicht installiert. Seit `rc4` weicht die Prüfung
auf `wget` und zuletzt auf die Bash selbst aus (`/dev/tcp`).
Erkennungsmerkmal im Diagnosebericht: `Abgefragt mit: Bash /dev/tcp` oder, in
älteren Fassungen, `curl ist nicht vorhanden`. Ein zweites: Der Dienst wird
**genau 15 Sekunden** nach dem Start wieder beendet — das ist der Rückbau nach
15 vergeblichen Prüfversuchen.
**`status=226/NAMESPACE`, „Failed to set up mount namespacing"** — ein Pfad in **`status=226/NAMESPACE`, „Failed to set up mount namespacing"** — ein Pfad in
`ReadWritePaths` der Diensteinheit existiert **aus Sicht des Dienstes** nicht. `ReadWritePaths` der Diensteinheit existiert **aus Sicht des Dienstes** nicht.
Der häufigste Fall ist ein Repository unterhalb von `/tmp`: Die Einheit setzt Der häufigste Fall ist ein Repository unterhalb von `/tmp`: Die Einheit setzt

View File

@ -55,6 +55,65 @@ readonly publicSettings=(
# uebrige. # uebrige.
secretValues=() secretValues=()
# serviceIsReachable prueft, ob eine Adresse mit HTTP 200 antwortet.
#
# Drei Wege, weil keiner ueberall vorhanden ist. Genau daran ist die Einrichtung
# in Issue #2 gescheitert: Der Dienst lief einwandfrei — Protokoll und
# Zeitstempel belegen es —, aber auf dem System war kein curl installiert. Die
# Bereitschaftspruefung hielt das fuer einen gescheiterten Start und baute eine
# funktionierende Anlage wieder zurueck.
#
# Ein Einrichtungsskript darf nicht voraussetzen, was es nicht selbst mitbringt.
serviceIsReachable() {
local requestURL="$1"
if command -v curl >/dev/null 2>&1; then
curl -fsS --max-time 5 "${requestURL}" >/dev/null 2>&1
return $?
fi
# wget endet bei 4xx und 5xx mit einem Fehler — dasselbe Verhalten wie
# "curl -f".
if command -v wget >/dev/null 2>&1; then
wget -q -O /dev/null --timeout=5 "${requestURL}" >/dev/null 2>&1
return $?
fi
bashHTTPStatusIsOK "${requestURL}"
}
# bashHTTPStatusIsOK spricht HTTP ohne jedes Zusatzwerkzeug.
#
# Der letzte Ausweg: /dev/tcp gehoert zur Bash selbst. Damit laesst sich die
# Bereitschaft auch auf einem System pruefen, auf dem weder curl noch wget
# liegen — und das ist bei einem schlanken Serverabbild der Normalfall, nicht
# die Ausnahme.
bashHTTPStatusIsOK() {
local strippedURL="${1#http://}"
local hostAndPort="${strippedURL%%/*}"
local requestPath="/${strippedURL#*/}"
# Ohne Pfadanteil liefert die Ersetzung oben die Adresse zurueck.
[[ "${requestPath}" == "/${hostAndPort}" ]] && requestPath="/"
local targetHost="${hostAndPort%%:*}"
local targetPort="${hostAndPort##*:}"
[[ "${targetPort}" == "${targetHost}" ]] && targetPort=80
exec 3<>"/dev/tcp/${targetHost}/${targetPort}" 2>/dev/null || return 1
printf 'GET %s HTTP/1.0\r\nHost: %s\r\nConnection: close\r\n\r\n' \
"${requestPath}" "${hostAndPort}" >&3
local statusLine=""
read -r -t 5 statusLine <&3
exec 3<&- 3>&- 2>/dev/null
[[ "${statusLine}" == *" 200 "* ]]
}
# writeHeading schreibt eine Ueberschrift. # writeHeading schreibt eine Ueberschrift.
writeHeading() { printf '\n## %s\n\n' "$1"; } writeHeading() { printf '\n## %s\n\n' "$1"; }
@ -73,6 +132,40 @@ redactSecrets() {
printf '%s' "${text}" printf '%s' "${text}"
} }
# httpToolInUse benennt das verwendete Werkzeug.
#
# Es gehoert in den Bericht: Steht dort der Notweg ueber /dev/tcp, weiss der
# Leser, dass auf diesem System weder curl noch wget liegt — und genau daran ist
# in Issue #2 eine Einrichtung gescheitert.
if command -v curl >/dev/null 2>&1; then
httpToolInUse="curl"
elif command -v wget >/dev/null 2>&1; then
httpToolInUse="wget (curl ist nicht vorhanden)"
else
httpToolInUse="Bash /dev/tcp (weder curl noch wget vorhanden)"
fi
# fetchHTTPBody holt eine Antwort mit dem vorhandenen Werkzeug.
fetchHTTPBody() {
local requestURL="$1"
if command -v curl >/dev/null 2>&1; then
curl -s --max-time 10 -w $'\n(HTTP %{http_code})' "${requestURL}" 2>&1 || echo '(nicht erreichbar)'
return
fi
if command -v wget >/dev/null 2>&1; then
wget -q -O - --timeout=10 "${requestURL}" 2>&1 || echo '(nicht erreichbar)'
return
fi
if bashHTTPStatusIsOK "${requestURL}"; then
echo "(HTTP 200 — Inhalt ohne curl oder wget nicht lesbar)"
else
echo "(nicht erreichbar oder kein HTTP 200)"
fi
}
# runAndCapture fuehrt ein Kommando aus und gibt seine Ausgabe redigiert zurueck. # runAndCapture fuehrt ein Kommando aus und gibt seine Ausgabe redigiert zurueck.
runAndCapture() { runAndCapture() {
local commandOutput local commandOutput
@ -195,15 +288,12 @@ writeHeading "Gesundheit"
healthAddress="${SYNCOVA_HTTP_LISTEN_ADDRESS:-127.0.0.1:8080}" healthAddress="${SYNCOVA_HTTP_LISTEN_ADDRESS:-127.0.0.1:8080}"
healthReport="" healthReport=""
if command -v curl >/dev/null 2>&1; then for endpointPath in /health/live /health/ready /api/v1/health; do
for endpointPath in /health/live /health/ready /api/v1/health; do responseBody="$(fetchHTTPBody "http://${healthAddress}${endpointPath}")"
responseBody="$(curl -s --max-time 10 -w $'\n(HTTP %{http_code})' \
"http://${healthAddress}${endpointPath}" 2>&1 || echo '(nicht erreichbar)')"
healthReport+="${endpointPath}: ${responseBody}"$'\n\n' healthReport+="${endpointPath}: ${responseBody}"$'\n\n'
done done
else
healthReport="curl ist nicht vorhanden." healthReport+="Abgefragt mit: ${httpToolInUse}"
fi
writeBlock "$(redactSecrets "${healthReport%$'\n\n'}")" writeBlock "$(redactSecrets "${healthReport%$'\n\n'}")"

View File

@ -282,3 +282,52 @@ func TestSetupRefusesVolatileRepositoryPaths(testInstance *testing.T) {
testInstance.Error("PrivateTmp wird nicht abgeschaltet, wenn das Repository unter /tmp liegt") 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)
}
}
}
}

View File

@ -370,6 +370,64 @@ validateRepositoryPath() {
Nur fuer Wegwerf-Umgebungen: SYNCOVA_SETUP_ALLOW_VOLATILE_REPOSITORY=ja" Nur fuer Wegwerf-Umgebungen: SYNCOVA_SETUP_ALLOW_VOLATILE_REPOSITORY=ja"
} }
# serviceIsReachable prueft, ob eine Adresse mit HTTP 200 antwortet.
#
# Drei Wege, weil keiner ueberall vorhanden ist. Genau daran ist die Einrichtung
# in Issue #2 gescheitert: Der Dienst lief einwandfrei — Protokoll und
# Zeitstempel belegen es —, aber auf dem System war kein curl installiert. Die
# Bereitschaftspruefung hielt das fuer einen gescheiterten Start und baute eine
# funktionierende Anlage wieder zurueck.
#
# Ein Einrichtungsskript darf nicht voraussetzen, was es nicht selbst mitbringt.
serviceIsReachable() {
local requestURL="$1"
if command -v curl >/dev/null 2>&1; then
curl -fsS --max-time 5 "${requestURL}" >/dev/null 2>&1
return $?
fi
# wget endet bei 4xx und 5xx mit einem Fehler — dasselbe Verhalten wie
# "curl -f".
if command -v wget >/dev/null 2>&1; then
wget -q -O /dev/null --timeout=5 "${requestURL}" >/dev/null 2>&1
return $?
fi
bashHTTPStatusIsOK "${requestURL}"
}
# bashHTTPStatusIsOK spricht HTTP ohne jedes Zusatzwerkzeug.
#
# Der letzte Ausweg: /dev/tcp gehoert zur Bash selbst. Damit laesst sich die
# Bereitschaft auch auf einem System pruefen, auf dem weder curl noch wget
# liegen — und das ist bei einem schlanken Serverabbild der Normalfall, nicht
# die Ausnahme.
bashHTTPStatusIsOK() {
local strippedURL="${1#http://}"
local hostAndPort="${strippedURL%%/*}"
local requestPath="/${strippedURL#*/}"
# Ohne Pfadanteil liefert die Ersetzung oben die Adresse zurueck.
[[ "${requestPath}" == "/${hostAndPort}" ]] && requestPath="/"
local targetHost="${hostAndPort%%:*}"
local targetPort="${hostAndPort##*:}"
[[ "${targetPort}" == "${targetHost}" ]] && targetPort=80
exec 3<>"/dev/tcp/${targetHost}/${targetPort}" 2>/dev/null || return 1
printf 'GET %s HTTP/1.0\r\nHost: %s\r\nConnection: close\r\n\r\n' \
"${requestPath}" "${hostAndPort}" >&3
local statusLine=""
read -r -t 5 statusLine <&3
exec 3<&- 3>&- 2>/dev/null
[[ "${statusLine}" == *" 200 "* ]]
}
# 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
@ -869,7 +927,7 @@ writeDetail "Der Dienst wird geprueft."
serviceIsReady="nein" serviceIsReady="nein"
for _attempt in $(seq 1 15); do for _attempt in $(seq 1 15); do
if curl -fsS "http://${listenAddress}/health/ready" >/dev/null 2>&1; then if serviceIsReachable "http://${listenAddress}/health/ready"; then
serviceIsReady="ja" serviceIsReady="ja"
break break
fi fi

View File

@ -62,6 +62,65 @@ abortWithMessage() {
exit 1 exit 1
} }
# serviceIsReachable prueft, ob eine Adresse mit HTTP 200 antwortet.
#
# Drei Wege, weil keiner ueberall vorhanden ist. Genau daran ist die Einrichtung
# in Issue #2 gescheitert: Der Dienst lief einwandfrei — Protokoll und
# Zeitstempel belegen es —, aber auf dem System war kein curl installiert. Die
# Bereitschaftspruefung hielt das fuer einen gescheiterten Start und baute eine
# funktionierende Anlage wieder zurueck.
#
# Ein Einrichtungsskript darf nicht voraussetzen, was es nicht selbst mitbringt.
serviceIsReachable() {
local requestURL="$1"
if command -v curl >/dev/null 2>&1; then
curl -fsS --max-time 5 "${requestURL}" >/dev/null 2>&1
return $?
fi
# wget endet bei 4xx und 5xx mit einem Fehler — dasselbe Verhalten wie
# "curl -f".
if command -v wget >/dev/null 2>&1; then
wget -q -O /dev/null --timeout=5 "${requestURL}" >/dev/null 2>&1
return $?
fi
bashHTTPStatusIsOK "${requestURL}"
}
# bashHTTPStatusIsOK spricht HTTP ohne jedes Zusatzwerkzeug.
#
# Der letzte Ausweg: /dev/tcp gehoert zur Bash selbst. Damit laesst sich die
# Bereitschaft auch auf einem System pruefen, auf dem weder curl noch wget
# liegen — und das ist bei einem schlanken Serverabbild der Normalfall, nicht
# die Ausnahme.
bashHTTPStatusIsOK() {
local strippedURL="${1#http://}"
local hostAndPort="${strippedURL%%/*}"
local requestPath="/${strippedURL#*/}"
# Ohne Pfadanteil liefert die Ersetzung oben die Adresse zurueck.
[[ "${requestPath}" == "/${hostAndPort}" ]] && requestPath="/"
local targetHost="${hostAndPort%%:*}"
local targetPort="${hostAndPort##*:}"
[[ "${targetPort}" == "${targetHost}" ]] && targetPort=80
exec 3<>"/dev/tcp/${targetHost}/${targetPort}" 2>/dev/null || return 1
printf 'GET %s HTTP/1.0\r\nHost: %s\r\nConnection: close\r\n\r\n' \
"${requestPath}" "${hostAndPort}" >&3
local statusLine=""
read -r -t 5 statusLine <&3
exec 3<&- 3>&- 2>/dev/null
[[ "${statusLine}" == *" 200 "* ]]
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Schalter # Schalter
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -374,7 +433,7 @@ healthAddress="${SYNCOVA_HTTP_LISTEN_ADDRESS:-127.0.0.1:8080}"
serviceIsReady="nein" serviceIsReady="nein"
for _attempt in $(seq 1 20); do for _attempt in $(seq 1 20); do
if curl -fsS "http://${healthAddress}/health/ready" >/dev/null 2>&1; then if serviceIsReachable "http://${healthAddress}/health/ready"; then
serviceIsReady="ja" serviceIsReady="ja"
break break
fi fi