296 lines
11 KiB
PHP
296 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Release;
|
|
|
|
use App\Models\AuditEvent;
|
|
use App\Services\PipelineStatus;
|
|
use App\Services\PromotionService;
|
|
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 release candidate 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 release candidate (shown until the next deploy). */
|
|
public ?string $lastTag = null;
|
|
|
|
public function mount(): void
|
|
{
|
|
abort_unless((bool) config('clusev.release_controls'), 404);
|
|
abort_unless(auth()->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<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),
|
|
'pipeline' => app(PipelineStatus::class)->forTrackedRc(),
|
|
'publicTags' => app(PromotionService::class)->publicTags(),
|
|
])->title(__('release.title'));
|
|
}
|
|
}
|