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
parent
58a94d1e9a
commit
4b68b8dc04
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -61,6 +61,7 @@ class ConfirmToken
|
|||
'wgSshGate',
|
||||
'wgSetPort',
|
||||
'wgSetSubnet',
|
||||
'releaseStaged',
|
||||
];
|
||||
|
||||
/** How long an issued confirm stays valid (seconds) — generous for a human click. */
|
||||
|
|
|
|||
|
|
@ -25,5 +25,9 @@ return [
|
|||
// 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'),
|
||||
|
||||
// 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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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="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>
|
||||
@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>
|
||||
</nav>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -7,6 +7,7 @@ use App\Livewire\Auth;
|
|||
use App\Livewire\Dashboard;
|
||||
use App\Livewire\Files;
|
||||
use App\Livewire\Help;
|
||||
use App\Livewire\Release;
|
||||
use App\Livewire\Servers;
|
||||
use App\Livewire\Services;
|
||||
use App\Livewire\Settings;
|
||||
|
|
@ -134,6 +135,12 @@ Route::middleware('auth')->group(function () {
|
|||
Route::get('/help', Help\Index::class)->name('help');
|
||||
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');
|
||||
|
||||
// Plain Blade view — deliberately NOT a Livewire component so it survives the
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue