diff --git a/app/Services/Deployment/UpdateChannel.php b/app/Services/Deployment/UpdateChannel.php index d648981..97d063e 100644 --- a/app/Services/Deployment/UpdateChannel.php +++ b/app/Services/Deployment/UpdateChannel.php @@ -46,6 +46,16 @@ final class UpdateChannel /** Written by the panel, consumed by the agent. */ private const REQUEST = 'deploy/update-request.json'; + /** + * Wie weit dieser Server gehen darf. + * + * Keine Anfrage an den Agenten, sondern Zustand, den er liest: die Datei + * bleibt liegen und gilt, bis sie jemand ändert. Deshalb steht sie neben + * dem Postkasten und nicht darin — eine Anfrage wird verbraucht, eine + * Decke nicht. + */ + private const CEILING = 'deploy/release-ceiling'; + /** * Die Bitte, eine hängende Sperre zu lösen — in einer EIGENEN Datei. * @@ -275,6 +285,11 @@ final class UpdateChannel // "three updates behind" from last week reads exactly like now. $behind = $agentWorking && isset($status['behind']) ? (int) $status['behind'] : null; + // Aus der Datei, nicht aus $status: siehe ceiling() weiter unten. Nur + // einmal gelesen, weil state() bei jedem Seitenaufbau läuft und die + // Datei sonst zweimal geöffnet würde, um zweimal dasselbe zu sagen. + $ceiling = $this->ceiling(); + return [ 'version' => $release->version, 'commit' => $release->commit, @@ -284,6 +299,22 @@ final class UpdateChannel // Null means "we do not know", which is not the same as "up to // date" and must not be shown as it. 'behind' => $behind, + // Die Decke aus der Datei, ihre Beanstandung vom Agenten: nur er + // sieht die Tags und kann sagen, ob sie ins Leere zeigt. + 'ceiling' => $ceiling, + 'ceiling_error' => ($status['ceiling_error'] ?? '') !== '' + ? (string) $status['ceiling_error'] + : null, + // Die Decke steht UNTER dem, was läuft. Kein Fehler, aber auch + // nicht „aktuell": es steht eine Decke, sie ist nur überholt. + // Hier gerechnet und nicht vom Agenten geholt — die Konsole hat + // beide Zahlen bereits, und eine Frage weniger an den Wirt ist + // eine Frage weniger, die veralten kann. + 'ceiling_passed' => $ceiling !== null + && version_compare(ltrim($ceiling, 'v'), $release->version, '<'), + 'releases' => is_array($status['releases'] ?? null) + ? array_values(array_filter($status['releases'], 'is_string')) + : [], 'available' => $behind !== null && $behind > 0, // 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 @@ -559,6 +590,76 @@ final class UpdateChannel return $this->submit($by, self::KIND_CHECK); } + /** + * Den Server auf eine Version festnageln — oder die Decke abnehmen. + * + * `null` nimmt sie ab. Rückgabe `false` heißt: die Form stimmt nicht, es + * wurde nichts geschrieben. + * + * Die Decke wirkt NICHT dadurch, dass hier etwas ausgelöst wird. Der Agent + * liest die Datei bei jedem Takt und rechnet `behind` dagegen; Knopf und + * Wartungsfenster folgen daraus von selbst. Die `KIND_CHECK`-Anfrage + * daneben ist reine Höflichkeit gegenüber der Anzeige: ohne sie stünde die + * alte Zahl bis zum nächsten Takt, und ein Betreiber, der gerade + * festgenagelt hat, läse sie als „hat nicht gewirkt". + */ + public function setCeiling(string $by, ?string $tag): bool + { + if ($tag === null) { + File::delete(storage_path('app/'.self::CEILING)); + $this->requestCheck($by); + + return true; + } + + // Dieselbe Form, die der Agent prüft. Beide Seiten prüfen sie, weil + // keine der anderen glauben kann: der Agent nicht der Datei, die + // Konsole nicht der Eingabe. + if (! preg_match('/^v\d+(\.\d+)*$/', $tag)) { + return false; + } + + $this->writeAtomic(self::CEILING, $tag); + $this->requestCheck($by); + + return true; + } + + /** + * Die gesetzte Decke, direkt aus der Datei. + * + * Nicht aus der Statusdatei des Agenten: die ist seine Rückmeldung und + * hinkt bis zu einem Takt hinterher. Was die Konsole selbst geschrieben + * hat, muss sie sofort anzeigen können. + */ + private function ceiling(): ?string + { + $path = storage_path('app/'.self::CEILING); + + if (! File::exists($path)) { + return null; + } + + $tag = trim((string) File::get($path)); + + return $tag === '' ? null : $tag; + } + + /** + * Schreiben, das der Agent nie halb sieht. + * + * `write()` daneben benutzt File::put und taugt dafür nicht: der Agent + * liest diese Datei bei jedem Takt, und eine halbe Zeile ist genau die + * Art Decke, die er als kaputt ablehnen müsste. + */ + private function writeAtomic(string $relative, string $contents): void + { + $path = storage_path('app/'.$relative); + File::ensureDirectoryExists(dirname($path)); + File::put($path.'.tmp', $contents); + File::move($path.'.tmp', $path); + } + /** * Den Proxy die Hostnamen aus der Konsole übernehmen lassen. * diff --git a/tests/Feature/ReleaseCeilingConsoleTest.php b/tests/Feature/ReleaseCeilingConsoleTest.php new file mode 100644 index 0000000..c381314 --- /dev/null +++ b/tests/Feature/ReleaseCeilingConsoleTest.php @@ -0,0 +1,83 @@ +setCeiling('chef@example.com', 'v1.8.0'))->toBeTrue() + ->and(trim(File::get($path)))->toBe('v1.8.0'); + + expect($channel->setCeiling('chef@example.com', null))->toBeTrue() + ->and(File::exists($path))->toBeFalse(); +}); + +it('refuses a ceiling that is not a version', function () { + // Die Form wird auf BEIDEN Seiten geprueft. Der Agent kann sich auf nichts + // verlassen, was aus einer Datei kommt — und die Konsole soll gar nicht + // erst etwas hinlegen, das er ablehnen muss. + $channel = app(UpdateChannel::class); + + expect($channel->setCeiling('chef@example.com', 'neueste'))->toBeFalse() + ->and(File::exists(storage_path('app/deploy/release-ceiling')))->toBeFalse(); +}); + +it('leaves no temporary file behind', function () { + // Atomar heisst: der Agent sieht die Datei ganz oder gar nicht. Bliebe die + // .tmp liegen, waere das der Beleg fuer ein File::put statt eines rename. + app(UpdateChannel::class)->setCeiling('chef@example.com', 'v1.8.0'); + + expect(File::exists(storage_path('app/deploy/release-ceiling.tmp')))->toBeFalse(); +}); + +it('reports the ceiling from the file, not from the agent', function () { + // Die Decke ist der Konsole eigener Zustand. Sie muss sofort stehen, statt + // zu warten, bis der Agent sie zurueckmeldet. + app(UpdateChannel::class)->setCeiling('chef@example.com', 'v1.8.0'); + + expect(app(UpdateChannel::class)->state()['ceiling'])->toBe('v1.8.0'); +}); + +it('passes the ceiling complaint from the agent through', function () { + File::put(storage_path('app/deploy/update-status.json'), json_encode([ + 'state' => 'idle', + 'ceiling_error' => 'ceiling_missing', + 'releases' => ['v1.8.1', 'v1.8.0'], + ])); + + $state = app(UpdateChannel::class)->state(); + + expect($state['ceiling_error'])->toBe('ceiling_missing') + ->and($state['releases'])->toBe(['v1.8.1', 'v1.8.0']); +}); + +it('marks a ceiling that sits below what is deployed', function () { + // Über das Auswahlfeld nicht erreichbar, über die Kommandozeile schon: + // Decke auf v1.8.0, jemand fährt `RELEASE=v1.8.1 update.sh`. Die Lage + // muss lesbar sein statt als „aktuell" durchzugehen — es steht ja eine + // Decke, sie ist nur überholt. + File::put(storage_path('app/deploy/release-ceiling'), 'v1.8.0'); + + $state = app(UpdateChannel::class)->state(); + + // `version` kommt aus Release::current(); in der Testumgebung ist das die + // VERSION-Datei des Checkouts, die über 1.8.0 liegt. + expect($state['ceiling_passed'])->toBeTrue(); +}); + +it('does not mark a ceiling that is still ahead', function () { + File::put(storage_path('app/deploy/release-ceiling'), 'v99.0.0'); + + expect(app(UpdateChannel::class)->state()['ceiling_passed'])->toBeFalse(); +});