mirror of
https://github.com/coollabsio/coolify.git
synced 2026-03-11 08:55:47 +00:00
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.
This commit is contained in:
parent
576f38da1c
commit
1739c04d32
8 changed files with 480 additions and 85 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ class GithubRunnerConfig extends BaseModel
|
|||
'labels' => 'array',
|
||||
'is_enabled' => 'boolean',
|
||||
'max_runners' => 'integer',
|
||||
'capacity_wait_timeout' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('github_runner_configs', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -32,7 +32,7 @@
|
|||
href="{{ route('server.docker-cleanup', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">Docker Cleanup</span>
|
||||
</a>
|
||||
<a class="sub-menu-item {{ $activeMenu === 'github-runners' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('server.github-runners', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">GitHub Runners</span>
|
||||
href="{{ route('server.github-runners', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">GitHub Runners (experimental)</span>
|
||||
</a>
|
||||
<a class="sub-menu-item {{ $activeMenu === 'destinations' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('server.destinations', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">Destinations</span>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<div>
|
||||
<div wire:init="initializeRepositories">
|
||||
<x-slot:title>
|
||||
{{ data_get_str($server, 'name')->limit(10) }} > GitHub Runners | Coolify
|
||||
</x-slot>
|
||||
|
|
@ -41,54 +41,57 @@
|
|||
</button>
|
||||
<a href="{{ getInstallationPath($this->selectedApp) }}" target="_blank"
|
||||
class="text-xs text-warning hover:underline">
|
||||
Manage Repository Access →
|
||||
Manage Accessible Repositories →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-data="{
|
||||
open: false,
|
||||
search: '',
|
||||
get repos() {
|
||||
return $wire.accessibleRepositories ?? [];
|
||||
},
|
||||
get filtered() {
|
||||
if (!this.search) return this.repos;
|
||||
const q = this.search.toLowerCase();
|
||||
return this.repos.filter(r => r.toLowerCase().includes(q));
|
||||
}
|
||||
}" @click.outside="open = false" class="relative mt-1">
|
||||
<div @click="open = !open"
|
||||
class="flex items-center gap-2 w-full input cursor-pointer">
|
||||
<input type="text" x-model="search" @click.stop @focus="open = true"
|
||||
@keydown.escape="open = false"
|
||||
placeholder="{{ $repositoriesLoading || ! $repositoriesLoaded ? 'Loading repositories...' : count($accessibleRepositories).' '.Str::plural('repository', count($accessibleRepositories)).' accessible — type to search...' }}"
|
||||
class="flex-1 text-sm border-0 outline-none bg-transparent px-2 py-0 focus:ring-0 placeholder:text-neutral-400 dark:placeholder:text-neutral-600 text-white" />
|
||||
<svg class="w-4 h-4 shrink-0 mr-2.5 duration-200 ease-out text-neutral-500"
|
||||
:class="{ 'rotate-180': open }" viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
<div x-show="open" x-transition
|
||||
class="absolute z-50 w-full mt-1 bg-coolgray-100 border border-coolgray-400 rounded shadow-lg max-h-60 overflow-auto scrollbar">
|
||||
<template x-if="filtered.length === 0">
|
||||
<div class="px-3 py-2 text-sm text-neutral-400">No matching repositories</div>
|
||||
</template>
|
||||
<template x-for="repo in filtered" :key="repo">
|
||||
<div class="px-3 py-2 text-sm font-mono text-neutral-300" x-text="repo"></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($repositoryError)
|
||||
<x-callout type="error" title="Could Not Load Repositories">
|
||||
{{ $repositoryError }}
|
||||
</x-callout>
|
||||
@elseif (count($accessibleRepositories) === 0)
|
||||
@elseif ($repositoriesLoaded && count($accessibleRepositories) === 0)
|
||||
<x-callout type="warning" title="No Repositories Loaded">
|
||||
<p>No repositories are accessible yet, or the GitHub App is set to "All repositories" (all org repos are covered automatically).</p>
|
||||
<p class="mt-1">If you expect specific repositories to appear, <a href="{{ getInstallationPath($this->selectedApp) }}" target="_blank" class="underline">manage repository access</a> in your GitHub App settings.</p>
|
||||
<p class="mt-1">If you expect specific repositories to appear, <a href="{{ getInstallationPath($this->selectedApp) }}" target="_blank" class="underline">manage accessible repositories</a> in your GitHub App settings.</p>
|
||||
</x-callout>
|
||||
@else
|
||||
<div x-data="{
|
||||
open: false,
|
||||
search: '',
|
||||
repos: @js($accessibleRepositories),
|
||||
get filtered() {
|
||||
if (!this.search) return this.repos;
|
||||
const q = this.search.toLowerCase();
|
||||
return this.repos.filter(r => r.toLowerCase().includes(q));
|
||||
}
|
||||
}" @click.outside="open = false" class="relative mt-1">
|
||||
<div @click="open = !open"
|
||||
class="flex items-center gap-2 w-full input cursor-pointer">
|
||||
<input type="text" x-model="search" @click.stop @focus="open = true"
|
||||
@keydown.escape="open = false"
|
||||
placeholder="{{ count($accessibleRepositories) }} {{ Str::plural('repository', count($accessibleRepositories)) }} accessible — type to search..."
|
||||
class="flex-1 text-sm border-0 outline-none bg-transparent px-2 py-0 focus:ring-0 placeholder:text-neutral-400 dark:placeholder:text-neutral-600 text-white" />
|
||||
<svg class="w-4 h-4 shrink-0 text-neutral-400 transition-transform" :class="{ 'rotate-180': open }" fill="none"
|
||||
stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div x-show="open" x-transition
|
||||
class="absolute z-50 w-full mt-1 bg-coolgray-100 border border-coolgray-400 rounded shadow-lg max-h-60 overflow-auto scrollbar">
|
||||
<template x-if="filtered.length === 0">
|
||||
<div class="px-3 py-2 text-sm text-neutral-400">No matching repositories</div>
|
||||
</template>
|
||||
<template x-for="repo in filtered" :key="repo">
|
||||
<div class="px-3 py-2 text-sm font-mono text-neutral-300" x-text="repo"></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
<span wire:loading wire:target="loadAccessibleRepositories" class="text-xs text-neutral-400 mt-1 inline-block">Loading...</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
|
|
@ -120,16 +123,22 @@
|
|||
helper="Labels for routing workflow jobs to this server. Workflows use runs-on to match these labels." />
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<div class="flex gap-2">
|
||||
<x-forms.input canGate="update" :canResource="$server" id="maxRunners" type="number"
|
||||
label="Max Concurrent Runners" required
|
||||
helper="Maximum number of runners that can run simultaneously on this server." />
|
||||
<x-forms.input canGate="update" :canResource="$server" id="capacityWaitTimeout" type="number"
|
||||
label="Capacity Wait Timeout (minutes)" required
|
||||
helper="How long queued jobs wait for a runner slot to become available before giving up. Default: 60 minutes (1 hour)." />
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<x-forms.input canGate="update" :canResource="$server" id="runnerUser"
|
||||
label="Runner User" required
|
||||
helper="Linux user to run the runner process as. Will be created if it doesn't exist." />
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<div class="flex gap-2">
|
||||
<x-forms.input canGate="update" :canResource="$server" id="runnerBaseDir"
|
||||
label="Base Directory" required
|
||||
helper="Directory on the server where runner binaries and working directories will be stored." />
|
||||
|
|
@ -141,9 +150,6 @@
|
|||
<div class="flex items-center gap-2 mt-2">
|
||||
<x-forms.button type="submit" canGate="update" :canResource="$server">Save</x-forms.button>
|
||||
@if ($this->config)
|
||||
<x-forms.button wire:click="preinstallBinary" canGate="update" :canResource="$server">
|
||||
Pre-install Binary
|
||||
</x-forms.button>
|
||||
<x-modal-confirmation title="Delete Runner Configuration?" buttonTitle="Delete Configuration"
|
||||
submitAction="deleteConfig"
|
||||
:actions="['This will remove the runner configuration from this server.', 'Active runners will not be affected until they complete.']"
|
||||
|
|
|
|||
248
tests/Feature/GithubRunnerCapacityTest.php
Normal file
248
tests/Feature/GithubRunnerCapacityTest.php
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\GithubRunnerStatus;
|
||||
use App\Jobs\ProvisionGithubRunnerJob;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\GithubRunnerConfig;
|
||||
use App\Models\GithubRunnerExecution;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerSetting;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function makeRunnerSetup(array $configOverrides = []): array
|
||||
{
|
||||
$team = Team::factory()->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);
|
||||
});
|
||||
|
|
@ -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()]),
|
||||
|
|
|
|||
Loading…
Reference in a new issue