testServer = (string) Setting::get('pipeline_test_server', ''); } /** 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']); } /** wire:poll target while the pipeline is in flight — drops the cached step results so the next * render re-fetches the live GitHub/test-server status. */ public function pollPipeline(PipelineStatus $pipeline): void { $pipeline->refresh(); } /** Save (or clear) the test-server URL whose /version.json the pipeline polls. */ public function saveTestServer(string $url): void { $url = trim($url); if ($url === '') { Setting::put('pipeline_test_server', ''); $this->testServer = ''; app(PipelineStatus::class)->refresh(); // drop the stale cached test-step status $this->dispatch('notify', message: __('release.test_server_cleared')); return; } if (filter_var($url, FILTER_VALIDATE_URL) === false || ! preg_match('#^https?://#i', $url)) { $this->dispatch('notify', message: __('release.test_server_invalid'), level: 'error'); return; } Setting::put('pipeline_test_server', $url); $this->testServer = $url; app(PipelineStatus::class)->refresh(); // reflect the new test server immediately $this->dispatch('notify', message: __('release.test_server_saved')); } /** * 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)->forTrackedBeta(), ])->title(__('release.title')); } }