diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index c5e12b7ee..018187a0d 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -7,6 +7,7 @@ use App\Jobs\CheckHelperImageJob; use App\Jobs\CheckTraefikVersionJob; use App\Jobs\CleanupInstanceStuffsJob; use App\Jobs\CleanupOrphanedPreviewContainersJob; +use App\Jobs\CleanupStaleGithubRunnersJob; use App\Jobs\PullChangelog; use App\Jobs\PullTemplatesFromCDN; use App\Jobs\RegenerateSslCertJob; @@ -55,6 +56,7 @@ class Kernel extends ConsoleKernel $this->scheduleInstance->job(new ScheduledJobManager)->everyMinute()->onOneServer(); $this->scheduleInstance->command('uploads:clear')->everyTwoMinutes(); + $this->scheduleInstance->job(new CleanupStaleGithubRunnersJob)->everyFiveMinutes()->onOneServer(); } else { // Instance Jobs @@ -84,6 +86,9 @@ class Kernel extends ConsoleKernel // Cleanup orphaned PR preview containers daily $this->scheduleInstance->job(new CleanupOrphanedPreviewContainersJob)->daily()->onOneServer(); + + // Cleanup stale GitHub Actions runners + $this->scheduleInstance->job(new CleanupStaleGithubRunnersJob)->everyFiveMinutes()->onOneServer(); } } diff --git a/app/Enums/GithubRunnerStatus.php b/app/Enums/GithubRunnerStatus.php new file mode 100644 index 000000000..82da58e6d --- /dev/null +++ b/app/Enums/GithubRunnerStatus.php @@ -0,0 +1,19 @@ +id, + workflowJobPayload: collect($workflowJob)->toArray(), + organizationLogin: data_get($payload, 'organization.login', ''), + repositoryId: (int) data_get($payload, 'repository.id', 0), + ); + + return response('Runner provisioning queued.'); + } + + if ($action === 'completed' && $workflowJob) { + CleanupGithubRunnerJob::dispatch( + workflowJobId: (int) data_get($workflowJob, 'id'), + ); + + return response('Runner cleanup queued.'); + } + + return response('workflow_job event received.'); + } if ($x_github_event === 'push') { $id = data_get($payload, 'repository.id'); $branch = data_get($payload, 'ref'); diff --git a/app/Jobs/CleanupGithubRunnerJob.php b/app/Jobs/CleanupGithubRunnerJob.php new file mode 100644 index 000000000..1f3db6721 --- /dev/null +++ b/app/Jobs/CleanupGithubRunnerJob.php @@ -0,0 +1,117 @@ +onQueue('high'); + } + + public function handle(): void + { + $execution = GithubRunnerExecution::where('workflow_job_id', $this->workflowJobId) + ->with('config.githubApp') + ->first(); + + if (! $execution) { + return; + } + + // Already cleaned up + if (in_array($execution->status, [GithubRunnerStatus::Completed, GithubRunnerStatus::Failed])) { + return; + } + + $execution->update(['status' => GithubRunnerStatus::Cleaning]); + + try { + $server = $execution->server; + + if ($execution->pid) { + instant_remote_process([ + "kill {$execution->pid} 2>/dev/null || true", + ], $server, throwError: false); + } + + if ($execution->runner_dir) { + instant_remote_process([ + "rm -rf {$execution->runner_dir}", + ], $server, throwError: false); + } + + $this->deregisterFromGithub($execution); + + $execution->update([ + 'status' => GithubRunnerStatus::Completed, + 'completed_at' => now(), + ]); + } catch (\Throwable $e) { + $execution->update([ + 'status' => GithubRunnerStatus::Failed, + 'error_message' => 'Cleanup failed: '.$e->getMessage(), + 'completed_at' => now(), + ]); + } + } + + private function deregisterFromGithub(GithubRunnerExecution $execution): void + { + if (! $execution->runner_id) { + ray('Runner deregister skipped: no runner_id for execution '.$execution->id); + + return; + } + + $githubApp = $execution->config?->githubApp; + if (! $githubApp || $githubApp->is_public) { + ray('Runner deregister skipped: no githubApp or is_public for execution '.$execution->id); + + return; + } + + $org = $githubApp->organization; + if (! $org) { + ray('Runner deregister skipped: no organization for execution '.$execution->id); + + return; + } + + try { + $token = generateGithubInstallationToken($githubApp); + $apiUrl = $githubApp->api_url ?? 'https://api.github.com'; + + ray("Deregistering runner {$execution->runner_id} from {$org} via DELETE /orgs/{$org}/actions/runners/{$execution->runner_id}"); + + $response = Http::GitHub($apiUrl, $token) + ->delete("/orgs/{$org}/actions/runners/{$execution->runner_id}"); + + ray('Runner deregister response: '.$response->status().' '.$response->body()); + } catch (\Throwable $e) { + ray('Runner deregister failed: '.$e->getMessage()); + } + } +} diff --git a/app/Jobs/CleanupStaleGithubRunnersJob.php b/app/Jobs/CleanupStaleGithubRunnersJob.php new file mode 100644 index 000000000..d9f214e33 --- /dev/null +++ b/app/Jobs/CleanupStaleGithubRunnersJob.php @@ -0,0 +1,165 @@ +onQueue('long-running'); + } + + public function handle(): void + { + $this->cleanupDeadRunners(); + $this->cleanupStaleRunners(); + } + + /** + * Check Running executions with a PID and mark them Failed if the process no longer exists. + * Uses a 5-minute grace period to avoid false-positives during startup. + */ + private function cleanupDeadRunners(): void + { + $gracePeriod = now()->subMinutes(5); + + $runningExecutions = GithubRunnerExecution::query() + ->where('status', GithubRunnerStatus::Running) + ->whereNotNull('pid') + ->where('started_at', '<', $gracePeriod) + ->with(['server', 'config.githubApp']) + ->get(); + + foreach ($runningExecutions as $execution) { + try { + $server = $execution->server; + + if (! $server->isFunctional()) { + continue; + } + + // kill -0 checks if the process exists without sending a signal. + // Exit code 0 = alive, non-zero = dead. + $result = instant_remote_process([ + "kill -0 {$execution->pid} 2>/dev/null && echo alive || echo dead", + ], $server, throwError: false); + + if (trim((string) $result) !== 'alive') { + if ($execution->runner_dir) { + instant_remote_process([ + "rm -rf {$execution->runner_dir}", + ], $server, throwError: false); + } + + $this->deregisterFromGithub($execution); + + $execution->update([ + 'status' => GithubRunnerStatus::Failed, + 'error_message' => 'Runner process died unexpectedly.', + 'completed_at' => now(), + ]); + } + } catch (\Throwable $e) { + $execution->update([ + 'status' => GithubRunnerStatus::Failed, + 'error_message' => 'Health check failed: '.$e->getMessage(), + 'completed_at' => now(), + ]); + } + } + } + + /** + * Mark any active executions older than 2 hours as timed out. + */ + private function cleanupStaleRunners(): void + { + $staleThreshold = now()->subHours(2); + + $staleExecutions = GithubRunnerExecution::query() + ->whereIn('status', [ + GithubRunnerStatus::Queued, + GithubRunnerStatus::Provisioning, + GithubRunnerStatus::Running, + GithubRunnerStatus::Cleaning, + ]) + ->where('created_at', '<', $staleThreshold) + ->with(['server', 'config.githubApp']) + ->get(); + + foreach ($staleExecutions as $execution) { + try { + $server = $execution->server; + + if ($execution->pid && $server->isFunctional()) { + instant_remote_process([ + "kill {$execution->pid} 2>/dev/null || true", + ], $server, throwError: false); + } + + if ($execution->runner_dir && $server->isFunctional()) { + instant_remote_process([ + "rm -rf {$execution->runner_dir}", + ], $server, throwError: false); + } + + $this->deregisterFromGithub($execution); + + $execution->update([ + 'status' => GithubRunnerStatus::TimedOut, + 'error_message' => 'Runner exceeded maximum execution time (2 hours).', + 'completed_at' => now(), + ]); + } catch (\Throwable $e) { + $execution->update([ + 'status' => GithubRunnerStatus::TimedOut, + 'error_message' => 'Stale cleanup failed: '.$e->getMessage(), + 'completed_at' => now(), + ]); + } + } + } + + private function deregisterFromGithub(GithubRunnerExecution $execution): void + { + if (! $execution->runner_id) { + return; + } + + $githubApp = $execution->config?->githubApp; + if (! $githubApp || $githubApp->is_public) { + return; + } + + $org = $githubApp->organization; + if (! $org) { + return; + } + + try { + $token = generateGithubInstallationToken($githubApp); + $apiUrl = $githubApp->api_url ?? 'https://api.github.com'; + + Http::GitHub($apiUrl, $token) + ->delete("/orgs/{$org}/actions/runners/{$execution->runner_id}"); + } catch (\Throwable) { + // Best-effort: don't block cleanup if the API call fails + } + } +} diff --git a/app/Jobs/GithubAppPermissionJob.php b/app/Jobs/GithubAppPermissionJob.php index 7cd1b86ac..7b1323a27 100644 --- a/app/Jobs/GithubAppPermissionJob.php +++ b/app/Jobs/GithubAppPermissionJob.php @@ -45,6 +45,7 @@ class GithubAppPermissionJob implements ShouldBeEncrypted, ShouldQueue $this->github_app->metadata = data_get($permissions, 'metadata'); $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->save(); $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); diff --git a/app/Jobs/ProvisionGithubRunnerJob.php b/app/Jobs/ProvisionGithubRunnerJob.php new file mode 100644 index 000000000..1fcac5513 --- /dev/null +++ b/app/Jobs/ProvisionGithubRunnerJob.php @@ -0,0 +1,299 @@ +onQueue('high'); + } + + public function handle(): void + { + $workflowJobId = data_get($this->workflowJobPayload, 'id'); + + // Idempotency: skip if already provisioning for this job + if (GithubRunnerExecution::where('workflow_job_id', $workflowJobId)->exists()) { + return; + } + + $githubApp = GithubApp::find($this->githubAppId); + if (! $githubApp) { + return; + } + + $requestedLabels = data_get($this->workflowJobPayload, 'labels', []); + + // Find a matching server with capacity + $config = $this->findMatchingConfig($githubApp, $requestedLabels); + if (! $config) { + ray('No matching GitHub runner config found for labels: '.implode(', ', $requestedLabels)); + + return; + } + + $runnerName = 'coolify-'.((string) new Cuid2(7)); + $runnerDir = "{$config->runner_base_dir}/{$runnerName}"; + + $execution = GithubRunnerExecution::create([ + 'server_id' => $config->server_id, + 'github_runner_config_id' => $config->id, + 'status' => GithubRunnerStatus::Queued, + 'runner_name' => $runnerName, + 'runner_dir' => $runnerDir, + 'workflow_job_id' => $workflowJobId, + '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') + ), + ]); + + try { + $execution->update(['status' => GithubRunnerStatus::Provisioning]); + + // Ensure a Coolify-managed runner group exists and the repo has access + $runnerGroupId = $this->ensureRunnerGroup($githubApp); + $this->ensureRepositoryInRunnerGroup($githubApp, $runnerGroupId); + + // Generate JIT config via GitHub API + ['encoded_jit_config' => $jitConfig, 'runner_id' => $runnerId] = $this->generateJitConfig($githubApp, $config, $runnerName, $requestedLabels, $runnerGroupId); + + // Provision runner on server via SSH + $pid = $this->provisionRunner($config, $runnerName, $runnerDir, $jitConfig); + + $execution->update([ + 'status' => GithubRunnerStatus::Running, + 'pid' => $pid, + 'runner_id' => $runnerId, + 'started_at' => now(), + ]); + } catch (\Throwable $e) { + $execution->update([ + 'status' => GithubRunnerStatus::Failed, + 'error_message' => $e->getMessage(), + ]); + + // Attempt cleanup of the runner directory on the server + try { + $server = $config->server; + instant_remote_process(["rm -rf {$runnerDir}"], $server, throwError: false); + } catch (\Throwable) { + // Best-effort cleanup + } + + throw $e; + } + } + + private function findMatchingConfig(GithubApp $githubApp, array $requestedLabels): ?GithubRunnerConfig + { + return GithubRunnerConfig::query() + ->where('github_app_id', $githubApp->id) + ->whereHas('githubApp', fn ($q) => $q->where('organization', $this->organizationLogin)) + ->where('is_enabled', true) + ->with('server') + ->get() + ->filter(fn ($config) => $config->matchesLabels($requestedLabels)) + ->filter(fn ($config) => $config->server->isFunctional()) + ->filter(fn ($config) => $config->hasCapacity()) + ->sortBy(fn ($config) => $config->activeRunnerCount()) + ->first(); + } + + private function ensureRunnerGroup(GithubApp $githubApp): int + { + $token = generateGithubInstallationToken($githubApp); + $apiUrl = $githubApp->api_url ?? 'https://api.github.com'; + + if ($githubApp->runner_group_id) { + // Ensure existing group allows public repos + 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}", [ + 'allows_public_repositories' => true, + ]); + + return $githubApp->runner_group_id; + } + + $groupName = 'Coolify-'.((string) new Cuid2(7)); + + $response = Http::withHeaders([ + 'Authorization' => "Bearer {$token}", + 'Accept' => 'application/vnd.github+json', + 'X-GitHub-Api-Version' => '2022-11-28', + ])->post("{$apiUrl}/orgs/{$githubApp->organization}/actions/runner-groups", [ + 'name' => $groupName, + 'visibility' => 'selected', + 'allows_public_repositories' => true, + ]); + + if (! $response->successful()) { + throw new \RuntimeException( + 'Failed to create runner group: '.data_get($response->json(), 'message', $response->body()) + ); + } + + $runnerGroupId = (int) data_get($response->json(), 'id'); + $githubApp->update(['runner_group_id' => $runnerGroupId]); + + return $runnerGroupId; + } + + private function ensureRepositoryInRunnerGroup(GithubApp $githubApp, int $runnerGroupId): void + { + if ($this->repositoryId <= 0) { + ray("Skipping runner group repo assignment — repositoryId is {$this->repositoryId}"); + + return; + } + + $token = generateGithubInstallationToken($githubApp); + $apiUrl = $githubApp->api_url ?? 'https://api.github.com'; + $url = "{$apiUrl}/orgs/{$githubApp->organization}/actions/runner-groups/{$runnerGroupId}/repositories/{$this->repositoryId}"; + + ray("Adding repository {$this->repositoryId} to runner group {$runnerGroupId}: PUT {$url}"); + + $response = Http::withHeaders([ + 'Authorization' => "Bearer {$token}", + 'Accept' => 'application/vnd.github+json', + 'X-GitHub-Api-Version' => '2022-11-28', + ])->withBody('', 'application/json')->put($url); + + if (! $response->successful()) { + ray('Failed to add repository to runner group: '.$response->status().' '.data_get($response->json(), 'message', $response->body())); + } + } + + private function generateJitConfig(GithubApp $githubApp, GithubRunnerConfig $config, string $runnerName, array $requestedLabels, int $runnerGroupId): array + { + $token = generateGithubInstallationToken($githubApp); + $apiUrl = $githubApp->api_url ?? 'https://api.github.com'; + + $response = Http::withHeaders([ + 'Authorization' => "Bearer {$token}", + 'Accept' => 'application/vnd.github+json', + 'X-GitHub-Api-Version' => '2022-11-28', + ])->post("{$apiUrl}/orgs/{$config->organization}/actions/runners/generate-jitconfig", [ + 'name' => $runnerName, + 'runner_group_id' => $runnerGroupId, + 'labels' => $requestedLabels, + 'work_folder' => '_work', + ]); + + if (! $response->successful()) { + throw new \RuntimeException( + 'Failed to generate JIT runner config: '.data_get($response->json(), 'message', $response->body()) + ); + } + + return [ + 'encoded_jit_config' => data_get($response->json(), 'encoded_jit_config'), + 'runner_id' => data_get($response->json(), 'runner.id'), + ]; + } + + private function provisionRunner(GithubRunnerConfig $config, string $runnerName, string $runnerDir, string $jitConfig): int + { + $server = $config->server; + $user = $config->runner_user; + $baseDir = $config->runner_base_dir; + $cacheDir = "{$baseDir}/.cache"; + // Detect architecture from server + $uname = trim(instant_remote_process(['uname -m'], $server)); + $arch = $uname === 'aarch64' ? 'arm64' : 'x64'; + + $version = $config->runner_version ?? $this->getLatestRunnerVersion($config, $arch); + + // Ensure runner user and directories exist + instant_remote_process([ + "id -u {$user} &>/dev/null || useradd -m -s /bin/bash {$user}", + "usermod -aG docker {$user}", + "mkdir -p {$cacheDir}", + "mkdir -p {$runnerDir}", + ], $server); + + // Download runner binary if not cached + $tarball = "actions-runner-linux-{$arch}-{$version}.tar.gz"; + instant_remote_process([ + "if [ ! -f {$cacheDir}/{$tarball} ]; then curl -sL https://github.com/actions/runner/releases/download/v{$version}/{$tarball} -o {$cacheDir}/{$tarball}; fi", + "tar xzf {$cacheDir}/{$tarball} -C {$runnerDir}", + "chown -R {$user}:{$user} {$runnerDir}", + ], $server); + + // Start the JIT runner in background + $output = instant_remote_process([ + "cd {$runnerDir} && sudo -u {$user} nohup ./run.sh --jitconfig {$jitConfig} > {$runnerDir}/runner.log 2>&1 & echo \$!", + ], $server); + + $pid = (int) trim($output); + if ($pid <= 0) { + throw new \RuntimeException('Failed to start runner process — no PID returned.'); + } + + return $pid; + } + + private function getLatestRunnerVersion(GithubRunnerConfig $config, string $arch): string + { + $githubApp = GithubApp::find($this->githubAppId); + $apiUrl = $githubApp?->api_url ?? 'https://api.github.com'; + + try { + $token = generateGithubInstallationToken($githubApp); + $response = Http::withHeaders([ + 'Authorization' => "Bearer {$token}", + 'Accept' => 'application/vnd.github+json', + 'X-GitHub-Api-Version' => '2022-11-28', + ])->get("{$apiUrl}/orgs/{$config->organization}/actions/runners/downloads"); + + if ($response->successful()) { + $download = collect($response->json()) + ->first(fn ($d) => data_get($d, 'os') === 'linux' && data_get($d, 'architecture') === $arch); + + if ($download) { + // Extract version from filename like "actions-runner-linux-x64-2.321.0.tar.gz" + preg_match('/(\d+\.\d+\.\d+)/', data_get($download, 'filename', ''), $matches); + if (! empty($matches[1])) { + return $matches[1]; + } + } + } + } catch (\Throwable) { + // Fall through to default + } + + return '2.321.0'; + } +} diff --git a/app/Livewire/Server/GithubRunners.php b/app/Livewire/Server/GithubRunners.php new file mode 100644 index 000000000..4e39b19cc --- /dev/null +++ b/app/Livewire/Server/GithubRunners.php @@ -0,0 +1,355 @@ +server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); + $this->parameters = get_route_parameters(); + $this->loadConfig(); + } catch (\Throwable) { + $this->redirectRoute('server.index'); + } + } + + #[Computed] + public function githubApps() + { + return GithubApp::ownedByCurrentTeam() + ->whereNotNull('app_id') + ->whereNotNull('organization') + ->where('organization', '!=', '') + ->get(); + } + + #[Computed] + public function config(): ?GithubRunnerConfig + { + return $this->server->githubRunnerConfig; + } + + #[Computed] + public function activeRunnerCount(): int + { + 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 + { + if (! $this->selectedGithubAppId) { + return null; + } + + return GithubApp::find($this->selectedGithubAppId); + } + + #[Computed] + public function selectedAppHasRunnerPermission(): ?bool + { + return $this->selectedApp?->organization_self_hosted_runners === 'write'; + } + + public function loadConfig(): void + { + $config = $this->server->githubRunnerConfig; + if ($config) { + $this->selectedGithubAppId = $config->github_app_id; + $this->labels = implode(',', $config->labels ?? []); + $this->maxRunners = $config->max_runners; + $this->runnerUser = $config->runner_user; + $this->runnerBaseDir = $config->runner_base_dir; + $this->runnerVersion = $config->runner_version; + $this->isEnabled = $config->is_enabled; + $this->loadAccessibleRepositories(); + } + } + + public function updatedSelectedGithubAppId(): void + { + $this->loadAccessibleRepositories(); + } + + public function loadAccessibleRepositories(): void + { + $this->accessibleRepositories = []; + $this->repositoryError = null; + + $app = $this->selectedApp; + + if (! $app || ! $app->installation_id) { + return; + } + + try { + $token = generateGithubInstallationToken($app); + $allRepos = []; + $page = 1; + + do { + $result = loadRepositoryByPage($app, $token, $page); + $repos = data_get($result, 'repositories', []); + $totalCount = data_get($result, 'total_count', 0); + + foreach ($repos as $repo) { + $allRepos[] = data_get($repo, 'full_name'); + } + + $page++; + } while (count($allRepos) < $totalCount && count($allRepos) < 500 && count($repos) > 0); + + sort($allRepos); + $this->accessibleRepositories = $allRepos; + } catch (\Throwable $e) { + $this->repositoryError = 'Could not load repositories: '.$e->getMessage(); + } + } + + public function submit() + { + try { + $this->authorize('update', $this->server); + $this->validate(); + + if (! $this->selectedGithubAppId) { + throw new \Exception('Please select a GitHub App.'); + } + + $labelsArray = array_map('trim', explode(',', $this->labels)); + $labelsArray = array_values(array_filter($labelsArray)); + + if (empty($labelsArray)) { + throw new \Exception('At least one label is required.'); + } + + $config = $this->server->githubRunnerConfig; + + if ($config) { + $config->update([ + 'github_app_id' => $this->selectedGithubAppId, + 'labels' => $labelsArray, + 'max_runners' => $this->maxRunners, + 'runner_user' => $this->runnerUser, + 'runner_base_dir' => $this->runnerBaseDir, + 'runner_version' => $this->runnerVersion ?: null, + 'is_enabled' => $this->isEnabled, + ]); + } else { + GithubRunnerConfig::create([ + 'server_id' => $this->server->id, + 'github_app_id' => $this->selectedGithubAppId, + 'labels' => $labelsArray, + 'max_runners' => $this->maxRunners, + 'runner_user' => $this->runnerUser, + 'runner_base_dir' => $this->runnerBaseDir, + 'runner_version' => $this->runnerVersion ?: null, + 'is_enabled' => $this->isEnabled, + ]); + } + + $this->server->refresh(); + $this->dispatch('success', 'GitHub Runner configuration saved.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function toggleEnabled() + { + try { + $this->authorize('update', $this->server); + $config = $this->server->githubRunnerConfig; + if (! $config) { + return; + } + + $config->update(['is_enabled' => ! $config->is_enabled]); + $this->isEnabled = $config->fresh()->is_enabled; + $this->dispatch('success', $this->isEnabled ? 'Runners enabled.' : 'Runners disabled.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function deleteConfig() + { + try { + $this->authorize('update', $this->server); + $config = $this->server->githubRunnerConfig; + if (! $config) { + return; + } + + if ($config->activeRunnerCount() > 0) { + throw new \Exception('Cannot delete configuration while runners are active.'); + } + + $config->delete(); + $this->server->refresh(); + $this->loadConfig(); + $this->dispatch('success', 'GitHub Runner configuration deleted.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function preinstallBinary() + { + try { + $this->authorize('update', $this->server); + $config = $this->server->githubRunnerConfig; + if (! $config) { + throw new \Exception('Save configuration first.'); + } + + $baseDir = $config->runner_base_dir; + $cacheDir = "{$baseDir}/.cache"; + $user = $config->runner_user; + $version = $config->runner_version ?? '2.321.0'; + + // Detect architecture from server + $uname = trim(instant_remote_process(['uname -m'], $this->server)); + $arch = $uname === 'aarch64' ? 'arm64' : 'x64'; + $tarball = "actions-runner-linux-{$arch}-{$version}.tar.gz"; + + instant_remote_process([ + "id -u {$user} &>/dev/null || useradd -m -s /bin/bash {$user}", + "usermod -aG docker {$user}", + "mkdir -p {$cacheDir}", + "if [ ! -f {$cacheDir}/{$tarball} ]; then curl -sL https://github.com/actions/runner/releases/download/v{$version}/{$tarball} -o {$cacheDir}/{$tarball}; fi", + ], $this->server); + + $this->dispatch('success', 'Runner binary pre-installed on server.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function cancelExecution(int $executionId) + { + try { + $this->authorize('update', $this->server); + + $execution = GithubRunnerExecution::where('id', $executionId) + ->where('server_id', $this->server->id) + ->with('config.githubApp') + ->firstOrFail(); + + if (! $execution->isActive()) { + $this->dispatch('error', 'This execution is already finished.'); + + return; + } + + $server = $execution->server; + + if ($execution->pid && $server->isFunctional()) { + instant_remote_process([ + "kill {$execution->pid} 2>/dev/null || true", + ], $server, throwError: false); + } + + if ($execution->runner_dir && $server->isFunctional()) { + instant_remote_process([ + "rm -rf {$execution->runner_dir}", + ], $server, throwError: false); + } + + $this->deregisterRunnerFromGithub($execution); + + $execution->update([ + 'status' => GithubRunnerStatus::Failed, + 'error_message' => 'Cancelled by user.', + 'completed_at' => now(), + ]); + + $this->dispatch('success', "Runner {$execution->runner_name} cancelled."); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + private function deregisterRunnerFromGithub(GithubRunnerExecution $execution): void + { + if (! $execution->runner_id) { + return; + } + + $githubApp = $execution->config?->githubApp; + if (! $githubApp || $githubApp->is_public) { + return; + } + + $org = $githubApp->organization; + if (! $org) { + return; + } + + try { + $token = generateGithubInstallationToken($githubApp); + $apiUrl = $githubApp->api_url ?? 'https://api.github.com'; + + Http::GitHub($apiUrl, $token) + ->delete("/orgs/{$org}/actions/runners/{$execution->runner_id}"); + } catch (\Throwable) { + // Best-effort + } + } + + public function render() + { + return view('livewire.server.github-runners'); + } +} diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index 0a38e6088..78e4c005a 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -67,6 +67,8 @@ class Change extends Component public ?string $pullRequests = null; + public ?string $organizationSelfHostedRunners = null; + public $applications; public $privateKeys; @@ -87,6 +89,7 @@ class Change extends Component 'contents' => 'nullable|string', 'metadata' => 'nullable|string', 'pullRequests' => 'nullable|string', + 'organizationSelfHostedRunners' => 'nullable|string', 'privateKeyId' => 'nullable|int', ]; @@ -122,6 +125,7 @@ class Change extends Component $this->github_app->contents = $this->contents; $this->github_app->metadata = $this->metadata; $this->github_app->pull_requests = $this->pullRequests; + $this->github_app->organization_self_hosted_runners = $this->organizationSelfHostedRunners; } else { // Sync FROM model (on load/refresh) $this->name = $this->github_app->name; @@ -140,6 +144,7 @@ class Change extends Component $this->contents = $this->github_app->contents; $this->metadata = $this->github_app->metadata; $this->pullRequests = $this->github_app->pull_requests; + $this->organizationSelfHostedRunners = $this->github_app->organization_self_hosted_runners; } } @@ -175,6 +180,7 @@ class Change extends Component 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.'); } catch (\Throwable $e) { // Provide better error message for unsupported key formats diff --git a/app/Models/GithubApp.php b/app/Models/GithubApp.php index ab82c9a9c..c9a7c5d78 100644 --- a/app/Models/GithubApp.php +++ b/app/Models/GithubApp.php @@ -14,6 +14,7 @@ class GithubApp extends BaseModel 'is_public' => 'boolean', 'is_system_wide' => 'boolean', 'type' => 'string', + 'runner_group_id' => 'integer', ]; protected $hidden = [ @@ -88,6 +89,11 @@ class GithubApp extends BaseModel return $this->belongsTo(PrivateKey::class); } + public function runnerConfigs() + { + return $this->hasMany(GithubRunnerConfig::class); + } + public function type(): Attribute { return Attribute::make( diff --git a/app/Models/GithubRunnerConfig.php b/app/Models/GithubRunnerConfig.php new file mode 100644 index 000000000..7a7586ea7 --- /dev/null +++ b/app/Models/GithubRunnerConfig.php @@ -0,0 +1,63 @@ + 'array', + 'is_enabled' => 'boolean', + 'max_runners' => 'integer', + ]; + } + + public function organization(): Attribute + { + return Attribute::make( + get: fn () => $this->githubApp?->organization, + ); + } + + public function server(): BelongsTo + { + return $this->belongsTo(Server::class); + } + + public function githubApp(): BelongsTo + { + return $this->belongsTo(GithubApp::class); + } + + public function executions(): HasMany + { + return $this->hasMany(GithubRunnerExecution::class); + } + + public function activeRunnerCount(): int + { + return $this->executions() + ->whereIn('status', ['queued', 'provisioning', 'running', 'cleaning']) + ->count(); + } + + public function hasCapacity(): bool + { + return $this->activeRunnerCount() < $this->max_runners; + } + + public function matchesLabels(array $requestedLabels): bool + { + $configLabels = collect($this->labels)->map(fn ($l) => strtolower($l)); + + return collect($requestedLabels) + ->every(fn ($label) => $configLabels->contains(strtolower($label))); + } +} diff --git a/app/Models/GithubRunnerExecution.php b/app/Models/GithubRunnerExecution.php new file mode 100644 index 000000000..2b47ff2bf --- /dev/null +++ b/app/Models/GithubRunnerExecution.php @@ -0,0 +1,49 @@ + GithubRunnerStatus::class, + 'workflow_job_id' => 'integer', + 'runner_id' => 'integer', + 'pid' => 'integer', + 'started_at' => 'datetime', + 'completed_at' => 'datetime', + ]; + } + + public function server(): BelongsTo + { + return $this->belongsTo(Server::class); + } + + public function config(): BelongsTo + { + return $this->belongsTo(GithubRunnerConfig::class, 'github_runner_config_id'); + } + + public function isActive(): bool + { + return $this->status->isActive(); + } + + public function duration(): ?string + { + if (! $this->started_at) { + return null; + } + + $end = $this->completed_at ?? now(); + + return $this->started_at->diffForHumans($end, true); + } +} diff --git a/app/Models/Server.php b/app/Models/Server.php index 5099a9fec..88de89b8d 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -977,6 +977,16 @@ $schema://$host { return $standalone_docker->concat($swarm_docker); } + public function githubRunnerConfig() + { + return $this->hasOne(GithubRunnerConfig::class); + } + + public function githubRunnerExecutions() + { + return $this->hasMany(GithubRunnerExecution::class); + } + public function standaloneDockers() { return $this->hasMany(StandaloneDocker::class); diff --git a/bootstrap/helpers/github.php b/bootstrap/helpers/github.php index 4a61960fb..219175a45 100644 --- a/bootstrap/helpers/github.php +++ b/bootstrap/helpers/github.php @@ -130,6 +130,10 @@ function getPermissionsPath(GithubApp $source) $github = GithubApp::where('uuid', $source->uuid)->first(); $name = str(Str::kebab($github->name)); + if (str($github->organization)->isNotEmpty()) { + return "$github->html_url/organizations/$github->organization/settings/apps/$name/permissions"; + } + return "$github->html_url/settings/apps/$name/permissions"; } diff --git a/database/migrations/2026_02_27_000001_add_runners_permission_to_github_apps.php b/database/migrations/2026_02_27_000001_add_runners_permission_to_github_apps.php new file mode 100644 index 000000000..e3a1df07f --- /dev/null +++ b/database/migrations/2026_02_27_000001_add_runners_permission_to_github_apps.php @@ -0,0 +1,22 @@ +string('organization_self_hosted_runners')->nullable()->after('administration'); + }); + } + + public function down(): void + { + Schema::table('github_apps', function (Blueprint $table) { + $table->dropColumn('organization_self_hosted_runners'); + }); + } +}; diff --git a/database/migrations/2026_02_27_000002_create_github_runner_configs_table.php b/database/migrations/2026_02_27_000002_create_github_runner_configs_table.php new file mode 100644 index 000000000..8a4facfe6 --- /dev/null +++ b/database/migrations/2026_02_27_000002_create_github_runner_configs_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('uuid')->unique(); + $table->foreignId('server_id')->unique()->constrained()->cascadeOnDelete(); + $table->foreignId('github_app_id')->constrained()->cascadeOnDelete(); + $table->json('labels')->default('["self-hosted","coolify"]'); + $table->boolean('is_enabled')->default(true); + $table->integer('max_runners')->default(4); + $table->string('runner_user')->default('runner'); + $table->string('runner_version')->nullable(); + $table->string('runner_arch')->default('x64'); + $table->string('runner_base_dir')->default('/opt/github-runners'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('github_runner_configs'); + } +}; diff --git a/database/migrations/2026_02_27_000003_create_github_runner_executions_table.php b/database/migrations/2026_02_27_000003_create_github_runner_executions_table.php new file mode 100644 index 000000000..86ee96b56 --- /dev/null +++ b/database/migrations/2026_02_27_000003_create_github_runner_executions_table.php @@ -0,0 +1,38 @@ +id(); + $table->string('uuid')->unique(); + $table->foreignId('server_id')->constrained()->cascadeOnDelete(); + $table->foreignId('github_runner_config_id')->constrained()->cascadeOnDelete(); + $table->string('status')->default('queued'); + $table->string('runner_name')->nullable(); + $table->string('runner_dir')->nullable(); + $table->unsignedBigInteger('workflow_job_id'); + $table->string('workflow_name')->nullable(); + $table->string('repository_full_name')->nullable(); + $table->unsignedBigInteger('runner_id')->nullable(); + $table->integer('pid')->nullable(); + $table->text('error_message')->nullable(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + + $table->index(['server_id', 'status']); + $table->index('workflow_job_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('github_runner_executions'); + } +}; diff --git a/database/migrations/2026_03_03_125100_add_runner_group_id_to_github_apps.php b/database/migrations/2026_03_03_125100_add_runner_group_id_to_github_apps.php new file mode 100644 index 000000000..8fa32c429 --- /dev/null +++ b/database/migrations/2026_03_03_125100_add_runner_group_id_to_github_apps.php @@ -0,0 +1,28 @@ +unsignedBigInteger('runner_group_id')->nullable()->after('installation_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('github_apps', function (Blueprint $table) { + $table->dropColumn('runner_group_id'); + }); + } +}; diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php index 2d7649fab..62004c2ee 100644 --- a/resources/views/components/server/sidebar.blade.php +++ b/resources/views/components/server/sidebar.blade.php @@ -31,6 +31,9 @@ + + diff --git a/resources/views/livewire/server/github-runners.blade.php b/resources/views/livewire/server/github-runners.blade.php new file mode 100644 index 000000000..04e98ac22 --- /dev/null +++ b/resources/views/livewire/server/github-runners.blade.php @@ -0,0 +1,246 @@ +
The selected GitHub App does not have the organization_self_hosted_runners: write permission.
+ 1. Add it in your GitHub App settings, + then 2. re-sync permissions in Coolify's Source settings. +
+No repositories are accessible yet, or the GitHub App is set to "All repositories" (all org repos are covered automatically).
+If you expect specific repositories to appear, manage repository access in your GitHub App settings.
+| Runner | +Workflow | +Repository | +Status | +Duration | +Started | ++ |
|---|---|---|---|---|---|---|
| {{ $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())
+ |
+