From 1739c04d3288cbee396ef42c812623e27ef3d472 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 3 Mar 2026 18:37:10 +0100 Subject: [PATCH] feat(github-runners): add capacity wait timeout and deferred repo loading Add configurable `capacity_wait_timeout` to runner configs and use it in `ProvisionGithubRunnerJob` to re-dispatch jobs while at capacity, preserving initial wait start time and stopping after timeout. Improve runner provisioning by caching an extracted runner template and copying it into new runner directories. Update Livewire GitHub Runners UI to lazy-load accessible repositories via `wire:init`, track loading state, add timeout input, and remove preinstall binary action. Add/extend feature tests for capacity retry timeout behavior and deferred repository loading. --- app/Jobs/ProvisionGithubRunnerJob.php | 55 +++- app/Livewire/Server/GithubRunners.php | 68 ++--- app/Models/GithubRunnerConfig.php | 1 + ...timeout_to_github_runner_configs_table.php | 25 ++ .../views/components/server/sidebar.blade.php | 2 +- .../livewire/server/github-runners.blade.php | 90 ++++--- tests/Feature/GithubRunnerCapacityTest.php | 248 ++++++++++++++++++ ...ithubRunnersAccessibleRepositoriesTest.php | 76 ++++++ 8 files changed, 480 insertions(+), 85 deletions(-) create mode 100644 database/migrations/2026_03_03_134703_add_capacity_wait_timeout_to_github_runner_configs_table.php create mode 100644 tests/Feature/GithubRunnerCapacityTest.php 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()]),