Der Update-Agent sagt, dass er lebt — auch wenn er nicht arbeiten kann

Gemeldet: „seit 1.5.1 muss ich nach jedem Update install-agent.sh fahren,
sonst kommt kein Update mehr". Die Konsole meldete „Der Update-Dienst auf dem
Server laeuft nicht" und schickte genau dorthin.

Er lief. Das Journal zeigt zwei Tage lang lueckenlos 57-59 Laeufe je Stunde.
Aber ueber zweiundachtzig Minuten hinweg startete und endete jeder Lauf in
DERSELBEN Sekunde, waehrend ein arbeitender Lauf zwei braucht: sie stiegen
alle sofort wieder aus, an der Sperre eines anderen Vorgangs — `flock -n 9 ||
exit 0`. Lautlos. Kein Journal-Eintrag (systemd sieht einen sauberen Lauf),
keine Zeile in der Statusdatei, nichts in der Konsole.

Und die Statusdatei war die einzige Lebendmeldung, die es gab. Sie wird erst
nach rund 190 Zeilen geschrieben — nach dem `git fetch` und nach einem
`docker compose exec`, beide ohne Zeitgrenze und beide unter der Sperre. Ein
Lauf, der davor aussteigt, hinterlaesst nichts, und nach zwanzig Minuten
schliesst die Konsole daraus, der Dienst sei tot. Sie schloss falsch, und die
Handlungsanweisung dazu aendert an einer gehaltenen Sperre nichts.

Drei Aenderungen:

  * Ein Lebenszeichen (agent-alive.json) als ERSTES bei jedem Lauf, vor allem,
    was blockieren kann. Zwei Zustaende: `running` heisst "ich habe die Sperre
    und arbeite", `blocked` heisst "ich bin ausgestiegen" — mit `since` (seit
    wann ununterbrochen) und `held_by` (wer, per fuser und ps).
  * Zeitgrenzen: 45 Sekunden um den docker-exec, 120 um den git fetch. Ohne
    sie wartet ein Abruf gegen eine tote Verbindung, bis das Betriebssystem
    ihn nach vielen Minuten aufgibt — und haelt dabei die Sperre.
  * Die Konsole liest das Lebenszeichen statt der Statusdatei. Der Unterschied
    ist der Punkt: Status heisst "zuletzt ERFOLGREICH nachgesehen",
    Lebenszeichen heisst "zuletzt ueberhaupt gelaufen". Ein blockierter Agent
    gilt als lebendig, aber seine Zahlen zaehlen nicht mehr als aktuell —
    `behind` und `target_release` fallen auf "unbekannt", statt eine Stunde
    alte Auskunft als frisch auszugeben.

Statt "laeuft nicht" steht dort jetzt "laeuft, kommt aber seit HH:MM nicht an
die Arbeit", mit dem Prozess darunter.

Ein Agent von VOR dieser Aenderung schreibt die Datei nicht — fuer den gilt
weiter die alte Regel. Ihn dafuer fuer tot zu erklaeren waere derselbe Fehler
mit umgekehrtem Vorzeichen; ein Test haelt das fest.

Widerlegt und damit ausgeschlossen: Besitzrechte (Wirt, .env und Behaelter
fuehren alle 1001), das Ausführbar-Bit (100755 im Repo), systemds
Startdrosselung (seit v1.1.0 abgeschaltet), ein zwischengespeicherter Zustand
im Panel (es liest bei jedem Aufruf frisch) und eine Luecke im Timer (keine).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/versandtakt
nexxo 2026-08-04 01:31:19 +02:00
parent 9f2a45c7bf
commit d1755f5921
6 changed files with 243 additions and 32 deletions

View File

@ -109,6 +109,23 @@ final class UpdateChannel
/** Written by the agent after every check and every run. */
private const STATUS = 'deploy/update-status.json';
/**
* Das Lebenszeichen geschrieben als ERSTES bei jedem Lauf des Agenten.
*
* STATUS allein konnte die Frage „läuft der Agent noch" nicht beantworten.
* Es wird erst am Ende eines Laufs geschrieben; ein Lauf, der vorher
* aussteigt, weil ein anderer Vorgang die Sperre hält, hinterlässt nichts.
* Die Konsole sah dann zwanzig Minuten lang keine neue Zahl und schloss
* daraus, der Dienst sei tot er lief die ganze Zeit, kam nur nicht an
* die Arbeit. Ein Betreiber hat deswegen zweiundachtzig Minuten lang ein
* Installationsskript gefahren, das an dieser Lage nichts ändert.
*
* Die Datei trennt beides: `at` heißt „der Agent war eben hier", `state`
* sagt, ob er arbeiten konnte. Ältere Agenten schreiben sie nicht dann
* bleibt es beim alten Verhalten (siehe agentIsAlive()).
*/
private const ALIVE = 'deploy/agent-alive.json';
/**
* The outcome of the last actual run, kept apart from the periodic check.
*
@ -177,14 +194,28 @@ final class UpdateChannel
$restartLastRun = $this->readJson(self::RESTART_LAST_RUN);
$request = $this->pendingRequest();
$alive = $this->readJson(self::ALIVE);
$checkedAt = $this->timestamp($status['checked_at'] ?? null);
$agentAlive = $this->agentIsAlive($status);
$agentAlive = $this->agentIsAlive($status, $alive);
// 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
? $this->timestamp($alive['since'] ?? null)
: null;
// Was der Agent zuletzt gemeldet hat, gilt nur, wenn er auch arbeiten
// konnte. „Drei Updates zurück" von vor einer Stunde liest sich genau
// wie von jetzt.
$agentWorking = $agentAlive && $blockedSince === null;
// Missing on a request an older panel wrote — see KIND_RUN.
$requestKind = $request['kind'] ?? self::KIND_RUN;
// A figure from an agent that has since stopped is not current, and
// "three updates behind" from last week reads exactly like now.
$behind = $agentAlive && isset($status['behind']) ? (int) $status['behind'] : null;
$behind = $agentWorking && isset($status['behind']) ? (int) $status['behind'] : null;
return [
'version' => $release->version,
@ -199,13 +230,17 @@ final class UpdateChannel
// The tag an update would install, e.g. "v1.1.0". An update is
// always to a released version now — a commit landing on main is
// not an update, and the console has nothing to say about it.
'target_release' => $agentAlive && ! empty($status['target_release'])
'target_release' => $agentWorking && ! empty($status['target_release'])
? (string) $status['target_release']
: null,
'remote_commit' => isset($status['remote_commit']) ? (string) $status['remote_commit'] : null,
'checked_at' => $checkedAt,
'agent_seen' => $agentAlive,
// Seit wann der Agent nur noch überspringt, und wer die Sperre
// hält. Beides null, solange er arbeitet.
'blocked_since' => $blockedSince,
'blocked_by' => $blockedSince !== null ? trim((string) ($alive['held_by'] ?? '')) : null,
// 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
@ -353,10 +388,29 @@ final class UpdateChannel
/**
* Has the agent checked in recently enough to be considered alive?
*
* Das Lebenszeichen zuerst, die Statusdatei nur als Rückfall. Der
* Unterschied ist der ganze Punkt: STATUS bedeutet „zuletzt ERFOLGREICH
* nachgesehen", ALIVE bedeutet „zuletzt überhaupt gelaufen". Ein Agent,
* der jede Minute anspringt und an einer gehaltenen Sperre wieder
* aussteigt, erneuert nur das zweite und genau der Fall wurde vorher als
* „Dienst läuft nicht" gemeldet, mitsamt der Aufforderung, ein
* Installationsskript zu fahren, das daran nichts ändert.
*
* Fehlt das Lebenszeichen ganz, läuft dort ein Agent von vor dieser
* Änderung: dann gilt wieder die alte Regel, statt ihn für tot zu erklären,
* weil er eine Datei nicht kennt.
*
* @param array<string, mixed> $status
* @param array<string, mixed> $alive
*/
private function agentIsAlive(array $status): bool
private function agentIsAlive(array $status, array $alive = []): bool
{
$heartbeat = $this->timestamp($alive['at'] ?? null);
if ($heartbeat !== null) {
return $heartbeat->gt(Carbon::now()->subMinutes(self::AGENT_STALE_AFTER_MINUTES));
}
$checkedAt = $this->timestamp($status['checked_at'] ?? null);
return $checkedAt !== null
@ -368,7 +422,8 @@ final class UpdateChannel
{
$status = $this->readJson(self::STATUS);
return ($status['state'] ?? null) === 'running' && $this->agentIsAlive($status);
return ($status['state'] ?? null) === 'running'
&& $this->agentIsAlive($status, $this->readJson(self::ALIVE));
}
/** Is an update run already asked for, and still current? */

View File

@ -43,6 +43,21 @@ RESTARTLAST="$STATE_DIR/restart-last-run.json"
# initial admin password an instance holds until somebody notes it down.
ARCHIVE_KEY="$STATE_DIR/archive-key.json"
LOCK="$STATE_DIR/.agent.lock"
# Ein Lebenszeichen, geschrieben als ERSTES bei jedem Lauf — vor dem Abruf der
# Gegenstelle, vor allem, was blockieren kann.
#
# Bis hierher war "wann hat der Agent zuletzt geschrieben" dasselbe wie "wann
# hat er zuletzt ERFOLGREICH nachgesehen": beides stand nur in der Statusdatei,
# und die wird erst nach rund 190 Zeilen geschrieben. Steigt ein Lauf davor aus
# — weil ein anderer die Sperre haelt —, sieht die Konsole zwanzig Minuten lang
# gar nichts und schliesst daraus, der Dienst sei tot. Er lief die ganze Zeit;
# er kam nur nicht an die Arbeit. Ein Betreiber wurde dadurch zweiundachtzig
# Minuten lang zu install-agent.sh geschickt, das an dieser Lage nichts aendert.
#
# Zwei Zustaende, eine Datei: "running" heisst, dieser Lauf hat die Sperre und
# 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"
# 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
@ -76,10 +91,57 @@ fi
mkdir -p "$STATE_DIR"
# Schreibt das Lebenszeichen. Bewusst ohne json_escape (das steht weiter unten,
# und hier oben darf noch nichts von unten gebraucht werden): der einzige freie
# Text ist die Ausgabe von ps, aus der Anfuehrungszeichen, Backslashes und
# Zeilenumbrueche entfernt werden.
write_alive() {
local state="$1" since="${2-}" held_by="${3-}"
held_by="$(printf '%s' "$held_by" | tr -d '"\\' | tr '\n\r\t' ' ')"
cat > "$ALIVE.tmp" 2>/dev/null <<EOF || return 0
{
"at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"state": "$state",
"since": "$since",
"held_by": "$held_by"
}
EOF
mv -f "$ALIVE.tmp" "$ALIVE" 2>/dev/null || true
}
# 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"
flock -n 9 || exit 0
if ! flock -n 9; then
# Der uebersprungene Lauf war bis hierher voellig stumm — kein Eintrag im
# Journal (systemd sieht einen sauberen Lauf), keine Zeile in der
# Statusdatei, nichts in der Konsole. Genau diese Stille hat einen Ausfall
# ueber achtzig Minuten unsichtbar gemacht. Jetzt hinterlaesst er, dass er
# uebersprungen hat, seit wann ununterbrochen, und wer die Sperre haelt.
#
# `since` wird aus dem vorigen Lebenszeichen uebernommen, solange die Serie
# 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.
HOLDER="$( { fuser "$LOCK" 2>/dev/null || true; } | tr -s ' ' | sed 's/^ *//;s/ *$//' )"
HOLDER_CMD=''
if [[ -n "$HOLDER" ]]; then
HOLDER_CMD="$(ps -o pid=,etime=,args= -p $HOLDER 2>/dev/null | head -2 | tr '\n' ' ' || true)"
fi
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)"
if [[ "$PREVIOUS_STATE" != "blocked" || -z "$PREVIOUS_SINCE" ]]; then
PREVIOUS_SINCE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
fi
write_alive blocked "$PREVIOUS_SINCE" "${HOLDER_CMD:-$HOLDER}"
exit 0
fi
write_alive running
sync_console_allowlist() {
[[ -w "$(dirname "$ALLOWFILE")" || -w "$ALLOWFILE" ]] || return 0
@ -89,7 +151,11 @@ sync_console_allowlist() {
# artisan command that logs anything as root leaves storage/logs owned by
# root, after which the application cannot append to its own log — and every
# page that logs answers 500 with nothing written to say why.
generated="$(docker compose exec -T -u www-data app php artisan clupilot:console-access caddy 2>/dev/null)" || return 0
# `timeout`, weil dieser Aufruf die Sperre haelt: haengt der Behaelter —
# gerade neu gestartet, ueberlastet, halb tot —, haengt der Agent mit, und
# jeder folgende Takt steigt still aus. Lieber diese Runde ohne Allowlist
# als eine Konsole, die minutenlang nichts mehr von sich hoert.
generated="$(timeout 45 docker compose exec -T -u www-data app php artisan clupilot:console-access caddy 2>/dev/null)" || return 0
# Never write an empty matcher: in Caddy that matches nothing, and the
# console would be unreachable from everywhere including the shell.
grep -q '@allowed remote_ip .' <<<"$generated" || return 0
@ -237,7 +303,12 @@ FETCH_ERROR=''
DEPLOYED_VERSION="$(release_manifest_version)"
[[ -n "$DEPLOYED_VERSION" ]] || DEPLOYED_VERSION="$(release_version)"
if git fetch --quiet --tags --force origin 2>/dev/null; then
# `timeout`, aus demselben Grund wie beim Aufruf in sync_console_allowlist: ein
# Abruf gegen eine tote Verbindung wartet, bis das Betriebssystem ihn nach
# vielen Minuten aufgibt — und haelt dabei die Sperre. Zwei Minuten sind
# grosszuegig fuer einen fetch gegen EINE Gegenstelle; laenger ist kein
# langsames Netz mehr, sondern eines, das nicht antwortet.
if timeout 120 git fetch --quiet --tags --force origin 2>/dev/null; then
# Newest by version order, not by tag date. Through the helper rather than
# `| head -1`: head exits after one line, git takes SIGPIPE, and pipefail
# ends the agent — see release_newest_tag.

View File

@ -115,6 +115,7 @@ return [
'update_check_requested' => 'Prüfung angefordert.',
'update_already_requested' => 'Es ist schon etwas angefordert — bitte kurz warten.',
'update_no_agent' => 'Der Update-Dienst auf dem Server läuft nicht. Einmalig auf dem Server einrichten: sudo bash /opt/clupilot/deploy/install-agent.sh',
'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',
'update_error' => [

View File

@ -115,6 +115,7 @@ return [
'update_check_requested' => 'Check requested.',
'update_already_requested' => 'Something has already been requested — please wait a moment.',
'update_no_agent' => 'The server-side update service is not running. Set it up once on the server: sudo bash /opt/clupilot/deploy/install-agent.sh',
'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',
'update_error' => [

View File

@ -168,6 +168,19 @@
@if (! $update['agent_seen'])
<x-ui.alert variant="warning" class="mt-4">{{ __('admin_settings.update_no_agent') }}</x-ui.alert>
@elseif ($update['blocked_since'])
{{-- Der Dienst LÄUFT, kommt aber nicht an die Arbeit.
Vorher stand hier die Meldung darüber „läuft
nicht", samt der Aufforderung, install-agent.sh zu
fahren, das an einer gehaltenen Sperre nichts
ändert. Ein Betreiber hat dem zweiundachtzig
Minuten lang geglaubt. --}}
<x-ui.alert variant="warning" class="mt-4">
{{ __('admin_settings.update_agent_blocked', ['since' => $update['blocked_since']->local()->isoFormat('HH:mm')]) }}
@if ($update['blocked_by'])
<span class="mt-1 block font-mono text-xs opacity-80">{{ $update['blocked_by'] }}</span>
@endif
</x-ui.alert>
@elseif ($update['last_error'])
<x-ui.alert variant="danger" class="mt-4">{{ $update['last_error'] }}</x-ui.alert>
@elseif (! $update['available'] && ! $update['running'] && ! $update['checking'])

View File

@ -5,6 +5,7 @@ use App\Models\User;
use App\Services\Deployment\UpdateChannel;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Livewire\Livewire;
/**
@ -778,11 +779,11 @@ it('shows one design while updating, not two swapping mid-run', function () {
//
// Both render the same partial now, so there is nothing left that can
// disagree.
$console = Illuminate\Support\Facades\File::get(resource_path('views/layouts/admin.blade.php'));
$maintenance = Illuminate\Support\Facades\File::get(resource_path('views/errors/503.blade.php'));
$console = File::get(resource_path('views/layouts/admin.blade.php'));
$maintenance = File::get(resource_path('views/errors/503.blade.php'));
expect($console)->toContain("partials.updating-panel")
->and($maintenance)->toContain("partials.updating-panel")
expect($console)->toContain('partials.updating-panel')
->and($maintenance)->toContain('partials.updating-panel')
// And nothing left behind that would render a second look.
->and($console)->not->toContain('update_overlay_title')
->and($maintenance)->not->toContain('<style>');
@ -799,7 +800,7 @@ it('keeps the deployment step in the overlay, and nothing else from the run', fu
// log was rsync listing every font file it copied, and reading it meant a
// file read on every console page load whether or not anything was
// running. The settings page still shows it, where somebody looks for it.
$panel = Illuminate\Support\Facades\File::get(resource_path('views/partials/updating-panel.blade.php'));
$panel = File::get(resource_path('views/partials/updating-panel.blade.php'));
expect($panel)->toContain('x-text="step"')
->not->toContain('x-text="runningSince"')
@ -813,14 +814,14 @@ it('shows that something is happening without asking anybody to read', function
// on the server answering another request — and it is indeterminate on
// purpose: a deployment has no honest percentage, and a bar that claims one
// always stalls at 90 %.
$panel = Illuminate\Support\Facades\File::get(resource_path('views/partials/updating-panel.blade.php'));
$panel = File::get(resource_path('views/partials/updating-panel.blade.php'));
expect($panel)->toContain('cpu-bar')
->toContain('@keyframes cpu-slide')
// Everyone sees it, not only the console: a customer on the 503 is
// asking the same question.
->and(str_contains(
Illuminate\Support\Str::before($panel, "@if (\$detail ?? false)"),
Str::before($panel, '@if ($detail ?? false)'),
'cpu-bar" aria-hidden="true"',
))->toBeTrue()
// And it survives somebody who has switched motion off.
@ -840,7 +841,7 @@ it('has no dark mode of its own, in a product that has none', function () {
$panel = preg_replace(
'/\{\{--.*?--\}\}/s',
'',
Illuminate\Support\Facades\File::get(resource_path('views/partials/updating-panel.blade.php')),
File::get(resource_path('views/partials/updating-panel.blade.php')),
);
expect($panel)->not->toContain('prefers-color-scheme')
@ -861,17 +862,17 @@ it('shows the step in one place, not in two of different sizes', function () {
// run started, and up to three seconds later the full-page overlay covered
// it saying the same thing. Reported as "first a small window, then the big
// one".
$card = Illuminate\Support\Facades\File::get(resource_path('views/livewire/admin/settings.blade.php'));
$layout = Illuminate\Support\Facades\File::get(resource_path('views/layouts/admin.blade.php'));
$card = File::get(resource_path('views/livewire/admin/settings.blade.php'));
$layout = File::get(resource_path('views/layouts/admin.blade.php'));
expect($card)->not->toContain("admin_settings.update_offline_hint")
expect($card)->not->toContain('admin_settings.update_offline_hint')
->and($layout)->toContain('admin_settings.update_offline_hint');
});
it('opens the overlay on the click, not on the next poll', function () {
// Three seconds is long enough to see the page arrange itself twice.
$settings = Illuminate\Support\Facades\File::get(app_path('Livewire/Admin/Settings.php'));
$layout = Illuminate\Support\Facades\File::get(resource_path('views/layouts/admin.blade.php'));
$settings = File::get(app_path('Livewire/Admin/Settings.php'));
$layout = File::get(resource_path('views/layouts/admin.blade.php'));
expect($settings)->toContain("dispatch('update-started')")
->and($layout)->toContain('@update-started.window="wasRunning = true"');
@ -896,7 +897,7 @@ it('does not read the agents start-up gap as the end of the run', function ()
// endpoint honestly answers "nothing is running" — and the watcher read
// that as "finished" and reloaded, so the overlay opened on the click,
// vanished a poll later, and the 503 arrived after it.
$watcher = Illuminate\Support\Facades\File::get(resource_path('js/app.js'));
$watcher = File::get(resource_path('js/app.js'));
// The overlay may only close once the SERVER has confirmed a run and then
// stopped reporting it.
@ -916,7 +917,7 @@ it('reproduces the gap the console used to reload on', function () {
expect(app(UpdateChannel::class)->state()['running'])->toBeTrue();
// The agent takes the request…
Illuminate\Support\Facades\File::delete(storage_path('app/deploy/update-request.json'));
File::delete(storage_path('app/deploy/update-request.json'));
// …and has not written its status yet.
expect(app(UpdateChannel::class)->state()['running'])->toBeFalse();
@ -929,7 +930,7 @@ it('does not hold the console behind the panel when the application comes back b
//
// A restart is seconds. Two minutes of nothing is an installation that came
// back broken, and then the operator needs the page.
$watcher = Illuminate\Support\Facades\File::get(resource_path('js/app.js'));
$watcher = File::get(resource_path('js/app.js'));
expect($watcher)->toContain('this.failedPolls > 40')
->toContain('this.stuck = true')
@ -943,7 +944,7 @@ it('offers the way out only once it is true, and only to an operator', function
// stop because somebody dismissed a panel. And the 503 page has no Alpine
// at all, so the affordance lives behind the same operator flag as the step
// and the log.
$panel = Illuminate\Support\Facades\File::get(resource_path('views/partials/updating-panel.blade.php'));
$panel = File::get(resource_path('views/partials/updating-panel.blade.php'));
expect($panel)->toContain('x-show="stuck"')
->toContain('@click="dismiss()"');
@ -951,7 +952,7 @@ it('offers the way out only once it is true, and only to an operator', function
// Two operator-only blocks, both gated by the same flag: the deployment
// detail and this. The 503 page passes no `detail`, so a customer sees
// neither the step nor a button suggesting they can call the update off.
expect(substr_count($panel, "@if (\$detail ?? false)"))->toBe(2);
expect(substr_count($panel, '@if ($detail ?? false)'))->toBe(2);
});
it('runs the workers as the same user as the web process', function () {
@ -961,7 +962,7 @@ it('runs the workers as the same user as the web process', function () {
// again, and every request for it answers 500 with "touch(): Utime failed".
// A compose service without `user:` runs as root, and the queue is what
// renders mails.
$compose = Illuminate\Support\Facades\File::get(base_path('docker-compose.yml'));
$compose = File::get(base_path('docker-compose.yml'));
foreach (['queue', 'reverb', 'scheduler'] as $service) {
// From the service key to the next one at the same indentation. An
@ -975,7 +976,7 @@ it('runs the workers as the same user as the web process', function () {
// And the deployment repairs whatever a root process left behind, at the
// end of the run as well as at the start.
expect(substr_count(
Illuminate\Support\Facades\File::get(base_path('deploy/update.sh')),
File::get(base_path('deploy/update.sh')),
"\nnormalise_ownership\n",
))->toBe(2);
});
@ -985,8 +986,8 @@ it('keeps answering its own status while the application is down', function () {
// the deployment. Behind maintenance mode it answered 503 for the whole
// run, so the overlay had nothing to show but "still waiting" and could
// never name the step it was on.
expect(Illuminate\Support\Facades\File::get(base_path('bootstrap/app.php')))
->toContain("preventRequestsDuringMaintenance(except: [")
expect(File::get(base_path('bootstrap/app.php')))
->toContain('preventRequestsDuringMaintenance(except: [')
->toContain("'update/state'")
->toContain("'*/update/state'");
});
@ -998,10 +999,79 @@ it('does not let a Livewire request during maintenance replace the page', functi
// for Laravel's 503 page. Overlay, then error page, then reload.
//
// 503 is not a fault here. It IS the event being watched.
$app = Illuminate\Support\Facades\File::get(resource_path('js/app.js'));
$app = File::get(resource_path('js/app.js'));
expect($app)->toContain('if (status === 503) {')
// And the poll that used to fire it stops for the duration.
->and(Illuminate\Support\Facades\File::get(resource_path('views/livewire/admin/settings.blade.php')))
->and(File::get(resource_path('views/livewire/admin/settings.blade.php')))
->toContain("@if (! \$update['running']) wire:poll");
});
/** Das Lebenszeichen, das der Agent als ERSTES bei jedem Lauf schreibt. */
function writeAlive(array $alive): void
{
File::ensureDirectoryExists(storage_path('app/deploy'));
File::put(storage_path('app/deploy/agent-alive.json'), json_encode($alive));
}
/**
* ── Der Agent lebt, kommt aber nicht an die Arbeit ────────────────────────
*
* Ein Lauf, der an einer gehaltenen Sperre aussteigt, hinterliess nichts:
* kein Journal-Eintrag (systemd sieht einen sauberen Lauf), keine Zeile in der
* Statusdatei. Die Konsole sah zwanzig Minuten lang keine neue Zahl und
* meldete „Update-Dienst laeuft nicht" — samt der Aufforderung,
* install-agent.sh zu fahren, das an einer gehaltenen Sperre nichts aendert.
* Ein Betreiber hat dem zweiundachtzig Minuten lang geglaubt.
*/
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']);
$state = app(UpdateChannel::class)->state();
expect($state['agent_seen'])->toBeTrue()
->and($state['blocked_since'])->not->toBeNull()
->and($state['blocked_by'])->toBe('4242 40:12 git fetch');
});
it('does not pass off an hour-old figure as current while the agent is blocked', function () {
// 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' => '']);
$state = app(UpdateChannel::class)->state();
expect($state['behind'])->toBeNull()
->and($state['available'])->toBeFalse()
->and($state['target_release'])->toBeNull();
});
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']);
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertDontSee(__('admin_settings.update_no_agent'))
->assertSee('4242 40:12 git fetch');
});
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.
writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 2]);
$state = app(UpdateChannel::class)->state();
expect($state['agent_seen'])->toBeTrue()
->and($state['behind'])->toBe(2)
->and($state['blocked_since'])->toBeNull();
});
it('does not call the agent alive on a heartbeat that is itself stale', function () {
writeAlive(['at' => now()->subHour()->toIso8601String(), 'state' => 'running', 'since' => '', 'held_by' => '']);
expect(app(UpdateChannel::class)->state()['agent_seen'])->toBeFalse();
});