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/zen' => Http::response('ok', 200, ['Date' => now()->toRfc7231String()]), "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/zen' => Http::response('ok', 200, ['Date' => now()->toRfc7231String()]), "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/zen' => Http::response('ok', 200, ['Date' => now()->toRfc7231String()]), '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/zen' => Http::response('ok', 200, ['Date' => now()->toRfc7231String()]), '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/zen' => Http::response('ok', 200, ['Date' => now()->toRfc7231String()]), '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/zen' => Http::response('ok', 200, ['Date' => now()->toRfc7231String()]), '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/zen' => Http::response('ok', 200, ['Date' => now()->toRfc7231String()]), '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/zen' => Http::response('ok', 200, ['Date' => now()->toRfc7231String()]), '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), ]); }