Files
coolify/tests/Feature/GithubRunnerFlowTest.php
Andras Bacsai b7a2c75739 fix(github): check clock skew only from failed GitHub App responses
Stop calling /zen before each installation token request. A short
network problem to GitHub failed deployments with a cURL timeout on
/zen. assertGithubClockInSync now takes the failed response and reads
its Date header, so the "System time is out of sync" error still shows
when GitHub rejects the JWT.

Call it on failed JWT requests in the webhook installation check, the
permission job, the source connection test, and the server transfer
checklist. Remove the /zen fakes from the tests.
2026-11-03 19:29:45 +01:00

977 lines
47 KiB
PHP

<?php
use App\Actions\Server\DeleteServer;
use App\Enums\GithubRunnerStatus;
use App\Jobs\CleanupGithubRunnerJob;
use App\Jobs\DeregisterGithubRunnerJob;
use App\Jobs\ProvisionGithubRunnerJob;
use App\Jobs\ReconcileGithubRunnersJob;
use App\Models\GithubApp;
use App\Models\GithubRunnerConfig;
use App\Models\GithubRunnerExecution;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Process\PendingProcess;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Sleep;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
Server::flushIdentityMap();
Sleep::fake();
$this->team = Team::factory()->create();
$this->githubApp = runnerTestGithubApp($this->team, 1111, 'runner-secret');
});
afterEach(function () {
Server::flushIdentityMap();
});
function runnerTestGithubApp(Team $team, int $appId, string $secret): GithubApp
{
$rsaKey = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
openssl_pkey_export($rsaKey, $pemKey);
$privateKey = PrivateKey::create(['name' => 'Runner key', 'private_key' => $pemKey, 'team_id' => $team->id]);
return GithubApp::create([
'name' => "runner-app-{$appId}",
'organization' => 'acme',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => $appId,
'installation_id' => $appId + 1,
'webhook_secret' => $secret,
'private_key_id' => $privateKey->id,
'team_id' => $team->id,
'is_system_wide' => false,
'runner_group_id' => 7,
]);
}
function runnerTestServer(Team $team, string $role = 'build'): Server
{
$server = Server::factory()->create(['team_id' => $team->id]);
$server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'server_role' => $role,
'is_build_server' => $role === 'build',
'force_disabled' => false,
]);
return $server->fresh();
}
function runnerTestConfig(Server $server, GithubApp $githubApp, array $attributes = []): GithubRunnerConfig
{
return GithubRunnerConfig::create([
'server_id' => $server->id,
'github_app_id' => $githubApp->id,
'labels' => ['coolify'],
'max_runners' => 2,
'docker_mode' => 'dind',
...$attributes,
]);
}
function runnerTestExecution(GithubApp $githubApp, array $attributes = []): GithubRunnerExecution
{
static $jobId = 1000;
return GithubRunnerExecution::create([
'github_app_id' => $githubApp->id,
'trigger_workflow_job_id' => ++$jobId,
'labels' => ['self-hosted', 'coolify'],
'status' => GithubRunnerStatus::Queued,
'queued_at' => now(),
...$attributes,
]);
}
function sendWorkflowJobWebhook($test, GithubApp $githubApp, string $secret, string $action, array $job, ?int $installationId = null)
{
$body = json_encode([
'action' => $action,
'workflow_job' => [
'id' => 5001,
'labels' => ['self-hosted', 'coolify'],
'html_url' => 'https://github.com/acme/api/actions/runs/1/job/5001',
'workflow_name' => 'CI',
'name' => 'test',
'runner_name' => null,
...$job,
],
'repository' => ['id' => 99, 'full_name' => 'acme/api'],
'organization' => ['login' => 'acme'],
'installation' => ['id' => $installationId ?? $githubApp->installation_id],
], JSON_THROW_ON_ERROR);
return $test->call('POST', '/webhooks/source/github/events', [], [], [], [
'HTTP_X-GitHub-Event' => 'workflow_job',
'HTTP_X-GitHub-Hook-Installation-Target-Id' => (string) $githubApp->app_id,
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $body, $secret),
'CONTENT_TYPE' => 'application/json',
], $body);
}
function fakeRunnerGithubApi(GithubApp $githubApp): void
{
Http::fake([
"https://api.github.com/app/installations/{$githubApp->installation_id}/access_tokens" => Http::response(['token' => 'installation-token'], 201),
'https://api.github.com/orgs/acme/actions/runners/generate-jitconfig' => Http::response([
'runner' => ['id' => 42, 'name' => 'runner'],
'encoded_jit_config' => 'SECRET-JIT-CONFIG',
], 201),
'https://api.github.com/orgs/acme/actions/runners/*' => Http::response(null, 204),
]);
}
function fakeWorkflowRunApi(GithubApp $githubApp, mixed $runResponse): void
{
Http::fake([
"https://api.github.com/app/installations/{$githubApp->installation_id}/access_tokens" => Http::response(['token' => 'installation-token'], 201),
'https://api.github.com/repos/acme/api/actions/runs/321' => $runResponse,
]);
}
describe('workflow_job webhook', function () {
beforeEach(function () {
Queue::fake();
$this->server = runnerTestServer($this->team);
runnerTestConfig($this->server, $this->githubApp);
});
it('creates one queued execution for duplicate deliveries of a queued job', function () {
sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'queued', [])->assertOk();
sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'queued', [])->assertOk();
$execution = GithubRunnerExecution::sole();
expect($execution->status)->toBe(GithubRunnerStatus::Queued)
->and($execution->trigger_workflow_job_id)->toBe(5001)
->and($execution->repository_full_name)->toBe('acme/api');
Queue::assertPushed(ProvisionGithubRunnerJob::class, 1);
});
it('ignores jobs that do not ask for a custom label', function (array $labels) {
sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'queued', ['labels' => $labels])
->assertOk()
->assertSee('No runner configuration matches');
expect(GithubRunnerExecution::count())->toBe(0);
Queue::assertNotPushed(ProvisionGithubRunnerJob::class);
})->with([
'generic self-hosted' => [['self-hosted']],
'unknown label' => [['self-hosted', 'coolify', 'gpu']],
'github hosted' => [['ubuntu-latest']],
]);
it('does not provision a runner for a pull request job when no matching configuration allows them', function (string $event) {
Http::preventStrayRequests();
fakeWorkflowRunApi($this->githubApp, Http::response(['id' => 321, 'event' => $event]));
sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'queued', ['run_id' => 321])
->assertOk()
->assertSee('does not allow pull request jobs');
expect(GithubRunnerExecution::count())->toBe(0);
Queue::assertNotPushed(ProvisionGithubRunnerJob::class);
})->with(['pull_request', 'pull_request_target']);
it('provisions a runner for a job that does not come from a pull request', function (mixed $runResponse) {
Http::preventStrayRequests();
fakeWorkflowRunApi($this->githubApp, $runResponse);
sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'queued', ['run_id' => 321])->assertOk();
expect(GithubRunnerExecution::sole()->status)->toBe(GithubRunnerStatus::Queued);
Queue::assertPushed(ProvisionGithubRunnerJob::class, 1);
})->with([
'push' => fn () => Http::response(['id' => 321, 'event' => 'push']),
'workflow run lookup fails' => fn () => Http::response(['message' => 'Server Error'], 500),
]);
it('provisions a runner for a pull request job without a lookup when a matching configuration allows them', function () {
Http::preventStrayRequests();
Http::fake();
GithubRunnerConfig::query()->update(['allow_pull_requests' => true]);
sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'queued', ['run_id' => 321])->assertOk();
expect(GithubRunnerExecution::count())->toBe(1);
Queue::assertPushed(ProvisionGithubRunnerJob::class, 1);
Http::assertNothingSent();
});
it('ignores jobs from another installation of the GitHub App', function (string $action) {
$execution = runnerTestExecution($this->githubApp, [
'trigger_workflow_job_id' => 5001,
'status' => GithubRunnerStatus::Running,
'server_id' => $this->server->id,
'runner_name' => 'coolify-runner-abc',
]);
sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', $action, [
'id' => 6001,
'runner_name' => 'coolify-runner-abc',
'conclusion' => 'success',
], installationId: 987654)
->assertOk()
->assertSee('another installation');
expect(GithubRunnerExecution::count())->toBe(1)
->and($execution->fresh()->status)->toBe(GithubRunnerStatus::Running);
Queue::assertNothingPushed();
})->with(['queued', 'completed']);
it('marks a pull request job when only some matching configurations allow pull request jobs', function () {
Http::preventStrayRequests();
fakeWorkflowRunApi($this->githubApp, Http::response(['id' => 321, 'event' => 'pull_request']));
runnerTestConfig(runnerTestServer($this->team), $this->githubApp, ['allow_pull_requests' => true]);
sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'queued', ['run_id' => 321])->assertOk();
expect(GithubRunnerExecution::sole()->is_pull_request)->toBeTrue();
Queue::assertPushed(ProvisionGithubRunnerJob::class, 1);
});
it('rejects an invalid signature', function () {
sendWorkflowJobWebhook($this, $this->githubApp, 'wrong-secret', 'queued', [])->assertSee('Invalid signature');
expect(GithubRunnerExecution::count())->toBe(0);
});
it('marks the runner that took the job as running, even when it was started for another job', function () {
$execution = runnerTestExecution($this->githubApp, [
'status' => GithubRunnerStatus::Idle,
'server_id' => $this->server->id,
'runner_name' => 'coolify-runner-abc',
]);
sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'in_progress', [
'id' => 7777,
'runner_name' => 'coolify-runner-abc',
])->assertOk();
$execution->refresh();
expect($execution->status)->toBe(GithubRunnerStatus::Running)
->and($execution->workflow_job_id)->toBe(7777)
->and($execution->started_at)->not->toBeNull();
});
it('starts a new runner for the job whose runner another job took', function () {
$stolen = runnerTestExecution($this->githubApp, [
'trigger_workflow_job_id' => 5001,
'workflow_job_id' => 5001,
'job_name' => 'build',
'status' => GithubRunnerStatus::Idle,
'server_id' => $this->server->id,
'runner_name' => 'coolify-runner-a',
'provision_attempts' => 1,
]);
foreach ([1, 2] as $delivery) {
sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'in_progress', [
'id' => 6001,
'name' => 'pr-test',
'runner_name' => 'coolify-runner-a',
])->assertOk();
}
$stolen->refresh();
$replacement = GithubRunnerExecution::query()->where('trigger_workflow_job_id', 5001)->sole();
expect($stolen->status)->toBe(GithubRunnerStatus::Running)
->and($stolen->trigger_workflow_job_id)->toBe(6001)
->and($stolen->workflow_job_id)->toBe(6001)
->and($replacement->id)->not->toBe($stolen->id)
->and($replacement->status)->toBe(GithubRunnerStatus::Queued)
->and($replacement->job_name)->toBe('build')
->and($replacement->labels)->toBe(['self-hosted', 'coolify'])
->and($replacement->provision_attempts)->toBe(1);
Queue::assertPushed(ProvisionGithubRunnerJob::class, 1);
Queue::assertPushed(ProvisionGithubRunnerJob::class, fn ($job) => $job->executionId === $replacement->id);
sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'completed', [
'id' => 6001,
'runner_name' => 'coolify-runner-a',
'conclusion' => 'success',
])->assertOk();
expect($stolen->fresh()->status)->toBe(GithubRunnerStatus::Completed)
->and($replacement->fresh()->status)->toBe(GithubRunnerStatus::Queued);
});
it('starts a new runner when the completed hook of the other job arrives before its in_progress hook', function () {
$stolen = runnerTestExecution($this->githubApp, [
'trigger_workflow_job_id' => 5001,
'status' => GithubRunnerStatus::Idle,
'server_id' => $this->server->id,
'runner_name' => 'coolify-runner-a',
'provision_attempts' => 1,
]);
sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'completed', [
'id' => 6001,
'runner_name' => 'coolify-runner-a',
'conclusion' => 'success',
])->assertOk();
expect($stolen->fresh()->status)->toBe(GithubRunnerStatus::Completed)
->and($stolen->fresh()->trigger_workflow_job_id)->toBe(6001)
->and(GithubRunnerExecution::query()->where('trigger_workflow_job_id', 5001)->sole()->status)->toBe(GithubRunnerStatus::Queued);
Queue::assertPushed(ProvisionGithubRunnerJob::class, 1);
});
it('replaces a stale execution of the job that took the runner', function () {
$stale = runnerTestExecution($this->githubApp, ['trigger_workflow_job_id' => 6001, 'status' => GithubRunnerStatus::TimedOut]);
$stolen = runnerTestExecution($this->githubApp, [
'trigger_workflow_job_id' => 5001,
'status' => GithubRunnerStatus::Idle,
'server_id' => $this->server->id,
'runner_name' => 'coolify-runner-a',
'provision_attempts' => 1,
]);
sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'in_progress', [
'id' => 6001,
'runner_name' => 'coolify-runner-a',
])->assertOk();
expect($stale->fresh())->toBeNull()
->and($stolen->fresh()->trigger_workflow_job_id)->toBe(6001)
->and(GithubRunnerExecution::query()->where('trigger_workflow_job_id', 5001)->sole()->status)->toBe(GithubRunnerStatus::Queued);
Queue::assertPushed(ProvisionGithubRunnerJob::class, 1);
});
it('does not start a new runner when the runner took another job with its own pending runner', function () {
$first = runnerTestExecution($this->githubApp, [
'trigger_workflow_job_id' => 5001,
'status' => GithubRunnerStatus::Idle,
'server_id' => $this->server->id,
'runner_name' => 'coolify-runner-a',
]);
$second = runnerTestExecution($this->githubApp, ['trigger_workflow_job_id' => 6001]);
sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'in_progress', [
'id' => 6001,
'runner_name' => 'coolify-runner-a',
])->assertOk();
expect(GithubRunnerExecution::count())->toBe(2)
->and($first->fresh()->status)->toBe(GithubRunnerStatus::Running)
->and($first->fresh()->workflow_job_id)->toBe(6001)
->and($second->fresh()->status)->toBe(GithubRunnerStatus::Queued);
Queue::assertNotPushed(ProvisionGithubRunnerJob::class);
});
it('does not start a new runner for a job whose runner was taken', function (Closure $setup, array $attributes) {
$setup();
$stolen = runnerTestExecution($this->githubApp, [
'trigger_workflow_job_id' => 5001,
'status' => GithubRunnerStatus::Idle,
'server_id' => $this->server->id,
'runner_name' => 'coolify-runner-a',
'provision_attempts' => 1,
...$attributes,
]);
sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'in_progress', [
'id' => 6001,
'runner_name' => 'coolify-runner-a',
])->assertOk();
expect(GithubRunnerExecution::count())->toBe(1)
->and($stolen->fresh()->status)->toBe(GithubRunnerStatus::Running);
Queue::assertNotPushed(ProvisionGithubRunnerJob::class);
})->with([
'runner limit of the job reached' => [fn () => null, ['provision_attempts' => ProvisionGithubRunnerJob::MAX_ATTEMPTS]],
'configuration disabled' => [fn () => GithubRunnerConfig::query()->update(['is_enabled' => false]), []],
'pull request job no longer allowed' => [fn () => null, ['is_pull_request' => true]],
]);
it('marks the runner running without a new runner when it took the job it was started for', function () {
$execution = runnerTestExecution($this->githubApp, [
'trigger_workflow_job_id' => 5001,
'status' => GithubRunnerStatus::Idle,
'server_id' => $this->server->id,
'runner_name' => 'coolify-runner-a',
]);
sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'in_progress', ['runner_name' => 'coolify-runner-a'])->assertOk();
$execution->refresh();
expect(GithubRunnerExecution::count())->toBe(1)
->and($execution->status)->toBe(GithubRunnerStatus::Running)
->and($execution->trigger_workflow_job_id)->toBe(5001)
->and($execution->workflow_job_id)->toBe(5001);
Queue::assertNotPushed(ProvisionGithubRunnerJob::class);
});
it('completes the runner and queues its cleanup', function () {
$execution = runnerTestExecution($this->githubApp, [
'status' => GithubRunnerStatus::Running,
'server_id' => $this->server->id,
'runner_name' => 'coolify-runner-abc',
]);
sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'completed', [
'runner_name' => 'coolify-runner-abc',
'conclusion' => 'success',
])->assertOk();
$execution->refresh();
expect($execution->status)->toBe(GithubRunnerStatus::Completed)
->and($execution->conclusion)->toBe('success');
Queue::assertPushed(CleanupGithubRunnerJob::class, fn ($job) => $job->executionId === $execution->id);
});
it('does not touch runners of another GitHub App with the same runner name', function () {
$otherTeam = Team::factory()->create();
$otherApp = runnerTestGithubApp($otherTeam, 2222, 'other-secret');
$victim = runnerTestExecution($this->githubApp, [
'status' => GithubRunnerStatus::Running,
'server_id' => $this->server->id,
'runner_name' => 'coolify-runner-abc',
]);
sendWorkflowJobWebhook($this, $otherApp, 'other-secret', 'completed', [
'runner_name' => 'coolify-runner-abc',
'conclusion' => 'cancelled',
])->assertOk();
expect($victim->fresh()->status)->toBe(GithubRunnerStatus::Running);
Queue::assertNotPushed(CleanupGithubRunnerJob::class);
});
it('cancels the queued execution of a job that ended without a Coolify runner', function () {
$execution = runnerTestExecution($this->githubApp, ['trigger_workflow_job_id' => 5001]);
sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'completed', ['conclusion' => 'cancelled'])->assertOk();
expect($execution->fresh()->status)->toBe(GithubRunnerStatus::Cancelled);
});
it('keeps the queued execution when its job ran on a Coolify runner started for another job', function () {
$queued = runnerTestExecution($this->githubApp, ['trigger_workflow_job_id' => 5001]);
runnerTestExecution($this->githubApp, [
'status' => GithubRunnerStatus::Running,
'server_id' => $this->server->id,
'runner_name' => 'coolify-runner-other',
]);
sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'completed', [
'runner_name' => 'coolify-runner-other',
'conclusion' => 'success',
])->assertOk();
expect($queued->fresh()->status)->toBe(GithubRunnerStatus::Queued);
});
});
describe('provisioning', function () {
it('starts a runner on the least busy matching server that can build', function () {
Queue::fake();
fakeRunnerGithubApi($this->githubApp);
Process::fake(['*' => Process::result(output: '29.8.0')]);
$busyServer = runnerTestServer($this->team);
$busyConfig = runnerTestConfig($busyServer, $this->githubApp);
runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Running, 'server_id' => $busyServer->id, 'github_runner_config_id' => $busyConfig->id]);
$freeServer = runnerTestServer($this->team, 'both');
runnerTestConfig($freeServer, $this->githubApp);
$execution = runnerTestExecution($this->githubApp);
(new ProvisionGithubRunnerJob($execution->id))->handle();
$execution->refresh();
expect($execution->status)->toBe(GithubRunnerStatus::Idle)
->and($execution->server_id)->toBe($freeServer->id)
->and($execution->runner_id)->toBe(42)
->and($execution->runner_name)->toBe("coolify-runner-{$execution->uuid}")
->and($execution->ready_at)->not->toBeNull();
Http::assertSent(fn ($request) => str_ends_with($request->url(), '/generate-jitconfig')
&& $request['labels'] === ['self-hosted', 'linux', 'coolify']
&& $request['runner_group_id'] === 7);
$commands = '';
Process::assertRan(function (PendingProcess $process) use (&$commands) {
$commands .= remoteProcessScript($process)."\n";
return true;
});
expect($commands)
->toContain("docker run -d --privileged --name coolify-runner-{$execution->uuid}-dind")
->toContain('--env-file /data/coolify/github-runners/'.$execution->uuid.'.env')
->not->toContain('SECRET-JIT-CONFIG')
->not->toContain('/var/run/docker.sock:/var/run/docker.sock');
});
it('keeps the execution queued when every server is at capacity', function () {
Queue::fake();
Http::fake();
Process::fake();
$server = runnerTestServer($this->team);
$config = runnerTestConfig($server, $this->githubApp, ['max_runners' => 1]);
runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Idle, 'server_id' => $server->id, 'github_runner_config_id' => $config->id]);
$execution = runnerTestExecution($this->githubApp);
(new ProvisionGithubRunnerJob($execution->id))->handle();
expect($execution->fresh()->status)->toBe(GithubRunnerStatus::Queued);
Http::assertNothingSent();
Process::assertNothingRan();
});
it('skips configs whose server is being deleted', function () {
Queue::fake();
fakeRunnerGithubApi($this->githubApp);
Process::fake(['*' => Process::result(output: '29.8.0')]);
$deletedServer = runnerTestServer($this->team);
runnerTestConfig($deletedServer, $this->githubApp);
$deletedServer->delete();
$server = runnerTestServer($this->team);
runnerTestConfig($server, $this->githubApp);
$execution = runnerTestExecution($this->githubApp);
(new ProvisionGithubRunnerJob($execution->id))->handle();
expect($execution->fresh()->server_id)->toBe($server->id);
});
it('places a pull request job only on a server whose configuration allows pull request jobs', function () {
Queue::fake();
fakeRunnerGithubApi($this->githubApp);
Process::fake(['*' => Process::result(output: '29.8.0')]);
$refusingServer = runnerTestServer($this->team);
runnerTestConfig($refusingServer, $this->githubApp, ['allow_pull_requests' => false]);
$allowingServer = runnerTestServer($this->team);
$allowingConfig = runnerTestConfig($allowingServer, $this->githubApp, ['allow_pull_requests' => true]);
runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Running, 'server_id' => $allowingServer->id, 'github_runner_config_id' => $allowingConfig->id]);
$execution = runnerTestExecution($this->githubApp, ['is_pull_request' => true]);
(new ProvisionGithubRunnerJob($execution->id))->handle();
expect($execution->fresh()->server_id)->toBe($allowingServer->id);
});
it('keeps a pull request job queued when no configuration with capacity allows pull request jobs', function () {
Queue::fake();
Process::fake();
runnerTestConfig(runnerTestServer($this->team), $this->githubApp, ['allow_pull_requests' => false]);
$execution = runnerTestExecution($this->githubApp, ['is_pull_request' => true]);
(new ProvisionGithubRunnerJob($execution->id))->handle();
expect($execution->fresh()->status)->toBe(GithubRunnerStatus::Queued);
Process::assertNothingRan();
});
it('stops provisioning when the server is deleted while GitHub registers the runner', function () {
Queue::fake();
Process::fake(['*' => Process::result(output: '29.8.0')]);
$server = runnerTestServer($this->team);
runnerTestConfig($server, $this->githubApp);
$execution = runnerTestExecution($this->githubApp);
Http::fake([
'https://api.github.com/app/installations/*' => Http::response(['token' => 'installation-token'], 201),
'https://api.github.com/orgs/acme/actions/runners/generate-jitconfig' => function () use ($server) {
DeleteServer::run($server->id);
return Http::response(['runner' => ['id' => 42], 'encoded_jit_config' => 'SECRET-JIT-CONFIG'], 201);
},
]);
(new ProvisionGithubRunnerJob($execution->id))->handle();
expect(GithubRunnerExecution::find($execution->id))->toBeNull();
Process::assertDidntRun(fn (PendingProcess $process) => str_contains(remoteProcessScript($process), '/home/runner/run.sh'));
Queue::assertPushed(DeregisterGithubRunnerJob::class, fn ($job) => $job->githubAppId === $this->githubApp->id && $job->runnerId === 42);
Queue::assertNotPushed(ProvisionGithubRunnerJob::class);
});
it('does not start runners on the Coolify host', function () {
Queue::fake();
$server = runnerTestServer($this->team);
$server->update(['ip' => 'host.docker.internal']);
runnerTestConfig($server, $this->githubApp);
$execution = runnerTestExecution($this->githubApp);
Process::fake();
(new ProvisionGithubRunnerJob($execution->id))->handle();
expect($execution->fresh()->status)->toBe(GithubRunnerStatus::Queued);
Process::assertNothingRan();
});
it('does not use deployments only servers, disabled configs, or other labels', function (string $role, array $configAttributes) {
Queue::fake();
Process::fake();
$server = runnerTestServer($this->team, $role);
runnerTestConfig($server, $this->githubApp, $configAttributes);
$execution = runnerTestExecution($this->githubApp);
(new ProvisionGithubRunnerJob($execution->id))->handle();
expect($execution->fresh()->status)->toBe(GithubRunnerStatus::Queued);
Process::assertNothingRan();
})->with([
'deployments only role' => ['deployment', []],
'disabled config' => ['build', ['is_enabled' => false]],
'other labels' => ['build', ['labels' => ['gpu']]],
]);
it('cleans up a runner that failed to start and retries the job later', function () {
Queue::fake();
Process::fake(['*' => Process::result(output: '29.8.0')]);
fakeRejectedJitConfig();
runnerTestConfig(runnerTestServer($this->team), $this->githubApp);
$execution = runnerTestExecution($this->githubApp);
$uuid = $execution->uuid;
(new ProvisionGithubRunnerJob($execution->id))->handle();
$execution->refresh();
expect($execution->status)->toBe(GithubRunnerStatus::Queued)
->and($execution->provision_attempts)->toBe(1)
->and($execution->server_id)->toBeNull()
->and($execution->runner_name)->toBeNull()
->and($execution->error_message)->toContain('Attempt 1 of 3 failed')->toContain('Resource not accessible by integration');
Process::assertRan(fn (PendingProcess $process) => str_contains(remoteProcessScript($process), "docker rm -f -v coolify-runner-{$uuid}"));
Queue::assertPushed(ProvisionGithubRunnerJob::class, fn ($job) => $job->executionId === $execution->id && $job->delay !== null);
Queue::assertNotPushed(CleanupGithubRunnerJob::class);
});
it('preserves a terminal execution when provisioning fails', function (GithubRunnerStatus $status, int $attempts) {
Queue::fake();
Process::fake(['*' => Process::result(output: '29.8.0')]);
runnerTestConfig(runnerTestServer($this->team), $this->githubApp);
$execution = runnerTestExecution($this->githubApp, ['provision_attempts' => $attempts]);
Http::fake([
'https://api.github.com/app/installations/*' => Http::response(['token' => 'installation-token'], 201),
'https://api.github.com/orgs/acme/actions/runners/42' => Http::response(null, 204),
'https://api.github.com/orgs/acme/actions/runners/generate-jitconfig' => function () use ($execution, $status) {
$execution->finish($status, 'Stopped externally');
return Http::response(['message' => 'Resource not accessible by integration'], 403);
},
]);
(new ProvisionGithubRunnerJob($execution->id))->handle();
expect($execution->fresh()->status)->toBe($status)
->and($execution->fresh()->error_message)->toBe('Stopped externally');
Queue::assertNotPushed(ProvisionGithubRunnerJob::class, fn ($job) => $job->executionId === $execution->id);
})->with([
'cancelled during first attempt' => [GithubRunnerStatus::Cancelled, 0],
'cancelled during last attempt' => [GithubRunnerStatus::Cancelled, 2],
'timed out' => [GithubRunnerStatus::TimedOut, 0],
'completed' => [GithubRunnerStatus::Completed, 0],
]);
it('does not start a runner cancelled while GitHub registers it', function () {
Queue::fake();
Process::fake(['*' => Process::result(output: '29.8.0')]);
runnerTestConfig(runnerTestServer($this->team), $this->githubApp);
$execution = runnerTestExecution($this->githubApp);
Http::fake([
'https://api.github.com/app/installations/*' => Http::response(['token' => 'installation-token'], 201),
'https://api.github.com/orgs/acme/actions/runners/42' => Http::response(null, 204),
'https://api.github.com/orgs/acme/actions/runners/generate-jitconfig' => function () use ($execution) {
$execution->finish(GithubRunnerStatus::Cancelled);
return Http::response(['runner' => ['id' => 42], 'encoded_jit_config' => 'SECRET-JIT-CONFIG'], 201);
},
]);
(new ProvisionGithubRunnerJob($execution->id))->handle();
expect($execution->fresh()->status)->toBe(GithubRunnerStatus::Cancelled)
->and($execution->fresh()->ready_at)->toBeNull();
Process::assertDidntRun(fn (PendingProcess $process) => str_contains(remoteProcessScript($process), '/home/runner/run.sh'));
Queue::assertPushed(CleanupGithubRunnerJob::class, fn ($job) => $job->executionId === $execution->id);
});
it('stops provisioning without touching GitHub or the host when the server is transferred meanwhile', function (bool $cancelled) {
Queue::fake();
Process::fake(['*' => Process::result(output: '29.8.0')]);
$server = runnerTestServer($this->team);
runnerTestConfig($server, $this->githubApp);
$execution = runnerTestExecution($this->githubApp);
Http::fake([
'https://api.github.com/app/installations/*' => Http::response(['token' => 'installation-token'], 201),
'https://api.github.com/orgs/acme/actions/runners/42' => Http::response(null, 204),
'https://api.github.com/orgs/acme/actions/runners/generate-jitconfig' => function () use ($server, $execution, $cancelled) {
// The transfer commits while GitHub registers the runner. Without the cancel, it commits right
// after the job read the execution, so only the server shows the transfer.
$server->settings()->update(['force_disabled' => true]);
$server->forceFill(['server_metadata' => ['transfer' => ['status' => 'transferred']]])->saveQuietly();
if ($cancelled) {
$execution->finish(GithubRunnerStatus::Cancelled);
}
return Http::response(['runner' => ['id' => 42], 'encoded_jit_config' => 'SECRET-JIT-CONFIG'], 201);
},
]);
(new ProvisionGithubRunnerJob($execution->id))->handle();
expect($execution->fresh()->status)->toBe(GithubRunnerStatus::Cancelled)
->and($execution->fresh()->ready_at)->toBeNull();
Process::assertDidntRun(fn (PendingProcess $process) => str_contains(remoteProcessScript($process), '/home/runner/run.sh'));
Queue::assertPushed(CleanupGithubRunnerJob::class, fn ($job) => $job->executionId === $execution->id);
(new CleanupGithubRunnerJob($execution->id))->handle();
Process::assertDidntRun(fn (PendingProcess $process) => str_contains(remoteProcessScript($process), 'docker rm'));
Http::assertNotSent(fn ($request) => $request->method() === 'DELETE');
})->with(['the transfer cancelled the execution' => true, 'the transfer commits after the last check' => false]);
it('cleans up cancellation during remote provisioning without marking the runner ready', function (bool $duringStart) {
Queue::fake();
runnerTestConfig(runnerTestServer($this->team), $this->githubApp);
$execution = runnerTestExecution($this->githubApp);
fakeRunnerGithubApi($this->githubApp);
Process::fake(['*' => function (PendingProcess $process) use ($execution, $duringStart) {
if (! $duringStart || str_contains(remoteProcessScript($process), '/home/runner/run.sh')) {
$execution->finish(GithubRunnerStatus::Cancelled);
}
return Process::result(output: '29.8.0');
}]);
(new ProvisionGithubRunnerJob($execution->id))->handle();
expect($execution->fresh()->status)->toBe(GithubRunnerStatus::Cancelled)
->and($execution->fresh()->ready_at)->toBeNull();
Queue::assertPushed(CleanupGithubRunnerJob::class, fn ($job) => $job->executionId === $execution->id);
if (! $duringStart) {
Http::assertNotSent(fn ($request) => str_ends_with($request->url(), '/generate-jitconfig'));
}
})->with(['during preparation' => false, 'during start' => true]);
it('does not retry an execution cancelled while failed resources are removed', function () {
Queue::fake();
runnerTestConfig(runnerTestServer($this->team), $this->githubApp);
$execution = runnerTestExecution($this->githubApp);
fakeRejectedJitConfig();
Process::fake(['*' => function (PendingProcess $process) use ($execution) {
if (str_contains(remoteProcessScript($process), 'docker rm -f -v')) {
$execution->finish(GithubRunnerStatus::Cancelled, 'Stopped externally');
}
return Process::result(output: '29.8.0');
}]);
(new ProvisionGithubRunnerJob($execution->id))->handle();
expect($execution->fresh()->status)->toBe(GithubRunnerStatus::Cancelled)
->and($execution->fresh()->error_message)->toBe('Stopped externally');
Queue::assertNotPushed(ProvisionGithubRunnerJob::class, fn ($job) => $job->executionId === $execution->id);
});
it('marks the execution failed and cleans up after the last attempt', function () {
Queue::fake();
Process::fake(['*' => Process::result(output: '29.8.0')]);
fakeRejectedJitConfig();
runnerTestConfig(runnerTestServer($this->team), $this->githubApp);
$execution = runnerTestExecution($this->githubApp, ['provision_attempts' => 2]);
(new ProvisionGithubRunnerJob($execution->id))->handle();
$execution->refresh();
expect($execution->status)->toBe(GithubRunnerStatus::Failed)
->and($execution->provision_attempts)->toBe(3)
->and($execution->error_message)->toContain('after 3 attempts')->toContain('Resource not accessible by integration');
Queue::assertPushed(CleanupGithubRunnerJob::class, fn ($job) => $job->executionId === $execution->id);
Queue::assertNotPushed(ProvisionGithubRunnerJob::class, fn ($job) => $job->executionId === $execution->id);
});
});
describe('cleanup and reconciliation', function () {
beforeEach(function () {
$this->server = runnerTestServer($this->team);
$this->config = runnerTestConfig($this->server, $this->githubApp, ['idle_timeout' => 10, 'job_timeout' => 60, 'capacity_wait_timeout' => 30]);
});
it('removes the containers and the GitHub runner, then provisions the next queued job', function () {
Queue::fake();
fakeRunnerGithubApi($this->githubApp);
Process::fake();
$execution = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Completed, 'server_id' => $this->server->id, 'runner_id' => 42]);
$next = runnerTestExecution($this->githubApp);
(new CleanupGithubRunnerJob($execution->id))->handle();
Process::assertRan(fn (PendingProcess $process) => str_contains(remoteProcessScript($process), "docker rm -f -v coolify-runner-{$execution->uuid}"));
Http::assertSent(fn ($request) => $request->method() === 'DELETE' && str_ends_with($request->url(), '/orgs/acme/actions/runners/42'));
Queue::assertPushed(ProvisionGithubRunnerJob::class, fn ($job) => $job->executionId === $next->id);
});
it('finishes runners whose container stopped, idled, or ran too long, and removes orphans', function () {
Queue::fake();
$stopped = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Running, 'server_id' => $this->server->id, 'github_runner_config_id' => $this->config->id, 'started_at' => now()]);
$idle = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Idle, 'server_id' => $this->server->id, 'github_runner_config_id' => $this->config->id, 'ready_at' => now()->subMinutes(11)]);
$long = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Running, 'server_id' => $this->server->id, 'github_runner_config_id' => $this->config->id, 'started_at' => now()->subMinutes(61)]);
$healthy = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Running, 'server_id' => $this->server->id, 'github_runner_config_id' => $this->config->id, 'started_at' => now()]);
$lines = [
"{$stopped->uuid} coolify-runner-{$stopped->uuid} exited",
"{$idle->uuid} coolify-runner-{$idle->uuid} running",
"{$long->uuid} coolify-runner-{$long->uuid} running",
"{$healthy->uuid} coolify-runner-{$healthy->uuid} running",
"{$healthy->uuid} coolify-runner-{$healthy->uuid}-dind running",
'orphanuuid coolify-runner-orphanuuid exited',
];
Process::fake(['*' => Process::result(output: implode("\n", $lines))]);
(new ReconcileGithubRunnersJob)->handle();
expect($stopped->fresh()->status)->toBe(GithubRunnerStatus::Failed)
->and($idle->fresh()->status)->toBe(GithubRunnerStatus::Cancelled)
->and($long->fresh()->status)->toBe(GithubRunnerStatus::TimedOut)
->and($healthy->fresh()->status)->toBe(GithubRunnerStatus::Running);
Queue::assertPushed(CleanupGithubRunnerJob::class, 3);
Process::assertRan(fn (PendingProcess $process) => str_contains(remoteProcessScript($process), 'docker rm -f -v coolify-runner-orphanuuid'));
Process::assertDidntRun(fn (PendingProcess $process) => str_contains(remoteProcessScript($process), "docker rm -f -v coolify-runner-{$healthy->uuid}"));
});
it('lets a late completed webhook replace a failure guessed from a stopped container', function () {
Queue::fake();
$execution = runnerTestExecution($this->githubApp, [
'status' => GithubRunnerStatus::Failed,
'server_id' => $this->server->id,
'runner_name' => 'coolify-runner-late',
'error_message' => 'The runner container stopped before GitHub reported a job result.',
]);
sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'completed', [
'runner_name' => 'coolify-runner-late',
'conclusion' => 'success',
])->assertOk();
$execution->refresh();
expect($execution->status)->toBe(GithubRunnerStatus::Completed)
->and($execution->error_message)->toBeNull();
});
it('times out queued jobs after the wait timeout and retries the others', function () {
Queue::fake();
Process::fake(['*' => Process::result(output: '')]);
$expired = runnerTestExecution($this->githubApp, ['queued_at' => now()->subMinutes(31)]);
$waiting = runnerTestExecution($this->githubApp, ['queued_at' => now()->subMinutes(5)]);
(new ReconcileGithubRunnersJob)->handle();
expect($expired->fresh()->status)->toBe(GithubRunnerStatus::TimedOut)
->and($waiting->fresh()->status)->toBe(GithubRunnerStatus::Queued);
Queue::assertPushed(ProvisionGithubRunnerJob::class, fn ($job) => $job->executionId === $waiting->id);
Queue::assertNotPushed(ProvisionGithubRunnerJob::class, fn ($job) => $job->executionId === $expired->id);
});
it('does not touch runners when the container list cannot be read', function () {
Queue::fake();
$execution = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Running, 'server_id' => $this->server->id, 'github_runner_config_id' => $this->config->id, 'started_at' => now()]);
Process::fake(['*' => Process::result(errorOutput: 'Cannot connect to the Docker daemon', exitCode: 1)]);
(new ReconcileGithubRunnersJob)->handle();
expect($execution->fresh()->status)->toBe(GithubRunnerStatus::Running);
Queue::assertNotPushed(CleanupGithubRunnerJob::class);
});
});
describe('server deletion', function () {
beforeEach(function () {
$this->server = runnerTestServer($this->team);
$this->config = runnerTestConfig($this->server, $this->githubApp);
});
it('deletes the runner configuration and executions of the server and lets the GitHub App be deleted', function () {
Queue::fake();
$running = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Running, 'server_id' => $this->server->id, 'github_runner_config_id' => $this->config->id, 'runner_id' => 42]);
$provisioning = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Provisioning, 'server_id' => $this->server->id, 'github_runner_config_id' => $this->config->id]);
$completed = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Completed, 'server_id' => $this->server->id, 'runner_id' => 41]);
$otherServer = runnerTestServer($this->team);
$otherConfig = runnerTestConfig($otherServer, $this->githubApp, ['is_enabled' => false]);
$other = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Completed, 'server_id' => $otherServer->id]);
$queued = runnerTestExecution($this->githubApp);
$this->server->delete();
DeleteServer::run($this->server->id);
expect(Server::withTrashed()->find($this->server->id))->toBeNull()
->and(GithubRunnerConfig::find($this->config->id))->toBeNull()
->and(GithubRunnerExecution::whereKey([$running->id, $provisioning->id, $completed->id])->exists())->toBeFalse()
->and($other->fresh())->not->toBeNull()
->and($otherConfig->fresh())->not->toBeNull()
->and($queued->fresh()->status)->toBe(GithubRunnerStatus::Queued);
Queue::assertPushed(DeregisterGithubRunnerJob::class, 1);
Queue::assertPushed(DeregisterGithubRunnerJob::class, fn ($job) => $job->githubAppId === $this->githubApp->id && $job->runnerId === 42);
$this->githubApp->delete();
expect(GithubApp::find($this->githubApp->id))->toBeNull();
});
it('keeps the GitHub runners of a transferred server registered when it deletes the archive', function () {
Queue::fake();
$running = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Running, 'server_id' => $this->server->id, 'github_runner_config_id' => $this->config->id, 'runner_id' => 42]);
$this->server->update(['server_metadata' => ['transfer' => ['status' => 'transferred']]]);
$this->server->delete();
DeleteServer::run($this->server->id);
expect(Server::withTrashed()->find($this->server->id))->toBeNull()
->and(GithubRunnerExecution::whereKey($running->id)->exists())->toBeFalse();
Queue::assertNotPushed(DeregisterGithubRunnerJob::class);
});
it('removes the runner from GitHub and ignores GitHub errors', function (int $status) {
Http::fake([
'https://api.github.com/app/installations/*' => Http::response(['token' => 'installation-token'], 201),
'https://api.github.com/orgs/acme/actions/runners/42' => Http::response(null, $status),
]);
(new DeregisterGithubRunnerJob($this->githubApp->id, 42))->handle();
Http::assertSent(fn ($request) => $request->method() === 'DELETE' && str_ends_with($request->url(), '/orgs/acme/actions/runners/42'));
})->with([204, 500]);
it('removes runners of servers deleted by older versions during reconciliation', function () {
Queue::fake();
Process::fake(['*' => Process::result(output: '')]);
$orphan = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Running, 'runner_id' => 42]);
$orphanWithoutRunner = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Provisioning]);
$queued = runnerTestExecution($this->githubApp);
$finished = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::TimedOut]);
(new ReconcileGithubRunnersJob)->handle();
expect(GithubRunnerExecution::whereKey([$orphan->id, $orphanWithoutRunner->id])->exists())->toBeFalse()
->and($queued->fresh()->status)->toBe(GithubRunnerStatus::Queued)
->and($finished->fresh()->status)->toBe(GithubRunnerStatus::TimedOut);
Queue::assertPushed(DeregisterGithubRunnerJob::class, 1);
Queue::assertPushed(DeregisterGithubRunnerJob::class, fn ($job) => $job->runnerId === 42);
$this->config->update(['is_enabled' => false]);
$this->githubApp->delete();
expect(GithubApp::find($this->githubApp->id))->toBeNull();
});
});
function fakeRejectedJitConfig(): void
{
Http::fake([
'https://api.github.com/app/installations/*' => Http::response(['token' => 'installation-token'], 201),
'https://api.github.com/orgs/acme/actions/runners/generate-jitconfig' => Http::response(['message' => 'Resource not accessible by integration'], 403),
]);
}