user()?->can('manage-panel'), 403); } /** Opens the wire-elements/modal confirm dialog (R5) before cutting a staging release candidate of $target. */ public function confirmDeployStaging(string $target, ReleasePlanner $planner): void { abort_unless(auth()->user()?->can('manage-panel'), 403); 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 { abort_unless(auth()->user()?->can('manage-panel'), 403); 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 release candidate of $target via the host bridge. $target must be one of the proposed values. */ public function deployStaging(string $target, ReleasePlanner $planner, ReleaseBridge $bridge): void { abort_unless(auth()->user()?->can('manage-panel'), 403); 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']); } /** wire:poll target while the pipeline is in flight — drops the cached step results so the next * render re-fetches the live GitHub status. */ public function pollPipeline(PipelineStatus $pipeline): void { $pipeline->refresh(); } /** The current in-flight release-candidate tag (vX.Y.Z-rcN), or null when the running version is stable. */ private function currentRcTag(): ?string { $v = (string) config('clusev.version'); return str_contains($v, '-rc') ? 'v'.ltrim($v, 'vV') : null; } /** Per-user throttle shared by all promotion dispatches (auto-expiring, never a lockout). */ private function throttlePromote(): bool { $key = 'promote:'.Auth::id(); if (RateLimiter::tooManyAttempts($key, 5)) { $this->dispatch('notify', message: __('release.throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error'); return false; } RateLimiter::hit($key, 600); return true; } /** Record a promotion outcome (action on success, action.'_failed' on failure). */ private function auditPromotion(string $action, string $target, bool $ok): void { AuditEvent::create([ 'user_id' => Auth::id(), 'actor' => Auth::user()?->name ?? 'system', 'action' => $ok ? $action : $action.'_failed', 'target' => $target, 'ip' => request()->ip(), ]); } public function confirmDeployPublic(): void { abort_unless(auth()->user()?->can('manage-panel'), 403); $tag = $this->currentRcTag(); if ($tag === null) { return; } $this->openConfirm('releasePublic', ['tag' => $tag], __('release.public_confirm_title'), __('release.public_confirm_body', ['tag' => $tag]), __('release.public_action'), danger: false, icon: 'tag'); } #[On('releasePublic')] public function applyDeployPublic(string $confirmToken, PromotionService $promotion): void { abort_unless(auth()->user()?->can('manage-panel'), 403); try { $payload = ConfirmToken::consume($confirmToken, 'releasePublic'); } catch (InvalidConfirmToken) { return; } $tag = (string) ($payload['params']['tag'] ?? ''); if ($tag === '' || $this->currentRcTag() !== $tag || ! $this->throttlePromote()) { return; } $ok = $promotion->deployPublic($tag); $this->auditPromotion('deploy.public', $tag, $ok); $this->dispatch('notify', message: $ok ? __('release.public_dispatched', ['tag' => $tag]) : __('release.promote_failed'), level: $ok ? 'info' : 'error'); } public function confirmYank(string $tag, PromotionService $promotion): void { abort_unless(auth()->user()?->can('manage-panel'), 403); if (! in_array($tag, $promotion->publicTags(), true)) { $this->dispatch('notify', message: __('release.yank_unknown'), level: 'error'); return; } $this->openConfirm('releaseYank', ['tag' => $tag], __('release.yank_confirm_title'), __('release.yank_confirm_body', ['tag' => $tag]), __('release.yank_action'), danger: true, icon: 'trash'); } #[On('releaseYank')] public function applyYank(string $confirmToken, PromotionService $promotion): void { abort_unless(auth()->user()?->can('manage-panel'), 403); try { $payload = ConfirmToken::consume($confirmToken, 'releaseYank'); } catch (InvalidConfirmToken) { return; } $tag = (string) ($payload['params']['tag'] ?? ''); if ($tag === '' || ! in_array($tag, $promotion->publicTags(), true) || ! $this->throttlePromote()) { return; } $ok = $promotion->yank($tag); $this->auditPromotion('deploy.yank', $tag, $ok); $this->dispatch('notify', message: $ok ? __('release.yank_dispatched', ['tag' => $tag]) : __('release.promote_failed'), level: $ok ? 'info' : 'error'); } /** * 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 $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), 'pipeline' => app(PipelineStatus::class)->forTrackedRc(), 'publicTags' => app(PromotionService::class)->publicTags(), ])->title(__('release.title')); } }