mirror of
https://github.com/coollabsio/coolify.git
synced 2026-03-11 08:55:47 +00:00
Implement end-to-end GitHub Actions runner support with provisioning, tracking, and cleanup flows. - handle `workflow_job` webhooks to provision and tear down runners - add runner config/execution models, enum states, and relationships - create jobs for provisioning, cleanup, and stale-runner reaping - schedule periodic stale runner cleanup in the console kernel - add Livewire server UI to manage runner configuration and executions - store GitHub App runner permissions and runner group metadata - add migrations for runner permissions, configs, executions, and group id - update GitHub permissions URL generation for organization app settings - include feature/unit tests for runner behavior and permission paths
165 lines
5.4 KiB
PHP
165 lines
5.4 KiB
PHP
<?php
|
|
|
|
namespace App\Jobs;
|
|
|
|
use App\Enums\GithubRunnerStatus;
|
|
use App\Models\GithubRunnerExecution;
|
|
use Illuminate\Bus\Queueable;
|
|
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
use Illuminate\Foundation\Bus\Dispatchable;
|
|
use Illuminate\Queue\InteractsWithQueue;
|
|
use Illuminate\Queue\SerializesModels;
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
class CleanupStaleGithubRunnersJob implements ShouldBeEncrypted, ShouldQueue
|
|
{
|
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
|
|
public $timeout = 300;
|
|
|
|
public $tries = 1;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->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
|
|
}
|
|
}
|
|
}
|