Der Waechter hinterlaesst, was er getan hat
Der Waechter redete bisher nur ins Journal auf dem Wirt — die Konsole im Container sieht ihn also nicht. Er schreibt jetzt zusaetzlich storage/app/deploy/watchdog-last-run.json (atomar, .tmp + mv) mit Ausgang (idle/healed/stood_down) und den say()-Meldungen des Laufs. WatchdogLog::lastRun() liest das robust (fehlend/kaputt -> null, stale-Erkennung nach 5 Minuten). Die mitgelieferte Testvorlage hatte selbst einen Fehler: die docker-Attrappe setzte ihren mehrzeiligen Vorgabewert ungequotet in generierten Shell-Code ein, wodurch die "ps"-Antwort einen Dienst verschluckte und der idle-Test faelschlich "healed" sah. Behoben durch Anfuehrungszeichen um den eingesetzten Wert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>main
parent
6bdab32b94
commit
daeea1db0e
|
|
@ -0,0 +1,66 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Deployment;
|
||||||
|
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was der Wächter zuletzt getan hat.
|
||||||
|
*
|
||||||
|
* Eigene Klasse und nicht ein weiteres Feld in `UpdateChannel`: der Wächter
|
||||||
|
* ist eine andere Sache als der Update-Kanal — er richtet einen liegen
|
||||||
|
* gebliebenen Stapel, während jener eine Auslieferung vermittelt. Und
|
||||||
|
* `UpdateChannel` ist bereits über neunhundert Zeilen lang.
|
||||||
|
*/
|
||||||
|
class WatchdogLog
|
||||||
|
{
|
||||||
|
private const FILE = 'deploy/watchdog-last-run.json';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Älter als das, und der Wächter läuft nicht mehr.
|
||||||
|
*
|
||||||
|
* Sein Zeitgeber steht auf einer Minute. Fünf lässt vier ausgefallenen
|
||||||
|
* Takten Luft — ein Wirt unter Last oder ein Lauf, der gerade heilt und
|
||||||
|
* dabei `sleep 15` macht, ist noch kein toter Wächter.
|
||||||
|
*/
|
||||||
|
private const STALE_AFTER_MINUTES = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{at: Carbon, outcome: string, actions: array<int, string>, stale: bool}|null
|
||||||
|
*/
|
||||||
|
public function lastRun(): ?array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$path = storage_path('app/'.self::FILE);
|
||||||
|
|
||||||
|
if (! File::exists($path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode((string) File::get($path), true);
|
||||||
|
|
||||||
|
if (! is_array($data) || ! isset($data['at'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$at = Carbon::parse((string) $data['at']);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'at' => $at,
|
||||||
|
'outcome' => (string) ($data['outcome'] ?? 'idle'),
|
||||||
|
'actions' => array_values(array_filter(
|
||||||
|
is_array($data['actions'] ?? null) ? $data['actions'] : [],
|
||||||
|
'is_string'
|
||||||
|
)),
|
||||||
|
'stale' => $at->lt(Carbon::now()->subMinutes(self::STALE_AFTER_MINUTES)),
|
||||||
|
];
|
||||||
|
} catch (Throwable) {
|
||||||
|
// Dieselbe Haltung wie `UpdateChannel::readJson()`: die Konsole
|
||||||
|
// liest das bei jedem Seitenaufbau, und „ich weiß es nicht" ist
|
||||||
|
// ein brauchbarer Zustand — eine geworfene Ausnahme nicht.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -37,6 +37,10 @@ say() {
|
||||||
logger -t "$LOG_TAG" -- "$*"
|
logger -t "$LOG_TAG" -- "$*"
|
||||||
fi
|
fi
|
||||||
printf '%s\n' "$*"
|
printf '%s\n' "$*"
|
||||||
|
# Jede Meldung ist zugleich ein Eintrag fuer die Konsole. Eine zweite
|
||||||
|
# Stelle, an der man daran denken muesste, waere eine Stelle, an der es
|
||||||
|
# irgendwann vergessen wird.
|
||||||
|
AKTIONEN+=("$*")
|
||||||
}
|
}
|
||||||
|
|
||||||
mkdir -p "$STATE_DIR" 2>/dev/null || true
|
mkdir -p "$STATE_DIR" 2>/dev/null || true
|
||||||
|
|
@ -101,6 +105,10 @@ darf_eingreifen() {
|
||||||
|
|
||||||
geheilt=false
|
geheilt=false
|
||||||
|
|
||||||
|
# Was dieser Lauf getan hat, in der Reihenfolge. Die Konsole liest daraus
|
||||||
|
# einen Satz; das Journal hat weiterhin die Langfassung.
|
||||||
|
AKTIONEN=()
|
||||||
|
|
||||||
# ── 1. Fehlt ein Dienst? ─────────────────────────────────────────────────────
|
# ── 1. Fehlt ein Dienst? ─────────────────────────────────────────────────────
|
||||||
#
|
#
|
||||||
# `config --services` liest die Profile aus der .env mit, vpn-dns und
|
# `config --services` liest die Profile aus der .env mit, vpn-dns und
|
||||||
|
|
@ -206,3 +214,45 @@ fi
|
||||||
if [[ "$geheilt" == true ]]; then
|
if [[ "$geheilt" == true ]]; then
|
||||||
say "Nachgesehen und eingegriffen."
|
say "Nachgesehen und eingegriffen."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── Was die Konsole davon erfaehrt ───────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Der Waechter redete bisher NUR ins Journal — und das liegt auf dem Wirt,
|
||||||
|
# waehrend die Konsole in einem Container laeuft. Sie sah ihn also gar nicht.
|
||||||
|
# Am 4. August 2026 hat genau das die Fehlersuche gekostet: der Waechter hielt
|
||||||
|
# die Sperre, der Agent kam nicht an die Arbeit, und die einzige Stelle, an der
|
||||||
|
# das gestanden haette, war von der Konsole aus unerreichbar.
|
||||||
|
#
|
||||||
|
# Drei Ausgaenge, weil sie drei verschiedene Dinge bedeuten:
|
||||||
|
# idle — nachgesehen, nichts zu tun. Der Normalfall.
|
||||||
|
# healed — eingegriffen. Was, steht in `actions`.
|
||||||
|
# stood_down — nicht drangekommen, weil ein Update die Sperre hielt.
|
||||||
|
# Betrieb, kein Fehler — aber es muss unterscheidbar sein.
|
||||||
|
#
|
||||||
|
# Atomar geschrieben: die Konsole liest diese Datei bei jedem Seitenaufbau,
|
||||||
|
# und eine halbe JSON-Datei bricht die Seite in dem Moment, in dem jemand
|
||||||
|
# nachsieht.
|
||||||
|
ausgang=idle
|
||||||
|
if [[ "$geheilt" == true ]]; then
|
||||||
|
ausgang=healed
|
||||||
|
elif [[ "$SPERRE" == verwehrt ]]; then
|
||||||
|
ausgang=stood_down
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Die Liste als JSON-Array. Anfuehrungszeichen, Backslashes und Umbrueche raus
|
||||||
|
# — der einzige freie Text sind die eigenen Meldungen oben, aber verlassen
|
||||||
|
# wird sich darauf nicht.
|
||||||
|
eintraege=''
|
||||||
|
for a in ${AKTIONEN+"${AKTIONEN[@]}"}; do
|
||||||
|
a="$(printf '%s' "$a" | tr -d '"\\' | tr '\n\r\t' ' ')"
|
||||||
|
eintraege+="\"$a\","
|
||||||
|
done
|
||||||
|
|
||||||
|
cat > "$STATE_DIR/watchdog-last-run.json.tmp" 2>/dev/null <<EOF && \
|
||||||
|
mv -f "$STATE_DIR/watchdog-last-run.json.tmp" "$STATE_DIR/watchdog-last-run.json" 2>/dev/null || true
|
||||||
|
{
|
||||||
|
"at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||||
|
"outcome": "$ausgang",
|
||||||
|
"actions": [${eintraege%,}]
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Services\Deployment\WatchdogLog;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\Facades\Process;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Waechter redet bisher nur ins Journal — und das Journal liegt auf dem
|
||||||
|
* WIRT, waehrend die Konsole in einem Container laeuft. Sie sieht ihn also
|
||||||
|
* gar nicht. Am 4. August 2026 hat genau das die Fehlersuche gekostet: der
|
||||||
|
* Waechter hielt die Sperre, der Agent kam nicht an die Arbeit, und die
|
||||||
|
* einzige Stelle, an der das gestanden haette, war unerreichbar.
|
||||||
|
*
|
||||||
|
* Hier laeuft das ECHTE Skript gegen eine docker-Attrappe.
|
||||||
|
*/
|
||||||
|
function runWatchdog(string $running = "app\nredis", bool $holdLock = false): array
|
||||||
|
{
|
||||||
|
$dir = storage_path('app/deploy');
|
||||||
|
File::ensureDirectoryExists($dir);
|
||||||
|
File::delete(File::glob($dir.'/*'));
|
||||||
|
|
||||||
|
$stub = $dir.'/stub';
|
||||||
|
File::ensureDirectoryExists($stub);
|
||||||
|
// '$running' bewusst in Anfuehrungszeichen: der Vorgabewert "app\nredis"
|
||||||
|
// traegt einen echten Zeilenumbruch, und ohne Quoting zerlegt genau der
|
||||||
|
// die generierte Zeile in zwei Shell-Befehle — `printf … app` und ein
|
||||||
|
// eigenstaendiges `redis`, das als unbekannter Befehl scheitert. `ps`
|
||||||
|
// meldete dann nur "app" und der Waechter sah faelschlich "redis" als
|
||||||
|
// fehlenden Dienst. In Anfuehrungszeichen bleibt der Zeilenumbruch Teil
|
||||||
|
// des einen printf-Arguments, so wie es die `config`-Zeile ohnehin schon
|
||||||
|
// (mit zwei getrennten Woertern statt einem Wert) richtig macht.
|
||||||
|
File::put($stub.'/docker', <<<SH
|
||||||
|
#!/bin/sh
|
||||||
|
case "\$*" in
|
||||||
|
*config*) printf '%s\\n' app redis ;;
|
||||||
|
*"ps "*) printf '%s\\n' '$running' ;;
|
||||||
|
*getent*) exit 0 ;;
|
||||||
|
*storage/framework/down*) exit 1 ;;
|
||||||
|
*" up "*) touch "\$STUB_UP_CALLED" ;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
SH);
|
||||||
|
Process::run("chmod +x {$stub}/docker");
|
||||||
|
|
||||||
|
$hold = $holdLock
|
||||||
|
? "flock storage/app/deploy/.agent.lock -c 'touch storage/app/deploy/.held; sleep 20' &\n"
|
||||||
|
."until [ -f storage/app/deploy/.held ]; do sleep 0.05; done\n"
|
||||||
|
: '';
|
||||||
|
|
||||||
|
$result = Process::path(base_path())->timeout(90)->env([
|
||||||
|
'PATH' => $stub.':'.env('PATH', '/usr/local/bin:/usr/bin:/bin'),
|
||||||
|
'STUB_UP_CALLED' => $dir.'/.stub-up-called',
|
||||||
|
])->run(<<<BASH
|
||||||
|
{$hold}
|
||||||
|
bash deploy/watchdog.sh >/dev/null 2>&1 || true
|
||||||
|
pkill -f 'sleep 20' 2>/dev/null || true
|
||||||
|
BASH);
|
||||||
|
|
||||||
|
expect($result->successful())->toBeTrue($result->errorOutput());
|
||||||
|
|
||||||
|
return json_decode(File::get($dir.'/watchdog-last-run.json'), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
File::deleteDirectory(storage_path('app/deploy'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records a run where there was nothing to do', function () {
|
||||||
|
$run = runWatchdog();
|
||||||
|
|
||||||
|
expect($run['outcome'])->toBe('idle')
|
||||||
|
->and($run['actions'])->toBe([])
|
||||||
|
->and($run['at'])->not->toBeEmpty();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records what it healed', function () {
|
||||||
|
// `app` fehlt in der Liste der laufenden Dienste — der Waechter startet
|
||||||
|
// die Dienste und muss das hinterlassen.
|
||||||
|
$run = runWatchdog(running: 'redis');
|
||||||
|
|
||||||
|
expect($run['outcome'])->toBe('healed')
|
||||||
|
->and($run['actions'])->not->toBeEmpty();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records that it stood down because the lock was held', function () {
|
||||||
|
// DER Zustand, der bisher unsichtbar war. Ohne ihn sieht ein Waechter,
|
||||||
|
// der seit einer Stunde nicht eingreifen kann, genauso aus wie einer,
|
||||||
|
// der nichts zu tun hat.
|
||||||
|
$run = runWatchdog(running: 'redis', holdLock: true);
|
||||||
|
|
||||||
|
expect($run['outcome'])->toBe('stood_down');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads nothing rather than falling over when the file is absent', function () {
|
||||||
|
File::ensureDirectoryExists(storage_path('app/deploy'));
|
||||||
|
|
||||||
|
expect(app(WatchdogLog::class)->lastRun())->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads nothing rather than falling over when the file is rubbish', function () {
|
||||||
|
File::ensureDirectoryExists(storage_path('app/deploy'));
|
||||||
|
File::put(storage_path('app/deploy/watchdog-last-run.json'), 'kein json {');
|
||||||
|
|
||||||
|
expect(app(WatchdogLog::class)->lastRun())->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls a run from long ago stale', function () {
|
||||||
|
// Ein toter Waechter muss als solcher lesbar sein. Bisher wuerde niemand
|
||||||
|
// es je erfahren.
|
||||||
|
File::ensureDirectoryExists(storage_path('app/deploy'));
|
||||||
|
File::put(storage_path('app/deploy/watchdog-last-run.json'), json_encode([
|
||||||
|
'at' => now()->subMinutes(30)->utc()->format('Y-m-d\TH:i:s\Z'),
|
||||||
|
'outcome' => 'idle',
|
||||||
|
'actions' => [],
|
||||||
|
]));
|
||||||
|
|
||||||
|
$run = app(WatchdogLog::class)->lastRun();
|
||||||
|
|
||||||
|
expect($run['stale'])->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call a fresh run stale', function () {
|
||||||
|
File::ensureDirectoryExists(storage_path('app/deploy'));
|
||||||
|
File::put(storage_path('app/deploy/watchdog-last-run.json'), json_encode([
|
||||||
|
'at' => now()->utc()->format('Y-m-d\TH:i:s\Z'),
|
||||||
|
'outcome' => 'idle',
|
||||||
|
'actions' => [],
|
||||||
|
]));
|
||||||
|
|
||||||
|
expect(app(WatchdogLog::class)->lastRun()['stale'])->toBeFalse();
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue