mirror of
https://github.com/coollabsio/coolify.git
synced 2026-03-11 08:55:47 +00:00
feat(github): add self-hosted Actions runner orchestration
Implement end-to-end GitHub Actions runner support with provisioning, tracking, and cleanup flows. - handle `workflow_job` webhooks to provision and tear down runners - add runner config/execution models, enum states, and relationships - create jobs for provisioning, cleanup, and stale-runner reaping - schedule periodic stale runner cleanup in the console kernel - add Livewire server UI to manage runner configuration and executions - store GitHub App runner permissions and runner group metadata - add migrations for runner permissions, configs, executions, and group id - update GitHub permissions URL generation for organization app settings - include feature/unit tests for runner behavior and permission paths
This commit is contained in:
parent
fb186841f4
commit
576f38da1c
29 changed files with 2077 additions and 0 deletions
|
|
@ -7,6 +7,7 @@ use App\Jobs\CheckHelperImageJob;
|
|||
use App\Jobs\CheckTraefikVersionJob;
|
||||
use App\Jobs\CleanupInstanceStuffsJob;
|
||||
use App\Jobs\CleanupOrphanedPreviewContainersJob;
|
||||
use App\Jobs\CleanupStaleGithubRunnersJob;
|
||||
use App\Jobs\PullChangelog;
|
||||
use App\Jobs\PullTemplatesFromCDN;
|
||||
use App\Jobs\RegenerateSslCertJob;
|
||||
|
|
@ -55,6 +56,7 @@ class Kernel extends ConsoleKernel
|
|||
$this->scheduleInstance->job(new ScheduledJobManager)->everyMinute()->onOneServer();
|
||||
|
||||
$this->scheduleInstance->command('uploads:clear')->everyTwoMinutes();
|
||||
$this->scheduleInstance->job(new CleanupStaleGithubRunnersJob)->everyFiveMinutes()->onOneServer();
|
||||
|
||||
} else {
|
||||
// Instance Jobs
|
||||
|
|
@ -84,6 +86,9 @@ class Kernel extends ConsoleKernel
|
|||
|
||||
// Cleanup orphaned PR preview containers daily
|
||||
$this->scheduleInstance->job(new CleanupOrphanedPreviewContainersJob)->daily()->onOneServer();
|
||||
|
||||
// Cleanup stale GitHub Actions runners
|
||||
$this->scheduleInstance->job(new CleanupStaleGithubRunnersJob)->everyFiveMinutes()->onOneServer();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
19
app/Enums/GithubRunnerStatus.php
Normal file
19
app/Enums/GithubRunnerStatus.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum GithubRunnerStatus: string
|
||||
{
|
||||
case Queued = 'queued';
|
||||
case Provisioning = 'provisioning';
|
||||
case Running = 'running';
|
||||
case Completed = 'completed';
|
||||
case Failed = 'failed';
|
||||
case TimedOut = 'timed_out';
|
||||
case Cleaning = 'cleaning';
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return in_array($this, [self::Queued, self::Provisioning, self::Running, self::Cleaning]);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,8 +3,10 @@
|
|||
namespace App\Http\Controllers\Webhook;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\CleanupGithubRunnerJob;
|
||||
use App\Jobs\GithubAppPermissionJob;
|
||||
use App\Jobs\ProcessGithubPullRequestWebhook;
|
||||
use App\Jobs\ProvisionGithubRunnerJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\PrivateKey;
|
||||
|
|
@ -224,6 +226,31 @@ class Github extends Controller
|
|||
|
||||
return response('cool');
|
||||
}
|
||||
if ($x_github_event === 'workflow_job') {
|
||||
$action = data_get($payload, 'action');
|
||||
$workflowJob = data_get($payload, 'workflow_job');
|
||||
|
||||
if ($action === 'queued' && $workflowJob) {
|
||||
ProvisionGithubRunnerJob::dispatch(
|
||||
githubAppId: $github_app->id,
|
||||
workflowJobPayload: collect($workflowJob)->toArray(),
|
||||
organizationLogin: data_get($payload, 'organization.login', ''),
|
||||
repositoryId: (int) data_get($payload, 'repository.id', 0),
|
||||
);
|
||||
|
||||
return response('Runner provisioning queued.');
|
||||
}
|
||||
|
||||
if ($action === 'completed' && $workflowJob) {
|
||||
CleanupGithubRunnerJob::dispatch(
|
||||
workflowJobId: (int) data_get($workflowJob, 'id'),
|
||||
);
|
||||
|
||||
return response('Runner cleanup queued.');
|
||||
}
|
||||
|
||||
return response('workflow_job event received.');
|
||||
}
|
||||
if ($x_github_event === 'push') {
|
||||
$id = data_get($payload, 'repository.id');
|
||||
$branch = data_get($payload, 'ref');
|
||||
|
|
|
|||
117
app/Jobs/CleanupGithubRunnerJob.php
Normal file
117
app/Jobs/CleanupGithubRunnerJob.php
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\GithubRunnerStatus;
|
||||
use App\Models\GithubRunnerExecution;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class CleanupGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $timeout = 120;
|
||||
|
||||
public $tries = 3;
|
||||
|
||||
public function backoff(): array
|
||||
{
|
||||
return [5, 10, 30];
|
||||
}
|
||||
|
||||
public function __construct(
|
||||
public int $workflowJobId,
|
||||
) {
|
||||
$this->onQueue('high');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$execution = GithubRunnerExecution::where('workflow_job_id', $this->workflowJobId)
|
||||
->with('config.githubApp')
|
||||
->first();
|
||||
|
||||
if (! $execution) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Already cleaned up
|
||||
if (in_array($execution->status, [GithubRunnerStatus::Completed, GithubRunnerStatus::Failed])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$execution->update(['status' => GithubRunnerStatus::Cleaning]);
|
||||
|
||||
try {
|
||||
$server = $execution->server;
|
||||
|
||||
if ($execution->pid) {
|
||||
instant_remote_process([
|
||||
"kill {$execution->pid} 2>/dev/null || true",
|
||||
], $server, throwError: false);
|
||||
}
|
||||
|
||||
if ($execution->runner_dir) {
|
||||
instant_remote_process([
|
||||
"rm -rf {$execution->runner_dir}",
|
||||
], $server, throwError: false);
|
||||
}
|
||||
|
||||
$this->deregisterFromGithub($execution);
|
||||
|
||||
$execution->update([
|
||||
'status' => GithubRunnerStatus::Completed,
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$execution->update([
|
||||
'status' => GithubRunnerStatus::Failed,
|
||||
'error_message' => 'Cleanup failed: '.$e->getMessage(),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function deregisterFromGithub(GithubRunnerExecution $execution): void
|
||||
{
|
||||
if (! $execution->runner_id) {
|
||||
ray('Runner deregister skipped: no runner_id for execution '.$execution->id);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$githubApp = $execution->config?->githubApp;
|
||||
if (! $githubApp || $githubApp->is_public) {
|
||||
ray('Runner deregister skipped: no githubApp or is_public for execution '.$execution->id);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$org = $githubApp->organization;
|
||||
if (! $org) {
|
||||
ray('Runner deregister skipped: no organization for execution '.$execution->id);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$token = generateGithubInstallationToken($githubApp);
|
||||
$apiUrl = $githubApp->api_url ?? 'https://api.github.com';
|
||||
|
||||
ray("Deregistering runner {$execution->runner_id} from {$org} via DELETE /orgs/{$org}/actions/runners/{$execution->runner_id}");
|
||||
|
||||
$response = Http::GitHub($apiUrl, $token)
|
||||
->delete("/orgs/{$org}/actions/runners/{$execution->runner_id}");
|
||||
|
||||
ray('Runner deregister response: '.$response->status().' '.$response->body());
|
||||
} catch (\Throwable $e) {
|
||||
ray('Runner deregister failed: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
165
app/Jobs/CleanupStaleGithubRunnersJob.php
Normal file
165
app/Jobs/CleanupStaleGithubRunnersJob.php
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\GithubRunnerStatus;
|
||||
use App\Models\GithubRunnerExecution;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class CleanupStaleGithubRunnersJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $timeout = 300;
|
||||
|
||||
public $tries = 1;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->onQueue('long-running');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$this->cleanupDeadRunners();
|
||||
$this->cleanupStaleRunners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Running executions with a PID and mark them Failed if the process no longer exists.
|
||||
* Uses a 5-minute grace period to avoid false-positives during startup.
|
||||
*/
|
||||
private function cleanupDeadRunners(): void
|
||||
{
|
||||
$gracePeriod = now()->subMinutes(5);
|
||||
|
||||
$runningExecutions = GithubRunnerExecution::query()
|
||||
->where('status', GithubRunnerStatus::Running)
|
||||
->whereNotNull('pid')
|
||||
->where('started_at', '<', $gracePeriod)
|
||||
->with(['server', 'config.githubApp'])
|
||||
->get();
|
||||
|
||||
foreach ($runningExecutions as $execution) {
|
||||
try {
|
||||
$server = $execution->server;
|
||||
|
||||
if (! $server->isFunctional()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// kill -0 checks if the process exists without sending a signal.
|
||||
// Exit code 0 = alive, non-zero = dead.
|
||||
$result = instant_remote_process([
|
||||
"kill -0 {$execution->pid} 2>/dev/null && echo alive || echo dead",
|
||||
], $server, throwError: false);
|
||||
|
||||
if (trim((string) $result) !== 'alive') {
|
||||
if ($execution->runner_dir) {
|
||||
instant_remote_process([
|
||||
"rm -rf {$execution->runner_dir}",
|
||||
], $server, throwError: false);
|
||||
}
|
||||
|
||||
$this->deregisterFromGithub($execution);
|
||||
|
||||
$execution->update([
|
||||
'status' => GithubRunnerStatus::Failed,
|
||||
'error_message' => 'Runner process died unexpectedly.',
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$execution->update([
|
||||
'status' => GithubRunnerStatus::Failed,
|
||||
'error_message' => 'Health check failed: '.$e->getMessage(),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark any active executions older than 2 hours as timed out.
|
||||
*/
|
||||
private function cleanupStaleRunners(): void
|
||||
{
|
||||
$staleThreshold = now()->subHours(2);
|
||||
|
||||
$staleExecutions = GithubRunnerExecution::query()
|
||||
->whereIn('status', [
|
||||
GithubRunnerStatus::Queued,
|
||||
GithubRunnerStatus::Provisioning,
|
||||
GithubRunnerStatus::Running,
|
||||
GithubRunnerStatus::Cleaning,
|
||||
])
|
||||
->where('created_at', '<', $staleThreshold)
|
||||
->with(['server', 'config.githubApp'])
|
||||
->get();
|
||||
|
||||
foreach ($staleExecutions as $execution) {
|
||||
try {
|
||||
$server = $execution->server;
|
||||
|
||||
if ($execution->pid && $server->isFunctional()) {
|
||||
instant_remote_process([
|
||||
"kill {$execution->pid} 2>/dev/null || true",
|
||||
], $server, throwError: false);
|
||||
}
|
||||
|
||||
if ($execution->runner_dir && $server->isFunctional()) {
|
||||
instant_remote_process([
|
||||
"rm -rf {$execution->runner_dir}",
|
||||
], $server, throwError: false);
|
||||
}
|
||||
|
||||
$this->deregisterFromGithub($execution);
|
||||
|
||||
$execution->update([
|
||||
'status' => GithubRunnerStatus::TimedOut,
|
||||
'error_message' => 'Runner exceeded maximum execution time (2 hours).',
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$execution->update([
|
||||
'status' => GithubRunnerStatus::TimedOut,
|
||||
'error_message' => 'Stale cleanup failed: '.$e->getMessage(),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function deregisterFromGithub(GithubRunnerExecution $execution): void
|
||||
{
|
||||
if (! $execution->runner_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$githubApp = $execution->config?->githubApp;
|
||||
if (! $githubApp || $githubApp->is_public) {
|
||||
return;
|
||||
}
|
||||
|
||||
$org = $githubApp->organization;
|
||||
if (! $org) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$token = generateGithubInstallationToken($githubApp);
|
||||
$apiUrl = $githubApp->api_url ?? 'https://api.github.com';
|
||||
|
||||
Http::GitHub($apiUrl, $token)
|
||||
->delete("/orgs/{$org}/actions/runners/{$execution->runner_id}");
|
||||
} catch (\Throwable) {
|
||||
// Best-effort: don't block cleanup if the API call fails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -45,6 +45,7 @@ class GithubAppPermissionJob implements ShouldBeEncrypted, ShouldQueue
|
|||
$this->github_app->metadata = data_get($permissions, 'metadata');
|
||||
$this->github_app->pull_requests = data_get($permissions, 'pull_requests');
|
||||
$this->github_app->administration = data_get($permissions, 'administration');
|
||||
$this->github_app->organization_self_hosted_runners = data_get($permissions, 'organization_self_hosted_runners');
|
||||
|
||||
$this->github_app->save();
|
||||
$this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret');
|
||||
|
|
|
|||
299
app/Jobs/ProvisionGithubRunnerJob.php
Normal file
299
app/Jobs/ProvisionGithubRunnerJob.php
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\GithubRunnerStatus;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\GithubRunnerConfig;
|
||||
use App\Models\GithubRunnerExecution;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $timeout = 300;
|
||||
|
||||
public $tries = 3;
|
||||
|
||||
public function backoff(): array
|
||||
{
|
||||
return [5, 15, 30];
|
||||
}
|
||||
|
||||
public function __construct(
|
||||
public int $githubAppId,
|
||||
public array $workflowJobPayload,
|
||||
public string $organizationLogin,
|
||||
public int $repositoryId = 0,
|
||||
) {
|
||||
$this->onQueue('high');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$workflowJobId = data_get($this->workflowJobPayload, 'id');
|
||||
|
||||
// Idempotency: skip if already provisioning for this job
|
||||
if (GithubRunnerExecution::where('workflow_job_id', $workflowJobId)->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$githubApp = GithubApp::find($this->githubAppId);
|
||||
if (! $githubApp) {
|
||||
return;
|
||||
}
|
||||
|
||||
$requestedLabels = data_get($this->workflowJobPayload, 'labels', []);
|
||||
|
||||
// Find a matching server with capacity
|
||||
$config = $this->findMatchingConfig($githubApp, $requestedLabels);
|
||||
if (! $config) {
|
||||
ray('No matching GitHub runner config found for labels: '.implode(', ', $requestedLabels));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$runnerName = 'coolify-'.((string) new Cuid2(7));
|
||||
$runnerDir = "{$config->runner_base_dir}/{$runnerName}";
|
||||
|
||||
$execution = GithubRunnerExecution::create([
|
||||
'server_id' => $config->server_id,
|
||||
'github_runner_config_id' => $config->id,
|
||||
'status' => GithubRunnerStatus::Queued,
|
||||
'runner_name' => $runnerName,
|
||||
'runner_dir' => $runnerDir,
|
||||
'workflow_job_id' => $workflowJobId,
|
||||
'workflow_name' => data_get($this->workflowJobPayload, 'workflow_name'),
|
||||
'repository_full_name' => data_get($this->workflowJobPayload, 'repository.full_name',
|
||||
data_get($this->workflowJobPayload, 'head_repository.full_name')
|
||||
),
|
||||
]);
|
||||
|
||||
try {
|
||||
$execution->update(['status' => GithubRunnerStatus::Provisioning]);
|
||||
|
||||
// Ensure a Coolify-managed runner group exists and the repo has access
|
||||
$runnerGroupId = $this->ensureRunnerGroup($githubApp);
|
||||
$this->ensureRepositoryInRunnerGroup($githubApp, $runnerGroupId);
|
||||
|
||||
// Generate JIT config via GitHub API
|
||||
['encoded_jit_config' => $jitConfig, 'runner_id' => $runnerId] = $this->generateJitConfig($githubApp, $config, $runnerName, $requestedLabels, $runnerGroupId);
|
||||
|
||||
// Provision runner on server via SSH
|
||||
$pid = $this->provisionRunner($config, $runnerName, $runnerDir, $jitConfig);
|
||||
|
||||
$execution->update([
|
||||
'status' => GithubRunnerStatus::Running,
|
||||
'pid' => $pid,
|
||||
'runner_id' => $runnerId,
|
||||
'started_at' => now(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$execution->update([
|
||||
'status' => GithubRunnerStatus::Failed,
|
||||
'error_message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
// Attempt cleanup of the runner directory on the server
|
||||
try {
|
||||
$server = $config->server;
|
||||
instant_remote_process(["rm -rf {$runnerDir}"], $server, throwError: false);
|
||||
} catch (\Throwable) {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function findMatchingConfig(GithubApp $githubApp, array $requestedLabels): ?GithubRunnerConfig
|
||||
{
|
||||
return GithubRunnerConfig::query()
|
||||
->where('github_app_id', $githubApp->id)
|
||||
->whereHas('githubApp', fn ($q) => $q->where('organization', $this->organizationLogin))
|
||||
->where('is_enabled', true)
|
||||
->with('server')
|
||||
->get()
|
||||
->filter(fn ($config) => $config->matchesLabels($requestedLabels))
|
||||
->filter(fn ($config) => $config->server->isFunctional())
|
||||
->filter(fn ($config) => $config->hasCapacity())
|
||||
->sortBy(fn ($config) => $config->activeRunnerCount())
|
||||
->first();
|
||||
}
|
||||
|
||||
private function ensureRunnerGroup(GithubApp $githubApp): int
|
||||
{
|
||||
$token = generateGithubInstallationToken($githubApp);
|
||||
$apiUrl = $githubApp->api_url ?? 'https://api.github.com';
|
||||
|
||||
if ($githubApp->runner_group_id) {
|
||||
// Ensure existing group allows public repos
|
||||
Http::withHeaders([
|
||||
'Authorization' => "Bearer {$token}",
|
||||
'Accept' => 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version' => '2022-11-28',
|
||||
])->patch("{$apiUrl}/orgs/{$githubApp->organization}/actions/runner-groups/{$githubApp->runner_group_id}", [
|
||||
'allows_public_repositories' => true,
|
||||
]);
|
||||
|
||||
return $githubApp->runner_group_id;
|
||||
}
|
||||
|
||||
$groupName = 'Coolify-'.((string) new Cuid2(7));
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => "Bearer {$token}",
|
||||
'Accept' => 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version' => '2022-11-28',
|
||||
])->post("{$apiUrl}/orgs/{$githubApp->organization}/actions/runner-groups", [
|
||||
'name' => $groupName,
|
||||
'visibility' => 'selected',
|
||||
'allows_public_repositories' => true,
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new \RuntimeException(
|
||||
'Failed to create runner group: '.data_get($response->json(), 'message', $response->body())
|
||||
);
|
||||
}
|
||||
|
||||
$runnerGroupId = (int) data_get($response->json(), 'id');
|
||||
$githubApp->update(['runner_group_id' => $runnerGroupId]);
|
||||
|
||||
return $runnerGroupId;
|
||||
}
|
||||
|
||||
private function ensureRepositoryInRunnerGroup(GithubApp $githubApp, int $runnerGroupId): void
|
||||
{
|
||||
if ($this->repositoryId <= 0) {
|
||||
ray("Skipping runner group repo assignment — repositoryId is {$this->repositoryId}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$token = generateGithubInstallationToken($githubApp);
|
||||
$apiUrl = $githubApp->api_url ?? 'https://api.github.com';
|
||||
$url = "{$apiUrl}/orgs/{$githubApp->organization}/actions/runner-groups/{$runnerGroupId}/repositories/{$this->repositoryId}";
|
||||
|
||||
ray("Adding repository {$this->repositoryId} to runner group {$runnerGroupId}: PUT {$url}");
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => "Bearer {$token}",
|
||||
'Accept' => 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version' => '2022-11-28',
|
||||
])->withBody('', 'application/json')->put($url);
|
||||
|
||||
if (! $response->successful()) {
|
||||
ray('Failed to add repository to runner group: '.$response->status().' '.data_get($response->json(), 'message', $response->body()));
|
||||
}
|
||||
}
|
||||
|
||||
private function generateJitConfig(GithubApp $githubApp, GithubRunnerConfig $config, string $runnerName, array $requestedLabels, int $runnerGroupId): array
|
||||
{
|
||||
$token = generateGithubInstallationToken($githubApp);
|
||||
$apiUrl = $githubApp->api_url ?? 'https://api.github.com';
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => "Bearer {$token}",
|
||||
'Accept' => 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version' => '2022-11-28',
|
||||
])->post("{$apiUrl}/orgs/{$config->organization}/actions/runners/generate-jitconfig", [
|
||||
'name' => $runnerName,
|
||||
'runner_group_id' => $runnerGroupId,
|
||||
'labels' => $requestedLabels,
|
||||
'work_folder' => '_work',
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new \RuntimeException(
|
||||
'Failed to generate JIT runner config: '.data_get($response->json(), 'message', $response->body())
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'encoded_jit_config' => data_get($response->json(), 'encoded_jit_config'),
|
||||
'runner_id' => data_get($response->json(), 'runner.id'),
|
||||
];
|
||||
}
|
||||
|
||||
private function provisionRunner(GithubRunnerConfig $config, string $runnerName, string $runnerDir, string $jitConfig): int
|
||||
{
|
||||
$server = $config->server;
|
||||
$user = $config->runner_user;
|
||||
$baseDir = $config->runner_base_dir;
|
||||
$cacheDir = "{$baseDir}/.cache";
|
||||
// Detect architecture from server
|
||||
$uname = trim(instant_remote_process(['uname -m'], $server));
|
||||
$arch = $uname === 'aarch64' ? 'arm64' : 'x64';
|
||||
|
||||
$version = $config->runner_version ?? $this->getLatestRunnerVersion($config, $arch);
|
||||
|
||||
// Ensure runner user and directories exist
|
||||
instant_remote_process([
|
||||
"id -u {$user} &>/dev/null || useradd -m -s /bin/bash {$user}",
|
||||
"usermod -aG docker {$user}",
|
||||
"mkdir -p {$cacheDir}",
|
||||
"mkdir -p {$runnerDir}",
|
||||
], $server);
|
||||
|
||||
// Download runner binary if not cached
|
||||
$tarball = "actions-runner-linux-{$arch}-{$version}.tar.gz";
|
||||
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}",
|
||||
"chown -R {$user}:{$user} {$runnerDir}",
|
||||
], $server);
|
||||
|
||||
// Start the JIT runner in background
|
||||
$output = instant_remote_process([
|
||||
"cd {$runnerDir} && sudo -u {$user} nohup ./run.sh --jitconfig {$jitConfig} > {$runnerDir}/runner.log 2>&1 & echo \$!",
|
||||
], $server);
|
||||
|
||||
$pid = (int) trim($output);
|
||||
if ($pid <= 0) {
|
||||
throw new \RuntimeException('Failed to start runner process — no PID returned.');
|
||||
}
|
||||
|
||||
return $pid;
|
||||
}
|
||||
|
||||
private function getLatestRunnerVersion(GithubRunnerConfig $config, string $arch): string
|
||||
{
|
||||
$githubApp = GithubApp::find($this->githubAppId);
|
||||
$apiUrl = $githubApp?->api_url ?? 'https://api.github.com';
|
||||
|
||||
try {
|
||||
$token = generateGithubInstallationToken($githubApp);
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => "Bearer {$token}",
|
||||
'Accept' => 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version' => '2022-11-28',
|
||||
])->get("{$apiUrl}/orgs/{$config->organization}/actions/runners/downloads");
|
||||
|
||||
if ($response->successful()) {
|
||||
$download = collect($response->json())
|
||||
->first(fn ($d) => data_get($d, 'os') === 'linux' && data_get($d, 'architecture') === $arch);
|
||||
|
||||
if ($download) {
|
||||
// Extract version from filename like "actions-runner-linux-x64-2.321.0.tar.gz"
|
||||
preg_match('/(\d+\.\d+\.\d+)/', data_get($download, 'filename', ''), $matches);
|
||||
if (! empty($matches[1])) {
|
||||
return $matches[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Fall through to default
|
||||
}
|
||||
|
||||
return '2.321.0';
|
||||
}
|
||||
}
|
||||
355
app/Livewire/Server/GithubRunners.php
Normal file
355
app/Livewire/Server/GithubRunners.php
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Server;
|
||||
|
||||
use App\Enums\GithubRunnerStatus;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\GithubRunnerConfig;
|
||||
use App\Models\GithubRunnerExecution;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
||||
class GithubRunners extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Server $server;
|
||||
|
||||
public array $parameters = [];
|
||||
|
||||
public ?int $selectedGithubAppId = null;
|
||||
|
||||
#[Validate(['required', 'string', 'min:1'])]
|
||||
public string $labels = 'self-hosted,coolify';
|
||||
|
||||
#[Validate(['required', 'integer', 'min:1', 'max:32'])]
|
||||
public int $maxRunners = 4;
|
||||
|
||||
#[Validate(['required', 'string', 'min:1'])]
|
||||
public string $runnerUser = 'runner';
|
||||
|
||||
#[Validate(['required', 'string', 'min:1'])]
|
||||
public string $runnerBaseDir = '/opt/github-runners';
|
||||
|
||||
public ?string $runnerVersion = null;
|
||||
|
||||
#[Validate('boolean')]
|
||||
public bool $isEnabled = true;
|
||||
|
||||
public array $accessibleRepositories = [];
|
||||
|
||||
public ?string $repositoryError = null;
|
||||
|
||||
public function mount(string $server_uuid): void
|
||||
{
|
||||
try {
|
||||
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->loadConfig();
|
||||
} catch (\Throwable) {
|
||||
$this->redirectRoute('server.index');
|
||||
}
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function githubApps()
|
||||
{
|
||||
return GithubApp::ownedByCurrentTeam()
|
||||
->whereNotNull('app_id')
|
||||
->whereNotNull('organization')
|
||||
->where('organization', '!=', '')
|
||||
->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function config(): ?GithubRunnerConfig
|
||||
{
|
||||
return $this->server->githubRunnerConfig;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function activeRunnerCount(): int
|
||||
{
|
||||
return $this->config?->activeRunnerCount() ?? 0;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function recentExecutions()
|
||||
{
|
||||
return GithubRunnerExecution::where('server_id', $this->server->id)
|
||||
->orderByDesc('created_at')
|
||||
->limit(25)
|
||||
->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function selectedApp(): ?GithubApp
|
||||
{
|
||||
if (! $this->selectedGithubAppId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return GithubApp::find($this->selectedGithubAppId);
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function selectedAppHasRunnerPermission(): ?bool
|
||||
{
|
||||
return $this->selectedApp?->organization_self_hosted_runners === 'write';
|
||||
}
|
||||
|
||||
public function loadConfig(): void
|
||||
{
|
||||
$config = $this->server->githubRunnerConfig;
|
||||
if ($config) {
|
||||
$this->selectedGithubAppId = $config->github_app_id;
|
||||
$this->labels = implode(',', $config->labels ?? []);
|
||||
$this->maxRunners = $config->max_runners;
|
||||
$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 updatedSelectedGithubAppId(): void
|
||||
{
|
||||
$this->loadAccessibleRepositories();
|
||||
}
|
||||
|
||||
public function loadAccessibleRepositories(): void
|
||||
{
|
||||
$this->accessibleRepositories = [];
|
||||
$this->repositoryError = null;
|
||||
|
||||
$app = $this->selectedApp;
|
||||
|
||||
if (! $app || ! $app->installation_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$token = generateGithubInstallationToken($app);
|
||||
$allRepos = [];
|
||||
$page = 1;
|
||||
|
||||
do {
|
||||
$result = loadRepositoryByPage($app, $token, $page);
|
||||
$repos = data_get($result, 'repositories', []);
|
||||
$totalCount = data_get($result, 'total_count', 0);
|
||||
|
||||
foreach ($repos as $repo) {
|
||||
$allRepos[] = data_get($repo, 'full_name');
|
||||
}
|
||||
|
||||
$page++;
|
||||
} while (count($allRepos) < $totalCount && count($allRepos) < 500 && count($repos) > 0);
|
||||
|
||||
sort($allRepos);
|
||||
$this->accessibleRepositories = $allRepos;
|
||||
} catch (\Throwable $e) {
|
||||
$this->repositoryError = 'Could not load repositories: '.$e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
$this->validate();
|
||||
|
||||
if (! $this->selectedGithubAppId) {
|
||||
throw new \Exception('Please select a GitHub App.');
|
||||
}
|
||||
|
||||
$labelsArray = array_map('trim', explode(',', $this->labels));
|
||||
$labelsArray = array_values(array_filter($labelsArray));
|
||||
|
||||
if (empty($labelsArray)) {
|
||||
throw new \Exception('At least one label is required.');
|
||||
}
|
||||
|
||||
$config = $this->server->githubRunnerConfig;
|
||||
|
||||
if ($config) {
|
||||
$config->update([
|
||||
'github_app_id' => $this->selectedGithubAppId,
|
||||
'labels' => $labelsArray,
|
||||
'max_runners' => $this->maxRunners,
|
||||
'runner_user' => $this->runnerUser,
|
||||
'runner_base_dir' => $this->runnerBaseDir,
|
||||
'runner_version' => $this->runnerVersion ?: null,
|
||||
'is_enabled' => $this->isEnabled,
|
||||
]);
|
||||
} else {
|
||||
GithubRunnerConfig::create([
|
||||
'server_id' => $this->server->id,
|
||||
'github_app_id' => $this->selectedGithubAppId,
|
||||
'labels' => $labelsArray,
|
||||
'max_runners' => $this->maxRunners,
|
||||
'runner_user' => $this->runnerUser,
|
||||
'runner_base_dir' => $this->runnerBaseDir,
|
||||
'runner_version' => $this->runnerVersion ?: null,
|
||||
'is_enabled' => $this->isEnabled,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->server->refresh();
|
||||
$this->dispatch('success', 'GitHub Runner configuration saved.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleEnabled()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
$config = $this->server->githubRunnerConfig;
|
||||
if (! $config) {
|
||||
return;
|
||||
}
|
||||
|
||||
$config->update(['is_enabled' => ! $config->is_enabled]);
|
||||
$this->isEnabled = $config->fresh()->is_enabled;
|
||||
$this->dispatch('success', $this->isEnabled ? 'Runners enabled.' : 'Runners disabled.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteConfig()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
$config = $this->server->githubRunnerConfig;
|
||||
if (! $config) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($config->activeRunnerCount() > 0) {
|
||||
throw new \Exception('Cannot delete configuration while runners are active.');
|
||||
}
|
||||
|
||||
$config->delete();
|
||||
$this->server->refresh();
|
||||
$this->loadConfig();
|
||||
$this->dispatch('success', 'GitHub Runner configuration deleted.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
$this->authorize('update', $this->server);
|
||||
|
||||
$execution = GithubRunnerExecution::where('id', $executionId)
|
||||
->where('server_id', $this->server->id)
|
||||
->with('config.githubApp')
|
||||
->firstOrFail();
|
||||
|
||||
if (! $execution->isActive()) {
|
||||
$this->dispatch('error', 'This execution is already finished.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$server = $execution->server;
|
||||
|
||||
if ($execution->pid && $server->isFunctional()) {
|
||||
instant_remote_process([
|
||||
"kill {$execution->pid} 2>/dev/null || true",
|
||||
], $server, throwError: false);
|
||||
}
|
||||
|
||||
if ($execution->runner_dir && $server->isFunctional()) {
|
||||
instant_remote_process([
|
||||
"rm -rf {$execution->runner_dir}",
|
||||
], $server, throwError: false);
|
||||
}
|
||||
|
||||
$this->deregisterRunnerFromGithub($execution);
|
||||
|
||||
$execution->update([
|
||||
'status' => GithubRunnerStatus::Failed,
|
||||
'error_message' => 'Cancelled by user.',
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$this->dispatch('success', "Runner {$execution->runner_name} cancelled.");
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
private function deregisterRunnerFromGithub(GithubRunnerExecution $execution): void
|
||||
{
|
||||
if (! $execution->runner_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$githubApp = $execution->config?->githubApp;
|
||||
if (! $githubApp || $githubApp->is_public) {
|
||||
return;
|
||||
}
|
||||
|
||||
$org = $githubApp->organization;
|
||||
if (! $org) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$token = generateGithubInstallationToken($githubApp);
|
||||
$apiUrl = $githubApp->api_url ?? 'https://api.github.com';
|
||||
|
||||
Http::GitHub($apiUrl, $token)
|
||||
->delete("/orgs/{$org}/actions/runners/{$execution->runner_id}");
|
||||
} catch (\Throwable) {
|
||||
// Best-effort
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.server.github-runners');
|
||||
}
|
||||
}
|
||||
|
|
@ -67,6 +67,8 @@ class Change extends Component
|
|||
|
||||
public ?string $pullRequests = null;
|
||||
|
||||
public ?string $organizationSelfHostedRunners = null;
|
||||
|
||||
public $applications;
|
||||
|
||||
public $privateKeys;
|
||||
|
|
@ -87,6 +89,7 @@ class Change extends Component
|
|||
'contents' => 'nullable|string',
|
||||
'metadata' => 'nullable|string',
|
||||
'pullRequests' => 'nullable|string',
|
||||
'organizationSelfHostedRunners' => 'nullable|string',
|
||||
'privateKeyId' => 'nullable|int',
|
||||
];
|
||||
|
||||
|
|
@ -122,6 +125,7 @@ class Change extends Component
|
|||
$this->github_app->contents = $this->contents;
|
||||
$this->github_app->metadata = $this->metadata;
|
||||
$this->github_app->pull_requests = $this->pullRequests;
|
||||
$this->github_app->organization_self_hosted_runners = $this->organizationSelfHostedRunners;
|
||||
} else {
|
||||
// Sync FROM model (on load/refresh)
|
||||
$this->name = $this->github_app->name;
|
||||
|
|
@ -140,6 +144,7 @@ class Change extends Component
|
|||
$this->contents = $this->github_app->contents;
|
||||
$this->metadata = $this->github_app->metadata;
|
||||
$this->pullRequests = $this->github_app->pull_requests;
|
||||
$this->organizationSelfHostedRunners = $this->github_app->organization_self_hosted_runners;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -175,6 +180,7 @@ class Change extends Component
|
|||
|
||||
GithubAppPermissionJob::dispatchSync($this->github_app);
|
||||
$this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret');
|
||||
$this->syncData(false);
|
||||
$this->dispatch('success', 'Github App permissions updated.');
|
||||
} catch (\Throwable $e) {
|
||||
// Provide better error message for unsupported key formats
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ class GithubApp extends BaseModel
|
|||
'is_public' => 'boolean',
|
||||
'is_system_wide' => 'boolean',
|
||||
'type' => 'string',
|
||||
'runner_group_id' => 'integer',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
|
|
@ -88,6 +89,11 @@ class GithubApp extends BaseModel
|
|||
return $this->belongsTo(PrivateKey::class);
|
||||
}
|
||||
|
||||
public function runnerConfigs()
|
||||
{
|
||||
return $this->hasMany(GithubRunnerConfig::class);
|
||||
}
|
||||
|
||||
public function type(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
|
|
|
|||
63
app/Models/GithubRunnerConfig.php
Normal file
63
app/Models/GithubRunnerConfig.php
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class GithubRunnerConfig extends BaseModel
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'labels' => 'array',
|
||||
'is_enabled' => 'boolean',
|
||||
'max_runners' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function organization(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->githubApp?->organization,
|
||||
);
|
||||
}
|
||||
|
||||
public function server(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Server::class);
|
||||
}
|
||||
|
||||
public function githubApp(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(GithubApp::class);
|
||||
}
|
||||
|
||||
public function executions(): HasMany
|
||||
{
|
||||
return $this->hasMany(GithubRunnerExecution::class);
|
||||
}
|
||||
|
||||
public function activeRunnerCount(): int
|
||||
{
|
||||
return $this->executions()
|
||||
->whereIn('status', ['queued', 'provisioning', 'running', 'cleaning'])
|
||||
->count();
|
||||
}
|
||||
|
||||
public function hasCapacity(): bool
|
||||
{
|
||||
return $this->activeRunnerCount() < $this->max_runners;
|
||||
}
|
||||
|
||||
public function matchesLabels(array $requestedLabels): bool
|
||||
{
|
||||
$configLabels = collect($this->labels)->map(fn ($l) => strtolower($l));
|
||||
|
||||
return collect($requestedLabels)
|
||||
->every(fn ($label) => $configLabels->contains(strtolower($label)));
|
||||
}
|
||||
}
|
||||
49
app/Models/GithubRunnerExecution.php
Normal file
49
app/Models/GithubRunnerExecution.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\GithubRunnerStatus;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class GithubRunnerExecution extends BaseModel
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => GithubRunnerStatus::class,
|
||||
'workflow_job_id' => 'integer',
|
||||
'runner_id' => 'integer',
|
||||
'pid' => 'integer',
|
||||
'started_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function server(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Server::class);
|
||||
}
|
||||
|
||||
public function config(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(GithubRunnerConfig::class, 'github_runner_config_id');
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->status->isActive();
|
||||
}
|
||||
|
||||
public function duration(): ?string
|
||||
{
|
||||
if (! $this->started_at) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$end = $this->completed_at ?? now();
|
||||
|
||||
return $this->started_at->diffForHumans($end, true);
|
||||
}
|
||||
}
|
||||
|
|
@ -977,6 +977,16 @@ $schema://$host {
|
|||
return $standalone_docker->concat($swarm_docker);
|
||||
}
|
||||
|
||||
public function githubRunnerConfig()
|
||||
{
|
||||
return $this->hasOne(GithubRunnerConfig::class);
|
||||
}
|
||||
|
||||
public function githubRunnerExecutions()
|
||||
{
|
||||
return $this->hasMany(GithubRunnerExecution::class);
|
||||
}
|
||||
|
||||
public function standaloneDockers()
|
||||
{
|
||||
return $this->hasMany(StandaloneDocker::class);
|
||||
|
|
|
|||
|
|
@ -130,6 +130,10 @@ function getPermissionsPath(GithubApp $source)
|
|||
$github = GithubApp::where('uuid', $source->uuid)->first();
|
||||
$name = str(Str::kebab($github->name));
|
||||
|
||||
if (str($github->organization)->isNotEmpty()) {
|
||||
return "$github->html_url/organizations/$github->organization/settings/apps/$name/permissions";
|
||||
}
|
||||
|
||||
return "$github->html_url/settings/apps/$name/permissions";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('github_apps', function (Blueprint $table) {
|
||||
$table->string('organization_self_hosted_runners')->nullable()->after('administration');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('github_apps', function (Blueprint $table) {
|
||||
$table->dropColumn('organization_self_hosted_runners');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('github_runner_configs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->foreignId('server_id')->unique()->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('github_app_id')->constrained()->cascadeOnDelete();
|
||||
$table->json('labels')->default('["self-hosted","coolify"]');
|
||||
$table->boolean('is_enabled')->default(true);
|
||||
$table->integer('max_runners')->default(4);
|
||||
$table->string('runner_user')->default('runner');
|
||||
$table->string('runner_version')->nullable();
|
||||
$table->string('runner_arch')->default('x64');
|
||||
$table->string('runner_base_dir')->default('/opt/github-runners');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('github_runner_configs');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('github_runner_executions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->foreignId('server_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('github_runner_config_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('status')->default('queued');
|
||||
$table->string('runner_name')->nullable();
|
||||
$table->string('runner_dir')->nullable();
|
||||
$table->unsignedBigInteger('workflow_job_id');
|
||||
$table->string('workflow_name')->nullable();
|
||||
$table->string('repository_full_name')->nullable();
|
||||
$table->unsignedBigInteger('runner_id')->nullable();
|
||||
$table->integer('pid')->nullable();
|
||||
$table->text('error_message')->nullable();
|
||||
$table->timestamp('started_at')->nullable();
|
||||
$table->timestamp('completed_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['server_id', 'status']);
|
||||
$table->index('workflow_job_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('github_runner_executions');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?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_apps', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('runner_group_id')->nullable()->after('installation_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('github_apps', function (Blueprint $table) {
|
||||
$table->dropColumn('runner_group_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -31,6 +31,9 @@
|
|||
<a class="sub-menu-item {{ $activeMenu === 'docker-cleanup' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
|
||||
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>
|
||||
</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>
|
||||
</a>
|
||||
|
|
|
|||
246
resources/views/livewire/server/github-runners.blade.php
Normal file
246
resources/views/livewire/server/github-runners.blade.php
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
<div>
|
||||
<x-slot:title>
|
||||
{{ data_get_str($server, 'name')->limit(10) }} > GitHub Runners | Coolify
|
||||
</x-slot>
|
||||
<livewire:server.navbar :server="$server" />
|
||||
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }" class="flex flex-col h-full gap-8 sm:flex-row">
|
||||
<x-server.sidebar :server="$server" activeMenu="github-runners" />
|
||||
<div class="w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2>GitHub Actions Runners</h2>
|
||||
@if ($this->config)
|
||||
<x-forms.button wire:click="toggleEnabled" canGate="update" :canResource="$server">
|
||||
{{ $this->config->is_enabled ? 'Disable' : 'Enable' }}
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
<div class="mt-1 mb-6">Use this server as a GitHub Actions self-hosted runner host. Runners are ephemeral (JIT) — spun up per workflow job and cleaned up automatically.</div>
|
||||
|
||||
{{-- Permission Warning --}}
|
||||
@if ($selectedGithubAppId && $this->selectedAppHasRunnerPermission === false)
|
||||
<div class="mb-4">
|
||||
<x-callout type="warning" title="Missing Permission">
|
||||
<p>The selected GitHub App does not have the <code>organization_self_hosted_runners: write</code> permission.</p>
|
||||
<p class="mt-1">
|
||||
1. Add it in your <a href="{{ getPermissionsPath($this->selectedApp) }}" target="_blank" class="underline">GitHub App settings</a>,
|
||||
then 2. re-sync permissions in <a href="{{ route('source.github.show', ['github_app_uuid' => $this->selectedApp->uuid]) }}" class="underline">Coolify's Source settings</a>.
|
||||
</p>
|
||||
</x-callout>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Accessible Repositories --}}
|
||||
@if ($selectedGithubAppId && $this->selectedApp && !$this->selectedApp->is_public)
|
||||
<div class="mb-4">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label class="text-sm font-medium">Accessible Repositories</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="button" wire:click="loadAccessibleRepositories"
|
||||
class="text-xs text-neutral-400 hover:text-white transition-colors">
|
||||
Refresh
|
||||
</button>
|
||||
<a href="{{ getInstallationPath($this->selectedApp) }}" target="_blank"
|
||||
class="text-xs text-warning hover:underline">
|
||||
Manage Repository Access →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($repositoryError)
|
||||
<x-callout type="error" title="Could Not Load Repositories">
|
||||
{{ $repositoryError }}
|
||||
</x-callout>
|
||||
@elseif (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>
|
||||
</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
|
||||
|
||||
{{-- Configuration Form --}}
|
||||
<form wire:submit="submit">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label for="selectedGithubAppId" class="block text-sm font-medium">GitHub App</label>
|
||||
<select wire:model.live="selectedGithubAppId" id="selectedGithubAppId"
|
||||
class="w-full mt-1 input">
|
||||
<option value="">Select a GitHub App...</option>
|
||||
@foreach ($this->githubApps as $app)
|
||||
<option value="{{ $app->id }}">
|
||||
{{ $app->name }}
|
||||
@if ($app->organization)
|
||||
({{ $app->organization }})
|
||||
@endif
|
||||
@if ($app->organization_self_hosted_runners === 'write')
|
||||
✓ Runner Permission
|
||||
@endif
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<x-forms.input canGate="update" :canResource="$server" id="labels"
|
||||
label="Labels (comma-separated)" required
|
||||
helper="Labels for routing workflow jobs to this server. Workflows use runs-on to match these labels." />
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<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="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">
|
||||
<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." />
|
||||
<x-forms.input canGate="update" :canResource="$server" id="runnerVersion"
|
||||
label="Runner Version (optional)"
|
||||
helper="Pin to a specific runner version (e.g. 2.321.0). Leave empty for latest." />
|
||||
</div>
|
||||
|
||||
<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.']"
|
||||
:confirmWithText="false" :confirmWithPassword="false"
|
||||
step2ButtonText="Delete Configuration" />
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{-- Status --}}
|
||||
@if ($this->config)
|
||||
<div class="mt-8">
|
||||
<h3 class="mb-2">Status</h3>
|
||||
<div class="flex items-center gap-4 text-sm">
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<span class="w-2 h-2 rounded-full {{ $this->config->is_enabled ? 'bg-success' : 'bg-error' }}"></span>
|
||||
{{ $this->config->is_enabled ? 'Enabled' : 'Disabled' }}
|
||||
</span>
|
||||
<span>Active Runners: {{ $this->activeRunnerCount }} / {{ $this->config->max_runners }}</span>
|
||||
<span>Organization: {{ $this->config->githubApp?->organization }}</span>
|
||||
<span>Labels: {{ implode(', ', $this->config->labels ?? []) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Executions --}}
|
||||
@if ($this->config)
|
||||
<div class="mt-8" wire:poll.10s>
|
||||
<h3 class="mb-4">Recent Executions</h3>
|
||||
@if ($this->recentExecutions->isEmpty())
|
||||
<div class="text-sm text-neutral-500">No runner executions yet. When a workflow job matches this server's labels, it will appear here.</div>
|
||||
@else
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left border-b border-neutral-700">
|
||||
<th class="pb-2 pr-4">Runner</th>
|
||||
<th class="pb-2 pr-4">Workflow</th>
|
||||
<th class="pb-2 pr-4">Repository</th>
|
||||
<th class="pb-2 pr-4">Status</th>
|
||||
<th class="pb-2 pr-4">Duration</th>
|
||||
<th class="pb-2 pr-4">Started</th>
|
||||
<th class="pb-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($this->recentExecutions as $execution)
|
||||
<tr class="border-b border-neutral-800">
|
||||
<td class="py-2 pr-4 font-mono text-xs">{{ $execution->runner_name }}</td>
|
||||
<td class="py-2 pr-4">{{ $execution->workflow_name ?? '-' }}</td>
|
||||
<td class="py-2 pr-4">{{ $execution->repository_full_name ?? '-' }}</td>
|
||||
<td class="py-2 pr-4">
|
||||
@switch($execution->status->value)
|
||||
@case('queued')
|
||||
<span class="px-2 py-0.5 text-xs rounded-full bg-warning/20 text-warning">Queued</span>
|
||||
@break
|
||||
@case('provisioning')
|
||||
<span class="px-2 py-0.5 text-xs rounded-full bg-blue-500/20 text-blue-400">Provisioning</span>
|
||||
@break
|
||||
@case('running')
|
||||
<span class="px-2 py-0.5 text-xs rounded-full bg-success/20 text-success">Running</span>
|
||||
@break
|
||||
@case('completed')
|
||||
<span class="px-2 py-0.5 text-xs rounded-full bg-neutral-500/20 text-neutral-400">Completed</span>
|
||||
@break
|
||||
@case('failed')
|
||||
<span class="px-2 py-0.5 text-xs rounded-full bg-error/20 text-error">Failed</span>
|
||||
@break
|
||||
@case('timed_out')
|
||||
<span class="px-2 py-0.5 text-xs rounded-full bg-orange-500/20 text-orange-400">Timed Out</span>
|
||||
@break
|
||||
@case('cleaning')
|
||||
<span class="px-2 py-0.5 text-xs rounded-full bg-blue-500/20 text-blue-400">Cleaning</span>
|
||||
@break
|
||||
@endswitch
|
||||
</td>
|
||||
<td class="py-2 pr-4">{{ $execution->duration() ?? '-' }}</td>
|
||||
<td class="py-2 pr-4">{{ $execution->started_at?->diffForHumans() ?? $execution->created_at->diffForHumans() }}</td>
|
||||
<td class="py-2">
|
||||
@if ($execution->isActive())
|
||||
<x-forms.button wire:click="cancelExecution({{ $execution->id }})"
|
||||
wire:confirm="Cancel this runner? This will kill the process, remove the runner directory, and deregister from GitHub."
|
||||
canGate="update" :canResource="$server"
|
||||
class="!py-0.5 !px-2 !text-xs">
|
||||
Cancel
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -136,6 +136,9 @@
|
|||
<x-forms.input id="pullRequests"
|
||||
helper="write access needed to use deployment status update in previews."
|
||||
label="Pull Request" readonly placeholder="N/A" />
|
||||
<x-forms.input id="organizationSelfHostedRunners"
|
||||
helper="write access needed to use GitHub Actions self-hosted runners."
|
||||
label="Runners" readonly placeholder="N/A" />
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ use App\Livewire\Server\CloudProviderToken\Show as CloudProviderTokenShow;
|
|||
use App\Livewire\Server\Delete as DeleteServer;
|
||||
use App\Livewire\Server\Destinations as ServerDestinations;
|
||||
use App\Livewire\Server\DockerCleanup;
|
||||
use App\Livewire\Server\GithubRunners;
|
||||
use App\Livewire\Server\Index as ServerIndex;
|
||||
use App\Livewire\Server\LogDrains;
|
||||
use App\Livewire\Server\PrivateKey\Show as PrivateKeyShow;
|
||||
|
|
@ -274,6 +275,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||
Route::get('/proxy/logs', ProxyLogs::class)->name('server.proxy.logs');
|
||||
Route::get('/terminal', ExecuteContainerCommand::class)->name('server.command')->middleware('can.access.terminal');
|
||||
Route::get('/docker-cleanup', DockerCleanup::class)->name('server.docker-cleanup');
|
||||
Route::get('/github-runners', GithubRunners::class)->name('server.github-runners');
|
||||
Route::get('/security', fn () => redirect(route('dashboard')))->name('server.security')->middleware('can.update.resource');
|
||||
Route::get('/security/patches', Patches::class)->name('server.security.patches')->middleware('can.update.resource');
|
||||
Route::get('/security/terminal-access', TerminalAccess::class)->name('server.security.terminal-access')->middleware('can.update.resource');
|
||||
|
|
|
|||
51
scripts/webhook-tunnel.sh
Executable file
51
scripts/webhook-tunnel.sh
Executable file
|
|
@ -0,0 +1,51 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# webhook-tunnel.sh
|
||||
#
|
||||
# Opens a Cloudflare quick tunnel to expose your local Coolify instance
|
||||
# so GitHub can deliver webhook events during local development.
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/webhook-tunnel.sh [local_port]
|
||||
#
|
||||
# Default local port: 8000
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
LOCAL_PORT="${1:-8000}"
|
||||
WEBHOOK_PATH="/source/github/events"
|
||||
|
||||
if ! command -v cloudflared &>/dev/null; then
|
||||
echo ""
|
||||
echo " ERROR: cloudflared is not installed."
|
||||
echo " Install it with: brew install cloudflared"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " GitHub Webhook Tunnel (via Cloudflare)"
|
||||
echo " ======================================="
|
||||
echo " Forwarding: public HTTPS -> localhost:${LOCAL_PORT}"
|
||||
echo ""
|
||||
echo " Waiting for tunnel URL..."
|
||||
echo ""
|
||||
|
||||
cloudflared tunnel --url "http://localhost:${LOCAL_PORT}" 2>&1 | while IFS= read -r line; do
|
||||
if [[ "$line" =~ https://[a-zA-Z0-9_-]+\.trycloudflare\.com ]]; then
|
||||
TUNNEL_URL="${BASH_REMATCH[0]}"
|
||||
echo ""
|
||||
echo " ✔ Tunnel is live!"
|
||||
echo ""
|
||||
echo " ┌─────────────────────────────────────────────────────────────────┐"
|
||||
echo " │ Webhook endpoint: │"
|
||||
echo " │ ${TUNNEL_URL}${WEBHOOK_PATH}"
|
||||
echo " │ │"
|
||||
echo " │ Configure your GitHub App: │"
|
||||
echo " │ GitHub -> App Settings -> Webhook URL -> paste the URL above │"
|
||||
echo " │ │"
|
||||
echo " │ Press Ctrl+C to stop the tunnel. │"
|
||||
echo " └─────────────────────────────────────────────────────────────────┘"
|
||||
echo ""
|
||||
fi
|
||||
done
|
||||
129
tests/Feature/CleanupStaleGithubRunnersJobTest.php
Normal file
129
tests/Feature/CleanupStaleGithubRunnersJobTest.php
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\GithubRunnerStatus;
|
||||
use App\Jobs\CleanupStaleGithubRunnersJob;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\GithubRunnerConfig;
|
||||
use App\Models\GithubRunnerExecution;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function makeExecution(array $attributes = []): GithubRunnerExecution
|
||||
{
|
||||
$team = Team::factory()->create();
|
||||
$privateKeyId = 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]);
|
||||
$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([
|
||||
'server_id' => $server->id,
|
||||
'github_app_id' => $githubApp->id,
|
||||
'labels' => ['self-hosted', 'coolify'],
|
||||
]);
|
||||
|
||||
return GithubRunnerExecution::create(array_merge([
|
||||
'server_id' => $server->id,
|
||||
'github_runner_config_id' => $config->id,
|
||||
'status' => GithubRunnerStatus::Running,
|
||||
'runner_name' => 'coolify-test',
|
||||
'runner_dir' => '/opt/github-runners/coolify-test',
|
||||
'workflow_job_id' => fake()->unique()->randomNumber(8, true),
|
||||
'pid' => 12345,
|
||||
'started_at' => now()->subMinutes(10),
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
it('skips dead-runner check for executions within the 5-minute grace period', function () {
|
||||
$execution = makeExecution(['started_at' => now()->subMinutes(2)]);
|
||||
|
||||
(new CleanupStaleGithubRunnersJob)->handle();
|
||||
|
||||
expect($execution->fresh()->status)->toBe(GithubRunnerStatus::Running);
|
||||
});
|
||||
|
||||
it('skips dead-runner check when server is not functional', function () {
|
||||
// Factory servers have no settings → isFunctional() returns false
|
||||
$execution = makeExecution(['started_at' => now()->subMinutes(10)]);
|
||||
|
||||
(new CleanupStaleGithubRunnersJob)->handle();
|
||||
|
||||
// Should remain Running because the health check is skipped for non-functional servers
|
||||
expect($execution->fresh()->status)->toBe(GithubRunnerStatus::Running);
|
||||
});
|
||||
|
||||
it('marks stale active executions as timed out after 2 hours', function () {
|
||||
$execution = makeExecution([
|
||||
'status' => GithubRunnerStatus::Running,
|
||||
'started_at' => now()->subHours(3),
|
||||
'created_at' => now()->subHours(3),
|
||||
]);
|
||||
|
||||
// Force the created_at to be old enough
|
||||
$execution->forceFill(['created_at' => now()->subHours(3)])->save();
|
||||
|
||||
(new CleanupStaleGithubRunnersJob)->handle();
|
||||
|
||||
$fresh = $execution->fresh();
|
||||
expect($fresh->status)->toBe(GithubRunnerStatus::TimedOut);
|
||||
expect($fresh->completed_at)->not->toBeNull();
|
||||
expect($fresh->error_message)->toContain('2 hours');
|
||||
});
|
||||
|
||||
it('does not touch active executions younger than 2 hours in stale cleanup', function () {
|
||||
$execution = makeExecution([
|
||||
'status' => GithubRunnerStatus::Provisioning,
|
||||
'started_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
(new CleanupStaleGithubRunnersJob)->handle();
|
||||
|
||||
expect($execution->fresh()->status)->toBe(GithubRunnerStatus::Provisioning);
|
||||
});
|
||||
|
||||
it('marks stale queued executions as timed out', function () {
|
||||
$execution = makeExecution([
|
||||
'status' => GithubRunnerStatus::Queued,
|
||||
'started_at' => null,
|
||||
'pid' => null,
|
||||
]);
|
||||
$execution->forceFill(['created_at' => now()->subHours(3)])->save();
|
||||
|
||||
(new CleanupStaleGithubRunnersJob)->handle();
|
||||
|
||||
expect($execution->fresh()->status)->toBe(GithubRunnerStatus::TimedOut);
|
||||
});
|
||||
|
||||
it('does not re-process already completed executions', function () {
|
||||
$execution = makeExecution([
|
||||
'status' => GithubRunnerStatus::Completed,
|
||||
'started_at' => now()->subHours(3),
|
||||
'completed_at' => now()->subHours(2),
|
||||
]);
|
||||
$execution->forceFill(['created_at' => now()->subHours(3)])->save();
|
||||
|
||||
(new CleanupStaleGithubRunnersJob)->handle();
|
||||
|
||||
expect($execution->fresh()->status)->toBe(GithubRunnerStatus::Completed);
|
||||
});
|
||||
35
tests/Feature/GithubAppPermissionsPathTest.php
Normal file
35
tests/Feature/GithubAppPermissionsPathTest.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function makeGithubApp(array $attributes = []): GithubApp
|
||||
{
|
||||
$team = Team::factory()->create();
|
||||
|
||||
return GithubApp::create(array_merge([
|
||||
'uuid' => fake()->uuid(),
|
||||
'name' => 'My Cool App',
|
||||
'html_url' => 'https://github.com',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'organization' => null,
|
||||
'team_id' => $team->id,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
it('returns user-level permissions path when no organization is set', function () {
|
||||
$app = makeGithubApp(['name' => 'My Cool App', 'organization' => null]);
|
||||
|
||||
expect(getPermissionsPath($app))
|
||||
->toBe('https://github.com/settings/apps/my-cool-app/permissions');
|
||||
});
|
||||
|
||||
it('returns organization-level permissions path when organization is set', function () {
|
||||
$app = makeGithubApp(['name' => 'My Cool App', 'organization' => 'coollabsio']);
|
||||
|
||||
expect(getPermissionsPath($app))
|
||||
->toBe('https://github.com/organizations/coollabsio/settings/apps/my-cool-app/permissions');
|
||||
});
|
||||
149
tests/Feature/GithubRunnerWebhookTest.php
Normal file
149
tests/Feature/GithubRunnerWebhookTest.php
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
<?php
|
||||
|
||||
use App\Models\GithubApp;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('returns pong for ping events on the webhook endpoint', function () {
|
||||
$response = $this->postJson('/source/github/events', [], [
|
||||
'X-GitHub-Event' => 'ping',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('pong');
|
||||
});
|
||||
|
||||
it('returns nothing to do when no github app found for workflow_job event', function () {
|
||||
$payload = [
|
||||
'action' => 'queued',
|
||||
'workflow_job' => [
|
||||
'id' => 12345,
|
||||
'labels' => ['self-hosted'],
|
||||
],
|
||||
'organization' => [
|
||||
'login' => 'test-org',
|
||||
],
|
||||
];
|
||||
|
||||
$secret = 'test-secret';
|
||||
$body = json_encode($payload);
|
||||
$signature = hash_hmac('sha256', $body, $secret);
|
||||
|
||||
$response = $this->postJson('/source/github/events', $payload, [
|
||||
'X-GitHub-Event' => 'workflow_job',
|
||||
'X-GitHub-Hook-Installation-Target-Id' => '999999',
|
||||
'X-Hub-Signature-256' => 'sha256='.$signature,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('Nothing to do. No GitHub App found.');
|
||||
});
|
||||
|
||||
it('dispatches provisioning job for queued workflow_job event', function () {
|
||||
$team = \App\Models\Team::factory()->create();
|
||||
$privateKey = \App\Models\PrivateKey::create([
|
||||
'name' => 'test-key',
|
||||
'private_key' => 'test',
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'test-app',
|
||||
'app_id' => 123456,
|
||||
'installation_id' => 789,
|
||||
'client_id' => 'Iv1.abc123',
|
||||
'client_secret' => 'secret',
|
||||
'webhook_secret' => 'test-webhook-secret',
|
||||
'private_key_id' => $privateKey->id,
|
||||
'team_id' => $team->id,
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'organization' => 'test-org',
|
||||
]);
|
||||
|
||||
\Illuminate\Support\Facades\Queue::fake();
|
||||
|
||||
$payload = [
|
||||
'action' => 'queued',
|
||||
'workflow_job' => [
|
||||
'id' => 12345,
|
||||
'labels' => ['self-hosted', 'coolify'],
|
||||
'workflow_name' => 'CI',
|
||||
],
|
||||
'organization' => [
|
||||
'login' => 'test-org',
|
||||
],
|
||||
'repository' => [
|
||||
'id' => 123456789,
|
||||
],
|
||||
];
|
||||
|
||||
$body = json_encode($payload);
|
||||
$signature = hash_hmac('sha256', $body, 'test-webhook-secret');
|
||||
|
||||
$response = $this->postJson('/source/github/events', $payload, [
|
||||
'X-GitHub-Event' => 'workflow_job',
|
||||
'X-GitHub-Hook-Installation-Target-Id' => '123456',
|
||||
'X-Hub-Signature-256' => 'sha256='.$signature,
|
||||
'Content-Type' => 'application/json',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('Runner provisioning queued.');
|
||||
|
||||
\Illuminate\Support\Facades\Queue::assertPushed(\App\Jobs\ProvisionGithubRunnerJob::class, function ($job) {
|
||||
return $job->repositoryId === 123456789;
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches cleanup job for completed workflow_job event', function () {
|
||||
$team = \App\Models\Team::factory()->create();
|
||||
$privateKey = \App\Models\PrivateKey::create([
|
||||
'name' => 'test-key',
|
||||
'private_key' => 'test',
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'test-app',
|
||||
'app_id' => 654321,
|
||||
'installation_id' => 789,
|
||||
'client_id' => 'Iv1.abc123',
|
||||
'client_secret' => 'secret',
|
||||
'webhook_secret' => 'cleanup-secret',
|
||||
'private_key_id' => $privateKey->id,
|
||||
'team_id' => $team->id,
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'organization' => 'test-org',
|
||||
]);
|
||||
|
||||
\Illuminate\Support\Facades\Queue::fake();
|
||||
|
||||
$payload = [
|
||||
'action' => 'completed',
|
||||
'workflow_job' => [
|
||||
'id' => 67890,
|
||||
'conclusion' => 'success',
|
||||
],
|
||||
'organization' => [
|
||||
'login' => 'test-org',
|
||||
],
|
||||
];
|
||||
|
||||
$body = json_encode($payload);
|
||||
$signature = hash_hmac('sha256', $body, 'cleanup-secret');
|
||||
|
||||
$response = $this->postJson('/source/github/events', $payload, [
|
||||
'X-GitHub-Event' => 'workflow_job',
|
||||
'X-GitHub-Hook-Installation-Target-Id' => '654321',
|
||||
'X-Hub-Signature-256' => 'sha256='.$signature,
|
||||
'Content-Type' => 'application/json',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('Runner cleanup queued.');
|
||||
|
||||
\Illuminate\Support\Facades\Queue::assertPushed(\App\Jobs\CleanupGithubRunnerJob::class);
|
||||
});
|
||||
156
tests/Feature/GithubRunnersAccessibleRepositoriesTest.php
Normal file
156
tests/Feature/GithubRunnersAccessibleRepositoriesTest.php
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Server\GithubRunners;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
// RSA-2048 key in PKCS8 PEM format — required by lcobucci/jwt Rsa\Sha256 signer
|
||||
$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) {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$privateKey = PrivateKey::create([
|
||||
'name' => 'test-key',
|
||||
'private_key' => $validKey,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$this->githubApp = GithubApp::create([
|
||||
'name' => 'Test App',
|
||||
'app_id' => 111,
|
||||
'installation_id' => 222,
|
||||
'client_id' => 'Iv1.abc',
|
||||
'client_secret' => 'secret',
|
||||
'webhook_secret' => 'hook-secret',
|
||||
'private_key_id' => $privateKey->id,
|
||||
'team_id' => $this->team->id,
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'organization' => 'test-org',
|
||||
'organization_self_hosted_runners' => 'write',
|
||||
]);
|
||||
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
]);
|
||||
});
|
||||
|
||||
describe('GithubRunners accessible repositories', function () {
|
||||
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()]),
|
||||
'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' => 2,
|
||||
'repositories' => [
|
||||
['full_name' => 'test-org/repo-b', 'name' => 'repo-b'],
|
||||
['full_name' => 'test-org/repo-a', 'name' => 'repo-a'],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
Livewire::test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
||||
->set('selectedGithubAppId', $this->githubApp->id)
|
||||
->call('loadAccessibleRepositories')
|
||||
->assertSet('repositoryError', null)
|
||||
->assertSet('accessibleRepositories', ['test-org/repo-a', 'test-org/repo-b']);
|
||||
});
|
||||
|
||||
test('loadAccessibleRepositories sorts repositories alphabetically', function () {
|
||||
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' => 3,
|
||||
'repositories' => [
|
||||
['full_name' => 'test-org/zebra', 'name' => 'zebra'],
|
||||
['full_name' => 'test-org/alpha', 'name' => 'alpha'],
|
||||
['full_name' => 'test-org/middle', 'name' => 'middle'],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
Livewire::test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
||||
->set('selectedGithubAppId', $this->githubApp->id)
|
||||
->call('loadAccessibleRepositories')
|
||||
->assertSet('accessibleRepositories', ['test-org/alpha', 'test-org/middle', 'test-org/zebra']);
|
||||
});
|
||||
|
||||
test('loadAccessibleRepositories returns empty list when no repos accessible', function () {
|
||||
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' => 0,
|
||||
'repositories' => [],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
Livewire::test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
||||
->set('selectedGithubAppId', $this->githubApp->id)
|
||||
->call('loadAccessibleRepositories')
|
||||
->assertSet('repositoryError', null)
|
||||
->assertSet('accessibleRepositories', []);
|
||||
});
|
||||
|
||||
test('loadAccessibleRepositories does nothing when no app is selected', function () {
|
||||
Livewire::test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
||||
->call('loadAccessibleRepositories')
|
||||
->assertSet('accessibleRepositories', [])
|
||||
->assertSet('repositoryError', null);
|
||||
});
|
||||
|
||||
test('loadAccessibleRepositories does nothing when app has no installation_id', function () {
|
||||
$this->githubApp->update(['installation_id' => null]);
|
||||
|
||||
Livewire::test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
||||
->set('selectedGithubAppId', $this->githubApp->id)
|
||||
->call('loadAccessibleRepositories')
|
||||
->assertSet('accessibleRepositories', [])
|
||||
->assertSet('repositoryError', null);
|
||||
});
|
||||
|
||||
test('changing selectedGithubAppId triggers repository reload', function () {
|
||||
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/my-repo', 'name' => 'my-repo'],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
Livewire::test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
||||
->set('selectedGithubAppId', $this->githubApp->id)
|
||||
->assertSet('accessibleRepositories', ['test-org/my-repo']);
|
||||
});
|
||||
});
|
||||
35
tests/Unit/GithubRunnerConfigLabelMatchTest.php
Normal file
35
tests/Unit/GithubRunnerConfigLabelMatchTest.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
use App\Models\GithubRunnerConfig;
|
||||
|
||||
it('matches when all requested labels are present', function () {
|
||||
$config = new GithubRunnerConfig;
|
||||
$config->labels = ['self-hosted', 'linux', 'x64', 'coolify'];
|
||||
|
||||
expect($config->matchesLabels(['self-hosted', 'linux']))->toBeTrue();
|
||||
expect($config->matchesLabels(['self-hosted', 'coolify']))->toBeTrue();
|
||||
expect($config->matchesLabels(['self-hosted']))->toBeTrue();
|
||||
});
|
||||
|
||||
it('does not match when a requested label is missing', function () {
|
||||
$config = new GithubRunnerConfig;
|
||||
$config->labels = ['self-hosted', 'linux', 'x64'];
|
||||
|
||||
expect($config->matchesLabels(['self-hosted', 'gpu']))->toBeFalse();
|
||||
expect($config->matchesLabels(['self-hosted', 'arm64']))->toBeFalse();
|
||||
});
|
||||
|
||||
it('matches labels case-insensitively', function () {
|
||||
$config = new GithubRunnerConfig;
|
||||
$config->labels = ['self-hosted', 'Linux', 'X64'];
|
||||
|
||||
expect($config->matchesLabels(['Self-Hosted', 'linux']))->toBeTrue();
|
||||
expect($config->matchesLabels(['SELF-HOSTED', 'x64']))->toBeTrue();
|
||||
});
|
||||
|
||||
it('matches when requesting empty labels', function () {
|
||||
$config = new GithubRunnerConfig;
|
||||
$config->labels = ['self-hosted'];
|
||||
|
||||
expect($config->matchesLabels([]))->toBeTrue();
|
||||
});
|
||||
23
tests/Unit/GithubRunnerStatusTest.php
Normal file
23
tests/Unit/GithubRunnerStatusTest.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\GithubRunnerStatus;
|
||||
|
||||
it('identifies active statuses correctly', function () {
|
||||
expect(GithubRunnerStatus::Queued->isActive())->toBeTrue();
|
||||
expect(GithubRunnerStatus::Provisioning->isActive())->toBeTrue();
|
||||
expect(GithubRunnerStatus::Running->isActive())->toBeTrue();
|
||||
expect(GithubRunnerStatus::Cleaning->isActive())->toBeTrue();
|
||||
});
|
||||
|
||||
it('identifies inactive statuses correctly', function () {
|
||||
expect(GithubRunnerStatus::Completed->isActive())->toBeFalse();
|
||||
expect(GithubRunnerStatus::Failed->isActive())->toBeFalse();
|
||||
expect(GithubRunnerStatus::TimedOut->isActive())->toBeFalse();
|
||||
});
|
||||
|
||||
it('creates from string values', function () {
|
||||
expect(GithubRunnerStatus::from('queued'))->toBe(GithubRunnerStatus::Queued);
|
||||
expect(GithubRunnerStatus::from('running'))->toBe(GithubRunnerStatus::Running);
|
||||
expect(GithubRunnerStatus::from('completed'))->toBe(GithubRunnerStatus::Completed);
|
||||
expect(GithubRunnerStatus::from('timed_out'))->toBe(GithubRunnerStatus::TimedOut);
|
||||
});
|
||||
Loading…
Reference in a new issue