coolify/app/Jobs/ProvisionGithubRunnerJob.php
Andras Bacsai 1739c04d32
Some checks are pending
Staging Build / build-push (aarch64, linux/aarch64, ubuntu-24.04-arm) (push) Waiting to run
Staging Build / build-push (amd64, linux/amd64, ubuntu-24.04) (push) Waiting to run
Staging Build / merge-manifest (push) Blocked by required conditions
feat(github-runners): add capacity wait timeout and deferred repo loading
Add configurable `capacity_wait_timeout` to runner configs and use it in
`ProvisionGithubRunnerJob` to re-dispatch jobs while at capacity, preserving
initial wait start time and stopping after timeout.

Improve runner provisioning by caching an extracted runner template and copying
it into new runner directories.

Update Livewire GitHub Runners UI to lazy-load accessible repositories via
`wire:init`, track loading state, add timeout input, and remove preinstall
binary action.

Add/extend feature tests for capacity retry timeout behavior and deferred
repository loading.
2026-03-03 18:37:10 +01:00

336 lines
13 KiB
PHP

<?php
namespace App\Jobs;
use App\Enums\GithubRunnerStatus;
use App\Models\GithubApp;
use App\Models\GithubRunnerConfig;
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;
use Visus\Cuid2\Cuid2;
class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $timeout = 300;
public $tries = 3;
public function backoff(): array
{
return [5, 15, 30];
}
public function __construct(
public int $githubAppId,
public array $workflowJobPayload,
public string $organizationLogin,
public int $repositoryId = 0,
public ?string $capacityWaitStartedAt = null,
) {
$this->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', []);
// Step 1: find configs that match labels (ignoring capacity)
$matchingConfigs = $this->findMatchingConfigsIgnoringCapacity($githubApp, $requestedLabels);
if ($matchingConfigs->isEmpty()) {
// No configured server handles these labels at all — nothing to do
return;
}
// Step 2: filter to configs that have capacity
$config = $matchingConfigs
->filter(fn ($c) => $c->hasCapacity())
->sortBy(fn ($c) => $c->activeRunnerCount())
->first();
if (! $config) {
// All matching configs are at capacity — wait and retry
$timeoutMinutes = $matchingConfigs->first()->capacity_wait_timeout;
$waitStartedAt = $this->capacityWaitStartedAt
? \Carbon\Carbon::parse($this->capacityWaitStartedAt)
: now();
if (now()->diffInMinutes($waitStartedAt) >= $timeoutMinutes) {
// Gave up waiting — log and drop so GitHub eventually cancels the job
logger()->warning('ProvisionGithubRunnerJob: gave up waiting for capacity', [
'workflow_job_id' => $workflowJobId,
'labels' => $requestedLabels,
'timeout_minutes' => $timeoutMinutes,
]);
return;
}
// 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,
capacityWaitStartedAt: $this->capacityWaitStartedAt ?? now()->toIso8601String(),
)->delay(15);
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 findMatchingConfigsIgnoringCapacity(GithubApp $githubApp, array $requestedLabels): \Illuminate\Support\Collection
{
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())
->values();
}
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, then populate runner dir from pre-extracted template
$tarball = "actions-runner-linux-{$arch}-{$version}.tar.gz";
$templateDir = "{$baseDir}/.templates/runner-{$arch}-{$version}";
instant_remote_process([
"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}",
"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';
}
}