clusev/app/Livewire/Release/Index.php

182 lines
6.7 KiB
PHP

<?php
namespace App\Livewire\Release;
use App\Models\AuditEvent;
use App\Services\ReleaseBridge;
use App\Services\ReleasePlanner;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* Dev-only Release control. "Deploy to Staging" cuts a beta of an allowed target version via the
* host release bridge (bump → commit → tag → push to Gitea). The page is reachable only when
* config('clusev.release_controls') is true; mount() re-guards as defence-in-depth.
*
* Deploy-to-Staging is irreversible (it pushes a tag the host CI acts on), so it is gated behind the
* shared R5 wire-elements/modal confirm: a button calls confirmDeployStaging($target) → openConfirm,
* and applyDeployStaging consumes the sealed ConfirmToken and runs deployStaging(). deployStaging()
* stays the executor (the tests call it directly).
*/
#[Layout('layouts.app')]
class Index extends Component
{
/** The request id of an in-flight staging release; non-null drives the wire:poll. */
public ?string $pendingId = null;
/** Unix seconds when the request was issued, to time out a silent host. */
public ?int $pendingSince = null;
/** The target X.Y.Z being staged (for display while it runs). */
public ?string $pendingTarget = null;
/** The tag of the last successfully-pushed staging beta (shown until the next deploy). */
public ?string $lastTag = null;
public function mount(): void
{
abort_unless((bool) config('clusev.release_controls'), 404);
}
/** Opens the wire-elements/modal confirm dialog (R5) before cutting a staging beta of $target. */
public function confirmDeployStaging(string $target, ReleasePlanner $planner): void
{
if (! in_array($target, $planner->allowedTargets((string) config('clusev.version')), true)) {
$this->dispatch('notify', message: __('release.invalid_target'), level: 'error');
return;
}
$this->openConfirm('releaseStaged', ['target' => $target],
__('release.confirm_title'), __('release.confirm_body', ['target' => $target]),
__('release.confirm_action'), danger: true, icon: 'tag');
}
/** Apply handler — called after the ConfirmAction modal confirms the staging release. */
#[On('releaseStaged')]
public function applyDeployStaging(string $confirmToken, ReleasePlanner $planner, ReleaseBridge $bridge): void
{
try {
$payload = ConfirmToken::consume($confirmToken, 'releaseStaged');
} catch (InvalidConfirmToken) {
return;
}
$target = (string) ($payload['params']['target'] ?? '');
if ($target === '') {
return;
}
$this->deployStaging($target, $planner, $bridge);
}
/** Cut + push a beta of $target via the host bridge. $target must be one of the proposed values. */
public function deployStaging(string $target, ReleasePlanner $planner, ReleaseBridge $bridge): void
{
if (! in_array($target, $planner->allowedTargets((string) config('clusev.version')), true)) {
$this->dispatch('notify', message: __('release.invalid_target'), level: 'error');
return;
}
$key = 'release-stage:'.Auth::id();
if (RateLimiter::tooManyAttempts($key, 3)) {
$this->dispatch('notify', message: __('release.throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error');
return;
}
RateLimiter::hit($key, 600);
$id = $bridge->requestStaging($target);
if ($id === null) {
$this->dispatch('notify', message: __('release.write_failed'), level: 'error');
return;
}
$this->pendingId = $id;
$this->pendingSince = time();
$this->pendingTarget = $target;
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => 'deploy.staging_release',
'target' => '→ '.$target,
'ip' => request()->ip(),
]);
}
/** Poll the host result while a release runs (driven by wire:poll). Times out a silent host. */
public function pollResult(ReleaseBridge $bridge): void
{
if ($this->pendingId === null) {
return;
}
$this->pendingSince ??= time();
$res = $bridge->result($this->pendingId);
if ($res === null) {
if (time() - $this->pendingSince > 60) {
$this->reset(['pendingId', 'pendingSince', 'pendingTarget']);
$this->dispatch('notify', message: __('release.no_host_response'), level: 'error');
}
return;
}
if ($res['ok']) {
$this->lastTag = (string) $res['tag'];
$this->dispatch('notify', message: __('release.staged', ['tag' => (string) $res['tag']]));
} else {
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => 'deploy.staging_release_failed',
'target' => (string) ($this->pendingTarget ?? '').' ('.$res['message'].')',
'ip' => request()->ip(),
]);
$this->dispatch('notify', message: __('release.failed', ['reason' => $res['message']]), level: 'error');
}
$this->reset(['pendingId', 'pendingSince', 'pendingTarget']);
}
/**
* Open the shared ConfirmAction modal (R5) for the staging release. The issued token carries NO
* audit descriptor — applyDeployStaging → deployStaging audits exactly once itself.
*
* @param array<string, string> $params
*/
private function openConfirm(string $event, array $params, string $heading, string $body, string $confirmLabel, bool $danger, string $icon): void
{
$this->dispatch('openModal',
component: 'modals.confirm-action',
arguments: [
'heading' => $heading,
'body' => $body,
'confirmLabel' => $confirmLabel,
'danger' => $danger,
'icon' => $icon,
'notify' => '',
'token' => ConfirmToken::issue($event, $params),
],
);
}
public function render()
{
$current = (string) config('clusev.version');
return view('livewire.release.index', [
'current' => $current,
'targets' => app(ReleasePlanner::class)->proposedTargets($current),
])->title(__('release.title'));
}
}