Merge branch 'claude/nice-moser-521659'
# Conflicts: # VERSION # deploy/install-agent.sh # deploy/update.shfeat/versandtakt
commit
37e1914246
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Services\Deployment\UpdateChannel;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
/**
|
||||
* Rückfrage, bevor ein Prozess auf dem Wirt beendet wird (R23).
|
||||
*
|
||||
* Es steht hier, was gleich stirbt — PID, Laufzeit und Kommandozeile aus dem
|
||||
* Lebenszeichen des Agenten —, und zwar VORHER. Das ist der ganze Sinn der
|
||||
* Rückfrage: eine Meldung hinterher wäre keine.
|
||||
*
|
||||
* Das Modal mutiert nichts. Es wirft ein Ereignis, das
|
||||
* App\Livewire\Admin\Settings auffängt; die Berechtigungsprüfung bleibt dort,
|
||||
* wo sie schon steht, statt hier verdoppelt zu werden.
|
||||
*
|
||||
* Der Halter wird HIER neu gelesen und nicht als Eigenschaft hereingereicht:
|
||||
* ein Modal ist ohne die Route-Middleware der Seite erreichbar (R20), und ein
|
||||
* vom Browser hydrierter Text wäre eine Angabe, die der Server nicht geprüft
|
||||
* hat — ausgerechnet in der Zeile, die den Betreiber entscheiden lässt.
|
||||
*/
|
||||
class ConfirmReleaseUpdateLock extends ModalComponent
|
||||
{
|
||||
/** Was der Agent zuletzt als Sperrenhalter gesehen hat. */
|
||||
public string $heldBy = '';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorize('deployment.unblock');
|
||||
|
||||
$this->heldBy = (string) (app(UpdateChannel::class)->state()['blocked_by'] ?? '');
|
||||
}
|
||||
|
||||
public function confirm(): void
|
||||
{
|
||||
$this->authorize('deployment.unblock');
|
||||
|
||||
$this->dispatch('update-lock-release-confirmed');
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.confirm-release-update-lock');
|
||||
}
|
||||
}
|
||||
|
|
@ -717,6 +717,33 @@ class Settings extends Component
|
|||
: 'admin_settings.update_already_requested'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine hängende Sperre auf dem Wirt lösen lassen.
|
||||
*
|
||||
* Der Auslöser kommt aus dem Bestätigungs-Modal (R23); die Prüfung steht
|
||||
* hier, an derselben Stelle wie bei jeder anderen Handlung dieser Seite,
|
||||
* statt im Modal verdoppelt zu werden.
|
||||
*
|
||||
* Eigene Berechtigung: das hier beendet einen laufenden Prozess auf der
|
||||
* Maschine, notfalls mit SIGKILL. Wer aktualisieren darf, darf damit nicht
|
||||
* automatisch auch in einen laufenden Vorgang hineingreifen.
|
||||
*/
|
||||
#[On('update-lock-release-confirmed')]
|
||||
public function releaseUpdateLock(): void
|
||||
{
|
||||
$this->authorize('deployment.unblock');
|
||||
|
||||
if (! $operator = $this->currentOperator()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$accepted = app(UpdateChannel::class)->requestUnblock($operator->email);
|
||||
|
||||
$this->dispatch('notify', message: __($accepted
|
||||
? 'admin_settings.release_lock_requested'
|
||||
: 'admin_settings.release_lock_already_requested'));
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$operator = $this->currentOperator();
|
||||
|
|
@ -778,6 +805,9 @@ class Settings extends Component
|
|||
'staff' => $staff,
|
||||
'roles' => Operator::operatorRoles(),
|
||||
'canManageStaff' => $operator?->can('staff.manage') ?? false,
|
||||
// Die Warnung über eine hängende Sperre sieht jeder, der die Seite
|
||||
// sieht. Den Griff dagegen nicht.
|
||||
'canReleaseLock' => $operator?->can('deployment.unblock') ?? false,
|
||||
'isOwner' => $operator?->hasRole('Owner') ?? false,
|
||||
// Damit der Hinweis unter dem Einladen-Formular dieselbe Frist
|
||||
// nennt wie die Mail und der Broker — eine Quelle, drei Leser.
|
||||
|
|
|
|||
|
|
@ -46,6 +46,32 @@ final class UpdateChannel
|
|||
/** Written by the panel, consumed by the agent. */
|
||||
private const REQUEST = 'deploy/update-request.json';
|
||||
|
||||
/**
|
||||
* Die Bitte, eine hängende Sperre zu lösen — in einer EIGENEN Datei.
|
||||
*
|
||||
* Nicht im Postkasten oben, und das ist keine Geschmacksfrage: der Agent
|
||||
* nimmt die Sperre in den ersten Zeilen seines Laufs, LANGE bevor er
|
||||
* `update-request.json` überhaupt ansieht. Ein blockierter Lauf steigt
|
||||
* vorher aus. Eine Entsperr-Bitte im Postkasten käme also genau dann nie
|
||||
* an, wenn man sie braucht.
|
||||
*
|
||||
* Sie wird deshalb im blockierten Zweig gelesen — und liegt getrennt, weil
|
||||
* `submit()` eine Bitte zur Zeit annimmt und ein blockierter Agent keine
|
||||
* abholt: eine dreißig Minuten lang wartende Update-Anfrage sperrte sonst
|
||||
* ausgerechnet das Entsperren aus.
|
||||
*/
|
||||
private const UNBLOCK_REQUEST = 'deploy/unblock-request.json';
|
||||
|
||||
/**
|
||||
* Was beim letzten Entsperren herauskam.
|
||||
*
|
||||
* Ohne diese Rückmeldung wäre der Knopf auf einem Wirt, dessen
|
||||
* root-eigener Helfer den neuen Schritt noch nicht kennt, ein Knopf, der
|
||||
* nichts tut und nichts sagt — und schickte den Betreiber genau dorthin
|
||||
* zurück, wo er ohne die Konsole schon war.
|
||||
*/
|
||||
private const UNBLOCK_LAST_RUN = 'deploy/unblock-last-run.json';
|
||||
|
||||
/** A request the agent only checks against, and never acts on. */
|
||||
private const KIND_CHECK = 'check';
|
||||
|
||||
|
|
@ -181,6 +207,28 @@ final class UpdateChannel
|
|||
*/
|
||||
private const AGENT_STALE_AFTER_MINUTES = 20;
|
||||
|
||||
/**
|
||||
* Wie viele Läufe hintereinander übersprungen sein müssen, ehe das eine
|
||||
* Blockade heißt.
|
||||
*
|
||||
* Ein einzelner übersprungener Lauf ist Betrieb: der Zeitgeber läuft
|
||||
* minütlich, der Wächter (deploy/watchdog.sh) kommt dazu, einer nimmt die
|
||||
* Sperre und der andere geht weg. Gemessen wurde ein Überholen von NEUN
|
||||
* SEKUNDEN — und die Konsole meldete dafür bereits „kommt seit 15:21 nicht
|
||||
* an die Arbeit". Der Besitzer hat daraufhin eine Stunde lang eine gesunde
|
||||
* Anlage auseinandergenommen.
|
||||
*
|
||||
* Zwei, nicht zehn: lieber früh gewarnt werden. Weg soll nur die Warnung
|
||||
* beim allerersten übersprungenen Lauf.
|
||||
*
|
||||
* Gezählt wird in LÄUFEN, nicht in Minuten. `check_interval_minutes` ist
|
||||
* eine Behauptung des Agentenskripts; wie oft der Zeitgeber wirklich
|
||||
* auslöst, steht in der systemd-Unit auf dem Wirt, die diese Anwendung
|
||||
* nicht sehen kann. Zwei übersprungene Läufe sind zwei übersprungene
|
||||
* Läufe, wie der Takt auch stehen mag.
|
||||
*/
|
||||
private const BLOCKED_AFTER_SKIPS = 2;
|
||||
|
||||
/**
|
||||
* What the panel needs to show, in one read.
|
||||
*
|
||||
|
|
@ -202,7 +250,17 @@ final class UpdateChannel
|
|||
// Der Agent läuft, kommt aber nicht an die Sperre. Ein eigener
|
||||
// Zustand, weil er weder „tot" ist noch „in Ordnung": seine Zahlen
|
||||
// stammen von vor der Blockade und altern still weiter.
|
||||
$blockedSince = ($alive['state'] ?? null) === 'blocked' && $agentAlive
|
||||
//
|
||||
// Erst ab BLOCKED_AFTER_SKIPS übersprungenen Läufen — und zwar für den
|
||||
// GANZEN Zustand, nicht nur für den Satz weiter unten in der Ansicht.
|
||||
// Nur die Meldung zu unterdrücken hätte den Fehlalarm gegen einen
|
||||
// stilleren getauscht: `blocked_since` blendet über $agentWorking auch
|
||||
// „N Aktualisierungen zurück" und die Zielversion aus, und die wären
|
||||
// dann beim ersten übersprungenen Lauf verschwunden, ohne dass
|
||||
// irgendwo stünde warum. Eine Schwelle, eine Stelle.
|
||||
$blockedSince = ($alive['state'] ?? null) === 'blocked'
|
||||
&& $agentAlive
|
||||
&& $this->skippedRuns($alive) >= self::BLOCKED_AFTER_SKIPS
|
||||
? $this->timestamp($alive['since'] ?? null)
|
||||
: null;
|
||||
|
||||
|
|
@ -241,6 +299,11 @@ final class UpdateChannel
|
|||
// hält. Beides null, solange er arbeitet.
|
||||
'blocked_since' => $blockedSince,
|
||||
'blocked_by' => $blockedSince !== null ? trim((string) ($alive['held_by'] ?? '')) : null,
|
||||
// Eine abgelegte Entsperr-Bitte, die der Agent noch nicht geholt
|
||||
// hat. Der Knopf bleibt solange stehen, aber gesperrt: ein zweiter
|
||||
// Klick wäre Ungeduld, nicht Absicht.
|
||||
'unblock_requested' => $this->pendingUnblockRequest() !== null,
|
||||
'unblock_error' => $this->unblockError(),
|
||||
// A pending CHECK or RESTART must never read as "running": nothing
|
||||
// is being deployed, the site never enters maintenance mode, and
|
||||
// the full-screen overlay (layouts/admin.blade.php) exists only
|
||||
|
|
@ -417,6 +480,27 @@ final class UpdateChannel
|
|||
&& $checkedAt->gt(Carbon::now()->subMinutes(self::AGENT_STALE_AFTER_MINUTES));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie viele Läufe der Agent ununterbrochen übersprungen hat.
|
||||
*
|
||||
* Vom Agenten mitgezählt (deploy/update-agent.sh), weil nur er weiß, wie
|
||||
* oft er angetreten ist. Aus `since` eine Zahl auszurechnen hieße, das
|
||||
* Taktintervall zu raten — und das steht auf dem Wirt, nicht hier.
|
||||
*
|
||||
* Fehlt das Feld, gilt EINS: das ist das Lebenszeichen eines Agenten von
|
||||
* vor dieser Zählung, das nach einem Update noch eine Minute liegen
|
||||
* bleibt. Darauf eine Blockade zu melden hieße raten; der nächste Takt
|
||||
* schreibt die Zahl und korrigiert es von selbst.
|
||||
*
|
||||
* @param array<string, mixed> $alive
|
||||
*/
|
||||
private function skippedRuns(array $alive): int
|
||||
{
|
||||
return isset($alive['skips']) && is_numeric($alive['skips'])
|
||||
? (int) $alive['skips']
|
||||
: 1;
|
||||
}
|
||||
|
||||
/** Is the agent in the middle of a run right now? */
|
||||
public function isRunning(): bool
|
||||
{
|
||||
|
|
@ -511,6 +595,66 @@ final class UpdateChannel
|
|||
return $this->submit($by, self::KIND_RESTART);
|
||||
}
|
||||
|
||||
/**
|
||||
* Den Agenten bitten, eine hängende Sperre auf dem Wirt zu lösen.
|
||||
*
|
||||
* Nicht über submit(): die Bitte liegt in ihrer eigenen Datei, weil der
|
||||
* Postkasten sie im entscheidenden Fall nie erreichen würde — siehe
|
||||
* UNBLOCK_REQUEST.
|
||||
*
|
||||
* Das Ergebnis des vorigen Versuchs wird dabei weggeräumt. Bliebe es
|
||||
* liegen, läse der Betreiber nach einem zweiten Klick weiter die
|
||||
* Fehlermeldung des ersten und hielte sie für die neue.
|
||||
*/
|
||||
public function requestUnblock(string $by): bool
|
||||
{
|
||||
if ($this->pendingUnblockRequest() !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->forget(self::UNBLOCK_LAST_RUN);
|
||||
|
||||
$this->write(self::UNBLOCK_REQUEST, json_encode([
|
||||
'requested_at' => Carbon::now()->toIso8601String(),
|
||||
'requested_by' => $by,
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Liegt eine Entsperr-Bitte, die der Agent noch holen kann? */
|
||||
public function pendingUnblockRequest(): ?array
|
||||
{
|
||||
$request = $this->readJson(self::UNBLOCK_REQUEST);
|
||||
|
||||
if ($request === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$at = $this->timestamp($request['requested_at'] ?? null);
|
||||
|
||||
if ($at === null || $at->lt(Carbon::now()->subMinutes(self::REQUEST_EXPIRES_MINUTES))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Warum das letzte Entsperren nicht geklappt hat, in der Sprache des
|
||||
* Betreibers — oder null, wenn es geklappt hat oder keins versucht wurde.
|
||||
*/
|
||||
private function unblockError(): ?string
|
||||
{
|
||||
$last = $this->readJson(self::UNBLOCK_LAST_RUN);
|
||||
|
||||
if (($last['state'] ?? null) !== 'failed') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->errorMessage($last);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the host to mint a collection key for one archive directory.
|
||||
*
|
||||
|
|
@ -643,6 +787,11 @@ final class UpdateChannel
|
|||
File::put($path, $contents);
|
||||
}
|
||||
|
||||
private function forget(string $relative): void
|
||||
{
|
||||
File::delete(storage_path('app/'.$relative));
|
||||
}
|
||||
|
||||
private function timestamp(mixed $value): ?Carbon
|
||||
{
|
||||
if (! is_string($value) || $value === '') {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
|
||||
/**
|
||||
* `deployment.unblock` — einen hängenden Sperrenhalter auf dem Wirt beenden.
|
||||
*
|
||||
* Eigene Berechtigung, nicht `site.manage`: das hier beendet einen laufenden
|
||||
* Prozess auf der Maschine, notfalls mit SIGKILL. Wer die Anlage aktualisieren
|
||||
* darf, darf damit nicht automatisch auch in einen laufenden Vorgang
|
||||
* hineingreifen.
|
||||
*
|
||||
* Owner und Admin, wie `site.manage` daneben: es ist der Griff, den der
|
||||
* Betreiber braucht, wenn die Konsole seit Minuten nichts mehr tut, und er
|
||||
* darf nicht ausgerechnet dann an einer Rollenfrage hängen.
|
||||
*
|
||||
* Guard `operator`, nicht `web`. Die RBAC ist am 29. Juli 2026 auf den
|
||||
* operator-Guard umgezogen; alles, was danach unter `web` angelegt wird,
|
||||
* landet in einem Guard, gegen den sich niemand anmeldet — und die Konsole
|
||||
* antwortet 403 für eine Rolle, die die Berechtigung sichtbar hat.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
|
||||
$permission = Permission::findOrCreate('deployment.unblock', 'operator');
|
||||
|
||||
foreach (['Owner', 'Admin'] as $role) {
|
||||
Role::findOrCreate($role, 'operator')->givePermissionTo($permission);
|
||||
}
|
||||
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
Permission::where('name', 'deployment.unblock')->where('guard_name', 'operator')->delete();
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
}
|
||||
};
|
||||
|
|
@ -241,6 +241,103 @@ case "${1:-}" in
|
|||
rm -f "$TMP"
|
||||
;;
|
||||
|
||||
release-update-lock)
|
||||
# Eine hängende Sperre lösen — der Griff, der bisher per SSH auf dem
|
||||
# Wirt lag.
|
||||
#
|
||||
# Der Dienstbenutzer sagt „gib die Sperre frei", nicht „töte 1234".
|
||||
# WELCHER Prozess das ist, sucht dieser Helfer selbst — dieselbe
|
||||
# Trennung wie bei apply-proxy-hosts. Dürfte der Anrufer die Nummer
|
||||
# liefern, wäre die Freigabe das Recht, jeden beliebigen Prozess als
|
||||
# root zu beenden, und damit nichts mehr wert.
|
||||
LOCK=/opt/clupilot/storage/app/deploy/.agent.lock
|
||||
[[ -e "$LOCK" ]] || { echo "nothing to release: no lock file at $LOCK" >&2; exit 0; }
|
||||
target="$(readlink -f "$LOCK")"
|
||||
|
||||
# Zuerst die eigene Hand von der Tuerklinke nehmen.
|
||||
#
|
||||
# sudo raeumt Deskriptoren ab dem dritten ab, dieser Helfer haelt die
|
||||
# Sperrdatei im Betrieb also nicht offen. Wird er anders aufgerufen,
|
||||
# schon — und dann taucht bei JEDEM Nachsehen etwas anderes in der
|
||||
# Halterliste auf, weil jede Kommandosubstitution ein eigener Prozess
|
||||
# ist, der den Deskriptor erbt. Die Runde kaeme nie zum Ende und
|
||||
# meldete am Schluss „konnte nicht beendet werden" ueber einer Sperre,
|
||||
# die niemand haelt.
|
||||
for fd in /proc/self/fd/*; do
|
||||
n="${fd##*/}"
|
||||
(( n > 2 )) || continue
|
||||
[[ "$(readlink -f "$fd" 2>/dev/null)" == "$target" ]] || continue
|
||||
eval "exec $n>&-" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# Wer die Datei OFFEN hält — nicht, wer das flock genommen hat.
|
||||
#
|
||||
# Ein flock hängt an der offenen Dateibeschreibung, und die wird an
|
||||
# Kindprozesse vererbt. Stirbt also der Agent, sein hängendes
|
||||
# `docker compose exec` aber nicht, bleibt die Sperre gehalten. Genau
|
||||
# das ist am 4. August 2026 passiert — nur den Sperrennehmer zu
|
||||
# beenden hätte an dem Tag nichts gelöst.
|
||||
#
|
||||
# Über /proc statt über fuser: hier läuft etwas als root und beendet
|
||||
# Prozesse, und dafür ist ein Paket, das auf einem schlanken Wirt
|
||||
# fehlen kann, eine schlechte Voraussetzung.
|
||||
#
|
||||
# Verschont bleiben PID 1 und der Anrufer samt seinen Vorfahren. Im
|
||||
# Betrieb ist das der blockierte Agent: sein `exec 9>` ist geglückt,
|
||||
# nur das `flock` nicht — er hält die Sperrdatei also selbst offen und
|
||||
# ist zugleich der, der hier anruft. Ihn mitzubeenden hieße, mitten im
|
||||
# Anruf aufzulegen, und die Konsole erführe nie, was geschehen ist.
|
||||
spare=" 1 "
|
||||
walk=$$
|
||||
while [[ "$walk" =~ ^[0-9]+$ ]] && (( walk > 0 )); do
|
||||
spare+="$walk "
|
||||
walk="$(sed -n 's/^PPid:[[:space:]]*//p' "/proc/$walk/status" 2>/dev/null || true)"
|
||||
done
|
||||
|
||||
holders() {
|
||||
local fd pid
|
||||
for fd in /proc/[0-9]*/fd/*; do
|
||||
[[ "$(readlink -f "$fd" 2>/dev/null)" == "$target" ]] || continue
|
||||
pid="${fd#/proc/}"
|
||||
pid="${pid%%/*}"
|
||||
[[ "$spare" == *" $pid "* ]] && continue
|
||||
echo "$pid"
|
||||
done | sort -un
|
||||
}
|
||||
|
||||
held="$(holders || true)"
|
||||
|
||||
if [[ -z "$held" ]]; then
|
||||
echo "nothing holds $LOCK" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Erst bitten. Was noch aufräumen kann, soll das dürfen.
|
||||
kill -TERM $held 2>/dev/null || true
|
||||
|
||||
# Fünf Sekunden, dann das, was niemand ignorieren kann. Eine Frist ohne
|
||||
# Nachdruck ist keine Frist, sondern eine Bitte — dieselbe Lehre wie
|
||||
# `timeout -k` in update-agent.sh, und derselbe Vorfall.
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||
sleep 0.5
|
||||
held="$(holders || true)"
|
||||
[[ -n "$held" ]] || break
|
||||
done
|
||||
|
||||
if [[ -n "$held" ]]; then
|
||||
kill -KILL $held 2>/dev/null || true
|
||||
sleep 0.5
|
||||
held="$(holders || true)"
|
||||
fi
|
||||
|
||||
if [[ -n "$held" ]]; then
|
||||
echo "could not end: $(echo "$held" | tr '\n' ' ')" >&2
|
||||
exit 7
|
||||
fi
|
||||
|
||||
echo "released $LOCK"
|
||||
;;
|
||||
|
||||
*)
|
||||
# Refused by name. sudoers already permits only the exact command lines
|
||||
# above, so this is the second of two locks, not the only one.
|
||||
|
|
@ -259,6 +356,7 @@ chmod 0755 "$HOST_STEP"
|
|||
cat > /etc/sudoers.d/clupilot-host-step <<EOF
|
||||
$APP_USER ALL=(root) NOPASSWD: $HOST_STEP ensure-rsync
|
||||
$APP_USER ALL=(root) NOPASSWD: $HOST_STEP apply-proxy-hosts
|
||||
$APP_USER ALL=(root) NOPASSWD: $HOST_STEP release-update-lock
|
||||
EOF
|
||||
chmod 0440 /etc/sudoers.d/clupilot-host-step
|
||||
visudo -cf /etc/sudoers.d/clupilot-host-step >/dev/null || {
|
||||
|
|
@ -350,6 +448,12 @@ Description=Wake the CluPilot update agent when the console asks for something
|
|||
|
||||
[Path]
|
||||
PathChanged=$ROOT/storage/app/deploy/update-request.json
|
||||
# Und die Bitte, eine hängende Sperre zu lösen. Sie liegt in einer eigenen
|
||||
# Datei (siehe update-agent.sh) und braucht deshalb ihre eigene Zeile hier.
|
||||
# Ohne sie käme der Griff erst beim nächsten Takt an — auf einem Wirt, der
|
||||
# diese Datei noch nicht kennt, ist genau das der Rückfall, und eine Minute
|
||||
# ist ein annehmbarer Preis gegenüber der Stunde, die es vorher kostete.
|
||||
PathChanged=$ROOT/storage/app/deploy/unblock-request.json
|
||||
Unit=clupilot-update-agent.service
|
||||
|
||||
[Install]
|
||||
|
|
|
|||
|
|
@ -58,6 +58,17 @@ LOCK="$STATE_DIR/.agent.lock"
|
|||
# arbeitet; "blocked" heisst, er ist ausgestiegen, weil jemand anderes sie
|
||||
# haelt — mit `since` (seit wann ununterbrochen) und `held_by` (wer).
|
||||
ALIVE="$STATE_DIR/agent-alive.json"
|
||||
# Die Bitte, eine haengende Sperre zu loesen — und was dabei herauskam.
|
||||
#
|
||||
# EIGENE Datei, nicht der Postkasten oben, und zwar zwangslaeufig: die Sperre
|
||||
# wird ein paar Zeilen weiter unten genommen, LANGE bevor dieses Skript
|
||||
# `update-request.json` ueberhaupt ansieht. Ein blockierter Lauf steigt vorher
|
||||
# aus. Eine Entsperr-Bitte im Postkasten erreichte den Agenten also genau dann
|
||||
# nie, wenn sie gebraucht wird — und zusaetzlich nimmt der Postkasten eine
|
||||
# Bitte zur Zeit an, sodass eine dreissig Minuten lang wartende Update-Anfrage
|
||||
# ausgerechnet das Entsperren aussperrte.
|
||||
UNBLOCK="$STATE_DIR/unblock-request.json"
|
||||
UNBLOCK_LAST="$STATE_DIR/unblock-last-run.json"
|
||||
# The reverse proxy's console allowlist, generated from the one the owner keeps
|
||||
# in the console. Without this the proxy has its own hard-coded list that runs
|
||||
# FIRST, so everything added in the console has no effect at all — and when the
|
||||
|
|
@ -96,7 +107,7 @@ mkdir -p "$STATE_DIR"
|
|||
# Text ist die Ausgabe von ps, aus der Anfuehrungszeichen, Backslashes und
|
||||
# Zeilenumbrueche entfernt werden.
|
||||
write_alive() {
|
||||
local state="$1" since="${2-}" held_by="${3-}"
|
||||
local state="$1" since="${2-}" held_by="${3-}" skips="${4-0}"
|
||||
held_by="$(printf '%s' "$held_by" | tr -d '"\\' | tr '\n\r\t' ' ')"
|
||||
|
||||
cat > "$ALIVE.tmp" 2>/dev/null <<EOF || return 0
|
||||
|
|
@ -104,12 +115,60 @@ write_alive() {
|
|||
"at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"state": "$state",
|
||||
"since": "$since",
|
||||
"held_by": "$held_by"
|
||||
"held_by": "$held_by",
|
||||
"skips": ${skips}
|
||||
}
|
||||
EOF
|
||||
mv -f "$ALIVE.tmp" "$ALIVE" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Was beim letzten Entsperren herauskam. Ohne diese Zeile waere der Knopf auf
|
||||
# einem Wirt, dessen root-eigener Helfer den Schritt noch nicht kennt, ein
|
||||
# Knopf, der nichts tut und nichts sagt.
|
||||
write_unblock() {
|
||||
cat > "$UNBLOCK_LAST.tmp" 2>/dev/null <<EOF || return 0
|
||||
{
|
||||
"state": "$1",
|
||||
"finished_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"error": "${2-}"
|
||||
}
|
||||
EOF
|
||||
mv -f "$UNBLOCK_LAST.tmp" "$UNBLOCK_LAST" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Den root-eigenen Helfer die Sperre loesen lassen.
|
||||
#
|
||||
# Aus diesem Skript heraus geht es nicht: der Sperrenhalter ist ein Prozess auf
|
||||
# dem Wirt, und dieser Agent laeuft unprivilegiert als Dienstbenutzer. Was er
|
||||
# darf, steht woertlich in sudoers und ist genau diese eine Befehlszeile.
|
||||
release_stuck_lock() {
|
||||
local step=/usr/local/sbin/clupilot-host-step have
|
||||
|
||||
if [[ ! -x "$step" ]]; then
|
||||
write_unblock failed unblock_helper_missing
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Kennt der Helfer den Schritt schon? Ein Wirt, auf dem install-agent.sh
|
||||
# seither nicht mehr lief, hat die aeltere Fassung — und die lehnt
|
||||
# `release-update-lock` als unbekannt ab. Das ist kein Fehler des
|
||||
# Betreibers, aber er muss es erfahren, statt einen Knopf zu druecken, der
|
||||
# still nichts tut.
|
||||
have="$("$step" contract 2>/dev/null || true)"
|
||||
[[ "$have" =~ ^[0-9]+$ ]] || have=0
|
||||
|
||||
if (( have < 3 )); then
|
||||
write_unblock failed unblock_helper_old
|
||||
return 0
|
||||
fi
|
||||
|
||||
if sudo -n "$step" release-update-lock >/dev/null 2>&1; then
|
||||
write_unblock ok
|
||||
else
|
||||
write_unblock failed unblock_failed
|
||||
fi
|
||||
}
|
||||
|
||||
# One agent at a time. Two overlapping runs of update.sh fight over the
|
||||
# checkout, and the loser leaves it half-updated.
|
||||
exec 9>"$LOCK"
|
||||
|
|
@ -125,6 +184,18 @@ if ! flock -n 9; then
|
|||
# laeuft — sonst stuende dort immer "seit einer Minute", und ein Zustand,
|
||||
# der seit einer Stunde klemmt, laese sich von einem gesunden Ueberholen
|
||||
# zweier Laeufe nicht unterscheiden.
|
||||
#
|
||||
# Und mit `skips` wird die Serie GEZAEHLT, nicht nur datiert. Bis hierher
|
||||
# meldete die Konsole schon beim ERSTEN uebersprungenen Lauf eine Blockade;
|
||||
# gemessen wurde das bei einem Ueberholen von neun Sekunden — Zeitgeber und
|
||||
# Waechter laufen beide minuetlich, einer nimmt die Sperre, der andere geht
|
||||
# weg. Das ist Betrieb. Der Besitzer hat daraufhin eine Stunde lang eine
|
||||
# gesunde Anlage auseinandergenommen.
|
||||
#
|
||||
# Gezaehlt wird HIER und nicht in der Konsole: aus `since` eine Zahl von
|
||||
# Laeufen zu machen hiesse, das Taktintervall zu raten, und das steht in
|
||||
# der systemd-Unit auf dem Wirt. Ab wann die Zahl eine Meldung wert ist,
|
||||
# entscheidet die Konsole (UpdateChannel::BLOCKED_AFTER_SKIPS).
|
||||
HOLDER="$( { fuser "$LOCK" 2>/dev/null || true; } | tr -s ' ' | sed 's/^ *//;s/ *$//' )"
|
||||
HOLDER_CMD=''
|
||||
if [[ -n "$HOLDER" ]]; then
|
||||
|
|
@ -133,16 +204,50 @@ if ! flock -n 9; then
|
|||
|
||||
PREVIOUS_SINCE="$(sed -n 's/.*"since"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$ALIVE" 2>/dev/null | head -1 || true)"
|
||||
PREVIOUS_STATE="$(sed -n 's/.*"state"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$ALIVE" 2>/dev/null | head -1 || true)"
|
||||
PREVIOUS_SKIPS="$(sed -n 's/.*"skips"[[:space:]]*:[[:space:]]*\([0-9]*\).*/\1/p' "$ALIVE" 2>/dev/null | head -1 || true)"
|
||||
|
||||
if [[ "$PREVIOUS_STATE" != "blocked" || -z "$PREVIOUS_SINCE" ]]; then
|
||||
# Kein Anschluss an eine laufende Serie: dieser Lauf ist der erste.
|
||||
PREVIOUS_SINCE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
SKIPS=1
|
||||
else
|
||||
# Ein Lebenszeichen von vor dieser Zaehlung hat kein `skips`. Es bleibt
|
||||
# nach einem Update eine Minute lang liegen; solange zaehlt die Serie
|
||||
# ab eins weiter, statt eine Zahl zu erfinden.
|
||||
[[ "$PREVIOUS_SKIPS" =~ ^[0-9]+$ ]] || PREVIOUS_SKIPS=0
|
||||
SKIPS=$(( PREVIOUS_SKIPS + 1 ))
|
||||
fi
|
||||
|
||||
write_alive blocked "$PREVIOUS_SINCE" "${HOLDER_CMD:-$HOLDER}" "$SKIPS"
|
||||
|
||||
# Und hier — und NUR hier — wird eine Bitte gelesen, die haengende Sperre
|
||||
# zu loesen. Dies ist der einzige Zweig, den ein blockierter Lauf erreicht,
|
||||
# und die Blockade ist der einzige Zustand, in dem die Bitte einen Sinn
|
||||
# hat.
|
||||
if [[ -f "$UNBLOCK" ]]; then
|
||||
UNBLOCK_AGE_MINUTES="$(( ( $(date +%s) - $(stat -c %Y "$UNBLOCK" 2>/dev/null || date +%s) ) / 60 ))"
|
||||
# Verbraucht, bevor irgendetwas passiert. Bliebe sie liegen, zuendete
|
||||
# sie bei der naechsten Blockade ungefragt — und das Beenden fremder
|
||||
# Prozesse ist nichts, was zweimal aus einem Klick folgen darf.
|
||||
rm -f "$UNBLOCK"
|
||||
|
||||
# Dieselbe Frist wie im Postkasten: was abgelegt wurde, als niemand es
|
||||
# abholen konnte, ist keine Anweisung fuer irgendwann.
|
||||
if (( UNBLOCK_AGE_MINUTES <= REQUEST_EXPIRES_MINUTES )); then
|
||||
release_stuck_lock
|
||||
fi
|
||||
fi
|
||||
|
||||
write_alive blocked "$PREVIOUS_SINCE" "${HOLDER_CMD:-$HOLDER}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
write_alive running
|
||||
|
||||
# Die Sperre ist frei — eine noch liegende Entsperr-Bitte hat sich damit
|
||||
# erledigt. Sie liegen zu lassen hiesse, sie bei der naechsten Blockade
|
||||
# auszufuehren, die mit ihr nichts zu tun hat.
|
||||
rm -f "$UNBLOCK"
|
||||
|
||||
sync_console_allowlist() {
|
||||
[[ -w "$(dirname "$ALLOWFILE")" || -w "$ALLOWFILE" ]] || return 0
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,8 @@ STATE_FILE="storage/app/deployed-commit"
|
|||
# fixed by deploy/install-agent.sh; this script only asks. Raising HOST_STEP_NEEDS
|
||||
# here is what makes an update tell the operator to run the installer again.
|
||||
HOST_STEP=/usr/local/sbin/clupilot-host-step
|
||||
# 3 seit `release-update-lock`: ohne den Schritt bleibt der Knopf „Sperre lösen"
|
||||
# in der Konsole ein Knopf, der nichts tun kann.
|
||||
HOST_STEP_NEEDS=3
|
||||
# Which step is running, for the console to show. Written as a KEY, not as the
|
||||
# sentence below it: the console is translated and this script is not, so an
|
||||
|
|
|
|||
|
|
@ -139,11 +139,30 @@ return [
|
|||
'update_agent_blocked' => 'Der Update-Dienst läuft, kommt aber seit :since nicht an die Arbeit — ein anderer Vorgang hält die Sperre. Solange das so ist, stammen die Angaben oben von vorher. Löst es sich nicht von selbst, hilft ein Blick auf den Prozess unten.',
|
||||
'update_log' => 'Protokoll des letzten Laufs',
|
||||
|
||||
// ── Die Sperre lösen ─────────────────────────────────────────────────
|
||||
// Der Griff, der bisher per SSH auf dem Wirt lag. Er beendet einen
|
||||
// laufenden Prozess, deshalb nennt die Rückfrage ihn vorher beim Namen.
|
||||
'update_release_lock' => 'Sperre lösen',
|
||||
'release_lock_pending' => 'Wird gelöst …',
|
||||
'release_lock_title' => 'Den Vorgang beenden, der die Sperre hält?',
|
||||
'release_lock_body' => 'Der Update-Dienst kommt nicht an die Arbeit, weil dieser Vorgang auf dem Server die Sperre hält:',
|
||||
'release_lock_note' => 'Er bekommt zuerst die Aufforderung, sich zu beenden; erst wenn er darauf nicht reagiert, wird er abgebrochen. Läuft gerade eine echte Aktualisierung, wird sie damit mittendrin abgebrochen.',
|
||||
'release_lock_cancel' => 'Abbrechen',
|
||||
'release_lock_confirm' => 'Vorgang beenden',
|
||||
'release_lock_requested' => 'Wird gelöst — der Dienst meldet sich binnen einer Minute zurück.',
|
||||
'release_lock_already_requested' => 'Ist schon angefordert — bitte kurz warten.',
|
||||
|
||||
'update_error' => [
|
||||
'repo_unreachable' => 'Das Repository ist vom Server aus nicht erreichbar.',
|
||||
'detached_no_release' => 'Der Checkout hängt an keinem Branch und ist auf keine Version gepinnt.',
|
||||
'update_failed' => 'Die Aktualisierung ist fehlgeschlagen (Code :code). Siehe Protokoll.',
|
||||
'restart_failed' => 'Der Neustart der Dienste ist fehlgeschlagen. Bitte manuell ausführen: docker compose restart queue scheduler reverb.',
|
||||
// Der Wirt kennt den Schritt noch nicht. Ein Knopf, der nichts tut und
|
||||
// nichts sagt, schickt den Betreiber genau dorthin zurück, wo er ohne
|
||||
// die Konsole schon war.
|
||||
'unblock_helper_missing' => 'Der Server-Helfer fehlt. Einmalig auf dem Server einrichten: sudo bash /opt/clupilot/deploy/install-agent.sh',
|
||||
'unblock_helper_old' => 'Der Server-Helfer kennt diesen Schritt noch nicht. Einmalig auf dem Server nachziehen: sudo bash /opt/clupilot/deploy/install-agent.sh',
|
||||
'unblock_failed' => 'Der Vorgang ließ sich nicht beenden. Auf dem Server nachsehen: fuser -v /opt/clupilot/storage/app/deploy/.agent.lock',
|
||||
],
|
||||
|
||||
'update_last_run' => 'Letzter Lauf: :state, :when.',
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ return [
|
|||
'customers.impersonate' => 'Sich als Kunde anmelden',
|
||||
'customers.manage' => 'Kundendaten sehen und ändern',
|
||||
'datacenters.manage' => 'Rechenzentren verwalten',
|
||||
'deployment.unblock' => 'Einen hängenden Vorgang auf dem Server beenden',
|
||||
'dpa.manage' => 'Auftragsverarbeitungsverträge',
|
||||
'hosts.manage' => 'Hosts anlegen, übernehmen, entfernen',
|
||||
'server.terminal' => 'Root-Sitzung auf dem CluPilot-Server selbst — Vollzugriff auf die gesamte Anlage',
|
||||
|
|
|
|||
|
|
@ -136,11 +136,30 @@ return [
|
|||
'update_agent_blocked' => 'The update service is running but has been unable to work since :since — another process holds the lock. While that lasts, the figures above are from before. If it does not clear on its own, look at the process named below.',
|
||||
'update_log' => 'Log of the last run',
|
||||
|
||||
// ── Releasing the lock ───────────────────────────────────────────────
|
||||
// The handle that used to live behind an SSH session. It ends a running
|
||||
// process, so the confirmation names it before it does.
|
||||
'update_release_lock' => 'Release the lock',
|
||||
'release_lock_pending' => 'Releasing …',
|
||||
'release_lock_title' => 'End the process holding the lock?',
|
||||
'release_lock_body' => 'The update service cannot work because this process on the server holds the lock:',
|
||||
'release_lock_note' => 'It is asked to stop first; only if it does not respond is it terminated. If a real update is running, this aborts it halfway.',
|
||||
'release_lock_cancel' => 'Cancel',
|
||||
'release_lock_confirm' => 'End the process',
|
||||
'release_lock_requested' => 'Releasing — the service reports back within a minute.',
|
||||
'release_lock_already_requested' => 'Already requested — please wait a moment.',
|
||||
|
||||
'update_error' => [
|
||||
'repo_unreachable' => 'The repository cannot be reached from the server.',
|
||||
'detached_no_release' => 'The checkout is on no branch and pinned to no release.',
|
||||
'update_failed' => 'The update failed (code :code). See the log.',
|
||||
'restart_failed' => 'Restarting the services failed. Please run manually: docker compose restart queue scheduler reverb.',
|
||||
// The host does not know the step yet. A button that does nothing and
|
||||
// says nothing sends the operator right back where they were without
|
||||
// the console.
|
||||
'unblock_helper_missing' => 'The server helper is missing. Set it up once on the server: sudo bash /opt/clupilot/deploy/install-agent.sh',
|
||||
'unblock_helper_old' => 'The server helper does not know this step yet. Bring it up to date once on the server: sudo bash /opt/clupilot/deploy/install-agent.sh',
|
||||
'unblock_failed' => 'The process could not be ended. Look on the server: fuser -v /opt/clupilot/storage/app/deploy/.agent.lock',
|
||||
],
|
||||
|
||||
'update_last_run' => 'Last run: :state, :when.',
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ return [
|
|||
'customers.impersonate' => 'Sign in as a customer',
|
||||
'customers.manage' => 'See and change customer data',
|
||||
'datacenters.manage' => 'Manage datacenters',
|
||||
'deployment.unblock' => 'End a stuck process on the server',
|
||||
'dpa.manage' => 'Data processing agreements',
|
||||
'hosts.manage' => 'Create, onboard and remove hosts',
|
||||
'server.terminal' => 'Root session on the CluPilot server itself — full access to the whole installation',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
<div class="rounded-lg bg-surface p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-danger-bg text-danger">
|
||||
<x-ui.icon name="alert-triangle" class="size-5" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-base font-semibold text-ink">{{ __('admin_settings.release_lock_title') }}</h3>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.release_lock_body') }}</p>
|
||||
|
||||
{{-- Wer gleich stirbt, im Wortlaut. Umbrechend gesetzt: eine
|
||||
Kommandozeile ist lang, und ein Kasten, der sie seitlich
|
||||
abschneidet, verschweigt genau den Teil, an dem man sie
|
||||
erkennt. --}}
|
||||
@if ($heldBy !== '')
|
||||
<p class="mt-3 break-all rounded-md border border-line bg-surface-2 px-3 py-2 font-mono text-xs text-ink">{{ $heldBy }}</p>
|
||||
@endif
|
||||
|
||||
<p class="mt-3 text-sm text-muted">{{ __('admin_settings.release_lock_note') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('admin_settings.release_lock_cancel') }}</x-ui.button>
|
||||
<x-ui.button variant="danger" wire:click="confirm" wire:loading.attr="disabled">
|
||||
<x-ui.icon name="alert-triangle" class="size-4" />{{ __('admin_settings.release_lock_confirm') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -186,6 +186,29 @@
|
|||
@if ($update['blocked_by'])
|
||||
<span class="mt-1 block font-mono text-xs opacity-80">{{ $update['blocked_by'] }}</span>
|
||||
@endif
|
||||
|
||||
{{-- Der Griff, der bisher per SSH auf dem Wirt lag.
|
||||
Die Konsole kann ihn nicht selbst ausführen —
|
||||
sie ist www-data in einem Behälter, der Halter
|
||||
ein Prozess auf dem Wirt —, also bittet sie den
|
||||
Agenten darum, und der ruft den root-eigenen
|
||||
Helfer. Bestätigt wird im Modal (R23), das
|
||||
vorher nennt, was es beendet. --}}
|
||||
@if ($canReleaseLock)
|
||||
<div class="mt-3">
|
||||
<x-ui.button variant="secondary" :disabled="$update['unblock_requested']"
|
||||
x-on:click="$dispatch('openModal', { component: 'admin.confirm-release-update-lock' })">
|
||||
<x-ui.icon name="alert-triangle" class="size-4" />
|
||||
{{ $update['unblock_requested']
|
||||
? __('admin_settings.release_lock_pending')
|
||||
: __('admin_settings.update_release_lock') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($update['unblock_error'])
|
||||
<span class="mt-3 block text-xs">{{ $update['unblock_error'] }}</span>
|
||||
@endif
|
||||
</x-ui.alert>
|
||||
@elseif ($update['last_error'])
|
||||
<x-ui.alert variant="danger" class="mt-4">{{ $update['last_error'] }}</x-ui.alert>
|
||||
|
|
|
|||
|
|
@ -1026,7 +1026,7 @@ function writeAlive(array $alive): void
|
|||
*/
|
||||
it('calls the agent alive on its heartbeat, even when its last check is old', function () {
|
||||
writeStatus(['state' => 'idle', 'checked_at' => now()->subHour()->toIso8601String(), 'behind' => 3]);
|
||||
writeAlive(['at' => now()->toIso8601String(), 'state' => 'blocked', 'since' => now()->subMinutes(40)->toIso8601String(), 'held_by' => '4242 40:12 git fetch']);
|
||||
writeAlive(['at' => now()->toIso8601String(), 'state' => 'blocked', 'since' => now()->subMinutes(40)->toIso8601String(), 'held_by' => '4242 40:12 git fetch', 'skips' => 40]);
|
||||
|
||||
$state = app(UpdateChannel::class)->state();
|
||||
|
||||
|
|
@ -1039,7 +1039,7 @@ it('does not pass off an hour-old figure as current while the agent is blocked',
|
|||
// Die Zahlen stammen von VOR der Blockade und altern still weiter. „Drei
|
||||
// Updates zurueck" von vor einer Stunde liest sich genau wie von jetzt.
|
||||
writeStatus(['state' => 'idle', 'checked_at' => now()->subHour()->toIso8601String(), 'behind' => 3, 'target_release' => 'v9.9.9']);
|
||||
writeAlive(['at' => now()->toIso8601String(), 'state' => 'blocked', 'since' => now()->subMinutes(40)->toIso8601String(), 'held_by' => '']);
|
||||
writeAlive(['at' => now()->toIso8601String(), 'state' => 'blocked', 'since' => now()->subMinutes(40)->toIso8601String(), 'held_by' => '', 'skips' => 40]);
|
||||
|
||||
$state = app(UpdateChannel::class)->state();
|
||||
|
||||
|
|
@ -1050,7 +1050,7 @@ it('does not pass off an hour-old figure as current while the agent is blocked',
|
|||
|
||||
it('says the service is blocked, not that it is not running', function () {
|
||||
writeStatus(['state' => 'idle', 'checked_at' => now()->subHour()->toIso8601String(), 'behind' => 1]);
|
||||
writeAlive(['at' => now()->toIso8601String(), 'state' => 'blocked', 'since' => now()->subMinutes(40)->toIso8601String(), 'held_by' => '4242 40:12 git fetch']);
|
||||
writeAlive(['at' => now()->toIso8601String(), 'state' => 'blocked', 'since' => now()->subMinutes(40)->toIso8601String(), 'held_by' => '4242 40:12 git fetch', 'skips' => 40]);
|
||||
|
||||
Livewire::actingAs(operator('Owner'), 'operator')
|
||||
->test(AdminSettings::class)
|
||||
|
|
@ -1058,6 +1058,65 @@ it('says the service is blocked, not that it is not running', function () {
|
|||
->assertSee('4242 40:12 git fetch');
|
||||
});
|
||||
|
||||
/**
|
||||
* ── Ein einzelner uebersprungener Lauf ist Betrieb, kein Fehler ───────────
|
||||
*
|
||||
* Der Zeitgeber laeuft minuetlich, der Waechter kommt dazu, einer nimmt die
|
||||
* Sperre und der andere ueberspringt. Gemessen am 4. August 2026: neun
|
||||
* Sekunden Ueberholen — und die Konsole meldete bereits eine Blockade. Der
|
||||
* Besitzer hat daraufhin eine Stunde lang eine gesunde Anlage auseinander-
|
||||
* genommen.
|
||||
*
|
||||
* Gezaehlt wird in LAEUFEN, nicht in Minuten: CHECK_INTERVAL_MINUTES ist eine
|
||||
* Behauptung des Skripts, das tatsaechliche Intervall steht in der
|
||||
* systemd-Unit auf dem Wirt.
|
||||
*/
|
||||
it('does not cry blockade over a single skipped run', function () {
|
||||
writeStatus(['state' => 'idle', 'checked_at' => now()->subMinute()->toIso8601String(), 'behind' => 3, 'target_release' => 'v9.9.9']);
|
||||
writeAlive(['at' => now()->toIso8601String(), 'state' => 'blocked', 'since' => now()->subSeconds(9)->toIso8601String(), 'held_by' => '4242 00:09 watchdog', 'skips' => 1]);
|
||||
|
||||
$state = app(UpdateChannel::class)->state();
|
||||
|
||||
// Und die Zahlen bleiben stehen. Nur die Meldung zu unterdruecken haette
|
||||
// den Fehlalarm gegen einen stilleren getauscht: „drei Aktualisierungen
|
||||
// zurueck" waere beim ersten uebersprungenen Lauf verschwunden, ohne dass
|
||||
// irgendwo stuende warum.
|
||||
expect($state['blocked_since'])->toBeNull()
|
||||
->and($state['blocked_by'])->toBeNull()
|
||||
->and($state['behind'])->toBe(3)
|
||||
->and($state['target_release'])->toBe('v9.9.9');
|
||||
|
||||
Livewire::actingAs(operator('Owner'), 'operator')
|
||||
->test(AdminSettings::class)
|
||||
->assertDontSee(__('admin_settings.update_agent_blocked', ['since' => '00:00']));
|
||||
});
|
||||
|
||||
it('reports the blockade from the second skipped run on', function () {
|
||||
// Bewusst zwei und nicht zehn: lieber frueh gewarnt werden, nur nicht
|
||||
// beim allerersten Lauf.
|
||||
writeStatus(['state' => 'idle', 'checked_at' => now()->subMinutes(2)->toIso8601String(), 'behind' => 3]);
|
||||
writeAlive(['at' => now()->toIso8601String(), 'state' => 'blocked', 'since' => now()->subMinutes(2)->toIso8601String(), 'held_by' => '4242 02:03 docker compose exec', 'skips' => 2]);
|
||||
|
||||
$state = app(UpdateChannel::class)->state();
|
||||
|
||||
expect($state['blocked_since'])->not->toBeNull()
|
||||
->and($state['blocked_by'])->toBe('4242 02:03 docker compose exec')
|
||||
->and($state['behind'])->toBeNull();
|
||||
});
|
||||
|
||||
it('says nothing about a blockade on a heartbeat from before the counter existed', function () {
|
||||
// Ein Lebenszeichen, das der vorige Stand geschrieben hat, bleibt nach
|
||||
// einem Update eine Minute lang liegen. Es hat kein `skips`, und daraus
|
||||
// eine Blockade zu machen waere geraten.
|
||||
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 2]);
|
||||
writeAlive(['at' => now()->toIso8601String(), 'state' => 'blocked', 'since' => now()->subMinutes(40)->toIso8601String(), 'held_by' => '4242 40:12 git fetch']);
|
||||
|
||||
$state = app(UpdateChannel::class)->state();
|
||||
|
||||
expect($state['blocked_since'])->toBeNull()
|
||||
->and($state['behind'])->toBe(2);
|
||||
});
|
||||
|
||||
it('keeps the old rule for an agent that writes no heartbeat at all', function () {
|
||||
// Ein Agent von vor dieser Aenderung kennt die Datei nicht. Ihn dafuer fuer
|
||||
// tot zu erklaeren waere derselbe Fehler mit umgekehrtem Vorzeichen.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,175 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Admin\ConfirmReleaseUpdateLock;
|
||||
use App\Livewire\Admin\Settings as AdminSettings;
|
||||
use App\Services\Deployment\UpdateChannel;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* Eine hängende Sperre aus der Konsole lösen.
|
||||
*
|
||||
* Am 4. August 2026 hielt ein einziger Aufruf — `timeout 45 docker compose
|
||||
* exec` ohne `--kill-after`, der nach vier Minuten noch stand — die Sperre
|
||||
* stundenlang, und jeder folgende Lauf wurde übersprungen. Der Griff dagegen
|
||||
* lag per SSH auf dem Wirt. Er liegt jetzt in der Konsole.
|
||||
*
|
||||
* Die Konsole kann ihn nicht selbst ausführen: sie ist www-data in einem
|
||||
* Behälter, der Sperrenhalter ist ein Prozess auf dem WIRT. Sie legt deshalb
|
||||
* eine Bitte ab, wie für alles andere hier auch — nur in einer EIGENEN Datei.
|
||||
* Der Postkasten `update-request.json` taugt dafür nicht: er nimmt eine Bitte
|
||||
* zur Zeit an, und genau die dreißig Minuten, in denen dort noch eine wartende
|
||||
* Update-Anfrage liegt, sind die, in denen jemand entsperren will.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
File::deleteDirectory(storage_path('app/deploy'));
|
||||
});
|
||||
|
||||
/** Ein Lebenszeichen, das eine echte Blockade meldet (zwei übersprungene Läufe). */
|
||||
function blockedHeartbeat(string $heldBy = '4242 02:03 docker compose exec -T app'): void
|
||||
{
|
||||
File::ensureDirectoryExists(storage_path('app/deploy'));
|
||||
File::put(storage_path('app/deploy/update-status.json'), json_encode([
|
||||
'state' => 'idle',
|
||||
'checked_at' => now()->subMinutes(5)->toIso8601String(),
|
||||
'behind' => 0,
|
||||
]));
|
||||
File::put(storage_path('app/deploy/agent-alive.json'), json_encode([
|
||||
'at' => now()->toIso8601String(),
|
||||
'state' => 'blocked',
|
||||
'since' => now()->subMinutes(5)->toIso8601String(),
|
||||
'held_by' => $heldBy,
|
||||
'skips' => 5,
|
||||
]));
|
||||
}
|
||||
|
||||
it('offers to release the lock to an operator who may', function () {
|
||||
blockedHeartbeat();
|
||||
|
||||
Livewire::actingAs(operator('Owner'), 'operator')
|
||||
->test(AdminSettings::class)
|
||||
->assertSee(__('admin_settings.update_release_lock'))
|
||||
->assertSee("component: 'admin.confirm-release-update-lock'", escape: false);
|
||||
});
|
||||
|
||||
it('offers nothing of the sort to an operator who may not', function () {
|
||||
// Die Warnung sieht jeder, der die Seite sieht. Den Griff nicht.
|
||||
blockedHeartbeat();
|
||||
|
||||
Livewire::actingAs(operator('Support'), 'operator')
|
||||
->test(AdminSettings::class)
|
||||
->assertDontSee(__('admin_settings.update_release_lock'));
|
||||
});
|
||||
|
||||
it('does not offer to release a lock nobody is holding', function () {
|
||||
File::ensureDirectoryExists(storage_path('app/deploy'));
|
||||
File::put(storage_path('app/deploy/update-status.json'), json_encode([
|
||||
'state' => 'idle',
|
||||
'checked_at' => now()->toIso8601String(),
|
||||
'behind' => 0,
|
||||
]));
|
||||
|
||||
Livewire::actingAs(operator('Owner'), 'operator')
|
||||
->test(AdminSettings::class)
|
||||
->assertDontSee(__('admin_settings.update_release_lock'));
|
||||
});
|
||||
|
||||
it('names the process it is about to end, before it ends it', function () {
|
||||
// Der Sinn der ganzen Rückfrage. PID und Kommandozeile stehen im
|
||||
// Lebenszeichen; sie erst NACH dem Beenden zu zeigen wäre eine Meldung,
|
||||
// keine Rückfrage.
|
||||
blockedHeartbeat('4242 02:03 docker compose exec -T app');
|
||||
|
||||
Livewire::actingAs(operator('Owner'), 'operator')
|
||||
->test(ConfirmReleaseUpdateLock::class)
|
||||
->assertSee('4242 02:03 docker compose exec -T app');
|
||||
});
|
||||
|
||||
it('leaves the deed to the page component, and only dispatches', function () {
|
||||
// R23: das Modal mutiert nichts. Die Berechtigungsprüfung bleibt an der
|
||||
// einen Stelle, an der sie schon steht.
|
||||
blockedHeartbeat();
|
||||
|
||||
Livewire::actingAs(operator('Owner'), 'operator')
|
||||
->test(ConfirmReleaseUpdateLock::class)
|
||||
->call('confirm')
|
||||
->assertDispatched('update-lock-release-confirmed');
|
||||
|
||||
expect(File::exists(storage_path('app/deploy/unblock-request.json')))->toBeFalse();
|
||||
});
|
||||
|
||||
it('leaves the request for the agent once the confirmation comes back', function () {
|
||||
blockedHeartbeat();
|
||||
$owner = operator('Owner');
|
||||
|
||||
Livewire::actingAs($owner, 'operator')
|
||||
->test(AdminSettings::class)
|
||||
->dispatch('update-lock-release-confirmed');
|
||||
|
||||
$request = json_decode(File::get(storage_path('app/deploy/unblock-request.json')), true);
|
||||
|
||||
expect($request['requested_by'])->toBe($owner->email)
|
||||
->and($request['requested_at'])->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('refuses to release the lock for an operator without the capability', function () {
|
||||
blockedHeartbeat();
|
||||
|
||||
Livewire::actingAs(operator('Support'), 'operator')
|
||||
->test(AdminSettings::class)
|
||||
->dispatch('update-lock-release-confirmed')
|
||||
->assertForbidden();
|
||||
|
||||
expect(File::exists(storage_path('app/deploy/unblock-request.json')))->toBeFalse();
|
||||
});
|
||||
|
||||
it('is not held up by an update request already waiting in the mailbox', function () {
|
||||
// Der Grund für die eigene Datei. Der Postkasten nimmt eine Bitte zur
|
||||
// Zeit an — und ein blockierter Agent holt sie nie ab, also läge dort
|
||||
// dreißig Minuten lang eine, die das Entsperren aussperrt.
|
||||
blockedHeartbeat();
|
||||
File::put(storage_path('app/deploy/update-request.json'), json_encode([
|
||||
'requested_at' => now()->toIso8601String(),
|
||||
'requested_by' => 'someone@example.com',
|
||||
'kind' => 'run',
|
||||
]));
|
||||
|
||||
expect(app(UpdateChannel::class)->requestUnblock('owner@example.com'))->toBeTrue()
|
||||
->and(File::exists(storage_path('app/deploy/unblock-request.json')))->toBeTrue();
|
||||
});
|
||||
|
||||
it('does not queue a second release while the first is still waiting', function () {
|
||||
blockedHeartbeat();
|
||||
$channel = app(UpdateChannel::class);
|
||||
|
||||
expect($channel->requestUnblock('owner@example.com'))->toBeTrue()
|
||||
->and($channel->requestUnblock('owner@example.com'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('says the release is on its way while the agent has not collected it', function () {
|
||||
blockedHeartbeat();
|
||||
app(UpdateChannel::class)->requestUnblock('owner@example.com');
|
||||
|
||||
expect(app(UpdateChannel::class)->state()['unblock_requested'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('reports back when the host helper was too old to do it', function () {
|
||||
// Der stille Fehlschlag, den dieses Repo nicht duldet: ein Wirt, auf dem
|
||||
// install-agent.sh seit dem neuen Schritt nicht mehr lief, kann nicht
|
||||
// entsperren — und ein Knopf, der nichts tut und nichts sagt, schickt den
|
||||
// Betreiber genau dorthin zurück, wo er ohne die Konsole schon war.
|
||||
blockedHeartbeat();
|
||||
File::put(storage_path('app/deploy/unblock-last-run.json'), json_encode([
|
||||
'state' => 'failed',
|
||||
'finished_at' => now()->toIso8601String(),
|
||||
'error' => 'unblock_helper_old',
|
||||
]));
|
||||
|
||||
$state = app(UpdateChannel::class)->state();
|
||||
|
||||
expect($state['unblock_error'])->toBe(__('admin_settings.update_error.unblock_helper_old'));
|
||||
|
||||
Livewire::actingAs(operator('Owner'), 'operator')
|
||||
->test(AdminSettings::class)
|
||||
->assertSee(__('admin_settings.update_error.unblock_helper_old'));
|
||||
});
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
|
||||
/**
|
||||
* Der Agent zählt, wie oft er hintereinander nicht an die Arbeit kam.
|
||||
*
|
||||
* Die Konsole macht daraus erst ab zwei Läufen eine Meldung (siehe
|
||||
* UpdateChannel::BLOCKED_AFTER_SKIPS). Damit sie das kann, muss der Agent die
|
||||
* Zahl liefern — ausrechnen kann die Konsole sie nicht: dafür müsste sie das
|
||||
* Taktintervall kennen, und das steht in der systemd-Unit auf dem Wirt.
|
||||
*
|
||||
* Hier läuft das ECHTE Skript, nicht eine Nachbildung seiner Logik. Das geht,
|
||||
* weil der Zweig, um den es hier geht, vor allem Teuren liegt: der Agent nimmt
|
||||
* die Sperre in der ersten Handvoll Zeilen, und wer sie nicht bekommt, steigt
|
||||
* aus, bevor irgendein `git fetch` oder `docker compose` in die Nähe kommt.
|
||||
* Ein Test, der die Zählung nachrechnet statt sie auszuführen, prüft nichts —
|
||||
* das steht schon in R19 im Repo.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Lässt den Agenten $times mal gegen eine gehaltene Sperre laufen.
|
||||
*
|
||||
* @param array<string, mixed>|null $seedAlive Lebenszeichen, das vorher liegt
|
||||
* @return array<int, array<string, mixed>> je ein Lebenszeichen pro Lauf
|
||||
*/
|
||||
function runBlockedAgent(int $times, ?array $seedAlive = null): array
|
||||
{
|
||||
$dir = storage_path('app/deploy');
|
||||
File::ensureDirectoryExists($dir);
|
||||
File::delete(File::glob($dir.'/.alive-*'));
|
||||
File::delete($dir.'/agent-alive.json');
|
||||
File::delete($dir.'/.held');
|
||||
|
||||
if ($seedAlive !== null) {
|
||||
File::put($dir.'/agent-alive.json', json_encode($seedAlive));
|
||||
}
|
||||
|
||||
$runs = '';
|
||||
for ($i = 1; $i <= $times; $i++) {
|
||||
$runs .= "bash deploy/update-agent.sh >/dev/null 2>&1\n";
|
||||
$runs .= "cp storage/app/deploy/agent-alive.json storage/app/deploy/.alive-{$i}\n";
|
||||
}
|
||||
|
||||
// Die Sperre wird nachweislich gehalten, bevor der Agent startet: der
|
||||
// Halter legt erst die Marke an, dann wird auf sie gewartet. Ohne diesen
|
||||
// Nachweis liefe der Agent bei einem Fehlschlag von flock in seinen
|
||||
// NORMALEN Weg — mit Abruf der Gegenstelle und allem, was daran hängt —
|
||||
// und der Test hätte still etwas ganz anderes gemessen.
|
||||
$result = Process::path(base_path())
|
||||
->timeout(60)
|
||||
->run(<<<BASH
|
||||
set -e
|
||||
flock storage/app/deploy/.agent.lock -c 'touch storage/app/deploy/.held; sleep 30' &
|
||||
halter=\$!
|
||||
until [ -f storage/app/deploy/.held ]; do sleep 0.05; done
|
||||
{$runs}
|
||||
kill \$halter 2>/dev/null || true
|
||||
BASH);
|
||||
|
||||
expect($result->successful())->toBeTrue($result->errorOutput());
|
||||
|
||||
return array_map(
|
||||
fn (int $i) => json_decode(File::get($dir."/.alive-{$i}"), true),
|
||||
range(1, $times)
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(function () {
|
||||
File::deleteDirectory(storage_path('app/deploy'));
|
||||
});
|
||||
|
||||
it('counts a single skipped run as one, not as a blockade', function () {
|
||||
// Der gemessene Fall: neun Sekunden Überholen zwischen Zeitgeber und
|
||||
// Wächter. Betrieb, kein Fehler.
|
||||
[$first] = runBlockedAgent(1);
|
||||
|
||||
expect($first['state'])->toBe('blocked')
|
||||
->and($first['skips'])->toBe(1);
|
||||
});
|
||||
|
||||
it('keeps counting while the same blockade holds', function () {
|
||||
[$first, $second] = runBlockedAgent(2);
|
||||
|
||||
expect($first['skips'])->toBe(1)
|
||||
->and($second['skips'])->toBe(2)
|
||||
// Und `since` bleibt stehen — sonst stünde dort immer „seit einer
|
||||
// Minute" und eine Stunde Stillstand sähe aus wie ein Überholen.
|
||||
->and($second['since'])->toBe($first['since']);
|
||||
});
|
||||
|
||||
it('starts the count over after a run that got the lock', function () {
|
||||
// Ein Lebenszeichen aus einem Lauf, der gearbeitet hat, beendet die Serie.
|
||||
// Ohne diesen Schnitt liefe der Zähler über eine gesunde Zwischenzeit
|
||||
// hinweg weiter und meldete eine Blockade, die längst vorbei war.
|
||||
[$first] = runBlockedAgent(1, [
|
||||
'at' => '2026-08-04T05:00:00Z',
|
||||
'state' => 'running',
|
||||
'since' => '',
|
||||
'held_by' => '',
|
||||
'skips' => 0,
|
||||
]);
|
||||
|
||||
expect($first['state'])->toBe('blocked')
|
||||
->and($first['skips'])->toBe(1);
|
||||
});
|
||||
|
||||
it('says zero skipped runs while it is working', function () {
|
||||
// Der Zustand, in dem der Agent die Sperre HAT. Ohne die ausdrückliche
|
||||
// Null bliebe die Zahl des letzten blockierten Laufs im Lebenszeichen
|
||||
// stehen, und die Konsole läse sie als fortdauernde Blockade.
|
||||
//
|
||||
// Hier läuft der Agent auf der freien Sperre — also durch seinen normalen
|
||||
// Weg. `git` und `docker` liegen dafür als Attrappen im PATH, die sofort
|
||||
// scheitern: dieser Test prüft, was der Agent ins Lebenszeichen schreibt,
|
||||
// und hat keinen Grund, dafür die Gegenstelle abzurufen oder den
|
||||
// Docker-Daemon anzufassen. Beide Fehlschläge sind Wege, die der Agent
|
||||
// ohnehin abfängt.
|
||||
$dir = storage_path('app/deploy');
|
||||
File::ensureDirectoryExists($dir);
|
||||
File::delete($dir.'/agent-alive.json');
|
||||
|
||||
// Der Ausgang des Laufs wird NICHT geprüft: mit einem `git`, das immer
|
||||
// scheitert, kann der Agent nicht bis zum Ende kommen, und ans Ende zu
|
||||
// kommen ist hier auch nicht die Frage. Das Lebenszeichen steht als
|
||||
// ERSTES, noch vor allem, was scheitern kann — genau deshalb gibt es die
|
||||
// Datei überhaupt.
|
||||
Process::path(base_path())->timeout(60)->run(<<<'BASH'
|
||||
stub="$(mktemp -d)"
|
||||
printf '#!/bin/sh\nexit 1\n' > "$stub/git"
|
||||
printf '#!/bin/sh\nexit 1\n' > "$stub/docker"
|
||||
chmod +x "$stub/git" "$stub/docker"
|
||||
PATH="$stub:$PATH" bash deploy/update-agent.sh >/dev/null 2>&1
|
||||
rm -rf "$stub"
|
||||
BASH);
|
||||
|
||||
$alive = json_decode(File::get($dir.'/agent-alive.json'), true);
|
||||
|
||||
expect($alive['state'])->toBe('running')
|
||||
->and($alive['skips'])->toBe(0);
|
||||
});
|
||||
|
|
@ -0,0 +1,227 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
|
||||
/**
|
||||
* Die Wirtsseite des Entsperrens.
|
||||
*
|
||||
* Zwei Stücke, beide auf dem Wirt:
|
||||
*
|
||||
* 1. Der Agent liest die Bitte im BLOCKIERTEN Zweig — das ist der einzige
|
||||
* Zweig, den ein blockierter Lauf erreicht.
|
||||
* 2. Der root-eigene Helfer beendet, was die Sperre offen hält.
|
||||
*
|
||||
* Das zweite Stück ist die Grenze, an der ein Fehler teuer wird: hier läuft
|
||||
* etwas als root und beendet Prozesse. Es wird deshalb ausgeführt, nicht
|
||||
* begutachtet — mit genau EINER Änderung am echten Skript, dem Pfad der
|
||||
* Sperrdatei. Der Test prüft vorher, dass die Zeile, die er ersetzt, auch
|
||||
* wirklich so dasteht; wird der Helfer umgebaut, fällt das hier auf und nicht
|
||||
* erst auf einem Server.
|
||||
*
|
||||
* Im Betrieb ist derselbe Pfad nicht umzulenken: die Datei gehört root, der
|
||||
* Dienstbenutzer darf sie nicht schreiben, und sudo räumt die Umgebung ab.
|
||||
*/
|
||||
afterEach(function () {
|
||||
File::deleteDirectory(storage_path('app/deploy'));
|
||||
});
|
||||
|
||||
/**
|
||||
* Der Helfer als eigenständiges Skript, mit umgelenkter Sperrdatei.
|
||||
*/
|
||||
function hostStepWithLockAt(string $lock): string
|
||||
{
|
||||
$installer = File::get(base_path('deploy/install-agent.sh'));
|
||||
|
||||
$start = strpos($installer, "cat > \"\$HOST_STEP\" <<'EOF'\n");
|
||||
expect($start)->not->toBeFalse('install-agent.sh schreibt den Helfer nicht mehr aus einem Here-Dokument.');
|
||||
|
||||
$start += strlen("cat > \"\$HOST_STEP\" <<'EOF'\n");
|
||||
$script = substr($installer, $start, strpos($installer, "\nEOF\n", $start) - $start);
|
||||
|
||||
$hardcoded = 'LOCK=/opt/clupilot/storage/app/deploy/.agent.lock';
|
||||
expect($script)->toContain($hardcoded);
|
||||
|
||||
$path = tempnam(sys_get_temp_dir(), 'host-step-').'.sh';
|
||||
File::put($path, str_replace($hardcoded, "LOCK=$lock", $script));
|
||||
chmod($path, 0755);
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* ── Der Agent nimmt die Bitte im blockierten Zweig an ─────────────────────
|
||||
*/
|
||||
it('answers the release request even though it never got the lock', function () {
|
||||
// Der ganze Grund für die eigene Datei: ein blockierter Lauf steigt aus,
|
||||
// bevor er den Postkasten überhaupt ansieht. Läse er die Bitte dort,
|
||||
// erreichte sie ihn genau dann nie, wenn sie gebraucht wird.
|
||||
//
|
||||
// Auf dieser Maschine gibt es keinen root-eigenen Helfer — und genau das
|
||||
// muss die Konsole erfahren, statt dass der Knopf still nichts tut.
|
||||
$dir = storage_path('app/deploy');
|
||||
File::ensureDirectoryExists($dir);
|
||||
File::put($dir.'/unblock-request.json', json_encode([
|
||||
'requested_at' => now()->toIso8601String(),
|
||||
'requested_by' => 'owner@example.com',
|
||||
]));
|
||||
|
||||
$result = Process::path(base_path())->timeout(60)->run(<<<'BASH'
|
||||
set -e
|
||||
flock storage/app/deploy/.agent.lock -c 'touch storage/app/deploy/.held; sleep 30' &
|
||||
halter=$!
|
||||
until [ -f storage/app/deploy/.held ]; do sleep 0.05; done
|
||||
bash deploy/update-agent.sh >/dev/null 2>&1
|
||||
kill $halter 2>/dev/null || true
|
||||
BASH);
|
||||
|
||||
expect($result->successful())->toBeTrue($result->errorOutput());
|
||||
|
||||
$last = json_decode(File::get($dir.'/unblock-last-run.json'), true);
|
||||
|
||||
expect($last['state'])->toBe('failed')
|
||||
->and($last['error'])->toBe('unblock_helper_missing')
|
||||
// Verbraucht, nicht liegengelassen: eine Bitte, die bleibt, zündet bei
|
||||
// der nächsten Blockade ungefragt.
|
||||
->and(File::exists($dir.'/unblock-request.json'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('throws away a release request that has been lying around too long', function () {
|
||||
// Dieselbe Frist wie im Postkasten. Eine Bitte, die abgelegt wurde, als
|
||||
// niemand sie abholen konnte, ist keine Anweisung für irgendwann.
|
||||
$dir = storage_path('app/deploy');
|
||||
File::ensureDirectoryExists($dir);
|
||||
File::put($dir.'/unblock-request.json', json_encode([
|
||||
'requested_at' => now()->subHours(3)->toIso8601String(),
|
||||
'requested_by' => 'owner@example.com',
|
||||
]));
|
||||
touch($dir.'/unblock-request.json', now()->subHours(3)->timestamp);
|
||||
|
||||
Process::path(base_path())->timeout(60)->run(<<<'BASH'
|
||||
flock storage/app/deploy/.agent.lock -c 'touch storage/app/deploy/.held; sleep 30' &
|
||||
halter=$!
|
||||
until [ -f storage/app/deploy/.held ]; do sleep 0.05; done
|
||||
bash deploy/update-agent.sh >/dev/null 2>&1
|
||||
kill $halter 2>/dev/null || true
|
||||
BASH);
|
||||
|
||||
expect(File::exists($dir.'/unblock-request.json'))->toBeFalse()
|
||||
->and(File::exists($dir.'/unblock-last-run.json'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('quietly drops a release request once the lock has freed itself', function () {
|
||||
// Der Lauf, der die Sperre BEKOMMT, findet die Bitte noch liegen. Sie
|
||||
// einfach liegen zu lassen hiesse, sie bei der nächsten Blockade
|
||||
// ungefragt auszuführen.
|
||||
$dir = storage_path('app/deploy');
|
||||
File::ensureDirectoryExists($dir);
|
||||
File::put($dir.'/unblock-request.json', json_encode([
|
||||
'requested_at' => now()->toIso8601String(),
|
||||
'requested_by' => 'owner@example.com',
|
||||
]));
|
||||
|
||||
Process::path(base_path())->timeout(60)->run(<<<'BASH'
|
||||
stub="$(mktemp -d)"
|
||||
printf '#!/bin/sh\nexit 1\n' > "$stub/git"
|
||||
printf '#!/bin/sh\nexit 1\n' > "$stub/docker"
|
||||
chmod +x "$stub/git" "$stub/docker"
|
||||
PATH="$stub:$PATH" bash deploy/update-agent.sh >/dev/null 2>&1
|
||||
rm -rf "$stub"
|
||||
BASH);
|
||||
|
||||
expect(File::exists($dir.'/unblock-request.json'))->toBeFalse();
|
||||
});
|
||||
|
||||
/**
|
||||
* ── Der root-eigene Helfer ────────────────────────────────────────────────
|
||||
*/
|
||||
it('ends the process that is holding the lock open', function () {
|
||||
$tmp = sys_get_temp_dir().'/'.uniqid('lockrelease-');
|
||||
File::ensureDirectoryExists($tmp);
|
||||
$helper = hostStepWithLockAt($tmp.'/.agent.lock');
|
||||
|
||||
$result = Process::timeout(60)->run(<<<BASH
|
||||
bash -c 'exec 9>"$tmp/.agent.lock"; sleep 60' &
|
||||
pid=\$!
|
||||
sleep 0.3
|
||||
bash {$helper} release-update-lock
|
||||
sleep 0.3
|
||||
kill -0 "\$pid" 2>/dev/null && echo "LEBT" || echo "BEENDET"
|
||||
BASH);
|
||||
|
||||
expect($result->output())->toContain('BEENDET');
|
||||
});
|
||||
|
||||
it('does not end the process that asked it to', function () {
|
||||
// Im Betrieb hat der blockierte Agent die Sperrdatei SELBST offen — sein
|
||||
// `exec 9>` ist geglückt, nur das `flock` nicht — und er ist der Vorfahr
|
||||
// des Helfers. Wer alle Offenhalter beendet, beendet den Anrufer mitten
|
||||
// im Anruf, und die Konsole erführe nie, was passiert ist.
|
||||
$tmp = sys_get_temp_dir().'/'.uniqid('lockrelease-');
|
||||
File::ensureDirectoryExists($tmp);
|
||||
$helper = hostStepWithLockAt($tmp.'/.agent.lock');
|
||||
|
||||
$result = Process::timeout(60)->run(<<<BASH
|
||||
exec 9>"$tmp/.agent.lock"
|
||||
bash {$helper} release-update-lock
|
||||
echo "ANRUFER LEBT NOCH"
|
||||
BASH);
|
||||
|
||||
// Und es bleibt bei „da ist nichts". Der Helfer bekommt den Deskriptor
|
||||
// des Anrufers vererbt (unter sudo nicht, im Test schon) und darf seine
|
||||
// EIGENEN Kindprozesse — jede Kommandosubstitution ist eine — nicht für
|
||||
// Halter halten: die sind bei jedem Nachsehen andere, die Runde käme nie
|
||||
// zum Ende, und am Schluss stünde „konnte nicht beendet werden" über
|
||||
// einer Sperre, die niemand hält.
|
||||
expect($result->successful())->toBeTrue($result->errorOutput())
|
||||
->and($result->output())->toContain('ANRUFER LEBT NOCH')
|
||||
->and($result->errorOutput())->toContain('nothing holds');
|
||||
});
|
||||
|
||||
it('insists when the holder ignores the polite request', function () {
|
||||
// `timeout 45 docker compose exec` ohne `--kill-after` war genau das:
|
||||
// etwas, das ein SIGTERM aussitzt. Eine Frist ohne Nachdruck ist keine
|
||||
// Frist, sondern eine Bitte.
|
||||
$tmp = sys_get_temp_dir().'/'.uniqid('lockrelease-');
|
||||
File::ensureDirectoryExists($tmp);
|
||||
$helper = hostStepWithLockAt($tmp.'/.agent.lock');
|
||||
|
||||
$result = Process::timeout(120)->run(<<<BASH
|
||||
bash -c 'trap "" TERM; exec 9>"$tmp/.agent.lock"; sleep 120' &
|
||||
pid=\$!
|
||||
sleep 0.3
|
||||
bash {$helper} release-update-lock
|
||||
sleep 0.3
|
||||
kill -0 "\$pid" 2>/dev/null && echo "LEBT" || echo "BEENDET"
|
||||
BASH);
|
||||
|
||||
expect($result->output())->toContain('BEENDET');
|
||||
});
|
||||
|
||||
it('says so instead of pretending when nothing holds the lock', function () {
|
||||
$tmp = sys_get_temp_dir().'/'.uniqid('lockrelease-');
|
||||
File::ensureDirectoryExists($tmp);
|
||||
touch($tmp.'/.agent.lock');
|
||||
$helper = hostStepWithLockAt($tmp.'/.agent.lock');
|
||||
|
||||
$result = Process::timeout(60)->run("bash {$helper} release-update-lock");
|
||||
|
||||
expect($result->successful())->toBeTrue()
|
||||
->and($result->output().$result->errorOutput())->toContain('nothing');
|
||||
});
|
||||
|
||||
/**
|
||||
* ── Der Vertrag zwischen Skript, sudoers und Updater ──────────────────────
|
||||
*/
|
||||
it('grants the new step in sudoers and raises the contract for it', function () {
|
||||
// Ein Schritt, den der Helfer kann und sudoers nicht erlaubt, scheitert
|
||||
// im Betrieb — und ein Wirt mit altem Helfer täte still gar nichts. Beide
|
||||
// Enden gehören zusammengehalten, sonst merkt es niemand.
|
||||
$installer = File::get(base_path('deploy/install-agent.sh'));
|
||||
$updater = File::get(base_path('deploy/update.sh'));
|
||||
|
||||
expect($installer)->toContain('release-update-lock)')
|
||||
->and($installer)->toContain('$HOST_STEP release-update-lock')
|
||||
->and($installer)->toContain('CONTRACT=3')
|
||||
->and($updater)->toContain('HOST_STEP_NEEDS=3');
|
||||
});
|
||||
Loading…
Reference in New Issue