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
parent
d90137c521
commit
073e147ff8
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue