coolify/app/Livewire/Server/GithubRunners.php
Andras Bacsai 576f38da1c 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
2026-03-03 14:27:19 +01:00

355 lines
11 KiB
PHP

<?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');
}
}