From 4b68b8dc04cdbf7415744cb9dfff228435ecc6b9 Mon Sep 17 00:00:00 2001 From: boban Date: Mon, 22 Jun 2026 23:39:47 +0200 Subject: [PATCH] feat(release): flag-gated /release page + Deploy-to-Staging via host bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/Livewire/Release/Index.php | 181 ++++++++++++++++++ app/Support/Confirm/ConfirmToken.php | 1 + config/clusev.php | 4 + resources/views/components/sidebar.blade.php | 5 + .../views/livewire/release/index.blade.php | 45 +++++ routes/web.php | 7 + tests/Feature/ReleaseGatingTest.php | 42 ++++ tests/Feature/ReleasePageTest.php | 99 ++++++++++ 8 files changed, 384 insertions(+) create mode 100644 app/Livewire/Release/Index.php create mode 100644 resources/views/livewire/release/index.blade.php create mode 100644 tests/Feature/ReleaseGatingTest.php create mode 100644 tests/Feature/ReleasePageTest.php diff --git a/app/Livewire/Release/Index.php b/app/Livewire/Release/Index.php new file mode 100644 index 0000000..857d9cb --- /dev/null +++ b/app/Livewire/Release/Index.php @@ -0,0 +1,181 @@ +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 $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')); + } +} diff --git a/app/Support/Confirm/ConfirmToken.php b/app/Support/Confirm/ConfirmToken.php index 78bfcae..ea97eae 100644 --- a/app/Support/Confirm/ConfirmToken.php +++ b/app/Support/Confirm/ConfirmToken.php @@ -61,6 +61,7 @@ class ConfirmToken 'wgSshGate', 'wgSetPort', 'wgSetSubnet', + 'releaseStaged', ]; /** How long an issued confirm stays valid (seconds) — generous for a human click. */ diff --git a/config/clusev.php b/config/clusev.php index e588ddc..2a5b078 100644 --- a/config/clusev.php +++ b/config/clusev.php @@ -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', ]; diff --git a/resources/views/components/sidebar.blade.php b/resources/views/components/sidebar.blade.php index 06011b4..05339be 100644 --- a/resources/views/components/sidebar.blade.php +++ b/resources/views/components/sidebar.blade.php @@ -39,6 +39,11 @@ {{ __('shell.nav_settings') }} {{ __('shell.nav_system') }} {{ __('shell.nav_versions') }} + @if (config('clusev.release_controls')) + + {{ __('release.nav') }} + + @endif {{ __('shell.nav_help') }} diff --git a/resources/views/livewire/release/index.blade.php b/resources/views/livewire/release/index.blade.php new file mode 100644 index 0000000..914e58a --- /dev/null +++ b/resources/views/livewire/release/index.blade.php @@ -0,0 +1,45 @@ +
+
+

{{ __('release.heading') }}

+

{{ __('release.subtitle') }}

+
+ + +
+ {{ __('release.current') }} + {{ $current }} +
+ + @if ($lastTag !== null) +

+ + {{ __('release.staged_label') }} {{ $lastTag }} +

+ @endif + + @if ($pendingId !== null) +

+ + {{ __('release.running', ['target' => $pendingTarget]) }} +

+ @else +
+ @if (isset($targets['continueBeta'])) + + {{ __('release.continue_beta', ['v' => $targets['continueBeta']]) }} + + @endif + + {{ __('release.bump_patch', ['v' => $targets['patch']]) }} + + + {{ __('release.bump_minor', ['v' => $targets['minor']]) }} + + + {{ __('release.bump_major', ['v' => $targets['major']]) }} + +
+

{{ __('release.hint') }}

+ @endif +
+
diff --git a/routes/web.php b/routes/web.php index 7401540..eb9ea0f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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 diff --git a/tests/Feature/ReleaseGatingTest.php b/tests/Feature/ReleaseGatingTest.php new file mode 100644 index 0000000..b74130a --- /dev/null +++ b/tests/Feature/ReleaseGatingTest.php @@ -0,0 +1,42 @@ +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')); + } +} diff --git a/tests/Feature/ReleasePageTest.php b/tests/Feature/ReleasePageTest.php new file mode 100644 index 0000000..4f458c3 --- /dev/null +++ b/tests/Feature/ReleasePageTest.php @@ -0,0 +1,99 @@ +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); + } +}