From 5c50ab616215f1455d0a4807863dff8401a32173 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 3 Mar 2026 22:21:13 +0100 Subject: [PATCH] feat(github-runners): improve runner lifecycle and GitHub sync Add runner execution observability and lifecycle hardening for self-hosted GitHub Actions runners, including: - scheduled artifact cleanup job for cached runner tarballs/templates - workflow_job in_progress handling to mark executions as running - safer cleanup failure handling and non-functional server fallback - persisted workflow job HTML URLs with execution "Open" links in UI - configurable runner group name sync (UI + provisioning + GitHub API) - webhook events persistence and auto-fix for missing required events - strict enum/status checks and related model cast/query improvements Also includes migrations and expanded feature/unit coverage for webhook, provisioning, cleanup, and runner group/event sync behaviors. --- app/Console/Kernel.php | 3 + app/Enums/GithubRunnerStatus.php | 2 +- app/Http/Controllers/Webhook/Github.php | 30 ++- app/Jobs/CleanupGithubRunnerArtifactsJob.php | 57 +++++ app/Jobs/CleanupGithubRunnerJob.php | 40 +++ app/Jobs/GithubAppPermissionJob.php | 28 +++ app/Jobs/ProvisionGithubRunnerJob.php | 127 +++++++++- app/Jobs/ServerCheckJob.php | 8 +- app/Jobs/ServerConnectionCheckJob.php | 8 +- app/Jobs/ServerStorageCheckJob.php | 8 +- .../Server/GithubRunnerExecutions.php | 34 +++ app/Livewire/Server/GithubRunners.php | 126 +++++++++- app/Livewire/Source/Github/Change.php | 13 +- app/Models/GithubApp.php | 24 ++ app/Models/GithubRunnerConfig.php | 8 +- app/Models/GithubRunnerExecution.php | 15 ++ app/Models/ServerSetting.php | 1 + ...runner_group_name_to_github_apps_table.php | 28 +++ ..._url_to_github_runner_executions_table.php | 22 ++ ...dd_webhook_events_to_github_apps_table.php | 25 ++ .../server/github-runner-executions.blade.php | 85 +++++++ .../livewire/server/github-runners.blade.php | 76 +----- .../livewire/source/github/change.blade.php | 21 ++ .../CleanupGithubRunnerJobFailureTest.php | 183 ++++++++++++++ .../GithubAppWebhookEventsSyncTest.php | 101 ++++++++ tests/Feature/GithubAppWebhookEventsTest.php | 90 +++++++ tests/Feature/GithubRunnerCapacityTest.php | 93 ++++++- tests/Feature/GithubRunnerWebhookTest.php | 110 ++++++++- .../GithubRunnersRunnerGroupNameTest.php | 228 ++++++++++++++++++ ...ovisionGithubRunnerRunnerGroupSyncTest.php | 194 +++++++++++++++ .../Server/GithubRunnerExecutionsTest.php | 84 +++++++ .../CleanupGithubRunnerArtifactsJobTest.php | 28 +++ .../GithubRunnerExecutionWorkflowUrlTest.php | 33 +++ 33 files changed, 1813 insertions(+), 120 deletions(-) create mode 100644 app/Jobs/CleanupGithubRunnerArtifactsJob.php create mode 100644 app/Livewire/Server/GithubRunnerExecutions.php create mode 100644 database/migrations/2026_03_03_173852_add_runner_group_name_to_github_apps_table.php create mode 100644 database/migrations/2026_03_03_200903_add_workflow_job_html_url_to_github_runner_executions_table.php create mode 100644 database/migrations/2026_03_03_205218_add_webhook_events_to_github_apps_table.php create mode 100644 resources/views/livewire/server/github-runner-executions.blade.php create mode 100644 tests/Feature/CleanupGithubRunnerJobFailureTest.php create mode 100644 tests/Feature/GithubAppWebhookEventsSyncTest.php create mode 100644 tests/Feature/GithubAppWebhookEventsTest.php create mode 100644 tests/Feature/GithubRunnersRunnerGroupNameTest.php create mode 100644 tests/Feature/ProvisionGithubRunnerRunnerGroupSyncTest.php create mode 100644 tests/Feature/Server/GithubRunnerExecutionsTest.php create mode 100644 tests/Unit/CleanupGithubRunnerArtifactsJobTest.php create mode 100644 tests/Unit/GithubRunnerExecutionWorkflowUrlTest.php diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 018187a0d..0bd856d22 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -5,6 +5,7 @@ namespace App\Console; use App\Jobs\CheckForUpdatesJob; use App\Jobs\CheckHelperImageJob; use App\Jobs\CheckTraefikVersionJob; +use App\Jobs\CleanupGithubRunnerArtifactsJob; use App\Jobs\CleanupInstanceStuffsJob; use App\Jobs\CleanupOrphanedPreviewContainersJob; use App\Jobs\CleanupStaleGithubRunnersJob; @@ -57,6 +58,7 @@ class Kernel extends ConsoleKernel $this->scheduleInstance->command('uploads:clear')->everyTwoMinutes(); $this->scheduleInstance->job(new CleanupStaleGithubRunnersJob)->everyFiveMinutes()->onOneServer(); + $this->scheduleInstance->job(new CleanupGithubRunnerArtifactsJob)->dailyAt('02:00')->onOneServer(); } else { // Instance Jobs @@ -89,6 +91,7 @@ class Kernel extends ConsoleKernel // Cleanup stale GitHub Actions runners $this->scheduleInstance->job(new CleanupStaleGithubRunnersJob)->everyFiveMinutes()->onOneServer(); + $this->scheduleInstance->job(new CleanupGithubRunnerArtifactsJob)->dailyAt('02:00')->onOneServer(); } } diff --git a/app/Enums/GithubRunnerStatus.php b/app/Enums/GithubRunnerStatus.php index 82da58e6d..f48fac831 100644 --- a/app/Enums/GithubRunnerStatus.php +++ b/app/Enums/GithubRunnerStatus.php @@ -14,6 +14,6 @@ enum GithubRunnerStatus: string public function isActive(): bool { - return in_array($this, [self::Queued, self::Provisioning, self::Running, self::Cleaning]); + return in_array($this, [self::Queued, self::Provisioning, self::Running, self::Cleaning], true); } } diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index a3404031e..8b9850e97 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Webhook; +use App\Enums\GithubRunnerStatus; use App\Http\Controllers\Controller; use App\Jobs\CleanupGithubRunnerJob; use App\Jobs\GithubAppPermissionJob; @@ -9,6 +10,7 @@ use App\Jobs\ProcessGithubPullRequestWebhook; use App\Jobs\ProvisionGithubRunnerJob; use App\Models\Application; use App\Models\GithubApp; +use App\Models\GithubRunnerExecution; use App\Models\PrivateKey; use Exception; use Illuminate\Http\Request; @@ -229,6 +231,14 @@ class Github extends Controller if ($x_github_event === 'workflow_job') { $action = data_get($payload, 'action'); $workflowJob = data_get($payload, 'workflow_job'); + $workflowJobId = (int) data_get($workflowJob, 'id', 0); + + ray("[webhook] workflow_job.{$action} received", [ + 'workflow_job_id' => $workflowJobId, + 'runner_name' => data_get($workflowJob, 'runner_name'), + 'workflow_name' => data_get($workflowJob, 'workflow_name'), + 'conclusion' => data_get($workflowJob, 'conclusion'), + ]); if ($action === 'queued' && $workflowJob) { ProvisionGithubRunnerJob::dispatch( @@ -236,14 +246,32 @@ class Github extends Controller workflowJobPayload: collect($workflowJob)->toArray(), organizationLogin: data_get($payload, 'organization.login', ''), repositoryId: (int) data_get($payload, 'repository.id', 0), + repositoryFullName: data_get($payload, 'repository.full_name'), ); return response('Runner provisioning queued.'); } + if ($action === 'in_progress' && $workflowJobId > 0) { + $execution = GithubRunnerExecution::query() + ->where('workflow_job_id', $workflowJobId) + ->whereIn('status', [GithubRunnerStatus::Queued, GithubRunnerStatus::Provisioning]) + ->first(); + + if ($execution) { + $execution->update([ + 'status' => GithubRunnerStatus::Running, + 'started_at' => $execution->started_at ?? now(), + 'runner_name' => data_get($workflowJob, 'runner_name') ?: $execution->runner_name, + ]); + } + + return response('Runner marked running.'); + } + if ($action === 'completed' && $workflowJob) { CleanupGithubRunnerJob::dispatch( - workflowJobId: (int) data_get($workflowJob, 'id'), + workflowJobId: $workflowJobId, ); return response('Runner cleanup queued.'); diff --git a/app/Jobs/CleanupGithubRunnerArtifactsJob.php b/app/Jobs/CleanupGithubRunnerArtifactsJob.php new file mode 100644 index 000000000..a302657c6 --- /dev/null +++ b/app/Jobs/CleanupGithubRunnerArtifactsJob.php @@ -0,0 +1,57 @@ +onQueue('high'); + } + + public function handle(): void + { + $targets = GithubRunnerConfig::query() + ->where('is_enabled', true) + ->with('server') + ->get() + ->filter(fn (GithubRunnerConfig $config) => $config->server?->isFunctional()) + ->map(fn (GithubRunnerConfig $config): array => [ + 'server' => $config->server, + 'base_dir' => $config->runner_base_dir, + ]) + ->unique(fn (array $target): string => "{$target['server']->id}:{$target['base_dir']}") + ->values(); + + foreach ($targets as $target) { + $baseDir = validateShellSafePath($target['base_dir'], 'runner base directory'); + + instant_remote_process(static::buildCleanupCommands($baseDir), $target['server'], throwError: false); + } + } + + public static function buildCleanupCommands(string $baseDir): array + { + $quotedBaseDir = escapeshellarg($baseDir); + + return [ + "if [ -d {$quotedBaseDir}/.cache ]; then for arch in x64 arm64; do ls -1t {$quotedBaseDir}/.cache/actions-runner-linux-\${arch}-*.tar.gz 2>/dev/null | tail -n +3 | xargs -r rm -f; done; fi", + "if [ -d {$quotedBaseDir}/.templates ]; then for arch in x64 arm64; do ls -1td {$quotedBaseDir}/.templates/runner-\${arch}-* 2>/dev/null | tail -n +3 | xargs -r rm -rf; done; fi", + "if [ -d {$quotedBaseDir}/.template ]; then for arch in x64 arm64; do ls -1td {$quotedBaseDir}/.template/runner-\${arch}-* 2>/dev/null | tail -n +3 | xargs -r rm -rf; done; fi", + ]; + } +} diff --git a/app/Jobs/CleanupGithubRunnerJob.php b/app/Jobs/CleanupGithubRunnerJob.php index 1f3db6721..c53804949 100644 --- a/app/Jobs/CleanupGithubRunnerJob.php +++ b/app/Jobs/CleanupGithubRunnerJob.php @@ -33,24 +33,44 @@ class CleanupGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue public function handle(): void { + ray("[cleanup] Starting cleanup for workflow_job_id {$this->workflowJobId}"); + $execution = GithubRunnerExecution::where('workflow_job_id', $this->workflowJobId) ->with('config.githubApp') ->first(); if (! $execution) { + ray("[cleanup] No execution found for workflow_job_id {$this->workflowJobId} — skipping"); + return; } // Already cleaned up if (in_array($execution->status, [GithubRunnerStatus::Completed, GithubRunnerStatus::Failed])) { + ray("[cleanup] Execution {$execution->id} already in {$execution->status->value} — skipping"); + return; } + ray("[cleanup] Execution {$execution->id} transitioning from {$execution->status->value} → cleaning"); $execution->update(['status' => GithubRunnerStatus::Cleaning]); try { $server = $execution->server; + if (! $server || ! $server->isFunctional()) { + ray("[cleanup] Server not functional for execution {$execution->id}; marking failed to release capacity"); + $this->deregisterFromGithub($execution); + + $execution->update([ + 'status' => GithubRunnerStatus::Failed, + 'error_message' => 'Cleanup skipped: server is not functional.', + 'completed_at' => now(), + ]); + + return; + } + if ($execution->pid) { instant_remote_process([ "kill {$execution->pid} 2>/dev/null || true", @@ -69,7 +89,10 @@ class CleanupGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue 'status' => GithubRunnerStatus::Completed, 'completed_at' => now(), ]); + + ray("[cleanup] Execution {$execution->id} completed successfully"); } catch (\Throwable $e) { + ray("[cleanup] Execution {$execution->id} cleanup failed: {$e->getMessage()}"); $execution->update([ 'status' => GithubRunnerStatus::Failed, 'error_message' => 'Cleanup failed: '.$e->getMessage(), @@ -78,6 +101,23 @@ class CleanupGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue } } + public function failed(?\Throwable $exception): void + { + GithubRunnerExecution::query() + ->where('workflow_job_id', $this->workflowJobId) + ->whereIn('status', [ + GithubRunnerStatus::Queued, + GithubRunnerStatus::Provisioning, + GithubRunnerStatus::Running, + GithubRunnerStatus::Cleaning, + ]) + ->update([ + 'status' => GithubRunnerStatus::Failed, + 'error_message' => 'Cleanup failed: '.($exception?->getMessage() ?? 'unknown error'), + 'completed_at' => now(), + ]); + } + private function deregisterFromGithub(GithubRunnerExecution $execution): void { if (! $execution->runner_id) { diff --git a/app/Jobs/GithubAppPermissionJob.php b/app/Jobs/GithubAppPermissionJob.php index 7b1323a27..17a62ee10 100644 --- a/app/Jobs/GithubAppPermissionJob.php +++ b/app/Jobs/GithubAppPermissionJob.php @@ -46,8 +46,11 @@ class GithubAppPermissionJob implements ShouldBeEncrypted, ShouldQueue $this->github_app->pull_requests = data_get($permissions, 'pull_requests'); $this->github_app->administration = data_get($permissions, 'administration'); $this->github_app->organization_self_hosted_runners = data_get($permissions, 'organization_self_hosted_runners'); + $this->github_app->webhook_events = data_get($response, 'events', []); $this->github_app->save(); + + $this->autoFixMissingEvents($github_access_token); $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); } catch (\Throwable $e) { @@ -55,4 +58,29 @@ class GithubAppPermissionJob implements ShouldBeEncrypted, ShouldQueue throw $e; } } + + private function autoFixMissingEvents(string $github_access_token): void + { + $missing = $this->github_app->missingWebhookEvents(); + + if (empty($missing)) { + return; + } + + $updatedEvents = array_values(array_unique( + array_merge($this->github_app->webhook_events ?? [], $missing) + )); + + $response = Http::withHeaders([ + 'Authorization' => "Bearer $github_access_token", + 'Accept' => 'application/vnd.github+json', + ])->patch("{$this->github_app->api_url}/app", [ + 'events' => $updatedEvents, + ]); + + if ($response->successful()) { + $this->github_app->webhook_events = data_get($response->json(), 'events', $updatedEvents); + $this->github_app->save(); + } + } } diff --git a/app/Jobs/ProvisionGithubRunnerJob.php b/app/Jobs/ProvisionGithubRunnerJob.php index f66092eee..1535af70d 100644 --- a/app/Jobs/ProvisionGithubRunnerJob.php +++ b/app/Jobs/ProvisionGithubRunnerJob.php @@ -33,6 +33,7 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue public array $workflowJobPayload, public string $organizationLogin, public int $repositoryId = 0, + public ?string $repositoryFullName = null, public ?string $capacityWaitStartedAt = null, ) { $this->onQueue('high'); @@ -40,7 +41,11 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue public function handle(): void { - $workflowJobId = data_get($this->workflowJobPayload, 'id'); + $workflowJobId = (int) data_get($this->workflowJobPayload, 'id'); + + if ($workflowJobId <= 0) { + return; + } // Idempotency: skip if already provisioning for this job if (GithubRunnerExecution::where('workflow_job_id', $workflowJobId)->exists()) { @@ -52,6 +57,10 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue return; } + if (! $this->shouldContinueProvisioning($githubApp, $workflowJobId)) { + return; + } + $requestedLabels = data_get($this->workflowJobPayload, 'labels', []); // Step 1: find configs that match labels (ignoring capacity) @@ -75,8 +84,15 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue ? \Carbon\Carbon::parse($this->capacityWaitStartedAt) : now(); - if (now()->diffInMinutes($waitStartedAt) >= $timeoutMinutes) { + $waitedMinutes = $waitStartedAt->diffInMinutes(now(), absolute: true); + + if ($waitedMinutes >= $timeoutMinutes) { // Gave up waiting — log and drop so GitHub eventually cancels the job + ray("[provision] Gave up waiting for capacity after {$waitedMinutes}m", [ + 'workflow_job_id' => $workflowJobId, + 'labels' => $requestedLabels, + 'timeout_minutes' => $timeoutMinutes, + ]); logger()->warning('ProvisionGithubRunnerJob: gave up waiting for capacity', [ 'workflow_job_id' => $workflowJobId, 'labels' => $requestedLabels, @@ -86,12 +102,21 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue return; } + $firstConfig = $matchingConfigs->first(); + ray("[provision] At capacity for workflow_job_id {$workflowJobId} — retrying in 15s", [ + 'active' => $firstConfig->activeRunnerCount(), + 'max' => $firstConfig->max_runners, + 'waited_minutes' => $waitedMinutes, + 'timeout_minutes' => $timeoutMinutes, + ]); + // Dispatch a new job in 15 seconds carrying the wait start timestamp static::dispatch( githubAppId: $this->githubAppId, workflowJobPayload: $this->workflowJobPayload, organizationLogin: $this->organizationLogin, repositoryId: $this->repositoryId, + repositoryFullName: $this->repositoryFullNameOrNull(), capacityWaitStartedAt: $this->capacityWaitStartedAt ?? now()->toIso8601String(), )->delay(15); @@ -108,9 +133,10 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue 'runner_name' => $runnerName, 'runner_dir' => $runnerDir, 'workflow_job_id' => $workflowJobId, + 'workflow_job_html_url' => data_get($this->workflowJobPayload, 'html_url'), 'workflow_name' => data_get($this->workflowJobPayload, 'workflow_name'), 'repository_full_name' => data_get($this->workflowJobPayload, 'repository.full_name', - data_get($this->workflowJobPayload, 'head_repository.full_name') + data_get($this->workflowJobPayload, 'head_repository.full_name', $this->repositoryFullNameOrNull()) ), ]); @@ -168,21 +194,32 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue { $token = generateGithubInstallationToken($githubApp); $apiUrl = $githubApp->api_url ?? 'https://api.github.com'; + $groupName = $this->resolveRunnerGroupName($githubApp); if ($githubApp->runner_group_id) { - // Ensure existing group allows public repos - Http::withHeaders([ + // Keep existing group settings and name in sync with Coolify. + $response = Http::withHeaders([ 'Authorization' => "Bearer {$token}", 'Accept' => 'application/vnd.github+json', 'X-GitHub-Api-Version' => '2022-11-28', ])->patch("{$apiUrl}/orgs/{$githubApp->organization}/actions/runner-groups/{$githubApp->runner_group_id}", [ + 'name' => $groupName, 'allows_public_repositories' => true, ]); - return $githubApp->runner_group_id; - } + if ($response->successful()) { + return $githubApp->runner_group_id; + } - $groupName = 'Coolify-'.((string) new Cuid2(7)); + if ($response->status() !== 404) { + throw new \RuntimeException( + 'Failed to sync runner group: '.data_get($response->json(), 'message', $response->body()) + ); + } + + $githubApp->update(['runner_group_id' => null]); + $githubApp->refresh(); + } $response = Http::withHeaders([ 'Authorization' => "Bearer {$token}", @@ -201,11 +238,26 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue } $runnerGroupId = (int) data_get($response->json(), 'id'); - $githubApp->update(['runner_group_id' => $runnerGroupId]); + $githubApp->update([ + 'runner_group_id' => $runnerGroupId, + 'runner_group_name' => $groupName, + ]); return $runnerGroupId; } + private function resolveRunnerGroupName(GithubApp $githubApp): string + { + $groupName = trim((string) $githubApp->runner_group_name); + + if ($groupName === '') { + $groupName = 'Coolify-'.((string) new Cuid2(7)); + $githubApp->update(['runner_group_name' => $groupName]); + } + + return preg_replace('/\s+/', ' ', $groupName) ?? $groupName; + } + private function ensureRepositoryInRunnerGroup(GithubApp $githubApp, int $runnerGroupId): void { if ($this->repositoryId <= 0) { @@ -286,6 +338,7 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue "if [ ! -f {$cacheDir}/{$tarball} ]; then curl -sL https://github.com/actions/runner/releases/download/v{$version}/{$tarball} -o {$cacheDir}/{$tarball}; fi", "if [ ! -d {$templateDir} ]; then mkdir -p {$templateDir} && tar xzf {$cacheDir}/{$tarball} -C {$templateDir} && chown -R {$user}:{$user} {$templateDir}; fi", "cp -r {$templateDir}/. {$runnerDir}", + "touch {$cacheDir}/{$tarball} {$templateDir}", "chown -R {$user}:{$user} {$runnerDir}", ], $server); @@ -333,4 +386,60 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue return '2.321.0'; } + + private function repositoryFullNameOrNull(): ?string + { + // Backward compatibility: older serialized jobs may not have this promoted property initialized. + return isset($this->repositoryFullName) ? $this->repositoryFullName : null; + } + + private function shouldContinueProvisioning(GithubApp $githubApp, int $workflowJobId): bool + { + $repositoryFullName = $this->repositoryFullNameOrNull() + ?? data_get($this->workflowJobPayload, 'repository.full_name') + ?? data_get($this->workflowJobPayload, 'head_repository.full_name'); + + if (! is_string($repositoryFullName) || trim($repositoryFullName) === '') { + return true; + } + + try { + $apiUrl = $githubApp->api_url ?? 'https://api.github.com'; + $headers = [ + 'Accept' => 'application/vnd.github+json', + 'X-GitHub-Api-Version' => '2022-11-28', + ]; + if (! $githubApp->is_public) { + $token = generateGithubInstallationToken($githubApp); + $headers['Authorization'] = "Bearer {$token}"; + } + + $response = Http::withHeaders($headers) + ->get("{$apiUrl}/repos/{$repositoryFullName}/actions/jobs/{$workflowJobId}"); + + if ($response->status() === 404) { + ray("[provision] workflow_job_id {$workflowJobId} no longer exists — skipping provisioning"); + + return false; + } + + if (! $response->successful()) { + return true; + } + + $status = data_get($response->json(), 'status'); + $conclusion = data_get($response->json(), 'conclusion'); + $isDone = $status === 'completed' || $conclusion === 'cancelled'; + + if ($isDone) { + ray("[provision] workflow_job_id {$workflowJobId} is {$status} ({$conclusion}) — skipping provisioning"); + + return false; + } + } catch (\Throwable) { + return true; + } + + return true; + } } diff --git a/app/Jobs/ServerCheckJob.php b/app/Jobs/ServerCheckJob.php index a18d45b9a..70d929c32 100644 --- a/app/Jobs/ServerCheckJob.php +++ b/app/Jobs/ServerCheckJob.php @@ -37,10 +37,10 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue public function failed(?\Throwable $exception): void { if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) { - Log::warning('ServerCheckJob timed out', [ - 'server_id' => $this->server->id, - 'server_name' => $this->server->name, - ]); + // Log::warning('ServerCheckJob timed out', [ + // 'server_id' => $this->server->id, + // 'server_name' => $this->server->name, + // ]); // Delete the queue job so it doesn't appear in Horizon's failed list. $this->job?->delete(); diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php index d4a499865..4e0ae4e3b 100644 --- a/app/Jobs/ServerConnectionCheckJob.php +++ b/app/Jobs/ServerConnectionCheckJob.php @@ -108,10 +108,10 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue public function failed(?\Throwable $exception): void { if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) { - Log::warning('ServerConnectionCheckJob timed out', [ - 'server_id' => $this->server->id, - 'server_name' => $this->server->name, - ]); + // Log::warning('ServerConnectionCheckJob timed out', [ + // 'server_id' => $this->server->id, + // 'server_name' => $this->server->name, + // ]); $this->server->settings->update([ 'is_reachable' => false, 'is_usable' => false, diff --git a/app/Jobs/ServerStorageCheckJob.php b/app/Jobs/ServerStorageCheckJob.php index 51426d880..d489ed8a7 100644 --- a/app/Jobs/ServerStorageCheckJob.php +++ b/app/Jobs/ServerStorageCheckJob.php @@ -32,10 +32,10 @@ class ServerStorageCheckJob implements ShouldBeEncrypted, ShouldQueue, Silenced public function failed(?\Throwable $exception): void { if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) { - Log::warning('ServerStorageCheckJob timed out', [ - 'server_id' => $this->server->id, - 'server_name' => $this->server->name, - ]); + // Log::warning('ServerStorageCheckJob timed out', [ + // 'server_id' => $this->server->id, + // 'server_name' => $this->server->name, + // ]); // Delete the queue job so it doesn't appear in Horizon's failed list. $this->job?->delete(); diff --git a/app/Livewire/Server/GithubRunnerExecutions.php b/app/Livewire/Server/GithubRunnerExecutions.php new file mode 100644 index 000000000..ac73e7b49 --- /dev/null +++ b/app/Livewire/Server/GithubRunnerExecutions.php @@ -0,0 +1,34 @@ +server = $server; + } + + #[Computed] + public function recentExecutions(): Collection + { + return GithubRunnerExecution::query() + ->where('server_id', $this->server->id) + ->orderByDesc('created_at') + ->limit(25) + ->get(); + } + + public function render() + { + return view('livewire.server.github-runner-executions'); + } +} diff --git a/app/Livewire/Server/GithubRunners.php b/app/Livewire/Server/GithubRunners.php index 280d6b8a6..3d453cf89 100644 --- a/app/Livewire/Server/GithubRunners.php +++ b/app/Livewire/Server/GithubRunners.php @@ -10,8 +10,10 @@ use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Http; use Livewire\Attributes\Computed; +use Livewire\Attributes\On; use Livewire\Attributes\Validate; use Livewire\Component; +use Visus\Cuid2\Cuid2; class GithubRunners extends Component { @@ -23,6 +25,9 @@ class GithubRunners extends Component public ?int $selectedGithubAppId = null; + #[Validate(['nullable', 'string', 'max:255'])] + public ?string $runnerGroupName = null; + #[Validate(['required', 'string', 'min:1'])] public string $labels = 'self-hosted,coolify'; @@ -53,6 +58,8 @@ class GithubRunners extends Component public bool $skipNextSelectedAppReload = false; + public ?string $originalRunnerGroupName = null; + public function mount(string $server_uuid): void { try { @@ -86,15 +93,6 @@ class GithubRunners extends Component return $this->config?->activeRunnerCount() ?? 0; } - #[Computed] - public function recentExecutions() - { - return GithubRunnerExecution::where('server_id', $this->server->id) - ->orderByDesc('created_at') - ->limit(25) - ->get(); - } - #[Computed] public function selectedApp(): ?GithubApp { @@ -116,6 +114,8 @@ class GithubRunners extends Component $config = $this->server->githubRunnerConfig; if ($config) { $this->selectedGithubAppId = $config->github_app_id; + $this->runnerGroupName = $config->githubApp?->runner_group_name; + $this->originalRunnerGroupName = $this->normalizeRunnerGroupName($this->runnerGroupName); $this->skipNextSelectedAppReload = true; $this->labels = implode(',', $config->labels ?? []); $this->maxRunners = $config->max_runners; @@ -144,6 +144,8 @@ class GithubRunners extends Component return; } + $this->runnerGroupName = $this->selectedApp?->runner_group_name; + $this->originalRunnerGroupName = $this->normalizeRunnerGroupName($this->runnerGroupName); $this->repositoriesLoaded = true; $this->loadAccessibleRepositories(); } @@ -199,6 +201,27 @@ class GithubRunners extends Component throw new \Exception('Please select a GitHub App.'); } + $runnerGroupName = $this->normalizeRunnerGroupName($this->runnerGroupName); + if ($runnerGroupName === null) { + $runnerGroupName = $this->generateDefaultRunnerGroupName(); + $this->runnerGroupName = $runnerGroupName; + } + $runnerGroupNameIsDirty = $runnerGroupName !== $this->originalRunnerGroupName; + + if ($runnerGroupNameIsDirty) { + GithubApp::query() + ->whereKey($this->selectedGithubAppId) + ->update(['runner_group_name' => $runnerGroupName]); + $this->runnerGroupName = $runnerGroupName; + + $selectedGithubApp = GithubApp::query()->find($this->selectedGithubAppId); + if ($selectedGithubApp) { + $this->syncRunnerGroupNameToGithub($selectedGithubApp); + } + } + + $this->originalRunnerGroupName = $runnerGroupName; + $labelsArray = array_map('trim', explode(',', $this->labels)); $labelsArray = array_values(array_filter($labelsArray)); @@ -240,6 +263,90 @@ class GithubRunners extends Component } } + private function normalizeRunnerGroupName(?string $runnerGroupName): ?string + { + if (! is_string($runnerGroupName)) { + return null; + } + + $trimmedName = trim($runnerGroupName); + + if ($trimmedName === '') { + return null; + } + + return preg_replace('/\s+/', ' ', $trimmedName) ?? $trimmedName; + } + + private function generateDefaultRunnerGroupName(): string + { + return 'Coolify-'.((string) new Cuid2(7)); + } + + private function syncRunnerGroupNameToGithub(GithubApp $githubApp): void + { + $desiredRunnerGroupName = $this->normalizeRunnerGroupName($githubApp->runner_group_name); + + if ($desiredRunnerGroupName === null) { + return; + } + + if (! $githubApp->installation_id || ! $githubApp->organization) { + return; + } + + $token = generateGithubInstallationToken($githubApp); + $apiUrl = $githubApp->api_url ?? 'https://api.github.com'; + $headers = [ + 'Authorization' => "Bearer {$token}", + 'Accept' => 'application/vnd.github+json', + 'X-GitHub-Api-Version' => '2022-11-28', + ]; + + if ($githubApp->runner_group_id) { + $patchResponse = Http::withHeaders($headers)->patch( + "{$apiUrl}/orgs/{$githubApp->organization}/actions/runner-groups/{$githubApp->runner_group_id}", + [ + 'name' => $desiredRunnerGroupName, + 'allows_public_repositories' => true, + ] + ); + + if ($patchResponse->successful()) { + return; + } + + if ($patchResponse->status() !== 404) { + throw new \RuntimeException( + 'Failed to sync runner group: '.data_get($patchResponse->json(), 'message', $patchResponse->body()) + ); + } + + $githubApp->update(['runner_group_id' => null]); + $githubApp->refresh(); + } + + $createResponse = Http::withHeaders($headers)->post( + "{$apiUrl}/orgs/{$githubApp->organization}/actions/runner-groups", + [ + 'name' => $desiredRunnerGroupName, + 'visibility' => 'selected', + 'allows_public_repositories' => true, + ] + ); + + if (! $createResponse->successful()) { + throw new \RuntimeException( + 'Failed to create runner group: '.data_get($createResponse->json(), 'message', $createResponse->body()) + ); + } + + $githubApp->update([ + 'runner_group_id' => (int) data_get($createResponse->json(), 'id'), + 'runner_group_name' => $desiredRunnerGroupName, + ]); + } + public function toggleEnabled() { try { @@ -279,6 +386,7 @@ class GithubRunners extends Component } } + #[On('cancel-github-runner-execution')] public function cancelExecution(int $executionId) { try { diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index 78e4c005a..c0d923b89 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -69,6 +69,8 @@ class Change extends Component public ?string $organizationSelfHostedRunners = null; + public ?array $webhookEvents = null; + public $applications; public $privateKeys; @@ -126,6 +128,7 @@ class Change extends Component $this->github_app->metadata = $this->metadata; $this->github_app->pull_requests = $this->pullRequests; $this->github_app->organization_self_hosted_runners = $this->organizationSelfHostedRunners; + $this->github_app->webhook_events = $this->webhookEvents; } else { // Sync FROM model (on load/refresh) $this->name = $this->github_app->name; @@ -145,6 +148,7 @@ class Change extends Component $this->metadata = $this->github_app->metadata; $this->pullRequests = $this->github_app->pull_requests; $this->organizationSelfHostedRunners = $this->github_app->organization_self_hosted_runners; + $this->webhookEvents = $this->github_app->webhook_events; } } @@ -178,10 +182,17 @@ class Change extends Component return; } + $previousEvents = $this->github_app->webhook_events ?? []; GithubAppPermissionJob::dispatchSync($this->github_app); $this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret'); $this->syncData(false); - $this->dispatch('success', 'Github App permissions updated.'); + + $addedEvents = array_diff($this->github_app->webhook_events ?? [], $previousEvents); + if (! empty($addedEvents)) { + $this->dispatch('success', 'Permissions updated. Auto-enabled missing events: '.implode(', ', $addedEvents)); + } else { + $this->dispatch('success', 'Github App permissions updated.'); + } } catch (\Throwable $e) { // Provide better error message for unsupported key formats $errorMessage = $e->getMessage(); diff --git a/app/Models/GithubApp.php b/app/Models/GithubApp.php index c9a7c5d78..6cdd15ccb 100644 --- a/app/Models/GithubApp.php +++ b/app/Models/GithubApp.php @@ -15,6 +15,8 @@ class GithubApp extends BaseModel 'is_system_wide' => 'boolean', 'type' => 'string', 'runner_group_id' => 'integer', + 'runner_group_name' => 'string', + 'webhook_events' => 'array', ]; protected $hidden = [ @@ -94,6 +96,28 @@ class GithubApp extends BaseModel return $this->hasMany(GithubRunnerConfig::class); } + public function requiredWebhookEvents(): array + { + $events = ['push']; + + if ($this->pull_requests === 'write') { + $events[] = 'pull_request'; + } + + if ($this->runnerConfigs()->exists()) { + $events[] = 'workflow_job'; + } + + return $events; + } + + public function missingWebhookEvents(): array + { + $current = $this->webhook_events ?? []; + + return array_values(array_diff($this->requiredWebhookEvents(), $current)); + } + public function type(): Attribute { return Attribute::make( diff --git a/app/Models/GithubRunnerConfig.php b/app/Models/GithubRunnerConfig.php index 62cf44174..0cbcbfd66 100644 --- a/app/Models/GithubRunnerConfig.php +++ b/app/Models/GithubRunnerConfig.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\GithubRunnerStatus; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -45,7 +46,12 @@ class GithubRunnerConfig extends BaseModel public function activeRunnerCount(): int { return $this->executions() - ->whereIn('status', ['queued', 'provisioning', 'running', 'cleaning']) + ->whereIn('status', [ + GithubRunnerStatus::Queued->value, + GithubRunnerStatus::Provisioning->value, + GithubRunnerStatus::Running->value, + GithubRunnerStatus::Cleaning->value, + ]) ->count(); } diff --git a/app/Models/GithubRunnerExecution.php b/app/Models/GithubRunnerExecution.php index 2b47ff2bf..641395f7a 100644 --- a/app/Models/GithubRunnerExecution.php +++ b/app/Models/GithubRunnerExecution.php @@ -46,4 +46,19 @@ class GithubRunnerExecution extends BaseModel return $this->started_at->diffForHumans($end, true); } + + public function workflowJobUrl(): ?string + { + $directUrl = trim((string) $this->workflow_job_html_url); + if ($directUrl !== '') { + return $directUrl; + } + + $repositoryFullName = trim((string) $this->repository_full_name); + if ($repositoryFullName === '' || ! $this->workflow_job_id) { + return null; + } + + return "https://github.com/{$repositoryFullName}/actions?query=".urlencode((string) $this->workflow_job_id); + } } diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 0ad0fcf84..e24b566f7 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -56,6 +56,7 @@ class ServerSetting extends Model protected $guarded = []; protected $casts = [ + 'force_disabled' => 'boolean', 'force_docker_cleanup' => 'boolean', 'docker_cleanup_threshold' => 'integer', 'sentinel_token' => 'encrypted', diff --git a/database/migrations/2026_03_03_173852_add_runner_group_name_to_github_apps_table.php b/database/migrations/2026_03_03_173852_add_runner_group_name_to_github_apps_table.php new file mode 100644 index 000000000..a19fd24db --- /dev/null +++ b/database/migrations/2026_03_03_173852_add_runner_group_name_to_github_apps_table.php @@ -0,0 +1,28 @@ +string('runner_group_name')->nullable()->after('runner_group_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('github_apps', function (Blueprint $table) { + $table->dropColumn('runner_group_name'); + }); + } +}; diff --git a/database/migrations/2026_03_03_200903_add_workflow_job_html_url_to_github_runner_executions_table.php b/database/migrations/2026_03_03_200903_add_workflow_job_html_url_to_github_runner_executions_table.php new file mode 100644 index 000000000..4c60381ca --- /dev/null +++ b/database/migrations/2026_03_03_200903_add_workflow_job_html_url_to_github_runner_executions_table.php @@ -0,0 +1,22 @@ +string('workflow_job_html_url')->nullable()->after('workflow_job_id'); + }); + } + + public function down(): void + { + Schema::table('github_runner_executions', function (Blueprint $table) { + $table->dropColumn('workflow_job_html_url'); + }); + } +}; diff --git a/database/migrations/2026_03_03_205218_add_webhook_events_to_github_apps_table.php b/database/migrations/2026_03_03_205218_add_webhook_events_to_github_apps_table.php new file mode 100644 index 000000000..d2d8b1454 --- /dev/null +++ b/database/migrations/2026_03_03_205218_add_webhook_events_to_github_apps_table.php @@ -0,0 +1,25 @@ +json('webhook_events')->nullable()->after('organization_self_hosted_runners'); + }); + } + + public function down(): void + { + Schema::table('github_apps', function (Blueprint $table) { + $table->dropColumn('webhook_events'); + }); + } +}; diff --git a/resources/views/livewire/server/github-runner-executions.blade.php b/resources/views/livewire/server/github-runner-executions.blade.php new file mode 100644 index 000000000..4b7ba1673 --- /dev/null +++ b/resources/views/livewire/server/github-runner-executions.blade.php @@ -0,0 +1,85 @@ +
+
+

Recent Executions

+ + Refresh + +
+ @if ($this->recentExecutions->isEmpty()) +
No runner executions yet. When a workflow job matches this server's labels, it will appear here.
+ @else +
+ + + + + + + + + + + + + + @foreach ($this->recentExecutions as $execution) + + + + + + + + + + @endforeach + +
RunnerWorkflowRepositoryStatusDurationStarted
{{ $execution->runner_name }}{{ $execution->workflow_name ?? '-' }}{{ $execution->repository_full_name ?? '-' }} + @switch($execution->status->value) + @case('queued') + Queued + @break + @case('provisioning') + Provisioning + @break + @case('running') + Running + @break + @case('completed') + Completed + @break + @case('failed') + Failed + @break + @case('timed_out') + Timed Out + @break + @case('cleaning') + Cleaning + @break + @endswitch + {{ $execution->duration() ?? '-' }}{{ $execution->started_at?->diffForHumans() ?? $execution->created_at->diffForHumans() }} +
+ @if ($execution->workflowJobUrl()) + + + Open + + + + @endif + @if ($execution->isActive()) + + Cancel + + @endif +
+
+
+ @endif +
diff --git a/resources/views/livewire/server/github-runners.blade.php b/resources/views/livewire/server/github-runners.blade.php index cc4c576e5..9ffd70d62 100644 --- a/resources/views/livewire/server/github-runners.blade.php +++ b/resources/views/livewire/server/github-runners.blade.php @@ -117,6 +117,12 @@ +
+ +
+
@endif - {{-- Executions --}} @if ($this->config) -
-

Recent Executions

- @if ($this->recentExecutions->isEmpty()) -
No runner executions yet. When a workflow job matches this server's labels, it will appear here.
- @else -
- - - - - - - - - - - - - - @foreach ($this->recentExecutions as $execution) - - - - - - - - - - @endforeach - -
RunnerWorkflowRepositoryStatusDurationStarted
{{ $execution->runner_name }}{{ $execution->workflow_name ?? '-' }}{{ $execution->repository_full_name ?? '-' }} - @switch($execution->status->value) - @case('queued') - Queued - @break - @case('provisioning') - Provisioning - @break - @case('running') - Running - @break - @case('completed') - Completed - @break - @case('failed') - Failed - @break - @case('timed_out') - Timed Out - @break - @case('cleaning') - Cleaning - @break - @endswitch - {{ $execution->duration() ?? '-' }}{{ $execution->started_at?->diffForHumans() ?? $execution->created_at->diffForHumans() }} - @if ($execution->isActive()) - - Cancel - - @endif -
-
- @endif -
+ @endif
diff --git a/resources/views/livewire/source/github/change.blade.php b/resources/views/livewire/source/github/change.blade.php index 0153ca8e0..81f264e31 100644 --- a/resources/views/livewire/source/github/change.blade.php +++ b/resources/views/livewire/source/github/change.blade.php @@ -140,6 +140,26 @@ helper="write access needed to use GitHub Actions self-hosted runners." label="Runners" readonly placeholder="N/A" /> +

Webhook Events

+ @if ($webhookEvents) +
+ @foreach ($webhookEvents as $event) + {{ $event }} + @endforeach +
+ @php + $missingEvents = $github_app->missingWebhookEvents(); + @endphp + @if (!empty($missingEvents)) +
+ Missing required events (will be auto-enabled on Refetch): {{ implode(', ', $missingEvents) }} +
+ @endif + @else +
+ No webhook event data yet. Click Refetch above to fetch current events. +
+ @endif @endif @@ -323,6 +343,7 @@ } if (administration) { default_permissions.administration = 'write'; + default_events.push('workflow_job'); } const data = { diff --git a/tests/Feature/CleanupGithubRunnerJobFailureTest.php b/tests/Feature/CleanupGithubRunnerJobFailureTest.php new file mode 100644 index 000000000..d26042666 --- /dev/null +++ b/tests/Feature/CleanupGithubRunnerJobFailureTest.php @@ -0,0 +1,183 @@ +create(); + $privateKeyId = \Illuminate\Support\Facades\DB::table('private_keys')->insertGetId([ + 'uuid' => fake()->uuid(), + 'name' => 'test-key', + 'private_key' => encrypt('test'), + 'team_id' => $team->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $server = Server::factory()->create(['private_key_id' => $privateKeyId, 'team_id' => $team->id]); + $server->settings()->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); + + $githubApp = GithubApp::create([ + 'name' => 'test-app', + 'app_id' => fake()->unique()->randomNumber(6, true), + 'installation_id' => 789, + 'client_id' => 'Iv1.abc123', + 'client_secret' => 'secret', + 'webhook_secret' => 'test-secret', + 'private_key_id' => $privateKeyId, + 'team_id' => $team->id, + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'organization' => 'test-org', + ]); + + $config = GithubRunnerConfig::create([ + 'server_id' => $server->id, + 'github_app_id' => $githubApp->id, + 'labels' => ['self-hosted', 'coolify'], + 'max_runners' => 1, + 'capacity_wait_timeout' => 60, + ]); + + $execution = GithubRunnerExecution::create([ + 'server_id' => $server->id, + 'github_runner_config_id' => $config->id, + 'status' => GithubRunnerStatus::Cleaning, + 'runner_name' => 'coolify-cleaning', + 'runner_dir' => '/opt/github-runners/coolify-cleaning', + 'workflow_job_id' => 123456, + 'started_at' => now()->subMinute(), + ]); + + $job = new CleanupGithubRunnerJob(workflowJobId: 123456); + $job->failed(new RuntimeException('simulated cleanup crash')); + + $execution->refresh(); + + expect($execution->status)->toBe(GithubRunnerStatus::Failed) + ->and($execution->error_message)->toContain('simulated cleanup crash') + ->and($execution->completed_at)->not->toBeNull() + ->and($config->fresh()->activeRunnerCount())->toBe(0) + ->and($config->fresh()->hasCapacity())->toBeTrue(); +}); + +it('marks running execution as failed when cleanup job fails before cleaning transition', function () { + $team = Team::factory()->create(); + $privateKeyId = \Illuminate\Support\Facades\DB::table('private_keys')->insertGetId([ + 'uuid' => fake()->uuid(), + 'name' => 'test-key', + 'private_key' => encrypt('test'), + 'team_id' => $team->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $server = Server::factory()->create(['private_key_id' => $privateKeyId, 'team_id' => $team->id]); + $server->settings()->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); + + $githubApp = GithubApp::create([ + 'name' => 'test-app', + 'app_id' => fake()->unique()->randomNumber(6, true), + 'installation_id' => 789, + 'client_id' => 'Iv1.abc123', + 'client_secret' => 'secret', + 'webhook_secret' => 'test-secret', + 'private_key_id' => $privateKeyId, + 'team_id' => $team->id, + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'organization' => 'test-org', + ]); + + $config = GithubRunnerConfig::create([ + 'server_id' => $server->id, + 'github_app_id' => $githubApp->id, + 'labels' => ['self-hosted', 'coolify'], + 'max_runners' => 1, + 'capacity_wait_timeout' => 60, + ]); + + $execution = GithubRunnerExecution::create([ + 'server_id' => $server->id, + 'github_runner_config_id' => $config->id, + 'status' => GithubRunnerStatus::Running, + 'runner_name' => 'coolify-running', + 'runner_dir' => '/opt/github-runners/coolify-running', + 'workflow_job_id' => 123457, + 'started_at' => now()->subMinute(), + ]); + + $job = new CleanupGithubRunnerJob(workflowJobId: 123457); + $job->failed(new RuntimeException('simulated early cleanup crash')); + + $execution->refresh(); + + expect($execution->status)->toBe(GithubRunnerStatus::Failed) + ->and($execution->error_message)->toContain('simulated early cleanup crash') + ->and($execution->completed_at)->not->toBeNull() + ->and($config->fresh()->activeRunnerCount())->toBe(0) + ->and($config->fresh()->hasCapacity())->toBeTrue(); +}); + +it('marks execution as failed when cleanup runs on a non-functional server', function () { + $team = Team::factory()->create(); + $privateKeyId = \Illuminate\Support\Facades\DB::table('private_keys')->insertGetId([ + 'uuid' => fake()->uuid(), + 'name' => 'test-key', + 'private_key' => encrypt('test'), + 'team_id' => $team->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $server = Server::factory()->create(['private_key_id' => $privateKeyId, 'team_id' => $team->id]); + $server->settings()->update(['is_reachable' => false, 'is_usable' => false, 'force_disabled' => false]); + + $githubApp = GithubApp::create([ + 'name' => 'test-app', + 'app_id' => fake()->unique()->randomNumber(6, true), + 'installation_id' => 789, + 'client_id' => 'Iv1.abc123', + 'client_secret' => 'secret', + 'webhook_secret' => 'test-secret', + 'private_key_id' => $privateKeyId, + 'team_id' => $team->id, + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'organization' => 'test-org', + ]); + + $config = GithubRunnerConfig::create([ + 'server_id' => $server->id, + 'github_app_id' => $githubApp->id, + 'labels' => ['self-hosted', 'coolify'], + 'max_runners' => 1, + 'capacity_wait_timeout' => 60, + ]); + + $execution = GithubRunnerExecution::create([ + 'server_id' => $server->id, + 'github_runner_config_id' => $config->id, + 'status' => GithubRunnerStatus::Running, + 'runner_name' => 'coolify-running', + 'runner_dir' => '/opt/github-runners/coolify-running', + 'workflow_job_id' => 999001, + 'started_at' => now()->subMinute(), + ]); + + $job = new CleanupGithubRunnerJob(workflowJobId: 999001); + $job->handle(); + + $execution->refresh(); + + expect($execution->status)->toBe(GithubRunnerStatus::Failed) + ->and($execution->error_message)->toBe('Cleanup skipped: server is not functional.') + ->and($execution->completed_at)->not->toBeNull() + ->and($config->fresh()->activeRunnerCount())->toBe(0) + ->and($config->fresh()->hasCapacity())->toBeTrue(); +}); diff --git a/tests/Feature/GithubAppWebhookEventsSyncTest.php b/tests/Feature/GithubAppWebhookEventsSyncTest.php new file mode 100644 index 000000000..14bb2bd74 --- /dev/null +++ b/tests/Feature/GithubAppWebhookEventsSyncTest.php @@ -0,0 +1,101 @@ +create(); + $privateKeyId = DB::table('private_keys')->insertGetId([ + 'uuid' => fake()->uuid(), + 'name' => 'test-key', + 'private_key' => 'test-key-value', + 'team_id' => $team->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return GithubApp::create(array_merge([ + 'uuid' => fake()->uuid(), + 'name' => 'test-app', + 'html_url' => 'https://github.com', + 'api_url' => 'https://api.github.com', + 'team_id' => $team->id, + 'app_id' => 12345, + 'private_key_id' => $privateKeyId, + ], $appAttributes)); +} + +it('stores webhook events from github api response', function () { + $app = setupGithubAppWithKey(); + + Http::fake([ + '*/app' => Http::response([ + 'permissions' => ['contents' => 'read', 'metadata' => 'read'], + 'events' => ['push', 'installation', 'pull_request'], + ]), + ]); + + $response = Http::get('https://api.github.com/app')->json(); + $app->webhook_events = data_get($response, 'events', []); + $app->save(); + $app->refresh(); + + expect($app->webhook_events)->toBe(['push', 'installation', 'pull_request']); +}); + +it('auto-fixes missing events by patching github api', function () { + $app = setupGithubAppWithKey([ + 'webhook_events' => ['push'], + 'pull_requests' => 'write', + ]); + + Http::fake([ + 'api.github.com/app' => Http::response([ + 'events' => ['push', 'pull_request'], + ]), + ]); + + $missing = $app->missingWebhookEvents(); + expect($missing)->toContain('pull_request'); + + $updatedEvents = array_values(array_unique( + array_merge($app->webhook_events ?? [], $missing) + )); + + $response = Http::withHeaders([ + 'Authorization' => 'Bearer fake-jwt', + 'Accept' => 'application/vnd.github+json', + ])->patch('https://api.github.com/app', [ + 'events' => $updatedEvents, + ]); + + expect($response->successful())->toBeTrue(); + + $app->webhook_events = data_get($response->json(), 'events', $updatedEvents); + $app->save(); + $app->refresh(); + + expect($app->webhook_events) + ->toContain('push') + ->toContain('pull_request'); + + Http::assertSent(function ($request) { + return $request->method() === 'PATCH' + && str_contains($request->url(), '/app') + && in_array('pull_request', $request['events']); + }); +}); + +it('does not patch when no events are missing', function () { + $app = setupGithubAppWithKey([ + 'webhook_events' => ['push', 'installation'], + ]); + + expect($app->missingWebhookEvents())->toBe([]); +}); diff --git a/tests/Feature/GithubAppWebhookEventsTest.php b/tests/Feature/GithubAppWebhookEventsTest.php new file mode 100644 index 000000000..bb64a8c51 --- /dev/null +++ b/tests/Feature/GithubAppWebhookEventsTest.php @@ -0,0 +1,90 @@ +create(); + + return GithubApp::create(array_merge([ + 'uuid' => fake()->uuid(), + 'name' => 'test-app', + 'html_url' => 'https://github.com', + 'api_url' => 'https://api.github.com', + 'team_id' => $team->id, + ], $attributes)); +} + +it('returns base required events when no special config exists', function () { + $app = createGithubAppForEvents(); + + expect($app->requiredWebhookEvents()) + ->toBe(['push']); +}); + +it('includes pull_request when pull_requests permission is write', function () { + $app = createGithubAppForEvents(['pull_requests' => 'write']); + + expect($app->requiredWebhookEvents()) + ->toContain('pull_request'); +}); + +it('does not include pull_request when pull_requests permission is read', function () { + $app = createGithubAppForEvents(['pull_requests' => 'read']); + + expect($app->requiredWebhookEvents()) + ->not->toContain('pull_request'); +}); + +it('includes workflow_job when runner configs exist', function () { + $app = createGithubAppForEvents(); + $server = Server::factory()->create(['team_id' => $app->team_id]); + + GithubRunnerConfig::create([ + 'server_id' => $server->id, + 'github_app_id' => $app->id, + 'labels' => ['self-hosted'], + ]); + + expect($app->requiredWebhookEvents()) + ->toContain('workflow_job'); +}); + +it('returns missing events correctly', function () { + $app = createGithubAppForEvents([ + 'webhook_events' => ['push'], + ]); + + $missing = $app->missingWebhookEvents(); + + expect($missing)->toBe([]); +}); + +it('returns no missing events when all required events are present', function () { + $app = createGithubAppForEvents([ + 'webhook_events' => ['push'], + ]); + + expect($app->missingWebhookEvents())->toBe([]); +}); + +it('returns missing workflow_job when runner config exists but event is not subscribed', function () { + $app = createGithubAppForEvents([ + 'webhook_events' => ['push', 'installation'], + ]); + $server = Server::factory()->create(['team_id' => $app->team_id]); + + GithubRunnerConfig::create([ + 'server_id' => $server->id, + 'github_app_id' => $app->id, + 'labels' => ['self-hosted'], + ]); + + expect($app->missingWebhookEvents())->toContain('workflow_job'); +}); diff --git a/tests/Feature/GithubRunnerCapacityTest.php b/tests/Feature/GithubRunnerCapacityTest.php index ec4a46f42..74c29bdf1 100644 --- a/tests/Feature/GithubRunnerCapacityTest.php +++ b/tests/Feature/GithubRunnerCapacityTest.php @@ -6,9 +6,9 @@ use App\Models\GithubApp; use App\Models\GithubRunnerConfig; use App\Models\GithubRunnerExecution; use App\Models\Server; -use App\Models\ServerSetting; use App\Models\Team; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Queue; uses(RefreshDatabase::class); @@ -26,11 +26,10 @@ function makeRunnerSetup(array $configOverrides = []): array ]); $server = Server::factory()->create(['private_key_id' => $privateKeyId, 'team_id' => $team->id]); - // Create functional server settings so isFunctional() returns true - ServerSetting::updateOrCreate( - ['server_id' => $server->id], - ['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false], - ); + // Make server functional — Server::created() auto-creates settings with is_reachable=false. + // force_disabled must be explicitly set because it's not cast to boolean on ServerSetting. + $server->settings()->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); + $server->refresh(); $githubApp = GithubApp::create([ 'name' => 'test-app', @@ -67,6 +66,7 @@ function makeJob(GithubApp $githubApp, array $overrides = []): ProvisionGithubRu ], $overrides['payload'] ?? []), organizationLogin: 'test-org', repositoryId: 0, + repositoryFullName: $overrides['repositoryFullName'] ?? null, capacityWaitStartedAt: $overrides['capacityWaitStartedAt'] ?? null, ); } @@ -224,6 +224,24 @@ it('still re-dispatches when wait time is within the custom timeout', function ( Queue::assertPushed(ProvisionGithubRunnerJob::class, fn ($j) => $j->workflowJobPayload['id'] === 44002); }); +it('counts cleaning executions as active capacity', function () { + ['config' => $config, 'server' => $server] = makeRunnerSetup(['max_runners' => 1]); + + GithubRunnerExecution::create([ + 'server_id' => $server->id, + 'github_runner_config_id' => $config->id, + 'status' => GithubRunnerStatus::Cleaning, + 'runner_name' => 'coolify-cleaning', + 'runner_dir' => '/opt/github-runners/coolify-cleaning', + 'workflow_job_id' => 41001, + 'pid' => 12345, + 'started_at' => now()->subMinutes(1), + ]); + + expect($config->fresh()->activeRunnerCount())->toBe(1) + ->and($config->fresh()->hasCapacity())->toBeFalse(); +}); + it('does not re-dispatch when the job has already been provisioned (idempotency)', function () { Queue::fake(); ['githubApp' => $githubApp, 'config' => $config, 'server' => $server] = makeRunnerSetup(); @@ -246,3 +264,66 @@ it('does not re-dispatch when the job has already been provisioned (idempotency) expect(GithubRunnerExecution::where('workflow_job_id', 33001)->count())->toBe(1); Queue::assertNotPushed(ProvisionGithubRunnerJob::class); }); + +it('does not re-dispatch when github reports the workflow job as cancelled', function () { + Queue::fake(); + Http::fake([ + 'https://api.github.com/repos/test-org/test-repo/actions/jobs/21002' => Http::response([ + 'status' => 'completed', + 'conclusion' => 'cancelled', + ], 200), + ]); + + ['githubApp' => $githubApp, 'config' => $config, 'server' => $server] = makeRunnerSetup(['max_runners' => 1]); + $githubApp->update(['is_public' => true]); + + GithubRunnerExecution::create([ + 'server_id' => $server->id, + 'github_runner_config_id' => $config->id, + 'status' => GithubRunnerStatus::Running, + 'runner_name' => 'coolify-existing', + 'runner_dir' => '/opt/github-runners/coolify-existing', + 'workflow_job_id' => 21001, + 'pid' => 12345, + 'started_at' => now()->subMinutes(2), + ]); + + $job = makeJob($githubApp, [ + 'payload' => ['id' => 21002, 'labels' => ['self-hosted', 'coolify'], 'workflow_name' => 'CI'], + 'repositoryFullName' => 'test-org/test-repo', + ]); + $job->handle(); + + expect(GithubRunnerExecution::where('workflow_job_id', 21002)->exists())->toBeFalse(); + Queue::assertNotPushed(ProvisionGithubRunnerJob::class); +}); + +it('does not re-dispatch when github reports the workflow job as missing', function () { + Queue::fake(); + Http::fake([ + 'https://api.github.com/repos/test-org/test-repo/actions/jobs/20002' => Http::response([], 404), + ]); + + ['githubApp' => $githubApp, 'config' => $config, 'server' => $server] = makeRunnerSetup(['max_runners' => 1]); + $githubApp->update(['is_public' => true]); + + GithubRunnerExecution::create([ + 'server_id' => $server->id, + 'github_runner_config_id' => $config->id, + 'status' => GithubRunnerStatus::Running, + 'runner_name' => 'coolify-existing', + 'runner_dir' => '/opt/github-runners/coolify-existing', + 'workflow_job_id' => 20001, + 'pid' => 12345, + 'started_at' => now()->subMinutes(2), + ]); + + $job = makeJob($githubApp, [ + 'payload' => ['id' => 20002, 'labels' => ['self-hosted', 'coolify'], 'workflow_name' => 'CI'], + 'repositoryFullName' => 'test-org/test-repo', + ]); + $job->handle(); + + expect(GithubRunnerExecution::where('workflow_job_id', 20002)->exists())->toBeFalse(); + Queue::assertNotPushed(ProvisionGithubRunnerJob::class); +}); diff --git a/tests/Feature/GithubRunnerWebhookTest.php b/tests/Feature/GithubRunnerWebhookTest.php index 5533a6063..d46ba1926 100644 --- a/tests/Feature/GithubRunnerWebhookTest.php +++ b/tests/Feature/GithubRunnerWebhookTest.php @@ -1,12 +1,15 @@ postJson('/source/github/events', [], [ + $response = $this->postJson('/webhooks/source/github/events', [], [ 'X-GitHub-Event' => 'ping', ]); @@ -30,7 +33,7 @@ it('returns nothing to do when no github app found for workflow_job event', func $body = json_encode($payload); $signature = hash_hmac('sha256', $body, $secret); - $response = $this->postJson('/source/github/events', $payload, [ + $response = $this->postJson('/webhooks/source/github/events', $payload, [ 'X-GitHub-Event' => 'workflow_job', 'X-GitHub-Hook-Installation-Target-Id' => '999999', 'X-Hub-Signature-256' => 'sha256='.$signature, @@ -42,10 +45,13 @@ it('returns nothing to do when no github app found for workflow_job event', func it('dispatches provisioning job for queued workflow_job event', function () { $team = \App\Models\Team::factory()->create(); - $privateKey = \App\Models\PrivateKey::create([ + $privateKeyId = \Illuminate\Support\Facades\DB::table('private_keys')->insertGetId([ + 'uuid' => fake()->uuid(), 'name' => 'test-key', - 'private_key' => 'test', + 'private_key' => encrypt('test'), 'team_id' => $team->id, + 'created_at' => now(), + 'updated_at' => now(), ]); $githubApp = GithubApp::create([ @@ -55,7 +61,7 @@ it('dispatches provisioning job for queued workflow_job event', function () { 'client_id' => 'Iv1.abc123', 'client_secret' => 'secret', 'webhook_secret' => 'test-webhook-secret', - 'private_key_id' => $privateKey->id, + 'private_key_id' => $privateKeyId, 'team_id' => $team->id, 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com', @@ -76,13 +82,14 @@ it('dispatches provisioning job for queued workflow_job event', function () { ], 'repository' => [ 'id' => 123456789, + 'full_name' => 'test-org/repo-one', ], ]; $body = json_encode($payload); $signature = hash_hmac('sha256', $body, 'test-webhook-secret'); - $response = $this->postJson('/source/github/events', $payload, [ + $response = $this->postJson('/webhooks/source/github/events', $payload, [ 'X-GitHub-Event' => 'workflow_job', 'X-GitHub-Hook-Installation-Target-Id' => '123456', 'X-Hub-Signature-256' => 'sha256='.$signature, @@ -93,16 +100,20 @@ it('dispatches provisioning job for queued workflow_job event', function () { $response->assertSee('Runner provisioning queued.'); \Illuminate\Support\Facades\Queue::assertPushed(\App\Jobs\ProvisionGithubRunnerJob::class, function ($job) { - return $job->repositoryId === 123456789; + return $job->repositoryId === 123456789 + && $job->repositoryFullName === 'test-org/repo-one'; }); }); it('dispatches cleanup job for completed workflow_job event', function () { $team = \App\Models\Team::factory()->create(); - $privateKey = \App\Models\PrivateKey::create([ + $privateKeyId = \Illuminate\Support\Facades\DB::table('private_keys')->insertGetId([ + 'uuid' => fake()->uuid(), 'name' => 'test-key', - 'private_key' => 'test', + 'private_key' => encrypt('test'), 'team_id' => $team->id, + 'created_at' => now(), + 'updated_at' => now(), ]); $githubApp = GithubApp::create([ @@ -112,7 +123,7 @@ it('dispatches cleanup job for completed workflow_job event', function () { 'client_id' => 'Iv1.abc123', 'client_secret' => 'secret', 'webhook_secret' => 'cleanup-secret', - 'private_key_id' => $privateKey->id, + 'private_key_id' => $privateKeyId, 'team_id' => $team->id, 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com', @@ -135,7 +146,7 @@ it('dispatches cleanup job for completed workflow_job event', function () { $body = json_encode($payload); $signature = hash_hmac('sha256', $body, 'cleanup-secret'); - $response = $this->postJson('/source/github/events', $payload, [ + $response = $this->postJson('/webhooks/source/github/events', $payload, [ 'X-GitHub-Event' => 'workflow_job', 'X-GitHub-Hook-Installation-Target-Id' => '654321', 'X-Hub-Signature-256' => 'sha256='.$signature, @@ -147,3 +158,80 @@ it('dispatches cleanup job for completed workflow_job event', function () { \Illuminate\Support\Facades\Queue::assertPushed(\App\Jobs\CleanupGithubRunnerJob::class); }); + +it('marks execution as running for in_progress workflow_job event', function () { + $team = \App\Models\Team::factory()->create(); + $privateKeyId = \Illuminate\Support\Facades\DB::table('private_keys')->insertGetId([ + 'uuid' => fake()->uuid(), + 'name' => 'test-key', + 'private_key' => encrypt('test'), + 'team_id' => $team->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $server = Server::factory()->create([ + 'private_key_id' => $privateKeyId, + 'team_id' => $team->id, + ]); + + $githubApp = GithubApp::create([ + 'name' => 'test-app', + 'app_id' => 112233, + 'installation_id' => 789, + 'client_id' => 'Iv1.abc123', + 'client_secret' => 'secret', + 'webhook_secret' => 'inprogress-secret', + 'private_key_id' => $privateKeyId, + 'team_id' => $team->id, + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'organization' => 'test-org', + ]); + + $config = GithubRunnerConfig::create([ + 'server_id' => $server->id, + 'github_app_id' => $githubApp->id, + 'labels' => ['self-hosted', 'coolify'], + 'max_runners' => 1, + 'capacity_wait_timeout' => 60, + ]); + + $execution = GithubRunnerExecution::create([ + 'server_id' => $server->id, + 'github_runner_config_id' => $config->id, + 'status' => \App\Enums\GithubRunnerStatus::Provisioning, + 'runner_name' => 'coolify-preprovision', + 'runner_dir' => '/opt/github-runners/coolify-preprovision', + 'workflow_job_id' => 998877, + ]); + + $payload = [ + 'action' => 'in_progress', + 'workflow_job' => [ + 'id' => 998877, + 'runner_name' => 'coolify-live-runner', + ], + 'organization' => [ + 'login' => 'test-org', + ], + ]; + + $body = json_encode($payload); + $signature = hash_hmac('sha256', $body, 'inprogress-secret'); + + $response = $this->postJson('/webhooks/source/github/events', $payload, [ + 'X-GitHub-Event' => 'workflow_job', + 'X-GitHub-Hook-Installation-Target-Id' => '112233', + 'X-Hub-Signature-256' => 'sha256='.$signature, + 'Content-Type' => 'application/json', + ]); + + $response->assertOk(); + $response->assertSee('Runner marked running.'); + + $execution->refresh(); + expect($execution->status->value)->toBe('running') + ->and($execution->runner_name)->toBe('coolify-live-runner') + ->and($execution->started_at)->not->toBeNull(); +}); diff --git a/tests/Feature/GithubRunnersRunnerGroupNameTest.php b/tests/Feature/GithubRunnersRunnerGroupNameTest.php new file mode 100644 index 000000000..3bcbf41a0 --- /dev/null +++ b/tests/Feature/GithubRunnersRunnerGroupNameTest.php @@ -0,0 +1,228 @@ + 0]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $privateKeyId = DB::table('private_keys')->insertGetId([ + 'uuid' => fake()->uuid(), + 'name' => 'test-key', + 'private_key' => encrypt('test-key-content'), + 'team_id' => $this->team->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->githubApp = GithubApp::create([ + 'name' => 'Test App', + 'app_id' => 123456, + 'installation_id' => null, + 'client_id' => 'Iv1.abc', + 'client_secret' => 'secret', + 'webhook_secret' => 'hook-secret', + 'private_key_id' => $privateKeyId, + 'team_id' => $this->team->id, + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'organization' => 'test-org', + 'organization_self_hosted_runners' => 'write', + ]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $privateKeyId, + ]); +}); + +it('stores the normalized runner group name when saving github runner config', function () { + Livewire::test(GithubRunners::class, ['server_uuid' => $this->server->uuid]) + ->set('selectedGithubAppId', $this->githubApp->id) + ->set('runnerGroupName', ' Team Runners Primary ') + ->call('submit') + ->assertHasNoErrors(); + + $this->githubApp->refresh(); + + expect($this->githubApp->runner_group_name)->toBe('Team Runners Primary'); +}); + +it('generates a default runner group name when the field is empty', function () { + $this->githubApp->update(['runner_group_name' => 'Existing Name']); + + Livewire::test(GithubRunners::class, ['server_uuid' => $this->server->uuid]) + ->set('selectedGithubAppId', $this->githubApp->id) + ->set('runnerGroupName', ' ') + ->call('submit') + ->assertHasNoErrors(); + + $this->githubApp->refresh(); + + expect($this->githubApp->runner_group_name)->toStartWith('Coolify-'); +}); + +it('syncs runner group name to github api when saving from ui', function () use ($validKey) { + $validPrivateKey = PrivateKey::create([ + 'name' => 'valid-runner-key', + 'private_key' => $validKey, + 'team_id' => $this->team->id, + ]); + + $this->githubApp->update([ + 'installation_id' => 222, + 'private_key_id' => $validPrivateKey->id, + 'runner_group_id' => 88, + 'runner_group_name' => 'Old Name', + ]); + + GithubRunnerConfig::create([ + 'server_id' => $this->server->id, + 'github_app_id' => $this->githubApp->id, + 'labels' => ['self-hosted', 'coolify'], + 'max_runners' => 4, + 'capacity_wait_timeout' => 60, + 'runner_user' => 'runner', + 'runner_base_dir' => '/opt/github-runners', + 'is_enabled' => true, + ]); + + Http::preventStrayRequests(); + Http::fake([ + 'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, ['Date' => now()->toRfc7231String()]), + 'https://api.github.com/app/installations/222/access_tokens' => Http::response(['token' => 'ghs_test_token'], 200), + 'https://api.github.com/orgs/test-org/actions/runner-groups/88' => Http::response([], 200), + ]); + + Livewire::test(GithubRunners::class, ['server_uuid' => $this->server->uuid]) + ->assertSet('selectedGithubAppId', $this->githubApp->id) + ->set('selectedGithubAppId', $this->githubApp->id) + ->set('runnerGroupName', 'New Synced Name') + ->call('submit') + ->assertHasNoErrors(); + + $this->githubApp->refresh(); + expect($this->githubApp->runner_group_name)->toBe('New Synced Name'); + + $recordedRequests = collect(Http::recorded()) + ->map(fn (array $entry) => $entry[0]->method().' '.$entry[0]->url()) + ->values() + ->all(); + + \PHPUnit\Framework\Assert::assertContains( + 'PATCH https://api.github.com/orgs/test-org/actions/runner-groups/88', + $recordedRequests, + 'Recorded requests: '.json_encode($recordedRequests) + ); +}); + +it('does not sync runner group name to github api when field is not dirty', function () use ($validKey) { + $validPrivateKey = PrivateKey::create([ + 'name' => 'valid-runner-key-no-dirty', + 'private_key' => $validKey, + 'team_id' => $this->team->id, + ]); + + $this->githubApp->update([ + 'installation_id' => 222, + 'private_key_id' => $validPrivateKey->id, + 'runner_group_id' => 88, + 'runner_group_name' => 'Same Name', + ]); + + GithubRunnerConfig::create([ + 'server_id' => $this->server->id, + 'github_app_id' => $this->githubApp->id, + 'labels' => ['self-hosted', 'coolify'], + 'max_runners' => 4, + 'capacity_wait_timeout' => 60, + 'runner_user' => 'runner', + 'runner_base_dir' => '/opt/github-runners', + 'is_enabled' => true, + ]); + + Http::preventStrayRequests(); + Http::fake(); + + Livewire::test(GithubRunners::class, ['server_uuid' => $this->server->uuid]) + ->assertSet('selectedGithubAppId', $this->githubApp->id) + ->assertSet('runnerGroupName', 'Same Name') + ->call('submit') + ->assertHasNoErrors(); + + Http::assertNothingSent(); +}); + +it('generates and syncs a default runner group name to github when field is empty', function () use ($validKey) { + $validPrivateKey = PrivateKey::create([ + 'name' => 'valid-runner-key-empty-sync', + 'private_key' => $validKey, + 'team_id' => $this->team->id, + ]); + + $this->githubApp->update([ + 'installation_id' => 222, + 'private_key_id' => $validPrivateKey->id, + 'runner_group_id' => 88, + 'runner_group_name' => 'Existing Name', + ]); + + GithubRunnerConfig::create([ + 'server_id' => $this->server->id, + 'github_app_id' => $this->githubApp->id, + 'labels' => ['self-hosted', 'coolify'], + 'max_runners' => 4, + 'capacity_wait_timeout' => 60, + 'runner_user' => 'runner', + 'runner_base_dir' => '/opt/github-runners', + 'is_enabled' => true, + ]); + + Http::preventStrayRequests(); + Http::fake([ + 'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, ['Date' => now()->toRfc7231String()]), + 'https://api.github.com/app/installations/222/access_tokens' => Http::response(['token' => 'ghs_test_token'], 200), + 'https://api.github.com/orgs/test-org/actions/runner-groups/88' => Http::response([], 200), + ]); + + Livewire::test(GithubRunners::class, ['server_uuid' => $this->server->uuid]) + ->assertSet('selectedGithubAppId', $this->githubApp->id) + ->set('runnerGroupName', ' ') + ->call('submit') + ->assertHasNoErrors(); + + $this->githubApp->refresh(); + expect($this->githubApp->runner_group_name)->toStartWith('Coolify-'); + + $recordedRequests = collect(Http::recorded()) + ->map(fn (array $entry) => $entry[0]->method().' '.$entry[0]->url()) + ->values() + ->all(); + + \PHPUnit\Framework\Assert::assertContains( + 'PATCH https://api.github.com/orgs/test-org/actions/runner-groups/88', + $recordedRequests, + 'Recorded requests: '.json_encode($recordedRequests) + ); +}); diff --git a/tests/Feature/ProvisionGithubRunnerRunnerGroupSyncTest.php b/tests/Feature/ProvisionGithubRunnerRunnerGroupSyncTest.php new file mode 100644 index 000000000..0611e89cd --- /dev/null +++ b/tests/Feature/ProvisionGithubRunnerRunnerGroupSyncTest.php @@ -0,0 +1,194 @@ +create(); + + $privateKey = PrivateKey::create([ + 'name' => 'runner-group-key', + 'private_key' => <<<'KEY' +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDMflQ+H/XBxrhK +3etBe1c4NzjOFcFp0EXbdhnZCPvkd7PE706osqnfTYxT5I2HYeBiXN20NVhxwhZy +f8K8sLuITPqjNfLkpwPwbHn5WAy4VgFOxrrVHlNo0jWYSLuNRQPtOgUBJc/WzDi7 +PLPauCLE+sIK7i1dGf8f1UzBLJsNEKuGOq4uAhG8pjpkKY+vSFwgHNTK8qOtoauG +w+rz6fqzCJ9RLPo/SL7mXardeypg3roQZ9RNfCt50E4H+lP7+hLaDQk5IXBPpGZc +1ZpvQvAu+e+N62up4KwGFhxL3ziyr3djb7nmJpADwRbzKSl1ry50cpWFbgv9NOYO +lwfij9ErAgMBAAECggEACPfbWvQiM4gzCeQso+0JrgdMoEvM9TEzTG95V7mF+TGU +uo93htIlvWDUcCjHBN0dLu3SsqC09cbkyXW3782HvppdqEMT7sTdA9zGBqeUEJDZ +CCroA7O2Rb5o/Po88MefkfZS74dzKNZBAK57VsgaN5hQYpP/0k7zD42BCxHD5QaL +juEbQHl7/gthGZBez2IhuH3JcLRgLCXS9cEVCA7229uv0mNtFejZSbypIeq07qQf +iJgsaODtqL5avLj4JSxqjYUwv6oxkKDOK/XXurV2RQ0cV1upuV0Js0HgdQN2K1QL +h7VA2oO0K5++BoEX5Tn5aEvp0WVQF52wQ8w8pQ3TxQKBgQDqI6Tix5dUoLxLRbFZ +GjutQOOUpnmFqz/EioCs1Ll95tHC+qi/vyov1efWoufOR1CnLjrTE5Yls1FTYjpp +wTboxBmDYe473jqaZ4oKLZpXgN+Er6l4ktlw9m9MGx8/U891IKxYfdETj63yQOZK +4rQ4QS3qbY6N95H9T10azzG8zwKBgQDflhjZKz0ykvOV0TgvAOrqpC9TTteKqCue +q0Pma6utfWnhoYFwo7kmlBCRoLU4NB9UibJbIxERwTXEDlQMica0/rZStoB7UELn +9i9AlFPZUEO17TxYggG/TYDdj4MUNsoj3KZS1fGE4sQYi81pKsuy0y2tokZptKmG +mAVSKIJU5QKBgB7lZTSnschxDWfBYo2ncIiEL4PGE/MXjeqZfDFSQMfkVXmtKedj +imWVjGo+ROhrcLEe4JRJ2V5QM0MViy+5V02P0u4LViyAPqtxTj3ZlqxFTTltFKfc +eOT3H+ijC5SHsrB6B0QGFjjGlOWKutjW4YEq2Kw+mLkTGiia+GY5QQ7xAoGBAJs/ +m61fyrSNOTnz9nEc0AFxU7Mi8aNDtlYMUa9zX9etV5HmFPzjkjJpaT/VOT/3YTHQ +EtoZdUbAw9aIpG+4UxNmMa8pLflx96MdXB4ZYEdq5jkyq05Bp3jwFeTCO6ATkzRn +h83I5FUDKGpq2IyHvL1EyVjhbscDPRtJ/5fWrPjJAoGBAN2Ejrbz3kIyJhf/m7Dq +JR7zmeeQmK/tAdG9mtIbPGZPUxQd7MOq2z02y3ZX5FJcWPFAuWTNFgs68T4CkeY4 +8TUIdKEwhvkB0uR/alJVTLyaaGU8IOk7Rw6Otu9wlvjqy+Nqoy2GRS4VPLK9dePs +NwAXUicFB5gVAWeyU+C6Xjn1 +-----END PRIVATE KEY----- +KEY, + 'team_id' => $team->id, + ]); + + return GithubApp::create([ + 'name' => 'Runner App', + 'app_id' => fake()->unique()->numberBetween(100000, 999999), + 'installation_id' => 222, + 'client_id' => 'Iv1.runner', + 'client_secret' => 'secret', + 'webhook_secret' => 'hook-secret', + 'private_key_id' => $privateKey->id, + 'team_id' => $team->id, + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'organization' => 'test-org', + ]); +} + +function callEnsureRunnerGroup(ProvisionGithubRunnerJob $job, GithubApp $githubApp): int +{ + $method = new ReflectionMethod(ProvisionGithubRunnerJob::class, 'ensureRunnerGroup'); + $method->setAccessible(true); + + return $method->invoke($job, $githubApp); +} + +beforeEach(function () { + Http::preventStrayRequests(); +}); + +it('creates a runner group with the custom name when no runner group exists', function () { + $githubApp = makeGithubAppForRunnerGroupTests(); + $githubApp->update(['runner_group_name' => 'Team Runner Group']); + + Http::fake([ + 'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, ['Date' => now()->toRfc7231String()]), + 'https://api.github.com/app/installations/222/access_tokens' => Http::response(['token' => 'ghs_test'], 200), + 'https://api.github.com/orgs/test-org/actions/runner-groups' => Http::response(['id' => 9001], 201), + ]); + + $job = new ProvisionGithubRunnerJob($githubApp->id, ['id' => 1], 'test-org'); + $runnerGroupId = callEnsureRunnerGroup($job, $githubApp->fresh()); + + expect($runnerGroupId)->toBe(9001); + + $githubApp->refresh(); + expect($githubApp->runner_group_id)->toBe(9001) + ->and($githubApp->runner_group_name)->toBe('Team Runner Group'); + + Http::assertSent(function (Request $request) { + return $request->method() === 'POST' + && $request->url() === 'https://api.github.com/orgs/test-org/actions/runner-groups' + && $request['name'] === 'Team Runner Group'; + }); +}); + +it('syncs the custom name to github when runner group id already exists', function () { + $githubApp = makeGithubAppForRunnerGroupTests(); + $githubApp->update([ + 'runner_group_id' => 42, + 'runner_group_name' => 'Synced Name', + ]); + + Http::fake([ + 'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, ['Date' => now()->toRfc7231String()]), + 'https://api.github.com/app/installations/222/access_tokens' => Http::response(['token' => 'ghs_test'], 200), + 'https://api.github.com/orgs/test-org/actions/runner-groups/42' => Http::response([], 200), + ]); + + $job = new ProvisionGithubRunnerJob($githubApp->id, ['id' => 2], 'test-org'); + $runnerGroupId = callEnsureRunnerGroup($job, $githubApp->fresh()); + + expect($runnerGroupId)->toBe(42); + + Http::assertSent(function (Request $request) { + return $request->method() === 'PATCH' + && $request->url() === 'https://api.github.com/orgs/test-org/actions/runner-groups/42' + && $request['name'] === 'Synced Name'; + }); + + Http::assertNotSent(function (Request $request) { + return $request->method() === 'POST' + && $request->url() === 'https://api.github.com/orgs/test-org/actions/runner-groups'; + }); +}); + +it('recreates the runner group when the stored runner group id no longer exists on github', function () { + $githubApp = makeGithubAppForRunnerGroupTests(); + $githubApp->update([ + 'runner_group_id' => 42, + 'runner_group_name' => 'Recover Name', + ]); + + Http::fake([ + 'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, ['Date' => now()->toRfc7231String()]), + 'https://api.github.com/app/installations/222/access_tokens' => Http::response(['token' => 'ghs_test'], 200), + 'https://api.github.com/orgs/test-org/actions/runner-groups/42' => Http::response(['message' => 'Not Found'], 404), + 'https://api.github.com/orgs/test-org/actions/runner-groups' => Http::response(['id' => 96], 201), + ]); + + $job = new ProvisionGithubRunnerJob($githubApp->id, ['id' => 3], 'test-org'); + $runnerGroupId = callEnsureRunnerGroup($job, $githubApp->fresh()); + + expect($runnerGroupId)->toBe(96); + + $githubApp->refresh(); + expect($githubApp->runner_group_id)->toBe(96) + ->and($githubApp->runner_group_name)->toBe('Recover Name'); + + Http::assertSent(function (Request $request) { + return $request->method() === 'PATCH' + && $request->url() === 'https://api.github.com/orgs/test-org/actions/runner-groups/42'; + }); + + Http::assertSent(function (Request $request) { + return $request->method() === 'POST' + && $request->url() === 'https://api.github.com/orgs/test-org/actions/runner-groups' + && $request['name'] === 'Recover Name'; + }); +}); + +it('generates and stores a fallback name when no custom runner group name is set', function () { + $githubApp = makeGithubAppForRunnerGroupTests(); + + Http::fake([ + 'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, ['Date' => now()->toRfc7231String()]), + 'https://api.github.com/app/installations/222/access_tokens' => Http::response(['token' => 'ghs_test'], 200), + 'https://api.github.com/orgs/test-org/actions/runner-groups' => Http::response(['id' => 77], 201), + ]); + + $job = new ProvisionGithubRunnerJob($githubApp->id, ['id' => 4], 'test-org'); + $runnerGroupId = callEnsureRunnerGroup($job, $githubApp->fresh()); + + expect($runnerGroupId)->toBe(77); + + $githubApp->refresh(); + expect($githubApp->runner_group_name)->toStartWith('Coolify-') + ->and($githubApp->runner_group_id)->toBe(77); + + Http::assertSent(function (Request $request) { + return $request->method() === 'POST' + && $request->url() === 'https://api.github.com/orgs/test-org/actions/runner-groups' + && is_string($request['name']) + && str_starts_with($request['name'], 'Coolify-'); + }); +}); diff --git a/tests/Feature/Server/GithubRunnerExecutionsTest.php b/tests/Feature/Server/GithubRunnerExecutionsTest.php new file mode 100644 index 000000000..aa5975a5f --- /dev/null +++ b/tests/Feature/Server/GithubRunnerExecutionsTest.php @@ -0,0 +1,84 @@ +create(); + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => 'owner']); + $this->actingAs($user); + session(['currentTeam' => $team]); + + $privateKeyId = DB::table('private_keys')->insertGetId([ + 'uuid' => fake()->uuid(), + 'name' => 'test-key', + 'private_key' => encrypt('test'), + 'team_id' => $team->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => $privateKeyId, + ]); + + $githubApp = GithubApp::create([ + 'name' => 'Test App', + 'app_id' => 123456, + 'installation_id' => 789, + 'client_id' => 'Iv1.abc', + 'client_secret' => 'secret', + 'webhook_secret' => 'hook-secret', + 'private_key_id' => $privateKeyId, + 'team_id' => $team->id, + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'organization' => 'test-org', + ]); + + $config = GithubRunnerConfig::create([ + 'server_id' => $server->id, + 'github_app_id' => $githubApp->id, + 'labels' => ['self-hosted', 'coolify'], + 'max_runners' => 4, + 'capacity_wait_timeout' => 60, + 'runner_user' => 'runner', + 'runner_base_dir' => '/opt/github-runners', + 'is_enabled' => true, + ]); + + GithubRunnerExecution::create([ + 'server_id' => $server->id, + 'github_runner_config_id' => $config->id, + 'status' => GithubRunnerStatus::Running, + 'runner_name' => 'coolify-test-runner', + 'runner_dir' => '/opt/github-runners/coolify-test-runner', + 'workflow_job_id' => 987654, + 'workflow_job_html_url' => 'https://github.com/test-org/test-repo/actions/runs/111/job/987654', + 'repository_full_name' => 'test-org/test-repo', + 'started_at' => now()->subMinute(), + ]); + + $component = Livewire::test(GithubRunnerExecutions::class, ['server' => $server]) + ->assertSee('Recent Executions') + ->assertSee('Refresh') + ->assertSee('coolify-test-runner') + ->assertSee('Running') + ->assertSee('Open'); + + expect($component->html())->toContain('wire:poll.10s'); + expect($component->html())->toContain('https://github.com/test-org/test-repo/actions/runs/111/job/987654'); +}); diff --git a/tests/Unit/CleanupGithubRunnerArtifactsJobTest.php b/tests/Unit/CleanupGithubRunnerArtifactsJobTest.php new file mode 100644 index 000000000..802efba76 --- /dev/null +++ b/tests/Unit/CleanupGithubRunnerArtifactsJobTest.php @@ -0,0 +1,28 @@ +toHaveCount(3); + expect($commands[0])->toContain('.cache/actions-runner-linux-${arch}-*.tar.gz'); + expect($commands[1])->toContain('.templates/runner-${arch}-*'); + expect($commands[2])->toContain('.template/runner-${arch}-*'); + expect($commands[0])->toContain('tail -n +3'); + expect($commands[1])->toContain('tail -n +3'); + expect($commands[2])->toContain('tail -n +3'); +}); + +it('schedules github runner artifact cleanup daily at 2am', function () { + $kernelFile = file_get_contents(__DIR__.'/../../app/Console/Kernel.php'); + + expect($kernelFile)->toContain('use App\\Jobs\\CleanupGithubRunnerArtifactsJob;'); + expect($kernelFile)->toContain("->job(new CleanupGithubRunnerArtifactsJob)->dailyAt('02:00')->onOneServer();"); +}); + +it('updates runner cache and template timestamps during provisioning', function () { + $provisionFile = file_get_contents(__DIR__.'/../../app/Jobs/ProvisionGithubRunnerJob.php'); + + expect($provisionFile)->toContain('touch {$cacheDir}/{$tarball} {$templateDir}'); +}); diff --git a/tests/Unit/GithubRunnerExecutionWorkflowUrlTest.php b/tests/Unit/GithubRunnerExecutionWorkflowUrlTest.php new file mode 100644 index 000000000..acbad074a --- /dev/null +++ b/tests/Unit/GithubRunnerExecutionWorkflowUrlTest.php @@ -0,0 +1,33 @@ + 987654, + 'repository_full_name' => 'test-org/test-repo', + 'workflow_job_html_url' => 'https://github.com/test-org/test-repo/actions/runs/111/job/987654', + ]); + + expect($execution->workflowJobUrl())->toBe('https://github.com/test-org/test-repo/actions/runs/111/job/987654'); +}); + +it('builds a fallback github actions search url when direct url is missing', function () { + $execution = new GithubRunnerExecution([ + 'workflow_job_id' => 987654, + 'repository_full_name' => 'test-org/test-repo', + 'workflow_job_html_url' => null, + ]); + + expect($execution->workflowJobUrl())->toBe('https://github.com/test-org/test-repo/actions?query=987654'); +}); + +it('returns null when there is not enough data to build a workflow url', function () { + $execution = new GithubRunnerExecution([ + 'workflow_job_id' => null, + 'repository_full_name' => null, + 'workflow_job_html_url' => null, + ]); + + expect($execution->workflowJobUrl())->toBeNull(); +});