Festnageln aus der Konsole

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).
feat/versandtakt
nexxo 2026-08-04 15:53:36 +02:00
parent d90137c521
commit 073e147ff8
7 changed files with 246 additions and 0 deletions

View File

@ -0,0 +1,44 @@
<?php
namespace App\Livewire\Admin;
use LivewireUI\Modal\ModalComponent;
/**
* Rückfrage vor dem Festnageln (R23).
*
* Das Modal mutiert nichts: es löst `pin-release-confirmed` aus, und
* App\Livewire\Admin\Settings führt die Handlung mit ihrer eigenen
* Berechtigungsprüfung aus. Damit bleibt die Prüfung an der einen Stelle, an
* der sie schon stand, statt hier verdoppelt zu werden.
*
* Die Version kommt als Argument aus dem Auswahlfeld der Seite (R20: kein
* Modal fürs Auswahlfeld selbst, nur für die Bestätigung der Handlung). Ein
* Modal ist ohne die Route-Middleware der Seite erreichbar, deshalb prüft
* auch `mount()` die Berechtigung selbst statt sich auf die aufrufende Seite
* zu verlassen.
*/
class ConfirmPinRelease extends ModalComponent
{
public string $version = '';
public function mount(string $version = ''): void
{
$this->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');
}
}

View File

@ -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.
*

View File

@ -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.',

View File

@ -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.',

View File

@ -0,0 +1,19 @@
{{-- Zweizeilige Rückfrage mit einem Knopf: braucht nach R24 kein
<x-ui.modal>, weil nichts darin scrollen kann. --}}
<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-warning-bg text-warning">
<x-ui.icon name="lock" class="size-5" />
</span>
<div class="min-w-0">
<h3 class="text-base font-semibold text-ink">{{ __('admin_settings.release_pin_title') }}</h3>
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.release_pin_body', ['version' => ltrim($version, 'v')]) }}</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_pin_cancel') }}</x-ui.button>
<x-ui.button variant="primary" wire:click="confirm" wire:loading.attr="disabled">
<x-ui.icon name="lock" class="size-4" />{{ __('admin_settings.release_pin_confirm') }}
</x-ui.button>
</div>
</div>

View File

@ -173,10 +173,56 @@
<x-ui.icon name="download" class="size-4" />
{{ __('admin_settings.update_now') }}
</x-ui.button>
{{-- 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'])
<select wire:model="ceilingChoice"
class="min-h-10 w-full rounded border border-line bg-surface px-3 text-sm text-ink sm:w-auto">
<option value="">{{ __('admin_settings.release_pin_none') }}</option>
@foreach ($update['releases'] as $tag)
<option value="{{ $tag }}">{{ ltrim($tag, 'v') }}</option>
@endforeach
</select>
<x-ui.button class="w-full sm:w-auto" variant="secondary"
x-on:click="$dispatch('openModal', { component: 'admin.confirm-pin-release', arguments: { version: $wire.ceilingChoice } })"
x-bind:disabled="! $wire.ceilingChoice">
<x-ui.icon name="lock" class="size-4" />
{{ __('admin_settings.release_pin_action') }}
</x-ui.button>
@endif
@endif
</div>
</div>
{{-- 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'])
<p class="mt-3 text-xs text-muted">
{{ $update['ceiling_passed']
? __('admin_settings.release_pinned_passed', ['version' => ltrim($update['ceiling'], 'v')])
: __('admin_settings.release_pinned_at', ['version' => ltrim($update['ceiling'], 'v')]) }}
<button type="button" wire:click="unpinRelease" class="underline">{{ __('admin_settings.release_unpin_action') }}</button>
</p>
@endif
@if ($update['ceiling_error'])
<x-ui.alert variant="warning" class="mt-3">
{{ __('admin_settings.release_ceiling_'.$update['ceiling_error']) }}
</x-ui.alert>
@endif
@if (! $update['agent_seen'])
<x-ui.alert variant="warning" class="mt-4">{{ __('admin_settings.update_no_agent') }}</x-ui.alert>
@elseif ($update['blocked_since'])

View File

@ -1,9 +1,12 @@
<?php
use App\Livewire\Admin\Settings;
use App\Models\Operator;
use App\Services\Deployment\UpdateChannel;
use App\Services\Deployment\UpdateWindow;
use App\Support\Settings as AppSettings;
use Illuminate\Support\Facades\File;
use Livewire\Livewire;
beforeEach(function () {
File::ensureDirectoryExists(storage_path('app/deploy'));
@ -190,3 +193,51 @@ it('takes the same state without a ceiling, and does request an update', functio
expect(File::exists(storage_path('app/deploy/update-request.json')))->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();
});