diff --git a/app/Jobs/ProvisionGithubRunnerJob.php b/app/Jobs/ProvisionGithubRunnerJob.php index 1fcac5513..f66092eee 100644 --- a/app/Jobs/ProvisionGithubRunnerJob.php +++ b/app/Jobs/ProvisionGithubRunnerJob.php @@ -33,6 +33,7 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue public array $workflowJobPayload, public string $organizationLogin, public int $repositoryId = 0, + public ?string $capacityWaitStartedAt = null, ) { $this->onQueue('high'); } @@ -53,10 +54,46 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue $requestedLabels = data_get($this->workflowJobPayload, 'labels', []); - // Find a matching server with capacity - $config = $this->findMatchingConfig($githubApp, $requestedLabels); + // 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) { - ray('No matching GitHub runner config found for labels: '.implode(', ', $requestedLabels)); + // 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; } @@ -114,7 +151,7 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue } } - private function findMatchingConfig(GithubApp $githubApp, array $requestedLabels): ?GithubRunnerConfig + private function findMatchingConfigsIgnoringCapacity(GithubApp $githubApp, array $requestedLabels): \Illuminate\Support\Collection { return GithubRunnerConfig::query() ->where('github_app_id', $githubApp->id) @@ -124,9 +161,7 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue ->get() ->filter(fn ($config) => $config->matchesLabels($requestedLabels)) ->filter(fn ($config) => $config->server->isFunctional()) - ->filter(fn ($config) => $config->hasCapacity()) - ->sortBy(fn ($config) => $config->activeRunnerCount()) - ->first(); + ->values(); } private function ensureRunnerGroup(GithubApp $githubApp): int @@ -244,11 +279,13 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue "mkdir -p {$runnerDir}", ], $server); - // Download runner binary if not cached + // 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", - "tar xzf {$cacheDir}/{$tarball} -C {$runnerDir}", + "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); diff --git a/app/Livewire/Server/GithubRunners.php b/app/Livewire/Server/GithubRunners.php index 4e39b19cc..280d6b8a6 100644 --- a/app/Livewire/Server/GithubRunners.php +++ b/app/Livewire/Server/GithubRunners.php @@ -29,6 +29,9 @@ class GithubRunners extends Component #[Validate(['required', 'integer', 'min:1', 'max:32'])] public int $maxRunners = 4; + #[Validate(['required', 'integer', 'min:1', 'max:1440'])] + public int $capacityWaitTimeout = 60; + #[Validate(['required', 'string', 'min:1'])] public string $runnerUser = 'runner'; @@ -44,6 +47,12 @@ class GithubRunners extends Component public ?string $repositoryError = null; + public bool $repositoriesLoaded = false; + + public bool $repositoriesLoading = false; + + public bool $skipNextSelectedAppReload = false; + public function mount(string $server_uuid): void { try { @@ -107,29 +116,50 @@ class GithubRunners extends Component $config = $this->server->githubRunnerConfig; if ($config) { $this->selectedGithubAppId = $config->github_app_id; + $this->skipNextSelectedAppReload = true; $this->labels = implode(',', $config->labels ?? []); $this->maxRunners = $config->max_runners; + $this->capacityWaitTimeout = $config->capacity_wait_timeout; $this->runnerUser = $config->runner_user; $this->runnerBaseDir = $config->runner_base_dir; $this->runnerVersion = $config->runner_version; $this->isEnabled = $config->is_enabled; - $this->loadAccessibleRepositories(); } } + public function initializeRepositories(): void + { + if ($this->repositoriesLoaded) { + return; + } + + $this->loadAccessibleRepositories(); + } + public function updatedSelectedGithubAppId(): void { + if ($this->skipNextSelectedAppReload) { + $this->skipNextSelectedAppReload = false; + + return; + } + + $this->repositoriesLoaded = true; $this->loadAccessibleRepositories(); } public function loadAccessibleRepositories(): void { + $this->repositoriesLoading = true; + $this->repositoriesLoaded = true; $this->accessibleRepositories = []; $this->repositoryError = null; $app = $this->selectedApp; if (! $app || ! $app->installation_id) { + $this->repositoriesLoading = false; + return; } @@ -154,6 +184,8 @@ class GithubRunners extends Component $this->accessibleRepositories = $allRepos; } catch (\Throwable $e) { $this->repositoryError = 'Could not load repositories: '.$e->getMessage(); + } finally { + $this->repositoriesLoading = false; } } @@ -181,6 +213,7 @@ class GithubRunners extends Component 'github_app_id' => $this->selectedGithubAppId, 'labels' => $labelsArray, 'max_runners' => $this->maxRunners, + 'capacity_wait_timeout' => $this->capacityWaitTimeout, 'runner_user' => $this->runnerUser, 'runner_base_dir' => $this->runnerBaseDir, 'runner_version' => $this->runnerVersion ?: null, @@ -192,6 +225,7 @@ class GithubRunners extends Component 'github_app_id' => $this->selectedGithubAppId, 'labels' => $labelsArray, 'max_runners' => $this->maxRunners, + 'capacity_wait_timeout' => $this->capacityWaitTimeout, 'runner_user' => $this->runnerUser, 'runner_base_dir' => $this->runnerBaseDir, 'runner_version' => $this->runnerVersion ?: null, @@ -245,38 +279,6 @@ class GithubRunners extends Component } } - public function preinstallBinary() - { - try { - $this->authorize('update', $this->server); - $config = $this->server->githubRunnerConfig; - if (! $config) { - throw new \Exception('Save configuration first.'); - } - - $baseDir = $config->runner_base_dir; - $cacheDir = "{$baseDir}/.cache"; - $user = $config->runner_user; - $version = $config->runner_version ?? '2.321.0'; - - // Detect architecture from server - $uname = trim(instant_remote_process(['uname -m'], $this->server)); - $arch = $uname === 'aarch64' ? 'arm64' : 'x64'; - $tarball = "actions-runner-linux-{$arch}-{$version}.tar.gz"; - - instant_remote_process([ - "id -u {$user} &>/dev/null || useradd -m -s /bin/bash {$user}", - "usermod -aG docker {$user}", - "mkdir -p {$cacheDir}", - "if [ ! -f {$cacheDir}/{$tarball} ]; then curl -sL https://github.com/actions/runner/releases/download/v{$version}/{$tarball} -o {$cacheDir}/{$tarball}; fi", - ], $this->server); - - $this->dispatch('success', 'Runner binary pre-installed on server.'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - public function cancelExecution(int $executionId) { try { diff --git a/app/Models/GithubRunnerConfig.php b/app/Models/GithubRunnerConfig.php index 7a7586ea7..62cf44174 100644 --- a/app/Models/GithubRunnerConfig.php +++ b/app/Models/GithubRunnerConfig.php @@ -16,6 +16,7 @@ class GithubRunnerConfig extends BaseModel 'labels' => 'array', 'is_enabled' => 'boolean', 'max_runners' => 'integer', + 'capacity_wait_timeout' => 'integer', ]; } diff --git a/database/migrations/2026_03_03_134703_add_capacity_wait_timeout_to_github_runner_configs_table.php b/database/migrations/2026_03_03_134703_add_capacity_wait_timeout_to_github_runner_configs_table.php new file mode 100644 index 000000000..5045e9bb0 --- /dev/null +++ b/database/migrations/2026_03_03_134703_add_capacity_wait_timeout_to_github_runner_configs_table.php @@ -0,0 +1,25 @@ +unsignedInteger('capacity_wait_timeout')->default(60)->after('max_runners'); + }); + } + + public function down(): void + { + Schema::table('github_runner_configs', function (Blueprint $table) { + $table->dropColumn('capacity_wait_timeout'); + }); + } +}; diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php index 62004c2ee..ece349df4 100644 --- a/resources/views/components/server/sidebar.blade.php +++ b/resources/views/components/server/sidebar.blade.php @@ -32,7 +32,7 @@ href="{{ route('server.docker-cleanup', ['server_uuid' => $server->uuid]) }}">Docker Cleanup GitHub Runners + href="{{ route('server.github-runners', ['server_uuid' => $server->uuid]) }}">GitHub Runners (experimental) Destinations diff --git a/resources/views/livewire/server/github-runners.blade.php b/resources/views/livewire/server/github-runners.blade.php index 04e98ac22..cc4c576e5 100644 --- a/resources/views/livewire/server/github-runners.blade.php +++ b/resources/views/livewire/server/github-runners.blade.php @@ -1,4 +1,4 @@ -
+
{{ data_get_str($server, 'name')->limit(10) }} > GitHub Runners | Coolify @@ -41,54 +41,57 @@ - Manage Repository Access → + Manage Accessible Repositories →
+
+
+ + + + +
+
+ + +
+
+ @if ($repositoryError) {{ $repositoryError }} - @elseif (count($accessibleRepositories) === 0) + @elseif ($repositoriesLoaded && count($accessibleRepositories) === 0)

No repositories are accessible yet, or the GitHub App is set to "All repositories" (all org repos are covered automatically).

-

If you expect specific repositories to appear, manage repository access in your GitHub App settings.

+

If you expect specific repositories to appear, manage accessible repositories in your GitHub App settings.

- @else -
-
- - - - -
-
- - -
-
@endif - Loading... @endif @@ -120,16 +123,22 @@ helper="Labels for routing workflow jobs to this server. Workflows use runs-on to match these labels." /> -
+
+ +
+ +
-
+
@@ -141,9 +150,6 @@
Save @if ($this->config) - - Pre-install Binary - create(); + $privateKeyId = \Illuminate\Support\Facades\DB::table('private_keys')->insertGetId([ + 'uuid' => fake()->uuid(), + 'name' => 'test-key', + 'private_key' => encrypt('test'), + 'team_id' => $team->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $server = Server::factory()->create(['private_key_id' => $privateKeyId, 'team_id' => $team->id]); + + // Create functional server settings so isFunctional() returns true + ServerSetting::updateOrCreate( + ['server_id' => $server->id], + ['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false], + ); + + $githubApp = GithubApp::create([ + 'name' => 'test-app', + 'app_id' => fake()->unique()->randomNumber(6, true), + 'installation_id' => 789, + 'client_id' => 'Iv1.abc123', + 'client_secret' => 'secret', + 'webhook_secret' => 'test-secret', + 'private_key_id' => $privateKeyId, + 'team_id' => $team->id, + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'organization' => 'test-org', + ]); + $config = GithubRunnerConfig::create(array_merge([ + 'server_id' => $server->id, + 'github_app_id' => $githubApp->id, + 'labels' => ['self-hosted', 'coolify'], + 'max_runners' => 2, + 'capacity_wait_timeout' => 60, + ], $configOverrides)); + + return compact('team', 'server', 'githubApp', 'config'); +} + +function makeJob(GithubApp $githubApp, array $overrides = []): ProvisionGithubRunnerJob +{ + return new ProvisionGithubRunnerJob( + githubAppId: $githubApp->id, + workflowJobPayload: array_merge([ + 'id' => fake()->unique()->randomNumber(8, true), + 'labels' => ['self-hosted', 'coolify'], + 'workflow_name' => 'CI', + ], $overrides['payload'] ?? []), + organizationLogin: 'test-org', + repositoryId: 0, + capacityWaitStartedAt: $overrides['capacityWaitStartedAt'] ?? null, + ); +} + +it('does not create an execution when no config matches the requested labels', function () { + Queue::fake(); + ['githubApp' => $githubApp] = makeRunnerSetup(['labels' => ['self-hosted', 'coolify']]); + + $job = makeJob($githubApp, ['payload' => ['id' => 99001, 'labels' => ['self-hosted', 'gpu'], 'workflow_name' => 'CI']]); + $job->handle(); + + expect(GithubRunnerExecution::where('workflow_job_id', 99001)->exists())->toBeFalse(); + Queue::assertNotPushed(ProvisionGithubRunnerJob::class); +}); + +it('re-dispatches with a delay when all matching configs are at capacity', function () { + Queue::fake(); + ['githubApp' => $githubApp, 'config' => $config, 'server' => $server] = makeRunnerSetup(['max_runners' => 1]); + + // Fill the single runner slot + GithubRunnerExecution::create([ + 'server_id' => $server->id, + 'github_runner_config_id' => $config->id, + 'status' => GithubRunnerStatus::Running, + 'runner_name' => 'coolify-existing', + 'runner_dir' => '/opt/github-runners/coolify-existing', + 'workflow_job_id' => 88001, + 'pid' => 12345, + 'started_at' => now()->subMinutes(5), + ]); + + $job = makeJob($githubApp, ['payload' => ['id' => 88002, 'labels' => ['self-hosted', 'coolify'], 'workflow_name' => 'CI']]); + $job->handle(); + + expect(GithubRunnerExecution::where('workflow_job_id', 88002)->exists())->toBeFalse(); + + Queue::assertPushed(ProvisionGithubRunnerJob::class, function ($pushedJob) { + return $pushedJob->capacityWaitStartedAt !== null + && $pushedJob->workflowJobPayload['id'] === 88002; + }); +}); + +it('preserves the original capacityWaitStartedAt when re-dispatching', function () { + Queue::fake(); + ['githubApp' => $githubApp, 'config' => $config, 'server' => $server] = makeRunnerSetup(['max_runners' => 1]); + + GithubRunnerExecution::create([ + 'server_id' => $server->id, + 'github_runner_config_id' => $config->id, + 'status' => GithubRunnerStatus::Running, + 'runner_name' => 'coolify-existing', + 'runner_dir' => '/opt/github-runners/coolify-existing', + 'workflow_job_id' => 77001, + 'pid' => 12345, + 'started_at' => now()->subMinutes(5), + ]); + + $originalStart = now()->subMinutes(30)->toIso8601String(); + $job = makeJob($githubApp, [ + 'payload' => ['id' => 77002, 'labels' => ['self-hosted', 'coolify'], 'workflow_name' => 'CI'], + 'capacityWaitStartedAt' => $originalStart, + ]); + $job->handle(); + + Queue::assertPushed(ProvisionGithubRunnerJob::class, function ($pushedJob) use ($originalStart) { + return $pushedJob->capacityWaitStartedAt === $originalStart; + }); +}); + +it('gives up silently when the capacity wait timeout is exceeded', function () { + Queue::fake(); + + ['githubApp' => $githubApp, 'config' => $config, 'server' => $server] = makeRunnerSetup([ + 'max_runners' => 1, + 'capacity_wait_timeout' => 60, + ]); + + GithubRunnerExecution::create([ + 'server_id' => $server->id, + 'github_runner_config_id' => $config->id, + 'status' => GithubRunnerStatus::Running, + 'runner_name' => 'coolify-existing', + 'runner_dir' => '/opt/github-runners/coolify-existing', + 'workflow_job_id' => 66001, + 'pid' => 12345, + 'started_at' => now()->subHours(2), + ]); + + $job = makeJob($githubApp, [ + 'payload' => ['id' => 66002, 'labels' => ['self-hosted', 'coolify'], 'workflow_name' => 'CI'], + 'capacityWaitStartedAt' => now()->subMinutes(61)->toIso8601String(), + ]); + $job->handle(); + + expect(GithubRunnerExecution::where('workflow_job_id', 66002)->exists())->toBeFalse(); + Queue::assertNotPushed(ProvisionGithubRunnerJob::class); +}); + +it('uses the configured timeout value for the capacity wait', function () { + Queue::fake(); + + ['githubApp' => $githubApp, 'config' => $config, 'server' => $server] = makeRunnerSetup([ + 'max_runners' => 1, + 'capacity_wait_timeout' => 10, // 10-minute custom timeout + ]); + + GithubRunnerExecution::create([ + 'server_id' => $server->id, + 'github_runner_config_id' => $config->id, + 'status' => GithubRunnerStatus::Running, + 'runner_name' => 'coolify-existing', + 'runner_dir' => '/opt/github-runners/coolify-existing', + 'workflow_job_id' => 55001, + 'pid' => 12345, + 'started_at' => now()->subMinutes(15), + ]); + + // Started 11 minutes ago — exceeds 10-minute custom timeout + $job = makeJob($githubApp, [ + 'payload' => ['id' => 55002, 'labels' => ['self-hosted', 'coolify'], 'workflow_name' => 'CI'], + 'capacityWaitStartedAt' => now()->subMinutes(11)->toIso8601String(), + ]); + $job->handle(); + + expect(GithubRunnerExecution::where('workflow_job_id', 55002)->exists())->toBeFalse(); + Queue::assertNotPushed(ProvisionGithubRunnerJob::class); +}); + +it('still re-dispatches when wait time is within the custom timeout', function () { + Queue::fake(); + + ['githubApp' => $githubApp, 'config' => $config, 'server' => $server] = makeRunnerSetup([ + 'max_runners' => 1, + 'capacity_wait_timeout' => 10, + ]); + + GithubRunnerExecution::create([ + 'server_id' => $server->id, + 'github_runner_config_id' => $config->id, + 'status' => GithubRunnerStatus::Running, + 'runner_name' => 'coolify-existing', + 'runner_dir' => '/opt/github-runners/coolify-existing', + 'workflow_job_id' => 44001, + 'pid' => 12345, + 'started_at' => now()->subMinutes(5), + ]); + + // Started 5 minutes ago — within 10-minute timeout + $job = makeJob($githubApp, [ + 'payload' => ['id' => 44002, 'labels' => ['self-hosted', 'coolify'], 'workflow_name' => 'CI'], + 'capacityWaitStartedAt' => now()->subMinutes(5)->toIso8601String(), + ]); + $job->handle(); + + Queue::assertPushed(ProvisionGithubRunnerJob::class, fn ($j) => $j->workflowJobPayload['id'] === 44002); +}); + +it('does not re-dispatch when the job has already been provisioned (idempotency)', function () { + Queue::fake(); + ['githubApp' => $githubApp, 'config' => $config, 'server' => $server] = makeRunnerSetup(); + + GithubRunnerExecution::create([ + 'server_id' => $server->id, + 'github_runner_config_id' => $config->id, + 'status' => GithubRunnerStatus::Running, + 'runner_name' => 'coolify-existing', + 'runner_dir' => '/opt/github-runners/coolify-existing', + 'workflow_job_id' => 33001, + 'pid' => 99, + 'started_at' => now(), + ]); + + $job = makeJob($githubApp, ['payload' => ['id' => 33001, 'labels' => ['self-hosted', 'coolify'], 'workflow_name' => 'CI']]); + $job->handle(); + + // Should exit early — no new jobs dispatched, existing execution count unchanged + expect(GithubRunnerExecution::where('workflow_job_id', 33001)->count())->toBe(1); + Queue::assertNotPushed(ProvisionGithubRunnerJob::class); +}); diff --git a/tests/Feature/GithubRunnersAccessibleRepositoriesTest.php b/tests/Feature/GithubRunnersAccessibleRepositoriesTest.php index 3b7945e5d..edc6c0531 100644 --- a/tests/Feature/GithubRunnersAccessibleRepositoriesTest.php +++ b/tests/Feature/GithubRunnersAccessibleRepositoriesTest.php @@ -2,6 +2,8 @@ use App\Livewire\Server\GithubRunners; use App\Models\GithubApp; +use App\Models\GithubRunnerConfig; +use App\Models\InstanceSettings; use App\Models\PrivateKey; use App\Models\Server; use App\Models\Team; @@ -16,6 +18,8 @@ uses(RefreshDatabase::class); $validKey = "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDMflQ+H/XBxrhK\n3etBe1c4NzjOFcFp0EXbdhnZCPvkd7PE706osqnfTYxT5I2HYeBiXN20NVhxwhZy\nf8K8sLuITPqjNfLkpwPwbHn5WAy4VgFOxrrVHlNo0jWYSLuNRQPtOgUBJc/WzDi7\nPLPauCLE+sIK7i1dGf8f1UzBLJsNEKuGOq4uAhG8pjpkKY+vSFwgHNTK8qOtoauG\nw+rz6fqzCJ9RLPo/SL7mXardeypg3roQZ9RNfCt50E4H+lP7+hLaDQk5IXBPpGZc\n1ZpvQvAu+e+N62up4KwGFhxL3ziyr3djb7nmJpADwRbzKSl1ry50cpWFbgv9NOYO\nlwfij9ErAgMBAAECggEACPfbWvQiM4gzCeQso+0JrgdMoEvM9TEzTG95V7mF+TGU\nuo93htIlvWDUcCjHBN0dLu3SsqC09cbkyXW3782HvppdqEMT7sTdA9zGBqeUEJDZ\nCCroA7O2Rb5o/Po88MefkfZS74dzKNZBAK57VsgaN5hQYpP/0k7zD42BCxHD5QaL\njuEbQHl7/gthGZBez2IhuH3JcLRgLCXS9cEVCA7229uv0mNtFejZSbypIeq07qQf\niJgsaODtqL5avLj4JSxqjYUwv6oxkKDOK/XXurV2RQ0cV1upuV0Js0HgdQN2K1QL\nh7VA2oO0K5++BoEX5Tn5aEvp0WVQF52wQ8w8pQ3TxQKBgQDqI6Tix5dUoLxLRbFZ\nGjutQOOUpnmFqz/EioCs1Ll95tHC+qi/vyov1efWoufOR1CnLjrTE5Yls1FTYjpp\nwTboxBmDYe473jqaZ4oKLZpXgN+Er6l4ktlw9m9MGx8/U891IKxYfdETj63yQOZK\n4rQ4QS3qbY6N95H9T10azzG8zwKBgQDflhjZKz0ykvOV0TgvAOrqpC9TTteKqCue\nq0Pma6utfWnhoYFwo7kmlBCRoLU4NB9UibJbIxERwTXEDlQMica0/rZStoB7UELn\n9i9AlFPZUEO17TxYggG/TYDdj4MUNsoj3KZS1fGE4sQYi81pKsuy0y2tokZptKmG\nmAVSKIJU5QKBgB7lZTSnschxDWfBYo2ncIiEL4PGE/MXjeqZfDFSQMfkVXmtKedj\nimWVjGo+ROhrcLEe4JRJ2V5QM0MViy+5V02P0u4LViyAPqtxTj3ZlqxFTTltFKfc\neOT3H+ijC5SHsrB6B0QGFjjGlOWKutjW4YEq2Kw+mLkTGiia+GY5QQ7xAoGBAJs/\nm61fyrSNOTnz9nEc0AFxU7Mi8aNDtlYMUa9zX9etV5HmFPzjkjJpaT/VOT/3YTHQ\nEtoZdUbAw9aIpG+4UxNmMa8pLflx96MdXB4ZYEdq5jkyq05Bp3jwFeTCO6ATkzRn\nh83I5FUDKGpq2IyHvL1EyVjhbscDPRtJ/5fWrPjJAoGBAN2Ejrbz3kIyJhf/m7Dq\nJR7zmeeQmK/tAdG9mtIbPGZPUxQd7MOq2z02y3ZX5FJcWPFAuWTNFgs68T4CkeY4\n8TUIdKEwhvkB0uR/alJVTLyaaGU8IOk7Rw6Otu9wlvjqy+Nqoy2GRS4VPLK9dePs\nNwAXUicFB5gVAWeyU+C6Xjn1\n-----END PRIVATE KEY-----"; beforeEach(function () use ($validKey) { + InstanceSettings::create(['id' => 0]); + $this->team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); @@ -51,6 +55,78 @@ beforeEach(function () use ($validKey) { }); describe('GithubRunners accessible repositories', function () { + test('mount does not load repositories before frontend initialization', function () { + GithubRunnerConfig::create([ + 'server_id' => $this->server->id, + 'github_app_id' => $this->githubApp->id, + 'labels' => ['self-hosted', 'coolify'], + 'max_runners' => 4, + 'capacity_wait_timeout' => 60, + 'runner_user' => 'runner', + 'runner_base_dir' => '/opt/github-runners', + 'is_enabled' => true, + ]); + + Http::fake([ + 'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, ['Date' => now()->toRfc7231String()]), + 'https://api.github.com/app/installations/222/access_tokens' => Http::response([ + 'token' => 'ghs_test_token', + 'expires_at' => now()->addHour()->toIso8601String(), + ], 200), + 'https://api.github.com/installation/repositories*' => Http::response([ + 'total_count' => 1, + 'repositories' => [ + ['full_name' => 'test-org/deferred-repo', 'name' => 'deferred-repo'], + ], + ], 200), + ]); + + $component = Livewire::test(GithubRunners::class, ['server_uuid' => $this->server->uuid]) + ->assertSet('selectedGithubAppId', $this->githubApp->id) + ->assertSet('repositoriesLoaded', false) + ->assertSet('accessibleRepositories', []); + + Http::assertNothingSent(); + + $component->call('initializeRepositories') + ->assertSet('repositoriesLoaded', true) + ->assertSet('accessibleRepositories', ['test-org/deferred-repo']); + }); + + test('initializeRepositories loads repositories only once for preselected app', function () { + GithubRunnerConfig::create([ + 'server_id' => $this->server->id, + 'github_app_id' => $this->githubApp->id, + 'labels' => ['self-hosted', 'coolify'], + 'max_runners' => 4, + 'capacity_wait_timeout' => 60, + 'runner_user' => 'runner', + 'runner_base_dir' => '/opt/github-runners', + 'is_enabled' => true, + ]); + + Http::fake([ + 'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, ['Date' => now()->toRfc7231String()]), + 'https://api.github.com/app/installations/222/access_tokens' => Http::response([ + 'token' => 'ghs_test_token', + 'expires_at' => now()->addHour()->toIso8601String(), + ], 200), + 'https://api.github.com/installation/repositories*' => Http::response([ + 'total_count' => 1, + 'repositories' => [ + ['full_name' => 'test-org/only-once', 'name' => 'only-once'], + ], + ], 200), + ]); + + Livewire::test(GithubRunners::class, ['server_uuid' => $this->server->uuid]) + ->assertSet('selectedGithubAppId', $this->githubApp->id) + ->call('initializeRepositories') + ->assertSet('accessibleRepositories', ['test-org/only-once']); + + Http::assertSentCount(3); + }); + test('loadAccessibleRepositories populates accessibleRepositories from GitHub API', function () { Http::fake([ 'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, ['Date' => now()->toRfc7231String()]),