diff --git a/README.md b/README.md index b2a7c0b256..d3fa07fb71 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,6 @@ Thank you so much! ### Huge Sponsors -* [Context.dev](https://www.context.dev/) - Web scraping API for AI agents * [Ginernet](https://ginernet.com/) - Hosting powerful servers in Spain * [SerpAPI](https://serpapi.com) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API. * [MVPS](https://www.mvps.net) - Cheap VPS servers at the highest possible quality @@ -164,6 +163,7 @@ Thank you so much! InterviewPal Decidable Host Havoc +martiinii ...and many more at [GitHub Sponsors](https://github.com/sponsors/coollabsio) diff --git a/app/Http/Controllers/Api/ServerDockerRegistriesController.php b/app/Http/Controllers/Api/ServerDockerRegistriesController.php index 2923335dcd..ce40571f14 100644 --- a/app/Http/Controllers/Api/ServerDockerRegistriesController.php +++ b/app/Http/Controllers/Api/ServerDockerRegistriesController.php @@ -231,7 +231,7 @@ class ServerDockerRegistriesController extends Controller #[OA\Post( summary: 'Check a registry login on a server', - description: 'Check that the saved login of the server for a registry still works. Runs docker login with the saved credentials on the server.', + description: 'Check that the saved login of the server for a registry still works. Runs docker login with the saved credentials on the server. Needs a token with the write and deploy permissions.', path: '/servers/{uuid}/registries/{registry}/check', operationId: 'check-server-registry-login', security: [['bearerAuth' => []]], diff --git a/app/Livewire/Server/GithubRunners.php b/app/Livewire/Server/GithubRunners.php index 598a8485fc..1da4a95f48 100644 --- a/app/Livewire/Server/GithubRunners.php +++ b/app/Livewire/Server/GithubRunners.php @@ -55,11 +55,6 @@ class GithubRunners extends Component public bool $allowPullRequests = false; - /** Null until checked over SSH. */ - public ?bool $isSysboxInstalled = null; - - protected $listeners = ['sysboxInstalled' => 'checkSysbox']; - public function mount(string $server_uuid) { try { @@ -178,7 +173,6 @@ class GithubRunners extends Component $githubApp = $this->selectedApp; $this->authorize('update', $githubApp); if ($this->dockerMode === GithubRunnerDockerMode::Sysbox->value && ! InstallSysbox::isInstalled($this->server)) { - $this->isSysboxInstalled = false; throw ValidationException::withMessages(['dockerMode' => 'Sysbox is not installed on this server. Install it, then save again.']); } @@ -258,25 +252,6 @@ class GithubRunners extends Component } } - public function checkSysbox(): void - { - $this->authorize('view', $this->server); - $this->isSysboxInstalled = InstallSysbox::isInstalled($this->server); - } - - public function installSysbox() - { - $this->authorize('update', $this->server); - - try { - $activity = InstallSysbox::run($this->server); - $this->dispatch('sysbox-install-started'); - $this->dispatch('activityMonitor', $activity->id, 'sysboxInstalled'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - /** * Creates the runner group when the App can run workflow jobs. An App that is not ready is saved anyway: * the page lists what it misses, and the runner group is created when the first job is provisioned. diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 45b617fab1..bf5d519018 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -850,8 +850,11 @@ class Show extends Component { try { $this->authorize('update', $this->server); + $providerRefreshed = $this->refreshProviderStatus(); if (! $this->server->isFunctional()) { - $this->dispatch('error', 'Validate the server connection before fetching details.'); + $providerRefreshed + ? $this->dispatch('info', 'Provider status refreshed. Validate the server connection to fetch server details.') + : $this->dispatch('error', 'Validate the server connection before fetching details.'); return; } @@ -868,6 +871,30 @@ class Show extends Component } } + /** + * Refresh the state of the linked cloud provider server, if there is one. + */ + private function refreshProviderStatus(): bool + { + if (! $this->server->cloudProviderToken || $this->server->isTransferredAway()) { + return false; + } + + $check = match (true) { + filled($this->server->hetzner_server_id) => $this->checkHetznerServerStatus(...), + filled($this->server->digitalocean_droplet_id) => $this->checkDigitalOceanDropletStatus(...), + filled($this->server->vultr_instance_id) => $this->checkVultrInstanceStatus(...), + filled($this->server->hostinger_virtual_machine_id) => $this->checkHostingerVirtualMachineStatus(...), + default => null, + }; + if (! $check) { + return false; + } + $check(); + + return true; + } + public function submit() { try { diff --git a/app/Livewire/Server/SysboxStatus.php b/app/Livewire/Server/SysboxStatus.php new file mode 100644 index 0000000000..06cb0549b3 --- /dev/null +++ b/app/Livewire/Server/SysboxStatus.php @@ -0,0 +1,62 @@ + 'checkSysbox']; + + public function mount(Server $server): void + { + $this->authorize('view', $server); + $this->server = $server; + $this->checkSysbox(); + } + + public function checkSysbox(): void + { + $this->authorize('view', $this->server); + $this->isInstalled = InstallSysbox::isInstalled($this->server); + } + + public function installSysbox() + { + $this->authorize('update', $this->server); + + try { + $activity = InstallSysbox::run($this->server); + $this->dispatch('sysbox-install-started'); + $this->dispatch('activityMonitor', $activity->id, 'sysboxInstalled'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function placeholder(): View + { + return view('livewire.server.sysbox-status-placeholder'); + } + + public function render(): View + { + return view('livewire.server.sysbox-status'); + } +} diff --git a/openapi.json b/openapi.json index 19c828a514..11f73af9eb 100644 --- a/openapi.json +++ b/openapi.json @@ -18391,7 +18391,7 @@ "Servers" ], "summary": "Check a registry login on a server", - "description": "Check that the saved login of the server for a registry still works. Runs docker login with the saved credentials on the server.", + "description": "Check that the saved login of the server for a registry still works. Runs docker login with the saved credentials on the server. Needs a token with the write and deploy permissions.", "operationId": "check-server-registry-login", "parameters": [ { diff --git a/openapi.yaml b/openapi.yaml index 03d18b3832..05ef6d55cf 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -11705,7 +11705,7 @@ paths: tags: - Servers summary: 'Check a registry login on a server' - description: 'Check that the saved login of the server for a registry still works. Runs docker login with the saved credentials on the server.' + description: 'Check that the saved login of the server for a registry still works. Runs docker login with the saved credentials on the server. Needs a token with the write and deploy permissions.' operationId: check-server-registry-login parameters: - diff --git a/resources/views/components/status-badge.blade.php b/resources/views/components/status-badge.blade.php index 1f1b3089b0..6cabaa1a40 100644 --- a/resources/views/components/status-badge.blade.php +++ b/resources/views/components/status-badge.blade.php @@ -6,6 +6,7 @@ 'dynamic' => false, 'pulse' => false, // ping the dot for a state that is still in progress 'size' => 'md', // sm fits inline next to 12px table text without growing the row + 'spinning' => false, // show the loading spinner instead of the dot, e.g. for a running deployment ]) @php @@ -29,12 +30,19 @@ @if ($dynamic) {{ $slot }} @else + @if ($spinning) + + @else @if ($pulse) @endif + @endif {{ collect([$label, $status])->filter()->join(' ') }} @endif @@ -43,12 +51,19 @@ @if ($dynamic) {{ $slot }} @else + @if ($spinning) + + @else @if ($pulse) @endif + @endif {{ collect([$label, $status])->filter()->join(' ') }} @endif @@ -57,12 +72,19 @@ @if ($dynamic) {{ $slot }} @else + @if ($spinning) + + @else @if ($pulse) @endif + @endif {{ collect([$label, $status])->filter()->join(' ') }} @endif diff --git a/resources/views/livewire/dashboard/active-deployments.blade.php b/resources/views/livewire/dashboard/active-deployments.blade.php index dc3ab8ed93..451a7a7837 100644 --- a/resources/views/livewire/dashboard/active-deployments.blade.php +++ b/resources/views/livewire/dashboard/active-deployments.blade.php @@ -81,14 +81,14 @@ {{ $deployment->server_name ?: '-' }}

- +

{{ $environmentPath ?: '-' }} · @@ -141,14 +141,14 @@ {{ $deployment->server_name ?: '-' }}

- +

{{ $environmentPath ?: '-' }} · diff --git a/resources/views/livewire/deployments-indicator.blade.php b/resources/views/livewire/deployments-indicator.blade.php index 7d745f7066..c325949389 100644 --- a/resources/views/livewire/deployments-indicator.blade.php +++ b/resources/views/livewire/deployments-indicator.blade.php @@ -48,7 +48,7 @@

{{ $deployment->application_name }}

-
diff --git a/resources/views/livewire/project/application/deployment/index.blade.php b/resources/views/livewire/project/application/deployment/index.blade.php index 11d868ad90..1bbdd9c467 100644 --- a/resources/views/livewire/project/application/deployment/index.blade.php +++ b/resources/views/livewire/project/application/deployment/index.blade.php @@ -279,6 +279,7 @@ @unless ($embedded) @@ -336,6 +337,10 @@ {{ \Carbon\Carbon::parse(data_get($deployment, 'created_at'))->diffForHumans(syntax: \Carbon\CarbonInterface::DIFF_ABSOLUTE, short: true) }} + @if ($deploymentStatus === 'in_progress') + + @else $statusType === 'success', @@ -343,6 +348,7 @@ 'bg-red-500' => $statusType === 'error', 'bg-neutral-400 dark:bg-neutral-500' => $statusType === 'neutral', ])> + @endif @endif
+ @if ($deploymentStatus === 'in_progress') + + @else $deploymentStatusType === 'success', @@ -343,6 +346,7 @@ 'bg-red-500' => $deploymentStatusType === 'error', 'bg-neutral-400 dark:bg-neutral-500' => $deploymentStatusType === 'neutral', ])> + @endif {{ $deploymentStatusLabel }} diff --git a/resources/views/livewire/security/private-key/index.blade.php b/resources/views/livewire/security/private-key/index.blade.php index fa59df1506..8c3fcad8e3 100644 --- a/resources/views/livewire/security/private-key/index.blade.php +++ b/resources/views/livewire/security/private-key/index.blade.php @@ -14,7 +14,7 @@ :confirmWithText="false" :confirmWithPassword="false"> diff --git a/resources/views/livewire/server/github-runners.blade.php b/resources/views/livewire/server/github-runners.blade.php index 9623b0453f..bcda87358c 100644 --- a/resources/views/livewire/server/github-runners.blade.php +++ b/resources/views/livewire/server/github-runners.blade.php @@ -171,32 +171,8 @@ :placeholder="config('constants.github_runner.image')" helper="Empty uses the latest official runner image, which Coolify pulls for every runner. Set a tag, for example ghcr.io/actions/actions-runner:2.337.0, to pin a version, or use a custom image based on the official one to add tools. GitHub stops sending jobs to runners that are more than 30 days old." /> -
- @if ($isSysboxInstalled === true) - - Jobs get a private Docker daemon in an unprivileged container. - - @elseif ($isSysboxInstalled === false) - - Isolated Docker needs the Sysbox runtime. Coolify installs Sysbox - {{ config('constants.github_runner.sysbox.version') }} from the official package - (Debian and Ubuntu, kernel 5.12 or newer). Docker reloads its configuration, and - running containers are not restarted. - @can('update', $server) -
- - Install Sysbox - -
- @endcan -
- @else - - Coolify checks if Sysbox is installed on this server. - - @endif +
+
diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index bbd3c5935b..96387be41d 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -28,6 +28,7 @@ 'Hostinger' => $hostingerVirtualMachineStatus, default => null, }; + $canRefreshProvider = $provider && $server->cloudProviderToken && ! $server->isTransferredAway(); $providerStatusType = match (true) { in_array($providerStatus, ['running', 'active']) => 'success', in_array($providerStatus, ['starting', 'initializing', 'pending', 'new', 'initial', 'creating', 'unsuspending', 'recreating', 'restoring']) => 'warning', @@ -265,35 +266,14 @@ - - @if ($provider) + @if ($provider) + - @if ($provider === 'Hetzner') - - - - @elseif ($provider === 'DigitalOcean') - - - - @elseif ($provider === 'Vultr') - - - - @elseif ($provider === 'Hostinger') - - - - @endif + + @endif + + @if ($provider) @if ($server->cloudProviderToken && ! $server->isTransferredAway()) @if ($provider === 'Hetzner' && !$server->isFunctional() && $hetznerServerStatus === 'off') + :disabled="! $server->isFunctional() && ! $canRefreshProvider" + :tooltip="$server->isFunctional() || $canRefreshProvider ? null : 'Validate the connection first.'"> {{ $server->server_metadata ? 'Refresh details' : 'Fetch server details' }} diff --git a/resources/views/livewire/server/sysbox-status-placeholder.blade.php b/resources/views/livewire/server/sysbox-status-placeholder.blade.php new file mode 100644 index 0000000000..d6a3e2a34e --- /dev/null +++ b/resources/views/livewire/server/sysbox-status-placeholder.blade.php @@ -0,0 +1,5 @@ +
+ + Coolify checks if Sysbox is installed on this server. + +
diff --git a/resources/views/livewire/server/sysbox-status.blade.php b/resources/views/livewire/server/sysbox-status.blade.php new file mode 100644 index 0000000000..b6bd94a7aa --- /dev/null +++ b/resources/views/livewire/server/sysbox-status.blade.php @@ -0,0 +1,22 @@ +
+ @if ($isInstalled) + + Jobs get a private Docker daemon in an unprivileged container. + + @else + + Isolated Docker needs the Sysbox runtime. Coolify installs Sysbox + {{ config('constants.github_runner.sysbox.version') }} from the official package + (Debian and Ubuntu, kernel 5.12 or newer). Docker reloads its configuration, and + running containers are not restarted. + @can('update', $server) +
+ + Install Sysbox + +
+ @endcan +
+ @endif +
diff --git a/routes/api.php b/routes/api.php index d5c8491805..476ef2ba45 100644 --- a/routes/api.php +++ b/routes/api.php @@ -201,7 +201,7 @@ Route::group([ Route::get('/servers/{uuid}/registries', [ServerDockerRegistriesController::class, 'index'])->middleware(['api.ability:read:sensitive']); Route::post('/servers/{uuid}/registries', [ServerDockerRegistriesController::class, 'login'])->middleware(['api.ability:write', 'api.ability:deploy']); - Route::post('/servers/{uuid}/registries/{registry}/check', [ServerDockerRegistriesController::class, 'check'])->middleware(['api.ability:write']); + Route::post('/servers/{uuid}/registries/{registry}/check', [ServerDockerRegistriesController::class, 'check'])->middleware(['api.ability:write', 'api.ability:deploy']); Route::delete('/servers/{uuid}/registries/{registry}', [ServerDockerRegistriesController::class, 'logout'])->middleware(['api.ability:delete']); Route::get('/servers/{uuid}/proxy', [ServerProxyController::class, 'show'])->middleware(['api.ability:read']); diff --git a/tests/Feature/GithubRunnerSettingsTest.php b/tests/Feature/GithubRunnerSettingsTest.php index 16fe8067df..6bfc8e142d 100644 --- a/tests/Feature/GithubRunnerSettingsTest.php +++ b/tests/Feature/GithubRunnerSettingsTest.php @@ -8,6 +8,7 @@ use App\Jobs\ProvisionGithubRunnerJob; use App\Livewire\Server\GithubRunnerExecutions; use App\Livewire\Server\GithubRunners; use App\Livewire\Server\Show; +use App\Livewire\Server\SysboxStatus; use App\Livewire\Source\Github\Change; use App\Livewire\Source\Github\Create; use App\Models\GithubApp; @@ -446,8 +447,7 @@ describe('isolated Docker with Sysbox', function () { ->set('githubAppId', $this->githubApp->id) ->set('dockerMode', 'sysbox') ->call('submit') - ->assertHasErrors('dockerMode') - ->assertSet('isSysboxInstalled', false); + ->assertHasErrors('dockerMode'); expect(GithubRunnerConfig::count())->toBe(0); }); @@ -469,29 +469,29 @@ describe('isolated Docker with Sysbox', function () { it('checks that Sysbox can mount its FUSE file system', function () { Process::fake(['*' => Process::result(output: '')]); - Livewire::actingAs($this->owner) - ->test(GithubRunners::class, ['server_uuid' => $this->server->uuid]) - ->call('checkSysbox'); + Livewire::withoutLazyLoading() + ->actingAs($this->owner) + ->test(SysboxStatus::class, ['server' => $this->server]); Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'test -x /usr/bin/fusermount3')); }); it('shows the install button when Sysbox is missing', function () { Process::fake(['*' => Process::result(output: '')]); - GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify']]); - Livewire::actingAs($this->owner) - ->test(GithubRunners::class, ['server_uuid' => $this->server->uuid]) - ->call('checkSysbox') - ->assertSet('isSysboxInstalled', false) + Livewire::withoutLazyLoading() + ->actingAs($this->owner) + ->test(SysboxStatus::class, ['server' => $this->server]) + ->assertSet('isInstalled', false) ->assertSee('Install Sysbox'); }); it('stops before the installation on servers without apt', function () { Process::fake(['*' => Process::result(output: '')]); - Livewire::actingAs($this->owner) - ->test(GithubRunners::class, ['server_uuid' => $this->server->uuid]) + Livewire::withoutLazyLoading() + ->actingAs($this->owner) + ->test(SysboxStatus::class, ['server' => $this->server]) ->call('installSysbox') ->assertNotDispatched('activityMonitor'); @@ -499,14 +499,15 @@ describe('isolated Docker with Sysbox', function () { }); it('does not let members install Sysbox', function () { - Process::fake(); + Process::fake(['*' => Process::result(output: '')]); - Livewire::actingAs($this->member) - ->test(GithubRunners::class, ['server_uuid' => $this->server->uuid]) + Livewire::withoutLazyLoading() + ->actingAs($this->member) + ->test(SysboxStatus::class, ['server' => $this->server]) ->call('installSysbox') ->assertForbidden(); - Process::assertNothingRan(); + Process::assertDidntRun(fn ($process) => str_contains(remoteProcessScript($process), 'apt-get')); }); }); diff --git a/tests/Feature/HostingerServerCreationTest.php b/tests/Feature/HostingerServerCreationTest.php index ac39d73271..378b03e8be 100644 --- a/tests/Feature/HostingerServerCreationTest.php +++ b/tests/Feature/HostingerServerCreationTest.php @@ -407,7 +407,7 @@ it('starts a stopped Hostinger VPS from the server page', function () { expect($server->fresh()->hostinger_virtual_machine_status)->toBe('starting'); }); -it('labels the Hostinger status refresh button on the server page', function () { +it('refreshes the Hostinger status from the server details action before the server is validated', function () { $server = Server::factory()->create([ 'team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id, @@ -416,10 +416,20 @@ it('labels the Hostinger status refresh button on the server page', function () 'hostinger_virtual_machine_status' => 'running', ]); + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([ + 'id' => 17923, + 'state' => 'stopped', + 'ipv4' => [['address' => $server->ip]], + ]), + ]); + Livewire::test(Show::class, ['server_uuid' => $server->uuid]) ->assertSee('Hostinger · Running') - ->assertSee('Refresh provider status') - ->assertSeeHtml('wire:click.prevent="checkHostingerVirtualMachineStatus(true)"'); + ->call('refreshServerMetadata') + ->assertSet('hostingerVirtualMachineStatus', 'stopped') + ->assertDispatched('info', 'Provider status refreshed. Validate the server connection to fetch server details.') + ->assertSee('Hostinger · Stopped'); }); it('does not validate a stopped Hostinger VPS over SSH', function () {