From 073e147ff840bdf53038eabd744e6eb7198f3bb9 Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 15:53:36 +0200 Subject: [PATCH] Festnageln aus der Konsole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 5: Bedienoberfläche zum Festnageln einer Release-Version, in der bestehenden Update-Handlungsleiste statt einer zweiten daneben. Bestätigt im Modal (R23), Auswahlfeld selbst ohne Modal (R20). --- app/Livewire/Admin/ConfirmPinRelease.php | 44 ++++++++++++++++ app/Livewire/Admin/Settings.php | 46 +++++++++++++++++ lang/de/admin_settings.php | 20 ++++++++ lang/en/admin_settings.php | 20 ++++++++ .../admin/confirm-pin-release.blade.php | 19 +++++++ .../views/livewire/admin/settings.blade.php | 46 +++++++++++++++++ tests/Feature/ReleaseCeilingConsoleTest.php | 51 +++++++++++++++++++ 7 files changed, 246 insertions(+) create mode 100644 app/Livewire/Admin/ConfirmPinRelease.php create mode 100644 resources/views/livewire/admin/confirm-pin-release.blade.php diff --git a/app/Livewire/Admin/ConfirmPinRelease.php b/app/Livewire/Admin/ConfirmPinRelease.php new file mode 100644 index 0000000..78ee5b6 --- /dev/null +++ b/app/Livewire/Admin/ConfirmPinRelease.php @@ -0,0 +1,44 @@ +authorize('site.manage'); + + $this->version = $version; + } + + public function confirm(): void + { + $this->authorize('site.manage'); + + $this->dispatch('pin-release-confirmed'); + $this->closeModal(); + } + + public function render() + { + return view('livewire.admin.confirm-pin-release'); + } +} diff --git a/app/Livewire/Admin/Settings.php b/app/Livewire/Admin/Settings.php index 8d5558d..f31ca03 100644 --- a/app/Livewire/Admin/Settings.php +++ b/app/Livewire/Admin/Settings.php @@ -717,6 +717,52 @@ class Settings extends Component : 'admin_settings.update_already_requested')); } + /** Die im Auswahlfeld stehende Version, ehe sie bestätigt wird. */ + public string $ceilingChoice = ''; + + /** + * Den Server auf eine Version festnageln. + * + * 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. + * + * `site.manage` wie `requestUpdate()`: festnageln entscheidet, welche + * Fassung dieser Server je bekommt, und ist damit dieselbe Entscheidung + * wie sie auszulösen — nur früher. + */ + #[On('pin-release-confirmed')] + public function pinRelease(): void + { + $this->authorize('site.manage'); + + if (! $operator = $this->currentOperator()) { + return; + } + + $accepted = app(UpdateChannel::class)->setCeiling($operator->email, $this->ceilingChoice ?: null); + + $this->dispatch('notify', message: __($accepted + ? 'admin_settings.release_pinned' + : 'admin_settings.release_pin_invalid')); + } + + /** + * Die Decke abnehmen — der Server nimmt wieder, was neu ist. + */ + public function unpinRelease(): void + { + $this->authorize('site.manage'); + + if (! $operator = $this->currentOperator()) { + return; + } + + app(UpdateChannel::class)->setCeiling($operator->email, null); + + $this->dispatch('notify', message: __('admin_settings.release_unpinned')); + } + /** * Eine hängende Sperre auf dem Wirt lösen lassen. * diff --git a/lang/de/admin_settings.php b/lang/de/admin_settings.php index 6da0b3d..a034307 100644 --- a/lang/de/admin_settings.php +++ b/lang/de/admin_settings.php @@ -152,6 +152,26 @@ return [ '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.', + // ── Festnageln ──────────────────────────────────────────────────────── + // Die Decke, die den Update-Agenten UND das Wartungsfenster begrenzt + // (App\Services\Deployment\UpdateChannel::setCeiling()). Bestätigt wird + // nur das Festnageln selbst im Modal (R23); das Auswahlfeld daneben + // braucht keins (R20). + 'release_pin_action' => 'Festnageln', + 'release_pin_none' => 'Neueste Version (nicht festgenagelt)', + 'release_pin_title' => 'Auf diese Version festnageln?', + 'release_pin_body' => 'Dieser Server nimmt dann nichts Neueres als :version — auch nicht im Wartungsfenster. Zurück geht es nicht; die Decke begrenzt nur nach oben.', + 'release_pin_cancel' => 'Abbrechen', + 'release_pin_confirm' => 'Festnageln', + 'release_pinned' => 'Festgenagelt.', + 'release_pin_invalid' => 'Das ist keine gültige Version.', + 'release_pinned_at' => 'Festgenagelt auf :version.', + 'release_pinned_passed' => 'Festgenagelt auf :version — dieser Server ist bereits weiter. Die Decke hält nichts mehr zurück; zurück geht es nicht.', + 'release_unpin_action' => 'Decke abnehmen', + 'release_unpinned' => 'Decke abgenommen.', + 'release_ceiling_ceiling_missing' => 'Die festgenagelte Version gibt es nicht (mehr). Es wird solange nichts installiert — bitte neu festnageln oder die Decke abnehmen.', + 'release_ceiling_ceiling_invalid' => 'Die festgenagelte Version ist unlesbar. Es wird solange nichts installiert — bitte neu festnageln oder die Decke abnehmen.', + '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.', diff --git a/lang/en/admin_settings.php b/lang/en/admin_settings.php index 1de8b8c..e861919 100644 --- a/lang/en/admin_settings.php +++ b/lang/en/admin_settings.php @@ -149,6 +149,26 @@ return [ 'release_lock_requested' => 'Releasing — the service reports back within a minute.', 'release_lock_already_requested' => 'Already requested — please wait a moment.', + // ── Pinning a ceiling ──────────────────────────────────────────────── + // The ceiling that limits both the update agent AND the maintenance + // window (App\Services\Deployment\UpdateChannel::setCeiling()). Only + // pinning itself is confirmed in a modal (R23); the select beside it + // needs none (R20). + 'release_pin_action' => 'Pin', + 'release_pin_none' => 'Newest release (not pinned)', + 'release_pin_title' => 'Pin to this release?', + 'release_pin_body' => 'This server will then take nothing newer than :version — not in the maintenance window either. There is no way back; the ceiling only limits upwards.', + 'release_pin_cancel' => 'Cancel', + 'release_pin_confirm' => 'Pin', + 'release_pinned' => 'Pinned.', + 'release_pin_invalid' => 'That is not a valid release.', + 'release_pinned_at' => 'Pinned to :version.', + 'release_pinned_passed' => 'Pinned to :version — this server is already past it. The ceiling no longer holds anything back; there is no way back.', + 'release_unpin_action' => 'Remove ceiling', + 'release_unpinned' => 'Ceiling removed.', + 'release_ceiling_ceiling_missing' => 'The pinned release no longer exists. Nothing will be installed until this is fixed — pin again or remove the ceiling.', + 'release_ceiling_ceiling_invalid' => 'The pinned release is unreadable. Nothing will be installed until this is fixed — pin again or remove the ceiling.', + '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.', diff --git a/resources/views/livewire/admin/confirm-pin-release.blade.php b/resources/views/livewire/admin/confirm-pin-release.blade.php new file mode 100644 index 0000000..549b5b3 --- /dev/null +++ b/resources/views/livewire/admin/confirm-pin-release.blade.php @@ -0,0 +1,19 @@ +{{-- Zweizeilige Rückfrage mit einem Knopf: braucht nach R24 kein + , weil nichts darin scrollen kann. --}} +
+
+ + + +
+

{{ __('admin_settings.release_pin_title') }}

+

{{ __('admin_settings.release_pin_body', ['version' => ltrim($version, 'v')]) }}

+
+
+
+ {{ __('admin_settings.release_pin_cancel') }} + + {{ __('admin_settings.release_pin_confirm') }} + +
+
diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php index d4fbd00..fe37b71 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -173,10 +173,56 @@ {{ __('admin_settings.update_now') }} + + {{-- Festnageln, in derselben Handlungsleiste wie Prüfen/Aktualisieren + statt einer zweiten Leiste daneben — dieselbe Begründung wie bei + den beiden Knöpfen selbst (flex-wrap, w-full sm:w-auto). Kein + Modal für das Auswahlfeld selbst (R20 gilt fürs Bearbeiten + bestehender Datensätze in Tabellenzeilen, nicht für ein + Auswahlfeld, das Teil der Seite ist); bestätigt wird nur das + Festnageln selbst im Modal (R23), weil die Wahl entscheidet, + welche Fassung dieser Server je bekommt. --}} + @if ($update['releases']) + + + + + {{ __('admin_settings.release_pin_action') }} + + @endif @endif + {{-- Der Zustand der Decke selbst — direkt unter der Leiste, die sie + setzt. „Festgenagelt, aber bereits weiter" ist ein eigener Satz und + nicht dieselbe Zeile mit anderer Zahl: wer v1.8.0 festgenagelt hat + und v1.8.1 laufen sieht, muss erfahren, dass die Decke nichts mehr + tut — sonst liest er sie als Zusicherung, die sie nicht ist. Zurück + geht es nicht (update.sh:222). --}} + @if ($update['ceiling']) +

+ {{ $update['ceiling_passed'] + ? __('admin_settings.release_pinned_passed', ['version' => ltrim($update['ceiling'], 'v')]) + : __('admin_settings.release_pinned_at', ['version' => ltrim($update['ceiling'], 'v')]) }} + +

+ @endif + + @if ($update['ceiling_error']) + + {{ __('admin_settings.release_ceiling_'.$update['ceiling_error']) }} + + @endif + @if (! $update['agent_seen']) {{ __('admin_settings.update_no_agent') }} @elseif ($update['blocked_since']) diff --git a/tests/Feature/ReleaseCeilingConsoleTest.php b/tests/Feature/ReleaseCeilingConsoleTest.php index 31ba07a..478983d 100644 --- a/tests/Feature/ReleaseCeilingConsoleTest.php +++ b/tests/Feature/ReleaseCeilingConsoleTest.php @@ -1,9 +1,12 @@ toBeTrue(); }); + +it('pins from the console', function () { + // Rolle, nicht Einzelberechtigung: die siebzehn Konsolen-Berechtigungen + // hängen am `operator`-Guard über Rollen (R21). `->role('Owner')` ist das + // Muster der übrigen Tests, z. B. SwitchOperatingModeTest. + $owner = Operator::factory()->role('Owner')->create(); + + File::put(storage_path('app/deploy/update-status.json'), json_encode([ + 'state' => 'idle', + 'releases' => ['v1.8.1', 'v1.8.0'], + ])); + + Livewire::actingAs($owner, 'operator') + ->test(Settings::class) + ->set('ceilingChoice', 'v1.8.0') + ->call('pinRelease'); + + expect(trim(File::get(storage_path('app/deploy/release-ceiling'))))->toBe('v1.8.0'); +}); + +it('takes the ceiling off again', function () { + $owner = Operator::factory()->role('Owner')->create(); + File::put(storage_path('app/deploy/release-ceiling'), 'v1.8.0'); + + Livewire::actingAs($owner, 'operator') + ->test(Settings::class) + ->call('unpinRelease'); + + expect(File::exists(storage_path('app/deploy/release-ceiling')))->toBeFalse(); +}); + +it('refuses to pin without site.manage', function () { + // Eine Livewire-Aktion ist ein oeffentlicher Endpunkt. Die eine Stelle, an + // der eine Regel NICHT allein stehen darf, ist das disabled-Attribut eines + // Knopfes. + // + // Angemeldet, aber ohne die Rolle, die `site.manage` traegt: `site.manage` + // liegt am operator-Guard bei Owner und Admin (siehe die Migration + // 2026_07_25_220001 und die Begruendung in 2026_08_04_160000). Geprueft + // wird also die Berechtigung, nicht die Anmeldung. + $staff = Operator::factory()->create(); + + Livewire::actingAs($staff, 'operator') + ->test(Settings::class) + ->set('ceilingChoice', 'v1.8.0') + ->call('pinRelease') + ->assertForbidden(); +});