feat(release): flag-gated /release page + Deploy-to-Staging via host bridge

Adds the dev-only Release page: a flag-gated route (config('clusev.release_controls'),
enforced by the component mount() guard) and a flag-gated sidebar item, plus the
Release Livewire component. Deploy-to-Staging is irreversible, so it goes through the
shared R5 wire-elements/modal confirm — confirmDeployStaging opens the modal and
applyDeployStaging consumes the sealed ConfirmToken and runs the deployStaging executor
(the host bridge writes the request, betaN is computed host-side). Registers the
releaseStaged confirm action in the ConfirmToken allow-list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-22 23:39:47 +02:00
parent 58a94d1e9a
commit 4b68b8dc04
8 changed files with 384 additions and 0 deletions

View File

@ -0,0 +1,181 @@
<?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'));
}
}

View File

@ -61,6 +61,7 @@ class ConfirmToken
'wgSshGate', 'wgSshGate',
'wgSetPort', 'wgSetPort',
'wgSetSubnet', 'wgSetSubnet',
'releaseStaged',
]; ];
/** How long an issued confirm stays valid (seconds) — generous for a human click. */ /** How long an issued confirm stays valid (seconds) — generous for a human click. */

View File

@ -25,5 +25,9 @@ return [
// ONLY in the gitignored .env — never in tracked files, so the public repo exposes no private URL. // ONLY in the gitignored .env — never in tracked files, so the public repo exposes no private URL.
'repository' => env('CLUSEV_REPOSITORY', 'https://github.com/clusev/clusev'), 'repository' => env('CLUSEV_REPOSITORY', 'https://github.com/clusev/clusev'),
// Dev-only release controls (the dashboard "Release" page + host git-push bridge). OFF by default
// so customer/staging installs never expose release controls. Set true ONLY in the dev .env.
'release_controls' => env('CLUSEV_RELEASE_CONTROLS', false),
'license' => 'AGPL-3.0', 'license' => 'AGPL-3.0',
]; ];

View File

@ -39,6 +39,11 @@
<x-nav-item icon="settings" href="/settings" :active="request()->is('settings*')">{{ __('shell.nav_settings') }}</x-nav-item> <x-nav-item icon="settings" href="/settings" :active="request()->is('settings*')">{{ __('shell.nav_settings') }}</x-nav-item>
<x-nav-item icon="shield" href="/system" :active="request()->is('system*')">{{ __('shell.nav_system') }}</x-nav-item> <x-nav-item icon="shield" href="/system" :active="request()->is('system*')">{{ __('shell.nav_system') }}</x-nav-item>
<x-nav-item icon="tag" href="/versions" :active="request()->is('versions*')" :badge="$updateAvailable ? '1' : null">{{ __('shell.nav_versions') }}</x-nav-item> <x-nav-item icon="tag" href="/versions" :active="request()->is('versions*')" :badge="$updateAvailable ? '1' : null">{{ __('shell.nav_versions') }}</x-nav-item>
@if (config('clusev.release_controls'))
<x-nav-item icon="tag" href="/release" :active="request()->is('release*')">
{{ __('release.nav') }}
</x-nav-item>
@endif
<x-nav-item icon="help-circle" href="/help" :active="request()->is('help*')">{{ __('shell.nav_help') }}</x-nav-item> <x-nav-item icon="help-circle" href="/help" :active="request()->is('help*')">{{ __('shell.nav_help') }}</x-nav-item>
</nav> </nav>

View File

@ -0,0 +1,45 @@
<div class="space-y-6">
<div class="space-y-1.5">
<h1 class="font-display text-2xl font-bold tracking-tight text-ink">{{ __('release.heading') }}</h1>
<p class="font-mono text-xs text-ink-3">{{ __('release.subtitle') }}</p>
</div>
<x-panel>
<div class="flex flex-col gap-1.5">
<span class="font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('release.current') }}</span>
<span class="font-mono text-lg text-ink">{{ $current }}</span>
</div>
@if ($lastTag !== null)
<p class="mt-4 flex items-center gap-1.5 font-mono text-[11px] text-online">
<x-icon name="tag" class="h-3.5 w-3.5 shrink-0" />
{{ __('release.staged_label') }} <span class="font-medium">{{ $lastTag }}</span>
</p>
@endif
@if ($pendingId !== null)
<p class="mt-5 flex items-center gap-1.5 font-mono text-[11px] text-warning" wire:poll.5s="pollResult">
<x-icon name="rotate" class="h-3.5 w-3.5 shrink-0 animate-spin" />
{{ __('release.running', ['target' => $pendingTarget]) }}
</p>
@else
<div class="mt-5 grid gap-2.5 sm:grid-cols-2">
@if (isset($targets['continueBeta']))
<x-btn variant="primary" wire:click="confirmDeployStaging('{{ $targets['continueBeta'] }}')" wire:loading.attr="disabled">
{{ __('release.continue_beta', ['v' => $targets['continueBeta']]) }}
</x-btn>
@endif
<x-btn variant="secondary" wire:click="confirmDeployStaging('{{ $targets['patch'] }}')" wire:loading.attr="disabled">
{{ __('release.bump_patch', ['v' => $targets['patch']]) }}
</x-btn>
<x-btn variant="secondary" wire:click="confirmDeployStaging('{{ $targets['minor'] }}')" wire:loading.attr="disabled">
{{ __('release.bump_minor', ['v' => $targets['minor']]) }}
</x-btn>
<x-btn variant="secondary" wire:click="confirmDeployStaging('{{ $targets['major'] }}')" wire:loading.attr="disabled">
{{ __('release.bump_major', ['v' => $targets['major']]) }}
</x-btn>
</div>
<p class="mt-3 font-mono text-[11px] text-ink-4">{{ __('release.hint') }}</p>
@endif
</x-panel>
</div>

View File

@ -7,6 +7,7 @@ use App\Livewire\Auth;
use App\Livewire\Dashboard; use App\Livewire\Dashboard;
use App\Livewire\Files; use App\Livewire\Files;
use App\Livewire\Help; use App\Livewire\Help;
use App\Livewire\Release;
use App\Livewire\Servers; use App\Livewire\Servers;
use App\Livewire\Services; use App\Livewire\Services;
use App\Livewire\Settings; use App\Livewire\Settings;
@ -134,6 +135,12 @@ Route::middleware('auth')->group(function () {
Route::get('/help', Help\Index::class)->name('help'); Route::get('/help', Help\Index::class)->name('help');
Route::get('/wireguard', Wireguard\Index::class)->name('wireguard'); Route::get('/wireguard', Wireguard\Index::class)->name('wireguard');
// Dev-only Release page. The route is always registered (routes/web.php is evaluated once at
// boot, so a boot-time config flag could not be toggled per-request), but the component's
// mount() abort_unless(config('clusev.release_controls')) is the real gate: with the flag off
// every hit 404s, exactly as if the route did not exist. The sidebar item is flag-gated too.
Route::get('/release', Release\Index::class)->name('release');
Route::get('/wg-status.json', fn (\App\Services\WgStatus $wg) => response()->json($wg->read()))->name('wg.status'); Route::get('/wg-status.json', fn (\App\Services\WgStatus $wg) => response()->json($wg->read()))->name('wg.status');
// Plain Blade view — deliberately NOT a Livewire component so it survives the // Plain Blade view — deliberately NOT a Livewire component so it survives the

View File

@ -0,0 +1,42 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ReleaseGatingTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->actingAs(User::factory()->create(['must_change_password' => false]));
}
public function test_release_route_is_404_when_the_flag_is_off(): void
{
config()->set('clusev.release_controls', false);
$this->get('/release')->assertNotFound();
}
public function test_release_route_loads_when_the_flag_is_on(): void
{
config()->set('clusev.release_controls', true);
$this->get('/release')->assertOk();
}
public function test_sidebar_hides_the_release_item_when_the_flag_is_off(): void
{
config()->set('clusev.release_controls', false);
$this->get('/audit')->assertOk()->assertDontSee(__('release.nav'));
}
public function test_sidebar_shows_the_release_item_when_the_flag_is_on(): void
{
config()->set('clusev.release_controls', true);
$this->get('/audit')->assertOk()->assertSee(__('release.nav'));
}
}

View File

@ -0,0 +1,99 @@
<?php
namespace Tests\Feature;
use App\Livewire\Release\Index;
use App\Models\AuditEvent;
use App\Models\User;
use App\Services\ReleaseBridge;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Livewire;
use Tests\TestCase;
class ReleasePageTest extends TestCase
{
use RefreshDatabase;
private string $dir;
protected function setUp(): void
{
parent::setUp();
config()->set('clusev.release_controls', true);
config()->set('clusev.version', '0.9.58');
$this->actingAs(User::factory()->create(['must_change_password' => false]));
$this->dir = storage_path('app/restart-signal');
@mkdir($this->dir, 0775, true);
@unlink($this->dir.'/release-request.json');
}
protected function tearDown(): void
{
@unlink($this->dir.'/release-request.json');
foreach (glob($this->dir.'/release-result-*.json') ?: [] as $f) {
@unlink($f);
}
parent::tearDown();
}
public function test_deploy_staging_for_an_allowed_target_writes_a_request_and_audits(): void
{
Livewire::test(Index::class)->call('deployStaging', '0.10.0');
$payload = json_decode((string) file_get_contents($this->dir.'/release-request.json'), true);
$this->assertSame('stage', $payload['action']);
$this->assertSame('0.10.0', $payload['target']);
$this->assertTrue(AuditEvent::where('action', 'deploy.staging_release')->exists());
}
public function test_deploy_staging_rejects_a_target_not_in_the_allowed_set(): void
{
Livewire::test(Index::class)->call('deployStaging', '5.5.5');
$this->assertFileDoesNotExist($this->dir.'/release-request.json');
}
public function test_deploy_staging_is_throttled_per_user(): void
{
$key = 'release-stage:'.auth()->id();
for ($i = 0; $i < 3; $i++) {
RateLimiter::hit($key, 600);
}
Livewire::test(Index::class)->call('deployStaging', '0.10.0');
$this->assertFileDoesNotExist($this->dir.'/release-request.json');
}
public function test_poll_result_surfaces_a_success(): void
{
$c = Livewire::test(Index::class)->call('deployStaging', '0.10.0');
$id = $c->get('pendingId');
file_put_contents(
$this->dir."/release-result-{$id}.json",
json_encode(['id' => $id, 'ok' => true, 'tag' => 'v0.10.0-beta1', 'message' => 'ok'])
);
$c->call('pollResult')->assertSet('pendingId', null)->assertSee('v0.10.0-beta1');
}
public function test_poll_result_audits_a_host_failure_as_an_error(): void
{
$c = Livewire::test(Index::class)->call('deployStaging', '0.10.0');
$id = $c->get('pendingId');
file_put_contents(
$this->dir."/release-result-{$id}.json",
json_encode(['id' => $id, 'ok' => false, 'tag' => null, 'message' => 'dirty-tree'])
);
$c->call('pollResult')->assertSet('pendingId', null);
$this->assertTrue(AuditEvent::where('action', 'deploy.staging_release_failed')->exists());
}
public function test_mount_aborts_when_the_flag_is_off(): void
{
config()->set('clusev.release_controls', false);
Livewire::test(Index::class)->assertStatus(404);
}
}