feat(github-runners): add ephemeral self-hosted runners on build servers (#12054)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-09-30 12:13:11 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 03a056709d
commit fd85b740f5
40 changed files with 3605 additions and 20 deletions
+165
View File
@@ -0,0 +1,165 @@
<?php
namespace App\Actions\Server;
use App\Models\Server;
use Lorisleiva\Actions\Concerns\AsAction;
use RuntimeException;
use Spatie\Activitylog\Models\Activity;
/**
* Installs the Sysbox container runtime (https://github.com/nestybox/sysbox) from the pinned .deb package.
* Sysbox lets GitHub runners use Docker without privileged containers.
*
* The package restarts Docker, or stops when containers exist, unless /etc/docker/daemon.json already sets
* "bip" and "default-address-pools". The installer writes the current values of both first, so the package
* only reloads Docker and running containers are not affected.
*/
class InstallSysbox
{
use AsAction;
public const RUNTIME = 'sysbox-runc';
/** ID-mapped mounts make shiftfs unnecessary from this kernel version. */
private const MINIMUM_KERNEL = '5.12';
private const DAEMON_CONFIG = '/etc/docker/daemon.json';
public function handle(Server $server): Activity
{
if (! filled(instant_remote_process(['command -v apt-get || true'], $server))) {
throw new RuntimeException('Automatic Sysbox installation is only available on Debian and Ubuntu servers. Install Sysbox manually: https://github.com/nestybox/sysbox/blob/master/docs/user-guide/install-package.md');
}
$architecture = trim((string) instant_remote_process(['dpkg --print-architecture'], $server));
if (! in_array($architecture, ['amd64', 'arm64'], true)) {
throw new RuntimeException("Sysbox packages are not available for the {$architecture} architecture.");
}
$kernel = trim((string) instant_remote_process(['uname -r'], $server));
if (! self::isKernelSupported($kernel)) {
throw new RuntimeException('Sysbox needs Linux kernel '.self::MINIMUM_KERNEL." or newer. This server runs {$kernel}.");
}
$daemonConfig = self::daemonConfigWithNetworkSettings(
instant_remote_process(['cat '.self::DAEMON_CONFIG.' 2>/dev/null || true'], $server),
(string) instant_remote_process(["docker network inspect bridge --format '{{range .IPAM.Config}}{{.Gateway}} {{.Subnet}}{{end}}'"], $server),
);
return remote_process(self::installCommands($architecture, $daemonConfig), $server);
}
/**
* Sysbox must be registered in Docker, its services must run, and FUSE 3 must be available.
*/
public static function isInstalled(Server $server): bool
{
$runtimes = instant_remote_process([self::installedCheckCommand()], $server, false);
return in_array(self::RUNTIME, explode(' ', trim((string) $runtimes)), true);
}
/**
* Prints the Docker runtimes when the Sysbox services run and FUSE 3 is available.
*/
public static function installedCheckCommand(): string
{
return "systemctl is-active --quiet sysbox-mgr sysbox-fs && test -x /usr/bin/fusermount3 && docker info --format '{{range \$name, \$runtime := .Runtimes}}{{\$name}} {{end}}'";
}
public static function isKernelSupported(string $kernel): bool
{
if (! preg_match('/\A(\d+)\.(\d+)/', $kernel, $matches)) {
return false;
}
return version_compare("{$matches[1]}.{$matches[2]}", self::MINIMUM_KERNEL, '>=');
}
/**
* Returns the Docker daemon configuration with "bip" and "default-address-pools" set to the values that
* Docker already uses, or null when both are already set.
*
* @param string $bridge "<gateway> <subnet>" of the default bridge network, for example "172.17.0.1 172.17.0.0/16".
*/
public static function daemonConfigWithNetworkSettings(?string $currentConfig, string $bridge): ?string
{
$config = filled(trim((string) $currentConfig)) ? json_decode($currentConfig) : new \stdClass;
if (! $config instanceof \stdClass) {
throw new RuntimeException(self::DAEMON_CONFIG.' is not a valid JSON object. Fix it, then install Sysbox again.');
}
$hasBip = filled($config->bip ?? null);
$hasAddressPools = filled($config->{'default-address-pools'} ?? null);
if ($hasBip && $hasAddressPools) {
return null;
}
if (! $hasBip) {
if (! preg_match('/\A(\d{1,3}(?:\.\d{1,3}){3}) \d{1,3}(?:\.\d{1,3}){3}\/(\d{1,2})\z/', trim($bridge), $matches)) {
throw new RuntimeException('Could not read the address of the default Docker bridge network.');
}
$config->bip = "{$matches[1]}/{$matches[2]}";
}
if (! $hasAddressPools) {
$config->{'default-address-pools'} = self::dockerDefaultAddressPools();
}
// Sysbox checks both keys with a regex that expects one key per indented line.
return json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n";
}
/**
* @return array<int, string>
*/
public static function installCommands(string $architecture, ?string $daemonConfig): array
{
$version = config('constants.github_runner.sysbox.version');
$checksum = config("constants.github_runner.sysbox.checksums.{$architecture}");
$package = "/tmp/sysbox-ce_{$version}_{$architecture}.deb";
$url = "https://github.com/nestybox/sysbox/releases/download/v{$version}/sysbox-ce_{$version}.linux_{$architecture}.deb";
$apt = 'DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=300';
$commands = ["echo 'Installing Sysbox {$version}.'"];
if ($daemonConfig !== null) {
$commands[] = "echo 'Saving the current Docker network settings in ".self::DAEMON_CONFIG." so that Docker does not restart.'";
$commands[] = 'mkdir -p /etc/docker';
$commands[] = 'cp '.self::DAEMON_CONFIG.' '.self::DAEMON_CONFIG.'.before-sysbox 2>/dev/null || true';
$commands[] = 'echo '.base64_encode($daemonConfig).' | base64 -d | tee '.self::DAEMON_CONFIG.' > /dev/null';
}
return [
...$commands,
"echo 'Downloading the Sysbox package.'",
"curl -fsSL -o {$package} {$url}",
"echo '{$checksum} {$package}' | sha256sum -c -",
"echo 'Installing the Sysbox package. Docker reloads its configuration, running containers are not restarted.'",
"{$apt} update",
// Sysbox needs fusermount3, but its "fuse" dependency is satisfied by FUSE 2 on Debian 12.
"{$apt} install -y fuse3 {$package}",
"rm -f {$package}",
'systemctl is-active --quiet sysbox-mgr sysbox-fs',
'test -x /usr/bin/fusermount3',
"docker info --format '{{range \$name, \$runtime := .Runtimes}}{{\$name}} {{end}}' | grep -qw ".self::RUNTIME,
"echo 'Sysbox is installed.'",
];
}
/**
* The address pools that Docker uses when "default-address-pools" is not set.
*
* @return array<int, array{base: string, size: int}>
*/
private static function dockerDefaultAddressPools(): array
{
$pools = [];
foreach (range(17, 31) as $octet) {
$pools[] = ['base' => "172.{$octet}.0.0/16", 'size' => 16];
}
$pools[] = ['base' => '192.168.0.0/16', 'size' => 20];
return $pools;
}
}
+2
View File
@@ -11,6 +11,7 @@ use App\Jobs\CleanupOrphanedPreviewContainersJob;
use App\Jobs\CleanupStaleMultiplexedConnections;
use App\Jobs\PullChangelog;
use App\Jobs\PullTemplatesFromCDN;
use App\Jobs\ReconcileGithubRunnersJob;
use App\Jobs\RegenerateSslCertJob;
use App\Jobs\RevalidateUnusableS3StoragesJob;
use App\Jobs\ServerManagerJob;
@@ -65,6 +66,7 @@ class Kernel extends ConsoleKernel
$this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer();
$this->scheduleInstance->job(new CheckMissingDatabaseBackupsJob)->hourly()->onOneServer();
$this->scheduleInstance->job(new RevalidateUnusableS3StoragesJob)->hourly()->onOneServer();
$this->scheduleInstance->job(new ReconcileGithubRunnersJob)->everyMinute()->onOneServer();
if (isDev()) {
// Instance Jobs
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace App\Enums;
enum GithubRunnerDockerMode: string
{
/** Jobs get their own privileged Docker daemon in a sidecar container. */
case Dind = 'dind';
/** Jobs get their own Docker daemon in an unprivileged Sysbox sidecar container. */
case Sysbox = 'sysbox';
/** Jobs get no Docker access. */
case None = 'none';
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Enums;
enum GithubRunnerStatus: string
{
case Queued = 'queued';
case Provisioning = 'provisioning';
case Idle = 'idle';
case Running = 'running';
case Completed = 'completed';
case Failed = 'failed';
case Cancelled = 'cancelled';
case TimedOut = 'timed_out';
/**
* States that still need work from Coolify.
*
* @return array<int, self>
*/
public static function active(): array
{
return [self::Queued, self::Provisioning, self::Idle, self::Running];
}
/**
* States in which a runner container exists on a server and uses its capacity.
*
* @return array<int, self>
*/
public static function occupying(): array
{
return [self::Provisioning, self::Idle, self::Running];
}
public function isActive(): bool
{
return in_array($this, self::active(), true);
}
public function label(): string
{
return match ($this) {
self::Idle => 'Waiting for job',
self::TimedOut => 'Timed out',
default => ucfirst($this->value),
};
}
}
@@ -772,6 +772,13 @@ class ServersController extends Controller
], 422);
}
if ($serverRole !== null && $serverRole !== ServerRole::BUILD && $server->hasEnabledGithubRunners()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['server_role' => ['Disable the GitHub runners before you change the role of this server.']],
], 422);
}
if ($serverRole === ServerRole::DEPLOYMENT && ! ModelsServer::buildServers($teamId)->whereKeyNot($server->id)->exists()) {
return response()->json([
'message' => 'Validation failed.',
+86
View File
@@ -2,20 +2,26 @@
namespace App\Http\Controllers\Webhook;
use App\Enums\GithubRunnerStatus;
use App\Exceptions\InvalidWebhookPayloadException;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
use App\Http\Controllers\Webhook\Concerns\ReadsWebhookPushPayload;
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\GithubRunnerConfig;
use App\Models\GithubRunnerExecution;
use App\Models\PrivateKey;
use Exception;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
@@ -255,6 +261,83 @@ class Github extends Controller
}
}
/**
* Handles GitHub Actions runner demand. Every lookup is scoped to the App whose signature was verified.
* GitHub can give a job to any idle runner with matching labels, so progress is matched by runner name.
*/
private function handleWorkflowJob(GithubApp $githubApp, Collection $payload)
{
$action = data_get($payload, 'action');
$jobId = (int) data_get($payload, 'workflow_job.id', 0);
$runnerName = (string) data_get($payload, 'workflow_job.runner_name', '');
if ($jobId <= 0) {
return response('Nothing to do. No workflow job found.');
}
$jobDetails = array_filter([
'workflow_job_id' => $jobId,
'workflow_job_html_url' => data_get($payload, 'workflow_job.html_url'),
'workflow_name' => data_get($payload, 'workflow_job.workflow_name'),
'job_name' => data_get($payload, 'workflow_job.name'),
'repository_full_name' => data_get($payload, 'repository.full_name'),
], fn ($value) => filled($value));
if ($action === 'queued') {
$labels = array_values(array_filter((array) data_get($payload, 'workflow_job.labels', []), 'is_string'));
$matches = $githubApp->runnerConfigs()
->where('is_enabled', true)
->get()
->contains(fn (GithubRunnerConfig $config) => $config->matchesLabels($labels));
if (! $matches) {
return response('Nothing to do. No runner configuration matches the job labels.');
}
$execution = GithubRunnerExecution::createOrFirst(
['github_app_id' => $githubApp->id, 'trigger_workflow_job_id' => $jobId],
[...$jobDetails, 'labels' => $labels, 'status' => GithubRunnerStatus::Queued, 'queued_at' => now()],
);
if ($execution->wasRecentlyCreated) {
ProvisionGithubRunnerJob::dispatch($execution->id);
}
return response('Runner queued.');
}
$execution = filled($runnerName)
? GithubRunnerExecution::query()->where('github_app_id', $githubApp->id)->where('runner_name', $runnerName)->first()
: null;
if ($action === 'in_progress' && $execution && in_array($execution->status, [GithubRunnerStatus::Provisioning, GithubRunnerStatus::Idle], true)) {
$execution->update([...$jobDetails, 'status' => GithubRunnerStatus::Running, 'started_at' => now()]);
return response('Runner marked running.');
}
if ($action === 'completed') {
if ($execution) {
$execution->update([...$jobDetails, 'conclusion' => data_get($payload, 'workflow_job.conclusion')]);
if ($execution->isActive() || $execution->status === GithubRunnerStatus::Failed) {
$execution->finish(GithubRunnerStatus::Completed);
CleanupGithubRunnerJob::dispatch($execution->id);
}
return response('Runner cleanup queued.');
}
// The job ended without a Coolify runner, so its queued runner is no longer needed.
GithubRunnerExecution::query()
->where('github_app_id', $githubApp->id)
->where('trigger_workflow_job_id', $jobId)
->where('status', GithubRunnerStatus::Queued)
->first()
?->finish(GithubRunnerStatus::Cancelled, 'The job ended before a runner started.');
return response('Queued runner cancelled.');
}
return response('Nothing to do.');
}
public function normal(Request $request)
{
try {
@@ -306,6 +389,9 @@ class Github extends Controller
return response('cool');
}
if ($x_github_event === 'workflow_job') {
return $this->handleWorkflowJob($github_app, $payload);
}
if ($x_github_event === 'push') {
$id = $this->webhookPayloadDatabaseId($payload, 'repository.id');
$branch = $this->webhookPushBranch(data_get($payload, 'ref'));
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace App\Jobs;
use App\Models\GithubRunnerExecution;
use App\Services\GithubRunner\GithubRunnerApi;
use App\Services\GithubRunner\GithubRunnerContainer;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
/**
* Removes the Docker resources of a finished runner, removes the runner from GitHub,
* and gives the free capacity to the next queued execution.
*/
class CleanupGithubRunnerJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 1;
public $timeout = 120;
public function __construct(public int $executionId)
{
$this->onQueue('high');
}
public function handle(): void
{
$execution = GithubRunnerExecution::query()->with(['server', 'githubApp'])->find($this->executionId);
if (! $execution) {
return;
}
if ($execution->server) {
instant_remote_process(GithubRunnerContainer::cleanupCommands($execution->uuid), $execution->server, false);
}
if ($execution->runner_id && $execution->githubApp) {
try {
(new GithubRunnerApi($execution->githubApp))->deleteRunner($execution->runner_id);
} catch (\Throwable $e) {
Log::warning('Could not remove GitHub runner', ['execution' => $execution->uuid, 'error' => $e->getMessage()]);
}
}
ProvisionGithubRunnerJob::dispatchQueued($execution->github_app_id);
}
}
+3
View File
@@ -45,6 +45,9 @@ 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->actions = data_get($permissions, 'actions');
$this->github_app->webhook_events = data_get($response, 'events', []);
$this->github_app->save();
$this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret');
+177
View File
@@ -0,0 +1,177 @@
<?php
namespace App\Jobs;
use App\Enums\GithubRunnerStatus;
use App\Models\GithubRunnerConfig;
use App\Models\GithubRunnerExecution;
use App\Models\Server;
use App\Services\GithubRunner\GithubRunnerApi;
use App\Services\GithubRunner\GithubRunnerContainer;
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\DB;
use Illuminate\Support\Sleep;
use RuntimeException;
/**
* Starts one ephemeral runner for a queued execution on the least busy matching build server.
* When no server has free capacity, the execution stays queued and is retried by
* CleanupGithubRunnerJob or ReconcileGithubRunnersJob. A runner that fails to start is removed and
* started again, possibly on another server, up to MAX_ATTEMPTS times.
*/
class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 1;
public $timeout = 900;
private const DOCKER_READY_ATTEMPTS = 60;
public const MAX_ATTEMPTS = 3;
private const RETRY_DELAY_SECONDS = 30;
public function __construct(public int $executionId)
{
$this->onQueue('high');
}
/**
* Starts provisioning for the oldest queued executions of a GitHub App.
*/
public static function dispatchQueued(int $githubAppId, int $limit = 10): void
{
GithubRunnerExecution::query()
->where('github_app_id', $githubAppId)
->where('status', GithubRunnerStatus::Queued)
->orderBy('id')
->limit($limit)
->pluck('id')
->each(fn (int $id) => self::dispatch($id));
}
public function handle(): void
{
$execution = $this->reserveServer();
if (! $execution) {
return;
}
try {
$config = $execution->config;
$server = $execution->server;
$api = new GithubRunnerApi($execution->githubApp);
$runnerGroupId = $execution->githubApp->runner_group_id ?: $api->ensureRunnerGroup()['id'];
$container = new GithubRunnerContainer($execution, $config);
instant_remote_process($container->prepareCommands(), $server, timeout: $this->timeout);
if ($container->usesDind()) {
$this->waitForDocker($container, $server);
}
$runner = $api->generateJitConfig($container->name(), $config->registeredLabels(), $runnerGroupId);
$execution->update(['runner_id' => $runner['runner_id']]);
instant_remote_process($container->startCommands($runner['encoded_jit_config']), $server);
$execution->update(['ready_at' => now()]);
GithubRunnerExecution::query()
->whereKey($execution->id)
->where('status', GithubRunnerStatus::Provisioning)
->update(['status' => GithubRunnerStatus::Idle]);
} catch (\Throwable $e) {
$this->handleFailure($execution, $e);
}
}
/**
* Removes the failed runner and queues the job again, or marks it failed after the last attempt.
* GitHub keeps the job queued, so a later runner with matching labels can still take it.
*/
private function handleFailure(GithubRunnerExecution $execution, \Throwable $e): void
{
$attempt = $execution->provision_attempts;
if ($attempt >= self::MAX_ATTEMPTS) {
$execution->finish(GithubRunnerStatus::Failed, "Could not start a runner after {$attempt} attempts: {$e->getMessage()}");
CleanupGithubRunnerJob::dispatch($execution->id);
return;
}
// The retry keeps the execution uuid, so its Docker resources must be gone before it starts again.
(new CleanupGithubRunnerJob($execution->id))->handle();
$execution->update([
'status' => GithubRunnerStatus::Queued,
'github_runner_config_id' => null,
'server_id' => null,
'runner_name' => null,
'runner_id' => null,
'ready_at' => null,
'error_message' => "Attempt {$attempt} of ".self::MAX_ATTEMPTS." failed: {$e->getMessage()}",
]);
self::dispatch($execution->id)->delay(now()->addSeconds(self::RETRY_DELAY_SECONDS * $attempt));
}
/**
* Picks the least busy matching server and moves the execution to "provisioning".
* Row locks on the execution and on the App's configs stop parallel jobs from overbooking a server.
*/
private function reserveServer(): ?GithubRunnerExecution
{
return DB::transaction(function () {
$execution = GithubRunnerExecution::query()->whereKey($this->executionId)->lockForUpdate()->first();
if (! $execution || $execution->status !== GithubRunnerStatus::Queued) {
return null;
}
$config = GithubRunnerConfig::query()
->where('github_app_id', $execution->github_app_id)
->where('is_enabled', true)
->orderBy('id')
->lockForUpdate()
->with('server.settings')
->get()
->filter(fn (GithubRunnerConfig $config) => $config->server !== null
&& $config->matchesLabels($execution->labels ?? [])
&& $config->server->isBuildServer()
&& ! $config->server->isLocalhost()
&& $config->server->isFunctional())
->map(fn (GithubRunnerConfig $config) => ['config' => $config, 'occupied' => $config->occupiedRunnerCount()])
->filter(fn (array $candidate) => $candidate['occupied'] < $candidate['config']->max_runners)
->sortBy('occupied')
->first()['config'] ?? null;
if (! $config) {
return null;
}
$execution->update([
'github_runner_config_id' => $config->id,
'server_id' => $config->server_id,
'status' => GithubRunnerStatus::Provisioning,
'runner_name' => GithubRunnerContainer::nameFor($execution->uuid),
'provision_attempts' => $execution->provision_attempts + 1,
]);
return $execution;
});
}
private function waitForDocker(GithubRunnerContainer $container, Server $server): void
{
for ($attempt = 0; $attempt < self::DOCKER_READY_ATTEMPTS; $attempt++) {
if (filled(instant_remote_process([$container->dockerReadyCommand()], $server, false))) {
return;
}
Sleep::for(1)->second();
}
throw new RuntimeException('The Docker sidecar did not start within '.self::DOCKER_READY_ATTEMPTS.' seconds.');
}
}
+153
View File
@@ -0,0 +1,153 @@
<?php
namespace App\Jobs;
use App\Enums\GithubRunnerStatus;
use App\Models\GithubRunnerConfig;
use App\Models\GithubRunnerExecution;
use App\Models\Server;
use App\Services\GithubRunner\GithubRunnerContainer;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
/**
* Compares runner executions with the runner containers on each build server. It cleans up
* stopped and orphaned runners, applies the wait, idle, and job timeouts, and restarts
* provisioning for queued executions whose dispatch was lost.
*/
class ReconcileGithubRunnersJob implements ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 1;
public $timeout = 300;
private const PROVISIONING_TIMEOUT_MINUTES = 20;
public function __construct()
{
$this->onQueue('high');
}
public function uniqueFor(): int
{
return $this->timeout;
}
public function handle(): void
{
Server::query()
->whereHas('githubRunnerConfig')
->with('settings')
->get()
->filter(fn (Server $server) => $server->isFunctional())
->each(fn (Server $server) => $this->reconcileServer($server));
$this->reconcileQueued();
}
private function reconcileServer(Server $server): void
{
$output = instant_remote_process([GithubRunnerContainer::listCommand()], $server, false);
if ($output === null) {
return;
}
$containers = $this->parseContainers($output);
$executions = GithubRunnerExecution::query()
->where('server_id', $server->id)
->whereIn('status', GithubRunnerStatus::occupying())
->with('config')
->get();
foreach ($executions as $execution) {
$failure = $this->failureFor($execution, $containers->get($execution->uuid, []));
if ($failure) {
$execution->finish(...$failure);
CleanupGithubRunnerJob::dispatch($execution->id);
}
}
$containers->keys()
->diff($executions->pluck('uuid'))
->each(fn (string $uuid) => instant_remote_process(GithubRunnerContainer::cleanupCommands($uuid), $server, false));
}
/**
* @param array<string, string> $containerStates container name => state
* @return array{0: GithubRunnerStatus, 1: string}|null
*/
private function failureFor(GithubRunnerExecution $execution, array $containerStates): ?array
{
if ($execution->status === GithubRunnerStatus::Provisioning) {
return $execution->updated_at->lt(now()->subMinutes(self::PROVISIONING_TIMEOUT_MINUTES))
? [GithubRunnerStatus::Failed, 'Provisioning did not finish in '.self::PROVISIONING_TIMEOUT_MINUTES.' minutes.']
: null;
}
$state = $containerStates[GithubRunnerContainer::nameFor($execution->uuid)] ?? null;
if (! in_array($state, ['running', 'restarting', 'paused'], true)) {
return [GithubRunnerStatus::Failed, 'The runner container stopped before GitHub reported a job result.'];
}
$config = $execution->config;
if ($execution->status === GithubRunnerStatus::Idle && $config
&& ($execution->ready_at ?? $execution->updated_at)->lt(now()->subMinutes($config->idle_timeout))) {
return [GithubRunnerStatus::Cancelled, "No job was assigned to the runner within {$config->idle_timeout} minutes."];
}
if ($execution->status === GithubRunnerStatus::Running && $config
&& ($execution->started_at ?? $execution->updated_at)->lt(now()->subMinutes($config->job_timeout))) {
return [GithubRunnerStatus::TimedOut, "The job ran longer than {$config->job_timeout} minutes."];
}
return null;
}
/**
* @return Collection<string, array<string, string>> execution uuid => [container name => state]
*/
private function parseContainers(string $output): Collection
{
$containers = collect();
foreach (preg_split('/\R/', trim($output)) as $line) {
$parts = preg_split('/\s+/', trim($line));
if (count($parts) !== 3 || ! preg_match('/\A[a-z0-9]+\z/', $parts[0])) {
continue;
}
[$uuid, $name, $state] = $parts;
$containers->put($uuid, [...$containers->get($uuid, []), $name => $state]);
}
return $containers;
}
private function reconcileQueued(): void
{
$queued = GithubRunnerExecution::query()
->where('status', GithubRunnerStatus::Queued)
->get()
->groupBy('github_app_id');
foreach ($queued as $githubAppId => $executions) {
$timeout = (int) (GithubRunnerConfig::query()
->where('github_app_id', $githubAppId)
->where('is_enabled', true)
->max('capacity_wait_timeout') ?? 0);
foreach ($executions as $execution) {
if (($execution->queued_at ?? $execution->created_at)->lt(now()->subMinutes($timeout))) {
$execution->finish(GithubRunnerStatus::TimedOut, "No build server had free capacity within {$timeout} minutes.");
}
}
ProvisionGithubRunnerJob::dispatchQueued($githubAppId);
}
}
}
@@ -0,0 +1,76 @@
<?php
namespace App\Livewire\Server;
use App\Enums\GithubRunnerStatus;
use App\Jobs\CleanupGithubRunnerJob;
use App\Models\GithubRunnerExecution;
use App\Models\Server;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Computed;
use Livewire\Component;
class GithubRunnerExecutions extends Component
{
use AuthorizesRequests;
public Server $server;
public function mount(Server $server): void
{
$this->authorize('view', $server);
$this->server = $server;
}
/**
* Runners of this server, plus queued jobs of its GitHub App that wait for free capacity.
*/
#[Computed]
public function executions(): Collection
{
return $this->query()->latest('id')->limit(25)->get();
}
public function cancel(int $executionId)
{
$this->authorize('update', $this->server);
try {
$execution = $this->query()->whereKey($executionId)->firstOrFail();
if (! $execution->isActive()) {
return;
}
$hadServer = $execution->server_id !== null;
$execution->finish(GithubRunnerStatus::Cancelled, 'Cancelled by a user.');
if ($hadServer) {
CleanupGithubRunnerJob::dispatch($execution->id);
}
unset($this->executions);
$this->dispatch('success', 'Runner cancelled.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
private function query(): Builder
{
$githubAppId = $this->server->githubRunnerConfig()->value('github_app_id');
return GithubRunnerExecution::query()->where(function (Builder $query) use ($githubAppId) {
$query->where('server_id', $this->server->id);
if ($githubAppId) {
$query->orWhere(fn (Builder $queued) => $queued
->whereNull('server_id')
->where('status', GithubRunnerStatus::Queued)
->where('github_app_id', $githubAppId));
}
});
}
public function render()
{
return view('livewire.server.github-runner-executions');
}
}
+291
View File
@@ -0,0 +1,291 @@
<?php
namespace App\Livewire\Server;
use App\Actions\Server\InstallSysbox;
use App\Enums\GithubRunnerDockerMode;
use App\Enums\GithubRunnerStatus;
use App\Jobs\CleanupGithubRunnerJob;
use App\Jobs\ProvisionGithubRunnerJob;
use App\Models\GithubApp;
use App\Models\GithubRunnerConfig;
use App\Models\GithubRunnerExecution;
use App\Models\Server;
use App\Rules\DockerImageFormat;
use App\Services\GithubRunner\GithubRunnerApi;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Computed;
use Livewire\Component;
class GithubRunners extends Component
{
use AuthorizesRequests;
/** Labels that GitHub or Coolify already set, so they cannot select Coolify runners alone. */
private const RESERVED_LABELS = ['self-hosted', 'linux', 'x64', 'arm64', 'arm', 'windows', 'macos'];
public Server $server;
public array $parameters = [];
public ?int $githubAppId = null;
public string $labels = 'coolify';
public int $maxRunners = 2;
public string $dockerMode = 'none';
public ?string $runnerImage = null;
public ?string $cpuLimit = null;
public ?string $memoryLimit = null;
public int $capacityWaitTimeout = 60;
public int $idleTimeout = 10;
public int $jobTimeout = 360;
public bool $isDedicated = false;
/** Null until checked over SSH. */
public ?bool $isSysboxInstalled = null;
protected $listeners = ['sysboxInstalled' => 'checkSysbox'];
public function mount(string $server_uuid)
{
try {
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
$this->authorize('view', $this->server);
if ($this->server->isLocalhost()) {
return redirect()->route('server.show', ['server_uuid' => $this->server->uuid]);
}
$this->parameters = get_route_parameters();
$this->syncData();
} catch (\Throwable) {
return redirect()->route('server.index');
}
}
protected function rules(): array
{
return [
'githubAppId' => ['required', 'integer', Rule::in($this->githubApps->pluck('id')->all())],
'labels' => ['required', 'string', 'max:1000'],
'maxRunners' => ['required', 'integer', 'min:1', 'max:32'],
'dockerMode' => ['required', Rule::enum(GithubRunnerDockerMode::class)],
'runnerImage' => ['nullable', 'string', 'max:255', new DockerImageFormat],
'cpuLimit' => ['nullable', 'string', 'regex:/\A\d+(\.\d+)?\z/'],
'memoryLimit' => ['nullable', 'string', 'regex:/\A\d+[bkmg]?\z/i'],
'capacityWaitTimeout' => ['required', 'integer', 'min:1', 'max:1440'],
'idleTimeout' => ['required', 'integer', 'min:1', 'max:1440'],
'jobTimeout' => ['required', 'integer', 'min:1', 'max:7200'],
'isDedicated' => ['required', 'boolean'],
];
}
/**
* Organization GitHub Apps of the server's team. System-wide Apps of other teams are not allowed.
*/
#[Computed]
public function githubApps(): Collection
{
return GithubApp::query()
->where('team_id', $this->server->team_id)
->whereNotNull('app_id')
->whereNotNull('installation_id')
->whereNotNull('organization')
->where('organization', '!=', '')
->orderBy('name')
->get();
}
#[Computed]
public function selectedApp(): ?GithubApp
{
return $this->githubApps->firstWhere('id', $this->githubAppId);
}
#[Computed]
public function config(): ?GithubRunnerConfig
{
return $this->server->githubRunnerConfig()->first();
}
private function syncData(): void
{
$config = $this->config;
if (! $config) {
$apps = $this->githubApps;
$this->githubAppId = ($apps->first(fn (GithubApp $app) => $app->missingRunnerRequirements() === []) ?? $apps->first())?->id;
return;
}
$this->githubAppId = $config->github_app_id;
$this->labels = implode(', ', $config->labels ?? []);
$this->maxRunners = $config->max_runners;
$this->dockerMode = $config->docker_mode->value;
$this->runnerImage = $config->runner_image;
$this->cpuLimit = $config->cpu_limit;
$this->memoryLimit = $config->memory_limit;
$this->capacityWaitTimeout = $config->capacity_wait_timeout;
$this->idleTimeout = $config->idle_timeout;
$this->jobTimeout = $config->job_timeout;
$this->isDedicated = $config->is_dedicated;
}
/**
* @return array<int, string>
*/
private function parseLabels(): array
{
$labels = collect(explode(',', $this->labels))
->map(fn (string $label) => strtolower(trim($label)))
->filter()
->unique()
->values();
foreach ($labels as $label) {
if (! preg_match('/\A[a-z0-9][a-z0-9._-]{0,99}\z/', $label)) {
throw ValidationException::withMessages(['labels' => "The label \"{$label}\" can only contain letters, numbers, dots, dashes, and underscores."]);
}
}
$custom = $labels->reject(fn (string $label) => in_array($label, self::RESERVED_LABELS, true))->values();
if ($custom->isEmpty()) {
throw ValidationException::withMessages(['labels' => 'Add at least one custom label, for example "coolify". Coolify only takes jobs that ask for a custom label.']);
}
return $custom->all();
}
public function submit()
{
$this->authorize('update', $this->server);
$this->validate();
$labels = $this->parseLabels();
$githubApp = $this->selectedApp;
$this->authorize('update', $githubApp);
if ($this->dockerMode === GithubRunnerDockerMode::Sysbox->value && ! InstallSysbox::isInstalled($this->server)) {
$this->isSysboxInstalled = false;
throw ValidationException::withMessages(['dockerMode' => 'Sysbox is not installed on this server. Install it, then save again.']);
}
try {
if (! $this->server->isBuildServer()) {
throw new \RuntimeException('GitHub Actions runners can only run on servers with the Build role.');
}
if ($this->config?->is_enabled ?? true) {
$this->prepareRunnerGroup($githubApp);
}
GithubRunnerConfig::updateOrCreate(['server_id' => $this->server->id], [
'github_app_id' => $githubApp->id,
'labels' => $labels,
'max_runners' => $this->maxRunners,
'docker_mode' => $this->dockerMode,
'runner_image' => filled($this->runnerImage) ? trim($this->runnerImage) : null,
'cpu_limit' => filled($this->cpuLimit) ? $this->cpuLimit : null,
'memory_limit' => filled($this->memoryLimit) ? strtolower($this->memoryLimit) : null,
'capacity_wait_timeout' => $this->capacityWaitTimeout,
'idle_timeout' => $this->idleTimeout,
'job_timeout' => $this->jobTimeout,
'is_dedicated' => $this->isDedicated,
]);
unset($this->config);
$this->syncData();
$this->dispatch('success', 'GitHub runner settings saved.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
/**
* Starts or stops taking workflow jobs on this server. The first enable saves the default settings.
* Disabling removes the idle runners so that GitHub cannot give them new jobs. Running jobs continue
* until they finish.
*/
public function toggleEnabled()
{
$this->authorize('update', $this->server);
try {
$config = $this->config;
if (! $config) {
return $this->submit();
}
if ($config->is_enabled) {
$config->update(['is_enabled' => false]);
$config->executions()->where('status', GithubRunnerStatus::Idle)->get()
->each(function (GithubRunnerExecution $execution) {
$execution->finish(GithubRunnerStatus::Cancelled, 'Runners were disabled on this server.');
CleanupGithubRunnerJob::dispatch($execution->id);
});
$this->dispatch('success', 'GitHub runners disabled. Running jobs continue until they finish.');
} else {
if (! $this->server->isBuildServer()) {
throw new \RuntimeException('GitHub Actions runners can only run on servers with the Build role.');
}
$this->authorize('update', $config->githubApp);
$this->prepareRunnerGroup($config->githubApp);
$config->update(['is_enabled' => true]);
ProvisionGithubRunnerJob::dispatchQueued($config->github_app_id);
$this->dispatch('success', 'GitHub runners enabled.');
}
unset($this->config);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function checkSysbox(): void
{
$this->authorize('view', $this->server);
$this->isSysboxInstalled = InstallSysbox::isInstalled($this->server);
}
public function installSysbox()
{
$this->authorize('update', $this->server);
try {
$activity = InstallSysbox::run($this->server);
$this->dispatch('sysbox-install-started');
$this->dispatch('activityMonitor', $activity->id, 'sysboxInstalled');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
/**
* Creates the runner group when the App can run workflow jobs. An App that is not ready is saved anyway:
* the page lists what it misses, and the runner group is created when the first job is provisioned.
*/
private function prepareRunnerGroup(GithubApp $githubApp): void
{
if ($githubApp->missingRunnerRequirements() !== []) {
$this->dispatch('warning', 'The GitHub App is not ready for runners. Coolify cannot take workflow jobs until you update its permissions on GitHub.');
return;
}
$group = (new GithubRunnerApi($githubApp))->ensureRunnerGroup();
if ($group['is_default']) {
$this->dispatch('warning', 'Runners use the Default runner group of the organization. Check which repositories can use that group on GitHub.');
}
}
public function render()
{
return view('livewire.server.github-runners');
}
}
+8 -1
View File
@@ -284,7 +284,7 @@ class Show extends Component
$this->serverRole = $this->server->settings->effectiveServerRole()->value;
$this->isMetricsEnabled = $this->server->settings->is_metrics_enabled;
$this->sentinelToken = auth()->user()->can('update', $this->server)
? $this->server->settings->sentinel_token
? $this->server->settings->ensureValidSentinelToken()
: '';
$this->sentinelMetricsRefreshRateSeconds = $this->server->settings->sentinel_metrics_refresh_rate_seconds;
$this->sentinelMetricsHistoryDays = $this->server->settings->sentinel_metrics_history_days;
@@ -456,6 +456,13 @@ class Show extends Component
$newRole = ServerRole::from($this->serverRole);
$currentRole = $this->server->settings()->firstOrFail()->effectiveServerRole();
if ($newRole !== ServerRole::BUILD && $this->server->hasEnabledGithubRunners()) {
$this->serverRole = $currentRole->value;
$this->dispatch('error', 'Disable the GitHub runners before you change the role of this server.');
return;
}
if ($newRole === ServerRole::BUILD && ! $this->server->isEmpty()) {
$this->serverRole = $currentRole->value;
$this->dispatch('error', 'Move or remove the existing resources before you set this server to build only.');
+18
View File
@@ -36,6 +36,8 @@ class Change extends Component
public ?bool $administration = false;
public ?bool $github_runners = false;
public $parameters;
public ?GithubApp $github_app = null;
@@ -308,6 +310,10 @@ class Change extends Component
// Sync data from model to properties
$this->syncData(false);
if (! $this->github_app->app_id) {
$this->preview_deployment_permissions = request()->boolean('previews', true);
$this->github_runners = filled($this->github_app->organization) && request()->boolean('runners');
}
$this->isConnected = $this->github_app->isConnected();
// Override name with kebab case for display
@@ -481,4 +487,16 @@ class Change extends Component
return handleError($e, $this);
}
}
/**
* After a delete, the confirmation modal refreshes the component before the redirect runs.
*/
public function render()
{
if (! $this->github_app) {
return '<div></div>';
}
return view('livewire.source.github.change');
}
}
+14 -2
View File
@@ -26,6 +26,10 @@ class Create extends Component
public bool $is_system_wide = false;
public bool $use_for_pull_request_previews = true;
public bool $use_for_github_runners = false;
private bool $shouldDeriveApiUrlAfterHtmlUrlUpdate = false;
public function mount()
@@ -58,12 +62,16 @@ class Create extends Component
$this->validate([
'name' => 'required|string',
'organization' => ['nullable', 'string', 'regex:/\A[^\s\/?#]+\z/'],
'organization' => [$this->use_for_github_runners ? 'required' : 'nullable', 'string', 'regex:/\A[^\s\/?#]+\z/'],
'api_url' => ['required', 'string', 'url', SafeExternalUrl::forGitSource()],
'html_url' => ['required', 'string', 'url', SafeExternalUrl::forGitSource()],
'custom_user' => 'required|string',
'custom_port' => 'required|int',
'is_system_wide' => 'required|bool',
'use_for_pull_request_previews' => 'required|bool',
'use_for_github_runners' => 'required|bool',
], [
'organization.required' => 'GitHub Actions runners are registered at organization level. Enter the organization.',
]);
$payload = [
'name' => $this->name,
@@ -80,7 +88,11 @@ class Create extends Component
session(['from' => session('from') + ['source_id' => $github_app->id]]);
}
return redirectRoute($this, 'source.github.show', ['github_app_uuid' => $github_app->uuid]);
return redirectRoute($this, 'source.github.show', [
'github_app_uuid' => $github_app->uuid,
'previews' => (int) $this->use_for_pull_request_previews,
'runners' => (int) $this->use_for_github_runners,
]);
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
+45
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use App\Enums\GithubRunnerStatus;
use App\Traits\Auditable;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Support\Facades\DB;
@@ -35,6 +36,10 @@ class GithubApp extends BaseModel
'metadata',
'pull_requests',
'administration',
'organization_self_hosted_runners',
'actions',
'webhook_events',
'runner_group_id',
];
protected $appends = ['type'];
@@ -43,6 +48,8 @@ class GithubApp extends BaseModel
'is_public' => 'boolean',
'is_system_wide' => 'boolean',
'type' => 'string',
'webhook_events' => 'array',
'runner_group_id' => 'integer',
];
protected $hidden = [
@@ -58,6 +65,15 @@ class GithubApp extends BaseModel
throw new \Exception('You cannot delete this GitHub App because it is in use by '.$applications_count.' application(s). Delete them first.');
}
// Runner rows cascade with the App, so live runner containers would lose their cleanup and keep taking jobs.
$hasLiveRunners = $github_app->runnerConfigs()->where('is_enabled', true)->exists()
|| GithubRunnerExecution::where('github_app_id', $github_app->id)
->whereIn('status', GithubRunnerStatus::occupying())
->exists();
if ($hasLiveRunners) {
throw new \Exception('You cannot delete this GitHub App because servers use it for GitHub Actions runners. Disable the runners on these servers and wait for running jobs to finish first.');
}
$privateKey = $github_app->privateKey;
if ($privateKey) {
// Check if key is used by anything EXCEPT this GitHub app
@@ -97,6 +113,35 @@ class GithubApp extends BaseModel
return $this->belongsTo(PrivateKey::class);
}
public function runnerConfigs()
{
return $this->hasMany(GithubRunnerConfig::class);
}
/**
* Permissions and webhook events that GitHub Actions runners need but the App does not have.
*
* @return array<int, string>
*/
public function missingRunnerRequirements(): array
{
$missing = [];
if (blank($this->organization)) {
$missing[] = 'An organization (runners are registered at organization level)';
}
if ($this->organization_self_hosted_runners !== 'write') {
$missing[] = 'Organization permission "Self-hosted runners": write';
}
if (! in_array($this->actions, ['read', 'write'], true)) {
$missing[] = 'Repository permission "Actions": read';
}
if (! in_array('workflow_job', $this->webhook_events ?? [], true)) {
$missing[] = 'Webhook event "Workflow job"';
}
return $missing;
}
public function type(): Attribute
{
return Attribute::make(
+99
View File
@@ -0,0 +1,99 @@
<?php
namespace App\Models;
use App\Enums\GithubRunnerDockerMode;
use App\Enums\GithubRunnerStatus;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class GithubRunnerConfig extends BaseModel
{
/**
* Labels that every Coolify runner registers in addition to the custom labels.
*/
public const DEFAULT_LABELS = ['self-hosted', 'linux'];
protected $fillable = [
'server_id',
'github_app_id',
'labels',
'is_enabled',
'is_dedicated',
'max_runners',
'docker_mode',
'runner_image',
'cpu_limit',
'memory_limit',
'capacity_wait_timeout',
'idle_timeout',
'job_timeout',
];
protected function casts(): array
{
return [
'labels' => 'array',
'is_enabled' => 'boolean',
'is_dedicated' => 'boolean',
'max_runners' => 'integer',
'docker_mode' => GithubRunnerDockerMode::class,
'capacity_wait_timeout' => 'integer',
'idle_timeout' => 'integer',
'job_timeout' => 'integer',
];
}
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);
}
/**
* Labels sent to GitHub when a runner is registered.
*
* @return array<int, string>
*/
public function registeredLabels(): array
{
return array_values(array_unique([...self::DEFAULT_LABELS, ...array_map('strtolower', $this->labels ?? [])]));
}
/**
* A job matches when it asks for at least one custom label and every label it asks for is registered.
* Jobs that only ask for generic labels such as "self-hosted" are left for other runners.
*
* @param array<int, string> $requestedLabels
*/
public function matchesLabels(array $requestedLabels): bool
{
$requested = array_map(fn ($label) => strtolower((string) $label), $requestedLabels);
$custom = array_map('strtolower', $this->labels ?? []);
if (array_intersect($requested, $custom) === []) {
return false;
}
return array_diff($requested, $this->registeredLabels()) === [];
}
public function occupiedRunnerCount(): int
{
return $this->executions()->whereIn('status', GithubRunnerStatus::occupying())->count();
}
public function runnerImage(): string
{
return filled($this->runner_image) ? $this->runner_image : config('constants.github_runner.image');
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
namespace App\Models;
use App\Enums\GithubRunnerStatus;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class GithubRunnerExecution extends BaseModel
{
protected $fillable = [
'github_app_id',
'github_runner_config_id',
'server_id',
'status',
'trigger_workflow_job_id',
'workflow_job_id',
'workflow_job_html_url',
'workflow_name',
'job_name',
'repository_full_name',
'labels',
'runner_name',
'runner_id',
'conclusion',
'error_message',
'provision_attempts',
'queued_at',
'ready_at',
'started_at',
'completed_at',
];
protected function casts(): array
{
return [
'status' => GithubRunnerStatus::class,
'trigger_workflow_job_id' => 'integer',
'workflow_job_id' => 'integer',
'runner_id' => 'integer',
'provision_attempts' => 'integer',
'labels' => 'array',
'queued_at' => 'datetime',
'ready_at' => 'datetime',
'started_at' => 'datetime',
'completed_at' => 'datetime',
];
}
public function githubApp(): BelongsTo
{
return $this->belongsTo(GithubApp::class);
}
public function config(): BelongsTo
{
return $this->belongsTo(GithubRunnerConfig::class, 'github_runner_config_id');
}
public function server(): BelongsTo
{
return $this->belongsTo(Server::class);
}
public function isActive(): bool
{
return $this->status->isActive();
}
/**
* Base name of the Docker resources (containers, network, volumes) of this runner.
*/
public function containerName(): string
{
return 'coolify-runner-'.$this->uuid;
}
/**
* Marks the runner as finished and keeps the first completion time.
*/
public function finish(GithubRunnerStatus $status, ?string $errorMessage = null): void
{
$this->update([
'status' => $status,
'error_message' => $errorMessage,
'completed_at' => $this->completed_at ?? now(),
]);
}
public function duration(): ?string
{
if (! $this->started_at) {
return null;
}
return $this->started_at->diffForHumans($this->completed_at ?? now(), true);
}
public function workflowJobUrl(): ?string
{
$url = trim((string) $this->workflow_job_html_url);
return str_starts_with($url, 'https://') ? $url : null;
}
}
+19 -2
View File
@@ -987,7 +987,8 @@ $siteAddress {
/**
* Usable dedicated (build-only) servers of a team. Servers with the combined role
* host deployments, so they are never picked as build servers.
* host deployments, so they are never picked as build servers. Servers dedicated to
* GitHub Actions runners are also left out.
*/
public static function buildServers($teamId): Builder
{
@@ -995,7 +996,8 @@ $siteAddress {
->whereRelation('settings', 'is_reachable', true)
->whereRelation('settings', 'is_usable', true)
->whereRelation('settings', 'is_swarm_worker', false)
->whereRelation('settings', 'force_disabled', false);
->whereRelation('settings', 'force_disabled', false)
->whereDoesntHave('githubRunnerConfig', fn (Builder $config) => $config->where('is_enabled', true)->where('is_dedicated', true));
return self::whereServerRole($query, ServerRole::BUILD);
}
@@ -1507,6 +1509,21 @@ $siteAddress {
return $standalone_docker->concat($swarm_docker);
}
public function githubRunnerConfig()
{
return $this->hasOne(GithubRunnerConfig::class);
}
public function githubRunnerExecutions()
{
return $this->hasMany(GithubRunnerExecution::class);
}
public function hasEnabledGithubRunners(): bool
{
return $this->githubRunnerConfig()->where('is_enabled', true)->exists();
}
public function standaloneDockers()
{
return $this->hasMany(StandaloneDocker::class);
@@ -0,0 +1,108 @@
<?php
namespace App\Services\GithubRunner;
use App\Models\GithubApp;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/**
* Organization-level GitHub Actions runner API calls made with the App installation token.
*/
class GithubRunnerApi
{
public function __construct(private GithubApp $githubApp) {}
/**
* Finds or creates the runner group of the App. The group is limited to private repositories.
* All GitHub plans can create runner groups, so a failed creation is an error. Coolify never falls back
* to the Default group, because that group can give the runners access to more repositories.
*
* @return array{id: int, is_default: bool}
*/
public function ensureRunnerGroup(): array
{
$org = $this->organization();
if ($this->githubApp->runner_group_id) {
$response = $this->client()->get("/orgs/{$org}/actions/runner-groups/{$this->githubApp->runner_group_id}");
if ($response->successful()) {
return ['id' => (int) $this->githubApp->runner_group_id, 'is_default' => (bool) $response->json('default')];
}
if ($response->status() !== 404) {
throw new RuntimeException('Could not read the runner group: '.$this->errorMessage($response));
}
}
$response = $this->client()->post("/orgs/{$org}/actions/runner-groups", [
'name' => 'Coolify '.$this->githubApp->uuid,
'visibility' => 'private',
'allows_public_repositories' => false,
]);
if (! $response->successful() || ! $response->json('id')) {
throw new RuntimeException('Could not create a runner group: '.$this->errorMessage($response));
}
$group = ['id' => (int) $response->json('id'), 'is_default' => false];
$this->githubApp->update(['runner_group_id' => $group['id']]);
return $group;
}
/**
* @param array<int, string> $labels
* @return array{runner_id: int, encoded_jit_config: string}
*/
public function generateJitConfig(string $runnerName, array $labels, int $runnerGroupId): array
{
$response = $this->client()->post("/orgs/{$this->organization()}/actions/runners/generate-jitconfig", [
'name' => $runnerName,
'runner_group_id' => $runnerGroupId,
'labels' => $labels,
'work_folder' => '_work',
]);
if (! $response->successful() || blank($response->json('encoded_jit_config'))) {
throw new RuntimeException('Could not register the runner on GitHub: '.$this->errorMessage($response));
}
return [
'runner_id' => (int) $response->json('runner.id'),
'encoded_jit_config' => (string) $response->json('encoded_jit_config'),
];
}
/**
* Removes a runner from GitHub. GitHub removes JIT runners itself after their job, so 404 is not an error.
*/
public function deleteRunner(int $runnerId): void
{
$response = $this->client()->delete("/orgs/{$this->organization()}/actions/runners/{$runnerId}");
if (! $response->successful() && $response->status() !== 404) {
throw new RuntimeException('Could not remove the runner from GitHub: '.$this->errorMessage($response));
}
}
private function client(): PendingRequest
{
return Http::GitHub($this->githubApp->api_url, generateGithubInstallationToken($this->githubApp))->timeout(30);
}
private function organization(): string
{
$organization = trim((string) $this->githubApp->organization);
if ($organization === '') {
throw new RuntimeException('GitHub Actions runners need a GitHub App that belongs to an organization.');
}
return rawurlencode($organization);
}
private function errorMessage(Response $response): string
{
return (string) ($response->json('message') ?? "HTTP {$response->status()}");
}
}
@@ -0,0 +1,196 @@
<?php
namespace App\Services\GithubRunner;
use App\Actions\Server\InstallSysbox;
use App\Enums\GithubRunnerDockerMode;
use App\Models\GithubRunnerConfig;
use App\Models\GithubRunnerExecution;
/**
* Builds the remote shell commands for one ephemeral runner. Each runner gets its own network,
* volumes, and (in dind and sysbox modes) a private Docker daemon. The host Docker socket is never mounted.
* Every command is a single line that is safe for parseCommandsByLineForSudo().
*/
class GithubRunnerContainer
{
public const LABEL = 'coolify.githubRunner';
public const EXECUTION_LABEL = 'coolify.githubRunnerExecution';
public const SECRETS_DIRECTORY = '/data/coolify/github-runners';
/** Group id of "docker" in the runner image. */
private const RUNNER_DOCKER_GID = 123;
private const RUNNER_UID = 1001;
private const PIDS_LIMIT = 4096;
public function __construct(private GithubRunnerExecution $execution, private GithubRunnerConfig $config) {}
public static function nameFor(string $executionUuid): string
{
return 'coolify-runner-'.$executionUuid;
}
public function name(): string
{
return self::nameFor($this->execution->uuid);
}
public function dindName(): string
{
return $this->name().'-dind';
}
public function usesDind(): bool
{
return $this->config->docker_mode !== GithubRunnerDockerMode::None;
}
/**
* Creates the network and volumes, pulls missing images, and starts the Docker sidecar.
*
* @return array<int, string>
*/
public function prepareCommands(): array
{
$name = $this->name();
$labels = $this->labelFlags();
$commands = [
"docker network create {$labels} {$name}",
$this->pullOrUseLocal($this->config->runnerImage()),
];
if (! $this->usesDind()) {
return $commands;
}
foreach (['sock', 'work', 'externals'] as $volume) {
$commands[] = "docker volume create {$labels} {$name}-{$volume}";
}
$commands[] = $this->pullIfMissing(config('constants.github_runner.dind_image'));
$isolation = $this->config->docker_mode === GithubRunnerDockerMode::Sysbox
? '--runtime='.InstallSysbox::RUNTIME
: '--privileged';
$commands[] = "docker run -d {$isolation}"
." --name {$this->dindName()} --network {$name} {$labels} {$this->limitFlags()}"
." {$this->sharedVolumeFlags()} "
.escapeshellarg(config('constants.github_runner.dind_image'))
.' dockerd --host=unix:///var/run/docker.sock --group='.self::RUNNER_DOCKER_GID;
return $commands;
}
/**
* Prints the version of the sidecar Docker daemon once it accepts connections.
*/
public function dockerReadyCommand(): string
{
return "docker exec {$this->dindName()} docker version --format '{{.Server.Version}}'";
}
/**
* Writes the JIT config to a root-only env file, starts the runner, and deletes the file.
* The secret is never part of the docker run command line.
*
* @return array<int, string>
*/
public function startCommands(string $encodedJitConfig): array
{
$name = $this->name();
$envFile = $this->envFile();
$environment = base64_encode("ACTIONS_RUNNER_INPUT_JITCONFIG={$encodedJitConfig}\n");
$commands = [
'mkdir -p '.self::SECRETS_DIRECTORY,
"echo {$environment} | base64 -d | tee {$envFile} > /dev/null",
"chmod 600 {$envFile}",
];
$runnerFlags = "--name {$name} --network {$name} {$this->labelFlags()} {$this->limitFlags()} --env-file {$envFile}";
if ($this->usesDind()) {
$commands[] = "docker exec {$this->dindName()} chown ".self::RUNNER_UID.':'.self::RUNNER_DOCKER_GID.' /home/runner/_work';
$runnerFlags .= ' -e DOCKER_HOST=unix:///var/run/docker.sock -e RUNNER_WAIT_FOR_DOCKER_IN_SECONDS=120 '.$this->sharedVolumeFlags();
}
$commands[] = "docker run -d {$runnerFlags} ".escapeshellarg($this->config->runnerImage()).' /home/runner/run.sh';
$commands[] = "rm -f {$envFile}";
return $commands;
}
/**
* Removes every Docker resource and the secret file of a runner. Missing resources are ignored.
*
* @return array<int, string>
*/
public static function cleanupCommands(string $executionUuid): array
{
$name = self::nameFor($executionUuid);
return [
"docker rm -f -v {$name} {$name}-dind > /dev/null 2>&1 || true",
"docker volume rm {$name}-sock {$name}-work {$name}-externals > /dev/null 2>&1 || true",
"docker network rm {$name} > /dev/null 2>&1 || true",
'rm -f '.self::SECRETS_DIRECTORY."/{$executionUuid}.env",
];
}
/**
* Lists runner containers as "<execution uuid> <container name> <state>" lines.
*/
public static function listCommand(): string
{
return 'docker ps -a --filter label='.self::LABEL.'=true --format \'{{.Label "'.self::EXECUTION_LABEL.'"}} {{.Names}} {{.State}}\'';
}
private function envFile(): string
{
return self::SECRETS_DIRECTORY."/{$this->execution->uuid}.env";
}
private function labelFlags(): string
{
return '--label '.self::LABEL.'=true --label '.self::EXECUTION_LABEL.'='.$this->execution->uuid;
}
private function limitFlags(): string
{
$flags = ['--pids-limit '.self::PIDS_LIMIT];
if (filled($this->config->cpu_limit)) {
$flags[] = '--cpus '.escapeshellarg($this->config->cpu_limit);
}
if (filled($this->config->memory_limit)) {
$memory = escapeshellarg($this->config->memory_limit);
$flags[] = "--memory {$memory} --memory-swap {$memory}";
}
return implode(' ', $flags);
}
private function sharedVolumeFlags(): string
{
$name = $this->name();
return "-v {$name}-sock:/var/run -v {$name}-work:/home/runner/_work -v {$name}-externals:/home/runner/externals";
}
/**
* Pulls the newest version of a moving tag such as "latest", or keeps the local image when the
* registry cannot be reached.
*/
private function pullOrUseLocal(string $image): string
{
$image = escapeshellarg($image);
return "docker pull {$image} || docker image inspect {$image} > /dev/null";
}
private function pullIfMissing(string $image): string
{
$image = escapeshellarg($image);
return "docker image inspect {$image} > /dev/null 2>&1 || docker pull {$image}";
}
}
+14
View File
@@ -117,6 +117,20 @@ return [
'connect_networks_interval_seconds' => env('PROXY_CONNECT_NETWORKS_INTERVAL_SECONDS', 3600),
],
'github_runner' => [
// GitHub stops sending jobs to runners that are more than 30 days behind, so the default follows
// "latest" and is pulled for every runner. Users can pin a tag in the runner settings.
'image' => env('GITHUB_RUNNER_IMAGE', 'ghcr.io/actions/actions-runner:latest'),
'dind_image' => env('GITHUB_RUNNER_DIND_IMAGE', 'docker:29.8-dind'),
'sysbox' => [
'version' => '0.7.1',
'checksums' => [
'amd64' => '9d6d5484f980d0a17f86c492c1262015c2afb66280bdb97215b79fde6a0261c5',
'arm64' => '04ca894ae0b53f0fa54eaacc173ce40363c9a95ea5450f773716a84ef650a69b',
],
],
],
'webhooks' => [
'feedback_discord_webhook' => env('FEEDBACK_DISCORD_WEBHOOK'),
'dev_webhook' => env('SERVEO_URL'),
@@ -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
{
/**
* Stores the GitHub Actions runner permissions, webhook events, and runner group of a GitHub App.
*/
public function up(): void
{
Schema::table('github_apps', function (Blueprint $table) {
$table->string('organization_self_hosted_runners')->nullable()->after('administration');
$table->string('actions')->nullable()->after('organization_self_hosted_runners');
$table->json('webhook_events')->nullable()->after('actions');
$table->unsignedBigInteger('runner_group_id')->nullable()->after('webhook_events');
});
}
public function down(): void
{
Schema::table('github_apps', function (Blueprint $table) {
$table->dropColumn(['organization_self_hosted_runners', 'actions', 'webhook_events', 'runner_group_id']);
});
}
};
@@ -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
{
/**
* One GitHub Actions runner configuration per build server.
*/
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');
$table->boolean('is_enabled')->default(true);
$table->boolean('is_dedicated')->default(false);
$table->unsignedSmallInteger('max_runners')->default(2);
$table->string('docker_mode')->default('none');
$table->string('runner_image')->nullable();
$table->string('cpu_limit')->nullable();
$table->string('memory_limit')->nullable();
$table->unsignedInteger('capacity_wait_timeout')->default(60);
$table->unsignedInteger('idle_timeout')->default(10);
$table->unsignedInteger('job_timeout')->default(360);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('github_runner_configs');
}
};
@@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* One row per ephemeral runner. The unique trigger job id stops duplicate webhook
* deliveries from starting a second runner.
*/
public function up(): void
{
Schema::create('github_runner_executions', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->foreignId('github_app_id')->constrained()->cascadeOnDelete();
$table->foreignId('github_runner_config_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('server_id')->nullable()->constrained()->nullOnDelete();
$table->string('status')->default('queued');
$table->unsignedBigInteger('trigger_workflow_job_id');
$table->unsignedBigInteger('workflow_job_id')->nullable();
$table->string('workflow_job_html_url')->nullable();
$table->string('workflow_name')->nullable();
$table->string('job_name')->nullable();
$table->string('repository_full_name')->nullable();
$table->json('labels')->nullable();
$table->string('runner_name')->nullable();
$table->unsignedBigInteger('runner_id')->nullable();
$table->string('conclusion')->nullable();
$table->text('error_message')->nullable();
$table->unsignedSmallInteger('provision_attempts')->default(0);
$table->timestamp('queued_at')->nullable();
$table->timestamp('ready_at')->nullable();
$table->timestamp('started_at')->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamps();
$table->unique(['github_app_id', 'trigger_workflow_job_id']);
$table->index(['github_app_id', 'runner_name']);
$table->index(['github_app_id', 'status']);
$table->index(['github_runner_config_id', 'status']);
});
}
public function down(): void
{
Schema::dropIfExists('github_runner_executions');
}
};
@@ -0,0 +1,6 @@
<span {{ $attributes->merge(['class' => 'inline-flex items-center']) }}>
<span
class="px-2 py-0.5 text-xs font-medium leading-normal rounded-full bg-coollabs/10 text-coollabs border border-coollabs/30 dark:bg-warning/15 dark:text-warning dark:border-warning/30">
Beta
</span>
</span>
@@ -135,6 +135,15 @@
'group' => 'Operations',
'visible' => $server->isFunctional(),
],
[
'label' => 'GitHub Runners',
'route' => 'server.github-runners',
'active' => $activeMenu === 'github-runners',
'icon' => 'play-circle',
'group' => 'Operations',
'visible' => ! $server->isLocalhost(),
'beta' => true,
],
[
'label' => 'Registries',
'route' => 'server.registries',
@@ -274,6 +283,8 @@
@elseif ($menuItem['warning'] ?? false)
<x-reicon name="alert-triangle"
class="ml-auto size-3.5 shrink-0 text-orange-500 dark:text-warning" />
@elseif ($menuItem['beta'] ?? false)
<x-beta-badge class="ml-auto shrink-0" />
@endif
</a>
@if ($menuItem['active'] && isset($menuItem['children']))
@@ -0,0 +1,55 @@
<div wire:poll.10s>
@forelse ($this->executions as $execution)
<div wire:key="github-runner-execution-{{ $execution->uuid }}"
class="flex items-center gap-4 border-b border-neutral-200 px-4 py-3 last:border-b-0 dark:border-white/[0.08]">
<x-status-badge :status="$execution->status->label()" :type="match ($execution->status) {
\App\Enums\GithubRunnerStatus::Completed => $execution->conclusion === 'success' ? 'success' : 'neutral',
\App\Enums\GithubRunnerStatus::Failed, \App\Enums\GithubRunnerStatus::TimedOut => 'error',
\App\Enums\GithubRunnerStatus::Cancelled => 'neutral',
default => 'warning',
}" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-neutral-950 dark:text-fg">
{{ $execution->repository_full_name ?? 'Unknown repository' }}
@if ($execution->workflow_name || $execution->job_name)
<span class="font-normal text-neutral-500 dark:text-fg-dim">
· {{ collect([$execution->workflow_name, $execution->job_name])->filter()->implode(' / ') }}
</span>
@endif
</p>
<p class="mt-0.5 truncate text-xs text-neutral-500 dark:text-fg-dim">
{{ $execution->created_at->diffForHumans() }}
@if ($execution->duration())
· ran {{ $execution->duration() }}
@endif
@if ($execution->conclusion)
· {{ $execution->conclusion }}
@endif
@if ($execution->error_message)
· {{ $execution->error_message }}
@endif
</p>
</div>
<div class="flex shrink-0 items-center gap-2">
@if ($execution->workflowJobUrl())
<a href="{{ $execution->workflowJobUrl() }}" target="_blank" rel="noopener noreferrer"
class="button">
Open
<x-external-link />
</a>
@endif
@if ($execution->isActive())
@can('update', $server)
<x-forms.button wire:click="cancel({{ $execution->id }})"
wire:confirm="Cancel this runner? A running job fails.">
Cancel
</x-forms.button>
@endcan
@endif
</div>
</div>
@empty
<x-empty size="sm" title="No runners yet"
description="Workflow jobs that ask for this server's labels appear here." icon-name="servers" />
@endforelse
</div>
@@ -0,0 +1,187 @@
<div>
<x-slot:title>
{{ data_get_str($server, 'name')->limit(10) }} > GitHub Runners | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div
class="server-settings-workspace application-settings-workspace mt-4 grid w-full max-w-none min-w-0 gap-8 lg:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
<x-server.sidebar :server="$server" activeMenu="github-runners" />
<div class="application-settings-form flex w-full flex-col gap-6">
@if (!$server->isBuildServer())
<x-application.settings-section id="github-runners-section" title="GitHub Actions runners"
helper="Each workflow job runs in a new container that is deleted after the job.">
<x-slot:actions>
<x-beta-badge />
<a class="button" href="{{ route('server.show', ['server_uuid' => $server->uuid]) }}"
{{ wireNavigate() }}>
General settings
<x-external-link />
</a>
</x-slot:actions>
<x-empty size="sm" title="This server is not enabled for builds"
description="GitHub Actions runners only run on servers with the Build role. Change the server role in the General settings."
icon-name="play-circle" />
</x-application.settings-section>
@elseif ($this->githubApps->isEmpty())
<x-callout type="info" title="No organization GitHub App">
Runners are registered at organization level. Add a GitHub App that belongs to an organization
under Sources, and select "Run workflow jobs on build servers" when you register it.
</x-callout>
@elseif (! $this->config?->is_enabled)
<x-application.settings-section id="github-runners-section" title="GitHub Actions runners"
helper="Each workflow job runs in a new container that is deleted after the job.">
<x-slot:actions>
<x-beta-badge />
</x-slot:actions>
<x-empty size="sm" title="Runners are disabled"
description="Enable runners to take GitHub Actions workflow jobs on this server."
icon-name="play-circle">
<x-slot:contents>
<x-forms.button canGate="update" :canResource="$server" isHighlighted
wire:click="toggleEnabled" wire:loading.attr="disabled" wire:target="toggleEnabled">
Enable runners
</x-forms.button>
</x-slot:contents>
</x-empty>
</x-application.settings-section>
@else
<form wire:submit="submit" class="contents">
<x-unsaved-bar action="submit"
targets="githubAppId,labels,maxRunners,dockerMode,runnerImage,cpuLimit,memoryLimit,capacityWaitTimeout,idleTimeout,jobTimeout,isDedicated" />
<x-application.settings-section id="github-runners-section" title="GitHub Actions runners"
helper="Each workflow job runs in a new container that is deleted after the job. Use runs-on: [self-hosted, <your label>] in the workflow.">
<x-slot:actions>
<x-beta-badge />
@can('update', $server)
<x-forms.button wire:click="toggleEnabled" wire:loading.attr="disabled"
wire:target="toggleEnabled">
Disable runners
</x-forms.button>
@endcan
</x-slot:actions>
<div x-cloak x-show="$wire.dockerMode === 'dind'">
<x-callout type="warning" title="Use only for trusted, private repositories">
In the privileged Docker mode, a malicious workflow can take control of this server.
Use Isolated Docker (Sysbox) or No Docker to keep jobs away from the server. Coolify
limits the runner group to private repositories.
</x-callout>
</div>
@if ($this->selectedApp && $this->selectedApp->missingRunnerRequirements() !== [])
<x-callout type="danger" title="The GitHub App is not ready" class="mt-4">
Update the App on GitHub, then click Refetch on the App's Permissions page. Missing:
<ul class="mt-1 list-disc pl-4">
@foreach ($this->selectedApp->missingRunnerRequirements() as $requirement)
<li>{{ $requirement }}</li>
@endforeach
</ul>
</x-callout>
@endif
<div class="mt-4 grid gap-4 lg:grid-cols-2">
<x-forms.listbox id="githubAppId" label="GitHub App" required canGate="update"
:canResource="$server" :options="$this->githubApps
->map(fn($app) => ['value' => $app->id, 'label' => $app->name . ' (' . $app->organization . ')'])
->values()
->all()"
helper="Organization GitHub App that receives the Workflow job webhook events." />
<x-forms.input id="labels" label="Labels" required canGate="update" :canResource="$server"
placeholder="coolify"
helper="Comma-separated custom labels. Coolify also registers self-hosted and linux. Jobs must ask for at least one custom label." />
<x-forms.listbox id="isDedicated" label="Application builds" canGate="update"
:canResource="$server" :options="[
['value' => false, 'label' => 'Also build applications'],
['value' => true, 'label' => 'Dedicated to runners'],
]"
helper="Dedicated servers are not used for application builds while runners are enabled." />
</div>
</x-application.settings-section>
<x-application.settings-section id="github-runners-resources-section" title="Resources"
helper="Limits apply to the runner container and to its Docker sidecar.">
<div class="grid gap-4 lg:grid-cols-3">
<x-forms.input id="maxRunners" type="number" min="1" max="32" label="Parallel runners"
required canGate="update" :canResource="$server"
helper="Jobs wait in the queue when all runners are busy." />
<x-forms.input id="cpuLimit" label="CPU limit" placeholder="2" canGate="update"
:canResource="$server" helper="Number of CPUs for each runner. Empty means no limit." />
<x-forms.input id="memoryLimit" label="Memory limit" placeholder="4g" canGate="update"
:canResource="$server" helper="Memory for each runner, for example 4g. Empty means no limit." />
<x-forms.listbox id="dockerMode" label="Docker in jobs" canGate="update"
:canResource="$server" :options="[
['value' => 'dind', 'label' => 'Docker (privileged, full host access)'],
['value' => 'sysbox', 'label' => 'Isolated Docker (Sysbox)'],
['value' => 'none', 'label' => 'No Docker (isolated, no container actions)'],
]"
helper="Docker: each job gets a private Docker daemon. The daemon runs privileged, so a job can get root access to this server. Use it only for trusted repositories.<br><br>Isolated Docker: each job gets a private Docker daemon in an unprivileged Sysbox container. Everything that needs Docker works, and root in the job has no rights on this server. Needs Sysbox on the server.<br><br>No Docker: jobs run in an unprivileged container. JavaScript actions, composite actions, and shell steps work. Docker container actions, services, container jobs, and docker commands fail.<br><br>The host Docker socket is never shared." />
<x-forms.input id="runnerImage" label="Runner image" canGate="update" :canResource="$server"
:placeholder="config('constants.github_runner.image')"
helper="Empty uses the latest official runner image, which Coolify pulls for every runner. Set a tag, for example ghcr.io/actions/actions-runner:2.337.0, to pin a version, or use a custom image based on the official one to add tools. GitHub stops sending jobs to runners that are more than 30 days old." />
</div>
<div x-cloak x-show="$wire.dockerMode === 'sysbox'" class="mt-4"
x-effect="if ($wire.dockerMode === 'sysbox' && $wire.isSysboxInstalled === null) $wire.checkSysbox()">
@if ($isSysboxInstalled === true)
<x-callout type="success" title="Sysbox is installed">
Jobs get a private Docker daemon in an unprivileged container.
</x-callout>
@elseif ($isSysboxInstalled === false)
<x-callout type="warning" title="Sysbox is not installed">
Isolated Docker needs the Sysbox runtime. Coolify installs Sysbox
{{ config('constants.github_runner.sysbox.version') }} from the official package
(Debian and Ubuntu, kernel 5.12 or newer). Docker reloads its configuration, and
running containers are not restarted.
@can('update', $server)
<div class="mt-3">
<x-forms.button type="button" wire:click="installSysbox"
wire:loading.attr="disabled" wire:target="installSysbox">
Install Sysbox
</x-forms.button>
</div>
@endcan
</x-callout>
@else
<x-callout type="info" title="Checking Sysbox">
Coolify checks if Sysbox is installed on this server.
</x-callout>
@endif
</div>
</x-application.settings-section>
<x-application.settings-section id="github-runners-timeouts-section" title="Timeouts"
helper="All values are in minutes.">
<div class="grid gap-4 lg:grid-cols-3">
<x-forms.input id="capacityWaitTimeout" type="number" min="1" max="1440"
label="Queue wait" required canGate="update" :canResource="$server"
helper="A job that waits longer for a free runner is dropped." />
<x-forms.input id="idleTimeout" type="number" min="1" max="1440" label="Idle runner"
required canGate="update" :canResource="$server"
helper="A runner that gets no job in this time is removed." />
<x-forms.input id="jobTimeout" type="number" min="1" max="7200" label="Job" required
canGate="update" :canResource="$server"
helper="A job that runs longer is stopped." />
</div>
</x-application.settings-section>
</form>
@can('update', $server)
<x-process-dialog @sysbox-install-started.window="processDialogOpen = true" closeWithX size="xl">
<x-slot:title>Install Sysbox</x-slot:title>
<x-slot:content>
<livewire:activity-monitor header="Logs" fullHeight />
</x-slot:content>
</x-process-dialog>
@endcan
@endif
@if ($server->isBuildServer() && $this->config?->is_enabled)
<x-application.settings-section id="github-runners-executions-section" title="Recent runners"
helper="Queued jobs, active runners, and their results. The list updates every 10 seconds." flush>
<livewire:server.github-runner-executions :server="$server" />
</x-application.settings-section>
@endif
</div>
</div>
</div>
@@ -260,19 +260,19 @@
@endphp
<div class="grid gap-4 lg:grid-cols-2">
<div class="application-settings-form"
<div class="application-settings-form flex"
x-data="{
webhookEndpoint: $wire.entangle('webhook_endpoint').live,
useCustomWebhookEndpoint: $wire.entangle('use_custom_webhook_endpoint').live,
customWebhookEndpoint: $wire.entangle('custom_webhook_endpoint').live,
}">
<x-application.settings-section title="Automated installation"
<x-application.settings-section title="Automated installation" class="[&>.application-settings-section-body]:flex [&>.application-settings-section-body]:flex-1 [&>.application-settings-section-body]:flex-col"
description="Register through GitHub's manifest flow with permissions and webhooks preconfigured.">
<x-slot:actions>
<x-status-badge label="Recommended" type="success" />
</x-slot:actions>
<div class="flex min-h-[24rem] flex-col gap-4">
<div class="flex min-h-[24rem] flex-1 flex-col gap-4">
@if (!isCloud() || isDev())
<x-forms.listbox id="use_custom_webhook_endpoint" label="Webhook endpoint"
:live="true" :options="[
@@ -310,23 +310,33 @@
]"
helper="Write access lets Coolify post deployment status and links on pull requests." />
<x-forms.listbox id="github_runners" label="GitHub Actions runners"
:disabled="blank($github_app->organization)"
:options="[
['value' => false, 'label' => 'Do not run workflow jobs'],
['value' => true, 'label' => 'Run workflow jobs on build servers'],
]"
:helper="filled($github_app->organization)
? 'Adds the organization Self-hosted runners (write) and Actions (read) permissions and the Workflow job webhook event.'
: 'Only GitHub Apps that belong to an organization can run workflow jobs. Delete this source and create a new one with an organization.'" />
<button type="button"
class="button mt-auto w-full justify-center button-highlighted"
x-on:click.prevent="createGithubApp(webhookEndpoint, useCustomWebhookEndpoint, customWebhookEndpoint, {{ Illuminate\Support\Js::from($preview_deployment_permissions) }}, {{ Illuminate\Support\Js::from($administration) }})">
x-on:click.prevent="createGithubApp(webhookEndpoint, useCustomWebhookEndpoint, customWebhookEndpoint, $wire.preview_deployment_permissions, {{ Illuminate\Support\Js::from($administration) }}, $wire.github_runners)">
Register with GitHub
</button>
</div>
</x-application.settings-section>
</div>
<div class="application-settings-form">
<x-application.settings-section title="Manual installation"
<div class="application-settings-form flex">
<x-application.settings-section title="Manual installation" class="[&>.application-settings-section-body]:flex [&>.application-settings-section-body]:flex-1 [&>.application-settings-section-body]:flex-col"
description="Enter GitHub App credentials manually for GitHub Enterprise or custom permission sets.">
<x-slot:actions>
<x-status-badge label="Advanced" type="neutral" />
</x-slot:actions>
<div class="flex min-h-[24rem] flex-col">
<div class="flex min-h-[24rem] flex-1 flex-col">
<div
class="flex size-10 items-center justify-center rounded-xl border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.035] dark:text-fg-dim">
<x-reicon name="settings" class="size-5" />
@@ -352,7 +362,7 @@
<script>
function createGithubApp(webhook_endpoint, use_custom_webhook_endpoint, custom_webhook_endpoint,
preview_deployment_permissions, administration) {
preview_deployment_permissions, administration, github_runners) {
const {
organization,
html_url
@@ -392,6 +402,11 @@
if (administration) {
default_permissions.administration = 'write';
}
if (github_runners && organization) {
default_permissions.organization_self_hosted_runners = 'write';
default_permissions.actions = 'read';
default_events.push('workflow_job');
}
const data = {
name,
@@ -6,9 +6,11 @@
<div class="grid gap-4 sm:grid-cols-2">
<x-forms.input id="name" label="Name" required />
<x-forms.input id="organization" label="Organization"
helper="If empty, your GitHub user will be used."
placeholder="Personal account when empty" />
<x-forms.input id="organization" label="Organization" :required="$use_for_github_runners"
:helper="$use_for_github_runners
? 'GitHub Actions runners are registered at organization level.'
: 'If empty, your GitHub user will be used. GitHub Actions runners need an organization.'"
:placeholder="$use_for_github_runners ? 'GitHub organization, for example coollabsio' : 'Personal account when empty'" />
</div>
@if (! isCloud())
@@ -25,6 +27,20 @@
</div>
@endif
<div>
<p class="text-[12px] font-medium text-neutral-700 dark:text-fg-dim">Use this App for</p>
<p class="mt-1 text-[12px] leading-5 text-neutral-500 dark:text-fg-dim">
Deploying applications from private repositories is always included. The choices below select the
permissions and webhook events on the next page.
</p>
<div class="mt-2 flex max-w-xs flex-col gap-1">
<x-forms.checkbox id="use_for_pull_request_previews" label="Pull request previews"
helper="Deploys pull requests and posts deployment status on them." />
<x-forms.checkbox id="use_for_github_runners" live label="GitHub Actions runners"
helper="Runs workflow jobs on your build servers. Needs an organization." />
</div>
</div>
<div x-data="{
open: false,
}" class="rounded-lg border border-neutral-200 dark:border-white/[0.08]">
@@ -1,4 +1,4 @@
<div class="application-settings-form">
<div class="application-settings-form flex flex-col gap-6">
<x-application.settings-section title="Permissions"
description="GitHub permissions currently granted to this App.">
<x-slot:actions>
@@ -24,4 +24,32 @@
label="Pull requests" readonly placeholder="N/A" />
</div>
</x-application.settings-section>
@php($missingRunnerRequirements = $github_app->missingRunnerRequirements())
<x-application.settings-section title="GitHub Actions runners"
description="Build servers can run the organization's workflow jobs in Docker containers. Configure them in the GitHub Runners menu of a build server.">
<div class="grid gap-4 lg:grid-cols-3">
<x-forms.input canGate="view" :canResource="$github_app"
label="Self-hosted runners" readonly placeholder="N/A"
:value="$github_app->organization_self_hosted_runners"
helper="Organization permission. Write access is needed to register runners." />
<x-forms.input canGate="view" :canResource="$github_app"
label="Actions" readonly placeholder="N/A" :value="$github_app->actions"
helper="Repository permission. Read access is needed for the Workflow job webhook event." />
<x-forms.input canGate="view" :canResource="$github_app"
label="Webhook events" readonly placeholder="N/A"
:value="implode(', ', $github_app->webhook_events ?? [])"
helper="Refetch to update this list after you change the App on GitHub." />
</div>
@if ($missingRunnerRequirements !== [])
<x-callout type="info" title="Not ready for runners" class="mt-4">
Update the App on GitHub, then click Refetch. Missing:
<ul class="mt-1 list-disc pl-4">
@foreach ($missingRunnerRequirements as $requirement)
<li>{{ $requirement }}</li>
@endforeach
</ul>
</x-callout>
@endif
</x-application.settings-section>
</div>
+2
View File
@@ -66,6 +66,7 @@ use App\Livewire\Server\Destinations as ServerDestinations;
use App\Livewire\Server\DockerCleanup;
use App\Livewire\Server\DockerImages;
use App\Livewire\Server\DockerRegistries as ServerDockerRegistries;
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;
@@ -401,6 +402,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/terminal', ExecuteContainerCommand::class)->name('server.command')->middleware('can.access.terminal');
Route::get('/docker-cleanup', DockerCleanup::class)->name('server.docker-cleanup');
Route::get('/images', DockerImages::class)->name('server.docker-images');
Route::get('/github-runners', GithubRunners::class)->name('server.github-runners');
Route::get('/registries', ServerRegistries::class)->name('server.registries')->middleware('can.update.resource');
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');
+4 -3
View File
@@ -382,9 +382,10 @@ start_backend() {
local host="${DEV_BIND_ADDRESS:-127.0.0.1}"
[[ "$host" == 0.0.0.0 ]] && host=127.0.0.1
echo "Using KVM profile: $profile"
# The host command must reach this instance's database, not the one in .env, and must
# not create root-owned files in storage/logs.
LOG_CHANNEL=stderr DB_HOST="$host" DB_PORT="$(env_value "$instance" FORWARD_DB_PORT)" \
# The host command must reach this instance's database and encrypt with this instance's
# APP_KEY, not the ones in .env, and must not create root-owned files in storage/logs.
LOG_CHANNEL=stderr APP_KEY="$(env_value "$instance" APP_KEY)" \
DB_HOST="$host" DB_PORT="$(env_value "$instance" FORWARD_DB_PORT)" \
REDIS_HOST="$host" REDIS_PORT="$(env_value "$instance" FORWARD_REDIS_PORT)" \
DEVELOPMENT_QEMU_INSTANCE="$instance" \
DEVELOPMENT_QEMU_SLOT="$(env_value "$instance" DEVELOPMENT_QEMU_SLOT)" \
+493
View File
@@ -0,0 +1,493 @@
<?php
use App\Enums\GithubRunnerStatus;
use App\Jobs\CleanupGithubRunnerJob;
use App\Jobs\ProvisionGithubRunnerJob;
use App\Jobs\ReconcileGithubRunnersJob;
use App\Models\GithubApp;
use App\Models\GithubRunnerConfig;
use App\Models\GithubRunnerExecution;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Process\PendingProcess;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Sleep;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
Server::flushIdentityMap();
Sleep::fake();
$this->team = Team::factory()->create();
$this->githubApp = runnerTestGithubApp($this->team, 1111, 'runner-secret');
});
afterEach(function () {
Server::flushIdentityMap();
});
function runnerTestGithubApp(Team $team, int $appId, string $secret): GithubApp
{
$rsaKey = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
openssl_pkey_export($rsaKey, $pemKey);
$privateKey = PrivateKey::create(['name' => 'Runner key', 'private_key' => $pemKey, 'team_id' => $team->id]);
return GithubApp::create([
'name' => "runner-app-{$appId}",
'organization' => 'acme',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => $appId,
'installation_id' => $appId + 1,
'webhook_secret' => $secret,
'private_key_id' => $privateKey->id,
'team_id' => $team->id,
'is_system_wide' => false,
'runner_group_id' => 7,
]);
}
function runnerTestServer(Team $team, string $role = 'build'): Server
{
$server = Server::factory()->create(['team_id' => $team->id]);
$server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'server_role' => $role,
'is_build_server' => $role === 'build',
'force_disabled' => false,
]);
return $server->fresh();
}
function runnerTestConfig(Server $server, GithubApp $githubApp, array $attributes = []): GithubRunnerConfig
{
return GithubRunnerConfig::create([
'server_id' => $server->id,
'github_app_id' => $githubApp->id,
'labels' => ['coolify'],
'max_runners' => 2,
'docker_mode' => 'dind',
...$attributes,
]);
}
function runnerTestExecution(GithubApp $githubApp, array $attributes = []): GithubRunnerExecution
{
static $jobId = 1000;
return GithubRunnerExecution::create([
'github_app_id' => $githubApp->id,
'trigger_workflow_job_id' => ++$jobId,
'labels' => ['self-hosted', 'coolify'],
'status' => GithubRunnerStatus::Queued,
'queued_at' => now(),
...$attributes,
]);
}
function sendWorkflowJobWebhook($test, GithubApp $githubApp, string $secret, string $action, array $job)
{
$body = json_encode([
'action' => $action,
'workflow_job' => [
'id' => 5001,
'labels' => ['self-hosted', 'coolify'],
'html_url' => 'https://github.com/acme/api/actions/runs/1/job/5001',
'workflow_name' => 'CI',
'name' => 'test',
'runner_name' => null,
...$job,
],
'repository' => ['id' => 99, 'full_name' => 'acme/api'],
'organization' => ['login' => 'acme'],
], JSON_THROW_ON_ERROR);
return $test->call('POST', '/webhooks/source/github/events', [], [], [], [
'HTTP_X-GitHub-Event' => 'workflow_job',
'HTTP_X-GitHub-Hook-Installation-Target-Id' => (string) $githubApp->app_id,
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $body, $secret),
'CONTENT_TYPE' => 'application/json',
], $body);
}
function fakeRunnerGithubApi(GithubApp $githubApp): void
{
Http::fake([
'https://api.github.com/zen' => Http::response('ok', 200, ['Date' => now()->toRfc7231String()]),
"https://api.github.com/app/installations/{$githubApp->installation_id}/access_tokens" => Http::response(['token' => 'installation-token'], 201),
'https://api.github.com/orgs/acme/actions/runners/generate-jitconfig' => Http::response([
'runner' => ['id' => 42, 'name' => 'runner'],
'encoded_jit_config' => 'SECRET-JIT-CONFIG',
], 201),
'https://api.github.com/orgs/acme/actions/runners/*' => Http::response(null, 204),
]);
}
describe('workflow_job webhook', function () {
beforeEach(function () {
Queue::fake();
$this->server = runnerTestServer($this->team);
runnerTestConfig($this->server, $this->githubApp);
});
it('creates one queued execution for duplicate deliveries of a queued job', function () {
sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'queued', [])->assertOk();
sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'queued', [])->assertOk();
$execution = GithubRunnerExecution::sole();
expect($execution->status)->toBe(GithubRunnerStatus::Queued)
->and($execution->trigger_workflow_job_id)->toBe(5001)
->and($execution->repository_full_name)->toBe('acme/api');
Queue::assertPushed(ProvisionGithubRunnerJob::class, 1);
});
it('ignores jobs that do not ask for a custom label', function (array $labels) {
sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'queued', ['labels' => $labels])
->assertOk()
->assertSee('No runner configuration matches');
expect(GithubRunnerExecution::count())->toBe(0);
Queue::assertNotPushed(ProvisionGithubRunnerJob::class);
})->with([
'generic self-hosted' => [['self-hosted']],
'unknown label' => [['self-hosted', 'coolify', 'gpu']],
'github hosted' => [['ubuntu-latest']],
]);
it('rejects an invalid signature', function () {
sendWorkflowJobWebhook($this, $this->githubApp, 'wrong-secret', 'queued', [])->assertSee('Invalid signature');
expect(GithubRunnerExecution::count())->toBe(0);
});
it('marks the runner that took the job as running, even when it was started for another job', function () {
$execution = runnerTestExecution($this->githubApp, [
'status' => GithubRunnerStatus::Idle,
'server_id' => $this->server->id,
'runner_name' => 'coolify-runner-abc',
]);
sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'in_progress', [
'id' => 7777,
'runner_name' => 'coolify-runner-abc',
])->assertOk();
$execution->refresh();
expect($execution->status)->toBe(GithubRunnerStatus::Running)
->and($execution->workflow_job_id)->toBe(7777)
->and($execution->started_at)->not->toBeNull();
});
it('completes the runner and queues its cleanup', function () {
$execution = runnerTestExecution($this->githubApp, [
'status' => GithubRunnerStatus::Running,
'server_id' => $this->server->id,
'runner_name' => 'coolify-runner-abc',
]);
sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'completed', [
'runner_name' => 'coolify-runner-abc',
'conclusion' => 'success',
])->assertOk();
$execution->refresh();
expect($execution->status)->toBe(GithubRunnerStatus::Completed)
->and($execution->conclusion)->toBe('success');
Queue::assertPushed(CleanupGithubRunnerJob::class, fn ($job) => $job->executionId === $execution->id);
});
it('does not touch runners of another GitHub App with the same runner name', function () {
$otherTeam = Team::factory()->create();
$otherApp = runnerTestGithubApp($otherTeam, 2222, 'other-secret');
$victim = runnerTestExecution($this->githubApp, [
'status' => GithubRunnerStatus::Running,
'server_id' => $this->server->id,
'runner_name' => 'coolify-runner-abc',
]);
sendWorkflowJobWebhook($this, $otherApp, 'other-secret', 'completed', [
'runner_name' => 'coolify-runner-abc',
'conclusion' => 'cancelled',
])->assertOk();
expect($victim->fresh()->status)->toBe(GithubRunnerStatus::Running);
Queue::assertNotPushed(CleanupGithubRunnerJob::class);
});
it('cancels the queued execution of a job that ended without a Coolify runner', function () {
$execution = runnerTestExecution($this->githubApp, ['trigger_workflow_job_id' => 5001]);
sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'completed', ['conclusion' => 'cancelled'])->assertOk();
expect($execution->fresh()->status)->toBe(GithubRunnerStatus::Cancelled);
});
it('keeps the queued execution when its job ran on a Coolify runner started for another job', function () {
$queued = runnerTestExecution($this->githubApp, ['trigger_workflow_job_id' => 5001]);
runnerTestExecution($this->githubApp, [
'status' => GithubRunnerStatus::Running,
'server_id' => $this->server->id,
'runner_name' => 'coolify-runner-other',
]);
sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'completed', [
'runner_name' => 'coolify-runner-other',
'conclusion' => 'success',
])->assertOk();
expect($queued->fresh()->status)->toBe(GithubRunnerStatus::Queued);
});
});
describe('provisioning', function () {
it('starts a runner on the least busy matching build server', function () {
Queue::fake();
fakeRunnerGithubApi($this->githubApp);
Process::fake(['*' => Process::result(output: '29.8.0')]);
$busyServer = runnerTestServer($this->team);
$busyConfig = runnerTestConfig($busyServer, $this->githubApp);
runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Running, 'server_id' => $busyServer->id, 'github_runner_config_id' => $busyConfig->id]);
$freeServer = runnerTestServer($this->team);
runnerTestConfig($freeServer, $this->githubApp);
$execution = runnerTestExecution($this->githubApp);
(new ProvisionGithubRunnerJob($execution->id))->handle();
$execution->refresh();
expect($execution->status)->toBe(GithubRunnerStatus::Idle)
->and($execution->server_id)->toBe($freeServer->id)
->and($execution->runner_id)->toBe(42)
->and($execution->runner_name)->toBe("coolify-runner-{$execution->uuid}")
->and($execution->ready_at)->not->toBeNull();
Http::assertSent(fn ($request) => str_ends_with($request->url(), '/generate-jitconfig')
&& $request['labels'] === ['self-hosted', 'linux', 'coolify']
&& $request['runner_group_id'] === 7);
$commands = '';
Process::assertRan(function (PendingProcess $process) use (&$commands) {
$commands .= $process->command."\n";
return true;
});
expect($commands)
->toContain("docker run -d --privileged --name coolify-runner-{$execution->uuid}-dind")
->toContain('--env-file /data/coolify/github-runners/'.$execution->uuid.'.env')
->not->toContain('SECRET-JIT-CONFIG')
->not->toContain('/var/run/docker.sock:/var/run/docker.sock');
});
it('keeps the execution queued when every server is at capacity', function () {
Queue::fake();
Http::fake();
Process::fake();
$server = runnerTestServer($this->team);
$config = runnerTestConfig($server, $this->githubApp, ['max_runners' => 1]);
runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Idle, 'server_id' => $server->id, 'github_runner_config_id' => $config->id]);
$execution = runnerTestExecution($this->githubApp);
(new ProvisionGithubRunnerJob($execution->id))->handle();
expect($execution->fresh()->status)->toBe(GithubRunnerStatus::Queued);
Http::assertNothingSent();
Process::assertNothingRan();
});
it('skips configs whose server is being deleted', function () {
Queue::fake();
fakeRunnerGithubApi($this->githubApp);
Process::fake(['*' => Process::result(output: '29.8.0')]);
$deletedServer = runnerTestServer($this->team);
runnerTestConfig($deletedServer, $this->githubApp);
$deletedServer->delete();
$server = runnerTestServer($this->team);
runnerTestConfig($server, $this->githubApp);
$execution = runnerTestExecution($this->githubApp);
(new ProvisionGithubRunnerJob($execution->id))->handle();
expect($execution->fresh()->server_id)->toBe($server->id);
});
it('does not start runners on the Coolify host', function () {
Queue::fake();
Process::fake();
$server = runnerTestServer($this->team);
$server->update(['ip' => 'host.docker.internal']);
runnerTestConfig($server, $this->githubApp);
$execution = runnerTestExecution($this->githubApp);
(new ProvisionGithubRunnerJob($execution->id))->handle();
expect($execution->fresh()->status)->toBe(GithubRunnerStatus::Queued);
Process::assertNothingRan();
});
it('does not use servers without the build role, disabled configs, or other labels', function (string $role, array $configAttributes) {
Queue::fake();
Process::fake();
$server = runnerTestServer($this->team, $role);
runnerTestConfig($server, $this->githubApp, $configAttributes);
$execution = runnerTestExecution($this->githubApp);
(new ProvisionGithubRunnerJob($execution->id))->handle();
expect($execution->fresh()->status)->toBe(GithubRunnerStatus::Queued);
Process::assertNothingRan();
})->with([
'combined role' => ['both', []],
'disabled config' => ['build', ['is_enabled' => false]],
'other labels' => ['build', ['labels' => ['gpu']]],
]);
it('cleans up a runner that failed to start and retries the job later', function () {
Queue::fake();
Process::fake(['*' => Process::result(output: '29.8.0')]);
fakeRejectedJitConfig();
runnerTestConfig(runnerTestServer($this->team), $this->githubApp);
$execution = runnerTestExecution($this->githubApp);
$uuid = $execution->uuid;
(new ProvisionGithubRunnerJob($execution->id))->handle();
$execution->refresh();
expect($execution->status)->toBe(GithubRunnerStatus::Queued)
->and($execution->provision_attempts)->toBe(1)
->and($execution->server_id)->toBeNull()
->and($execution->runner_name)->toBeNull()
->and($execution->error_message)->toContain('Attempt 1 of 3 failed')->toContain('Resource not accessible by integration');
Process::assertRan(fn (PendingProcess $process) => str_contains($process->command, "docker rm -f -v coolify-runner-{$uuid}"));
Queue::assertPushed(ProvisionGithubRunnerJob::class, fn ($job) => $job->executionId === $execution->id && $job->delay !== null);
Queue::assertNotPushed(CleanupGithubRunnerJob::class);
});
it('marks the execution failed and cleans up after the last attempt', function () {
Queue::fake();
Process::fake(['*' => Process::result(output: '29.8.0')]);
fakeRejectedJitConfig();
runnerTestConfig(runnerTestServer($this->team), $this->githubApp);
$execution = runnerTestExecution($this->githubApp, ['provision_attempts' => 2]);
(new ProvisionGithubRunnerJob($execution->id))->handle();
$execution->refresh();
expect($execution->status)->toBe(GithubRunnerStatus::Failed)
->and($execution->provision_attempts)->toBe(3)
->and($execution->error_message)->toContain('after 3 attempts')->toContain('Resource not accessible by integration');
Queue::assertPushed(CleanupGithubRunnerJob::class, fn ($job) => $job->executionId === $execution->id);
Queue::assertNotPushed(ProvisionGithubRunnerJob::class, fn ($job) => $job->executionId === $execution->id);
});
});
describe('cleanup and reconciliation', function () {
beforeEach(function () {
$this->server = runnerTestServer($this->team);
$this->config = runnerTestConfig($this->server, $this->githubApp, ['idle_timeout' => 10, 'job_timeout' => 60, 'capacity_wait_timeout' => 30]);
});
it('removes the containers and the GitHub runner, then provisions the next queued job', function () {
Queue::fake();
fakeRunnerGithubApi($this->githubApp);
Process::fake();
$execution = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Completed, 'server_id' => $this->server->id, 'runner_id' => 42]);
$next = runnerTestExecution($this->githubApp);
(new CleanupGithubRunnerJob($execution->id))->handle();
Process::assertRan(fn (PendingProcess $process) => str_contains($process->command, "docker rm -f -v coolify-runner-{$execution->uuid}"));
Http::assertSent(fn ($request) => $request->method() === 'DELETE' && str_ends_with($request->url(), '/orgs/acme/actions/runners/42'));
Queue::assertPushed(ProvisionGithubRunnerJob::class, fn ($job) => $job->executionId === $next->id);
});
it('finishes runners whose container stopped, idled, or ran too long, and removes orphans', function () {
Queue::fake();
$stopped = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Running, 'server_id' => $this->server->id, 'github_runner_config_id' => $this->config->id, 'started_at' => now()]);
$idle = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Idle, 'server_id' => $this->server->id, 'github_runner_config_id' => $this->config->id, 'ready_at' => now()->subMinutes(11)]);
$long = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Running, 'server_id' => $this->server->id, 'github_runner_config_id' => $this->config->id, 'started_at' => now()->subMinutes(61)]);
$healthy = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Running, 'server_id' => $this->server->id, 'github_runner_config_id' => $this->config->id, 'started_at' => now()]);
$lines = [
"{$stopped->uuid} coolify-runner-{$stopped->uuid} exited",
"{$idle->uuid} coolify-runner-{$idle->uuid} running",
"{$long->uuid} coolify-runner-{$long->uuid} running",
"{$healthy->uuid} coolify-runner-{$healthy->uuid} running",
"{$healthy->uuid} coolify-runner-{$healthy->uuid}-dind running",
'orphanuuid coolify-runner-orphanuuid exited',
];
Process::fake(['*' => Process::result(output: implode("\n", $lines))]);
(new ReconcileGithubRunnersJob)->handle();
expect($stopped->fresh()->status)->toBe(GithubRunnerStatus::Failed)
->and($idle->fresh()->status)->toBe(GithubRunnerStatus::Cancelled)
->and($long->fresh()->status)->toBe(GithubRunnerStatus::TimedOut)
->and($healthy->fresh()->status)->toBe(GithubRunnerStatus::Running);
Queue::assertPushed(CleanupGithubRunnerJob::class, 3);
Process::assertRan(fn (PendingProcess $process) => str_contains($process->command, 'docker rm -f -v coolify-runner-orphanuuid'));
Process::assertDidntRun(fn (PendingProcess $process) => str_contains($process->command, "docker rm -f -v coolify-runner-{$healthy->uuid}"));
});
it('lets a late completed webhook replace a failure guessed from a stopped container', function () {
Queue::fake();
$execution = runnerTestExecution($this->githubApp, [
'status' => GithubRunnerStatus::Failed,
'server_id' => $this->server->id,
'runner_name' => 'coolify-runner-late',
'error_message' => 'The runner container stopped before GitHub reported a job result.',
]);
sendWorkflowJobWebhook($this, $this->githubApp, 'runner-secret', 'completed', [
'runner_name' => 'coolify-runner-late',
'conclusion' => 'success',
])->assertOk();
$execution->refresh();
expect($execution->status)->toBe(GithubRunnerStatus::Completed)
->and($execution->error_message)->toBeNull();
});
it('times out queued jobs after the wait timeout and retries the others', function () {
Queue::fake();
Process::fake(['*' => Process::result(output: '')]);
$expired = runnerTestExecution($this->githubApp, ['queued_at' => now()->subMinutes(31)]);
$waiting = runnerTestExecution($this->githubApp, ['queued_at' => now()->subMinutes(5)]);
(new ReconcileGithubRunnersJob)->handle();
expect($expired->fresh()->status)->toBe(GithubRunnerStatus::TimedOut)
->and($waiting->fresh()->status)->toBe(GithubRunnerStatus::Queued);
Queue::assertPushed(ProvisionGithubRunnerJob::class, fn ($job) => $job->executionId === $waiting->id);
Queue::assertNotPushed(ProvisionGithubRunnerJob::class, fn ($job) => $job->executionId === $expired->id);
});
it('does not touch runners when the container list cannot be read', function () {
Queue::fake();
$execution = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Running, 'server_id' => $this->server->id, 'github_runner_config_id' => $this->config->id, 'started_at' => now()]);
Process::fake(['*' => Process::result(errorOutput: 'Cannot connect to the Docker daemon', exitCode: 1)]);
(new ReconcileGithubRunnersJob)->handle();
expect($execution->fresh()->status)->toBe(GithubRunnerStatus::Running);
Queue::assertNotPushed(CleanupGithubRunnerJob::class);
});
});
function fakeRejectedJitConfig(): void
{
Http::fake([
'https://api.github.com/zen' => Http::response('ok', 200, ['Date' => now()->toRfc7231String()]),
'https://api.github.com/app/installations/*' => Http::response(['token' => 'installation-token'], 201),
'https://api.github.com/orgs/acme/actions/runners/generate-jitconfig' => Http::response(['message' => 'Resource not accessible by integration'], 403),
]);
}
+676
View File
@@ -0,0 +1,676 @@
<?php
use App\Enums\GithubRunnerDockerMode;
use App\Enums\GithubRunnerStatus;
use App\Jobs\CleanupGithubRunnerJob;
use App\Jobs\GithubAppPermissionJob;
use App\Jobs\ProvisionGithubRunnerJob;
use App\Livewire\Server\GithubRunnerExecutions;
use App\Livewire\Server\GithubRunners;
use App\Livewire\Server\Show;
use App\Livewire\Source\Github\Change;
use App\Livewire\Source\Github\Create;
use App\Models\GithubApp;
use App\Models\GithubRunnerConfig;
use App\Models\GithubRunnerExecution;
use App\Models\InstanceSettings;
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 Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutVite();
Server::flushIdentityMap();
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->owner = User::factory()->create();
$this->member = User::factory()->create();
$this->team->members()->attach($this->owner->id, ['role' => 'owner']);
$this->team->members()->attach($this->member->id, ['role' => 'member']);
session(['currentTeam' => $this->team]);
$this->server = settingsTestServer($this->team, 'build');
$this->githubApp = settingsTestGithubApp($this->team, 3333);
});
afterEach(function () {
Server::flushIdentityMap();
});
function settingsTestServer(Team $team, string $role): Server
{
$server = Server::factory()->create(['team_id' => $team->id]);
$server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'server_role' => $role,
'is_build_server' => $role === 'build',
'is_swarm_worker' => false,
'force_disabled' => false,
]);
return $server->fresh();
}
function settingsTestGithubApp(Team $team, int $appId, array $attributes = []): GithubApp
{
$rsaKey = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
openssl_pkey_export($rsaKey, $pemKey);
$privateKey = PrivateKey::create(['name' => 'Runner key', 'private_key' => $pemKey, 'team_id' => $team->id]);
return GithubApp::create([
'name' => "settings-app-{$appId}",
'organization' => 'acme',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => $appId,
'installation_id' => $appId + 1,
'webhook_secret' => 'secret',
'private_key_id' => $privateKey->id,
'team_id' => $team->id,
'is_system_wide' => false,
'organization_self_hosted_runners' => 'write',
'actions' => 'read',
'webhook_events' => ['workflow_job'],
...$attributes,
]);
}
/**
* Attributes of a GitHub App that is missing every runner permission.
*/
function unreadyRunnerApp(): array
{
return ['organization_self_hosted_runners' => null, 'actions' => null, 'webhook_events' => ['push']];
}
function fakeRunnerGroupApi(GithubApp $githubApp, int $createStatus = 201): void
{
Http::fake([
'https://api.github.com/zen' => Http::response('ok', 200, ['Date' => now()->toRfc7231String()]),
"https://api.github.com/app/installations/{$githubApp->installation_id}/access_tokens" => Http::response(['token' => 'installation-token'], 201),
'https://api.github.com/orgs/acme/actions/runner-groups' => fn ($request) => $request->method() === 'POST'
? Http::response($createStatus === 201 ? ['id' => 55, 'default' => false] : ['message' => 'Upgrade to create runner groups'], $createStatus)
: Http::response(['runner_groups' => [['id' => 1, 'name' => 'Default', 'default' => true]]], 200),
]);
}
describe('runner settings page', function () {
it('saves the configuration and creates a runner group for private repositories only', function () {
fakeRunnerGroupApi($this->githubApp);
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->set('githubAppId', $this->githubApp->id)
->set('labels', 'Coolify, self-hosted, gpu')
->set('memoryLimit', '4G')
->call('submit')
->assertHasNoErrors()
->assertDispatched('success')
->assertSee('Recent runners');
$config = GithubRunnerConfig::sole();
expect($config->server_id)->toBe($this->server->id)
->and($config->labels)->toBe(['coolify', 'gpu'])
->and($config->memory_limit)->toBe('4g')
->and($config->docker_mode)->toBe(GithubRunnerDockerMode::None)
->and($this->githubApp->fresh()->runner_group_id)->toBe(55);
Http::assertSent(fn ($request) => $request->method() === 'POST'
&& str_ends_with($request->url(), '/orgs/acme/actions/runner-groups')
&& $request['allows_public_repositories'] === false
&& $request['visibility'] === 'private');
});
it('does not fall back to the Default runner group when group creation fails', function (int $status) {
fakeRunnerGroupApi($this->githubApp, $status);
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->set('githubAppId', $this->githubApp->id)
->call('submit')
->assertNotDispatched('success');
expect($this->githubApp->fresh()->runner_group_id)->toBeNull()
->and(GithubRunnerConfig::count())->toBe(0);
Http::assertNotSent(fn ($request) => $request->method() === 'GET'
&& str_ends_with($request->url(), '/orgs/acme/actions/runner-groups'));
})->with([403, 401, 422, 429, 500]);
it('requires a custom label with safe characters', function (string $labels) {
Http::fake();
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->set('githubAppId', $this->githubApp->id)
->set('labels', $labels)
->call('submit')
->assertHasErrors('labels');
expect(GithubRunnerConfig::count())->toBe(0);
})->with([
'only reserved labels' => ['self-hosted, linux, x64'],
'shell characters' => ['coolify;rm -rf /'],
]);
it('rejects resource limits that are not plain values', function (string $field, string $value) {
Http::fake();
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->set('githubAppId', $this->githubApp->id)
->set($field, $value)
->call('submit')
->assertHasErrors($field);
})->with([
'cpu' => ['cpuLimit', '2 --privileged'],
'memory' => ['memoryLimit', '4g; reboot'],
'image' => ['runnerImage', 'bad image; reboot'],
]);
it('rejects a GitHub App of another team', function () {
$otherApp = settingsTestGithubApp(Team::factory()->create(), 4444);
Http::fake();
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->set('githubAppId', $otherApp->id)
->call('submit')
->assertHasErrors('githubAppId');
expect(GithubRunnerConfig::count())->toBe(0);
Http::assertNothingSent();
});
it('does not let members change the configuration', function () {
Livewire::actingAs($this->member)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->set('githubAppId', $this->githubApp->id)
->call('submit')
->assertForbidden();
expect(GithubRunnerConfig::count())->toBe(0);
});
it('opens on Coolify Cloud because runners run on the user\'s own servers', function () {
config()->set('constants.coolify.self_hosted', false);
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->assertNoRedirect()
->assertSee('GitHub Actions runners');
});
it('does not open the page for the Coolify host', function () {
$server = settingsTestServer($this->team, 'build');
$server->update(['ip' => 'host.docker.internal']);
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $server->uuid])
->assertRedirect(route('server.show', ['server_uuid' => $server->uuid]));
$this->actingAs($this->owner)
->get(route('server.show', ['server_uuid' => $server->uuid]))
->assertDontSee(route('server.github-runners', ['server_uuid' => $server->uuid]));
});
it('does not open the page for a server of another team', function () {
$otherServer = settingsTestServer(Team::factory()->create(), 'build');
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $otherServer->uuid])
->assertRedirect(route('server.index'));
});
it('refuses to save runners on a server without the build role', function () {
$server = settingsTestServer($this->team, 'both');
Http::fake();
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $server->uuid])
->assertSee('This server is not enabled for builds')
->assertSee(route('server.show', ['server_uuid' => $server->uuid]))
->assertSee(route('server.github-runners', ['server_uuid' => $server->uuid]))
->assertDontSee('Recent runners')
->set('githubAppId', $this->githubApp->id)
->call('submit')
->assertDispatched('error');
expect(GithubRunnerConfig::count())->toBe(0);
});
it('disables runners and removes only the idle ones', function () {
Queue::fake();
$config = GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify']]);
$idle = GithubRunnerExecution::create(['github_app_id' => $this->githubApp->id, 'github_runner_config_id' => $config->id, 'server_id' => $this->server->id, 'trigger_workflow_job_id' => 1, 'status' => GithubRunnerStatus::Idle]);
$running = GithubRunnerExecution::create(['github_app_id' => $this->githubApp->id, 'github_runner_config_id' => $config->id, 'server_id' => $this->server->id, 'trigger_workflow_job_id' => 2, 'status' => GithubRunnerStatus::Running]);
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->call('toggleEnabled')
->assertDispatched('success')
->assertSee('Runners are disabled')
->assertDontSee('Parallel runners')
->assertDontSee('Recent runners');
expect($config->fresh()->is_enabled)->toBeFalse()
->and($idle->fresh()->status)->toBe(GithubRunnerStatus::Cancelled)
->and($running->fresh()->status)->toBe(GithubRunnerStatus::Running);
Queue::assertPushed(CleanupGithubRunnerJob::class, 1);
Queue::assertPushed(CleanupGithubRunnerJob::class, fn ($job) => $job->executionId === $idle->id);
});
it('enables disabled runners and starts the queued jobs', function () {
Queue::fake();
fakeRunnerGroupApi($this->githubApp);
$config = GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify'], 'is_enabled' => false]);
GithubRunnerExecution::create(['github_app_id' => $this->githubApp->id, 'trigger_workflow_job_id' => 1, 'status' => GithubRunnerStatus::Queued]);
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->call('toggleEnabled')
->assertDispatched('success')
->assertDontSee('Runners are disabled')
->assertSee('Parallel runners');
expect($config->fresh()->is_enabled)->toBeTrue();
Queue::assertPushed(ProvisionGithubRunnerJob::class);
});
it('shows only the enable button before runners are enabled', function () {
fakeRunnerGroupApi($this->githubApp);
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->assertSee('Runners are disabled')
->assertSee('Enable runners')
->assertDontSee('Parallel runners')
->assertDontSee('Recent runners')
->call('toggleEnabled')
->assertHasNoErrors()
->assertDispatched('success')
->assertSee('Parallel runners');
$config = GithubRunnerConfig::sole();
expect($config->is_enabled)->toBeTrue()
->and($config->github_app_id)->toBe($this->githubApp->id)
->and($config->labels)->toBe(['coolify']);
});
it('enables runners with an App that is not ready without calling GitHub', function () {
Http::fake();
$this->githubApp->update(unreadyRunnerApp());
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->call('toggleEnabled')
->assertHasNoErrors()
->assertDispatched('success')
->assertSee('Parallel runners')
->assertSee('The GitHub App is not ready');
expect(GithubRunnerConfig::sole()->is_enabled)->toBeTrue();
Http::assertNothingSent();
});
it('selects the first App that is ready for runners', function () {
$this->githubApp->update(['name' => 'a-first', ...unreadyRunnerApp()]);
$readyApp = settingsTestGithubApp($this->team, 4444, ['name' => 'b-ready']);
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->assertSet('githubAppId', $readyApp->id);
});
it('saves settings with an App that is not ready without calling GitHub', function () {
Http::fake();
$this->githubApp->update(unreadyRunnerApp());
$config = GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify']]);
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->set('maxRunners', 4)
->call('submit')
->assertHasNoErrors()
->assertDispatched('success');
expect($config->fresh()->max_runners)->toBe(4);
Http::assertNothingSent();
});
it('keeps runners disabled when saving other settings', function () {
fakeRunnerGroupApi($this->githubApp);
$config = GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify'], 'is_enabled' => false]);
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->set('maxRunners', 4)
->call('submit')
->assertDispatched('success');
expect($config->fresh()->is_enabled)->toBeFalse()
->and($config->fresh()->max_runners)->toBe(4);
});
it('does not let members disable runners', function () {
$config = GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify']]);
Livewire::actingAs($this->member)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->call('toggleEnabled')
->assertForbidden();
expect($config->fresh()->is_enabled)->toBeTrue();
});
});
describe('isolated Docker with Sysbox', function () {
it('does not save Isolated Docker while Sysbox is missing', function () {
fakeRunnerGroupApi($this->githubApp);
Process::fake(['*' => Process::result(output: 'io.containerd.runc.v2 runc')]);
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->set('githubAppId', $this->githubApp->id)
->set('dockerMode', 'sysbox')
->call('submit')
->assertHasErrors('dockerMode')
->assertSet('isSysboxInstalled', false);
expect(GithubRunnerConfig::count())->toBe(0);
});
it('saves Isolated Docker when Sysbox is installed', function () {
fakeRunnerGroupApi($this->githubApp);
Process::fake(['*' => Process::result(output: 'io.containerd.runc.v2 runc sysbox-runc ')]);
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->set('githubAppId', $this->githubApp->id)
->set('dockerMode', 'sysbox')
->call('submit')
->assertHasNoErrors();
expect(GithubRunnerConfig::sole()->docker_mode->value)->toBe('sysbox');
});
it('checks that Sysbox can mount its FUSE file system', function () {
Process::fake(['*' => Process::result(output: '')]);
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->call('checkSysbox');
Process::assertRan(fn ($process) => str_contains($process->command, 'test -x /usr/bin/fusermount3'));
});
it('shows the install button when Sysbox is missing', function () {
Process::fake(['*' => Process::result(output: '')]);
GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify']]);
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->call('checkSysbox')
->assertSet('isSysboxInstalled', false)
->assertSee('Install Sysbox');
});
it('stops before the installation on servers without apt', function () {
Process::fake(['*' => Process::result(output: '')]);
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->call('installSysbox')
->assertNotDispatched('activityMonitor');
Process::assertRanTimes(fn ($process) => str_contains($process->command, 'apt-get'), 1);
});
it('does not let members install Sysbox', function () {
Process::fake();
Livewire::actingAs($this->member)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->call('installSysbox')
->assertForbidden();
Process::assertNothingRan();
});
});
describe('runner executions list', function () {
beforeEach(function () {
Queue::fake();
$config = GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify']]);
$this->running = GithubRunnerExecution::create(['github_app_id' => $this->githubApp->id, 'github_runner_config_id' => $config->id, 'server_id' => $this->server->id, 'trigger_workflow_job_id' => 1, 'status' => GithubRunnerStatus::Running, 'repository_full_name' => 'acme/api']);
$this->queued = GithubRunnerExecution::create(['github_app_id' => $this->githubApp->id, 'trigger_workflow_job_id' => 2, 'status' => GithubRunnerStatus::Queued, 'repository_full_name' => 'acme/web']);
});
it('shows runners of the server and queued jobs of its GitHub App', function () {
$otherServer = settingsTestServer($this->team, 'build');
GithubRunnerExecution::create(['github_app_id' => $this->githubApp->id, 'server_id' => $otherServer->id, 'trigger_workflow_job_id' => 3, 'status' => GithubRunnerStatus::Running, 'repository_full_name' => 'acme/other']);
Livewire::actingAs($this->owner)
->test(GithubRunnerExecutions::class, ['server' => $this->server])
->assertSee('acme/api')
->assertSee('acme/web')
->assertDontSee('acme/other');
});
it('cancels a running runner and queues its cleanup', function () {
Livewire::actingAs($this->owner)
->test(GithubRunnerExecutions::class, ['server' => $this->server])
->call('cancel', $this->running->id)
->assertDispatched('success');
expect($this->running->fresh()->status)->toBe(GithubRunnerStatus::Cancelled);
Queue::assertPushed(CleanupGithubRunnerJob::class, fn ($job) => $job->executionId === $this->running->id);
});
it('does not let members cancel runners', function () {
Livewire::actingAs($this->member)
->test(GithubRunnerExecutions::class, ['server' => $this->server])
->call('cancel', $this->running->id)
->assertForbidden();
expect($this->running->fresh()->status)->toBe(GithubRunnerStatus::Running);
});
it('does not cancel runners of another server', function () {
$otherServer = settingsTestServer($this->team, 'build');
$other = GithubRunnerExecution::create(['github_app_id' => $this->githubApp->id, 'server_id' => $otherServer->id, 'trigger_workflow_job_id' => 4, 'status' => GithubRunnerStatus::Running]);
Livewire::actingAs($this->owner)
->test(GithubRunnerExecutions::class, ['server' => $this->server])
->call('cancel', $other->id);
expect($other->fresh()->status)->toBe(GithubRunnerStatus::Running);
});
});
describe('build server role', function () {
beforeEach(function () {
GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify']]);
});
it('blocks a role change in the UI while runners are enabled', function () {
Livewire::actingAs($this->owner)
->test(Show::class, ['server_uuid' => $this->server->uuid])
->set('serverRole', 'both')
->call('requestServerRoleChange')
->assertSet('serverRole', 'build')
->assertDispatched('error');
expect($this->server->settings->fresh()->server_role->value)->toBe('build');
});
it('blocks a role change through the API while runners are enabled', function () {
$token = $this->owner->createToken('runner-test', ['*']);
$token->accessToken->forceFill(['team_id' => $this->team->id])->save();
$this->withHeaders(['Authorization' => 'Bearer '.$token->plainTextToken])
->patchJson('/api/v1/servers/'.$this->server->uuid, ['server_role' => 'both'])
->assertStatus(422)
->assertJsonPath('errors.server_role.0', 'Disable the GitHub runners before you change the role of this server.');
});
it('leaves dedicated runner servers out of application builds', function () {
expect(Server::buildServers($this->team->id)->pluck('id'))->toContain($this->server->id);
$this->server->githubRunnerConfig->update(['is_dedicated' => true]);
expect(Server::buildServers($this->team->id)->pluck('id'))->not->toContain($this->server->id);
$this->server->githubRunnerConfig->update(['is_enabled' => false]);
expect(Server::buildServers($this->team->id)->pluck('id'))->toContain($this->server->id);
});
});
describe('GitHub App runner permissions', function () {
it('stores runner permissions and webhook events from GitHub', function () {
Http::fake([
'https://api.github.com/zen' => Http::response('ok', 200, ['Date' => now()->toRfc7231String()]),
'https://api.github.com/app' => Http::response([
'permissions' => ['contents' => 'read', 'metadata' => 'read', 'organization_self_hosted_runners' => 'write', 'actions' => 'read'],
'events' => ['push', 'workflow_job'],
]),
]);
GithubAppPermissionJob::dispatchSync($this->githubApp);
$githubApp = $this->githubApp->fresh();
expect($githubApp->organization_self_hosted_runners)->toBe('write')
->and($githubApp->actions)->toBe('read')
->and($githubApp->webhook_events)->toBe(['push', 'workflow_job'])
->and($githubApp->missingRunnerRequirements())->toBe([]);
});
it('lists what an App needs before it can run jobs', function () {
$githubApp = settingsTestGithubApp($this->team, 5555, [...unreadyRunnerApp(), 'organization' => null]);
expect($githubApp->missingRunnerRequirements())->toHaveCount(4);
});
it('shows the runner permissions on the GitHub App permissions tab', function () {
$this->githubApp->update(['organization_self_hosted_runners' => 'write', 'webhook_events' => ['push']]);
Livewire::withQueryParams(['github_app_uuid' => $this->githubApp->uuid])
->actingAs($this->owner)
->test(Change::class)
->set('activeTab', 'permissions')
->assertSee('GitHub Actions runners')
->assertSee('Not ready for runners')
->assertSee('Webhook event "Workflow job"')
->assertDontSee('Organization permission "Self-hosted runners": write');
});
it('shows the runner option disabled with a reason when the App has no organization', function () {
$personalApp = GithubApp::create(['name' => 'personal', 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com', 'custom_user' => 'git', 'custom_port' => 22, 'team_id' => $this->team->id, 'is_system_wide' => false]);
Livewire::withQueryParams(['github_app_uuid' => $personalApp->uuid])
->actingAs($this->owner)
->test(Change::class)
->assertSee('GitHub Actions runners')
->assertSee('Only GitHub Apps that belong to an organization can run workflow jobs.');
});
it('survives the confirmation modal refresh after deleting an App', function () {
$unregistered = GithubApp::create(['name' => 'to-delete', 'organization' => 'acme', 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com', 'custom_user' => 'git', 'custom_port' => 22, 'team_id' => $this->team->id, 'is_system_wide' => false]);
Livewire::withQueryParams(['github_app_uuid' => $unregistered->uuid])
->actingAs($this->owner)
->test(Change::class)
->call('delete')
->assertRedirect(route('source.all'))
->call('$refresh')
->assertOk();
expect(GithubApp::find($unregistered->id))->toBeNull();
});
it('blocks deleting an App while servers have enabled runners', function () {
GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify']]);
expect(fn () => $this->githubApp->delete())->toThrow(Exception::class, 'GitHub Actions runners');
expect(GithubApp::find($this->githubApp->id))->not->toBeNull()
->and(GithubRunnerConfig::count())->toBe(1);
});
it('blocks deleting an App while disabled runners still run jobs', function () {
$config = GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify'], 'is_enabled' => false]);
GithubRunnerExecution::create(['github_app_id' => $this->githubApp->id, 'github_runner_config_id' => $config->id, 'server_id' => $this->server->id, 'trigger_workflow_job_id' => 1, 'status' => GithubRunnerStatus::Running]);
expect(fn () => $this->githubApp->delete())->toThrow(Exception::class, 'GitHub Actions runners');
expect(GithubApp::find($this->githubApp->id))->not->toBeNull();
});
it('deletes an App after its runners are disabled and finished', function () {
$config = GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify'], 'is_enabled' => false]);
GithubRunnerExecution::create(['github_app_id' => $this->githubApp->id, 'github_runner_config_id' => $config->id, 'server_id' => $this->server->id, 'trigger_workflow_job_id' => 1, 'status' => GithubRunnerStatus::Completed]);
$this->githubApp->delete();
expect(GithubApp::find($this->githubApp->id))->toBeNull()
->and(GithubRunnerConfig::count())->toBe(0);
});
});
describe('new GitHub App purpose', function () {
it('requires an organization when the App is for GitHub Actions runners', function () {
Livewire::actingAs($this->owner)
->test(Create::class)
->set('use_for_github_runners', true)
->set('organization', '')
->call('createGitHubApp')
->assertHasErrors(['organization' => 'required']);
expect(GithubApp::where('team_id', $this->team->id)->count())->toBe(1);
});
it('marks the organization as required when GitHub Actions runners are selected', function () {
Livewire::actingAs($this->owner)
->test(Create::class)
->assertSee('Personal account when empty')
->set('use_for_github_runners', true)
->assertSee('GitHub organization, for example coollabsio')
->assertSeeHtml('required');
});
it('passes the selected purposes to the registration page', function () {
Livewire::actingAs($this->owner)
->test(Create::class)
->set('name', 'runner-purpose-app')
->set('organization', 'acme')
->set('use_for_pull_request_previews', false)
->set('use_for_github_runners', true)
->call('createGitHubApp')
->assertHasNoErrors()
->assertRedirectContains('previews=0&runners=1');
$githubApp = GithubApp::where('name', 'runner-purpose-app')->sole();
expect($githubApp->organization)->toBe('acme');
});
it('prefills the registration options from the selected purposes', function (array $query, bool $previews, bool $runners, ?string $organization) {
$unregistered = GithubApp::create(['name' => 'prefill', 'organization' => $organization, 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com', 'custom_user' => 'git', 'custom_port' => 22, 'team_id' => $this->team->id, 'is_system_wide' => false]);
Livewire::withQueryParams(['github_app_uuid' => $unregistered->uuid, ...$query])
->actingAs($this->owner)
->test(Change::class)
->assertSet('preview_deployment_permissions', $previews)
->assertSet('github_runners', $runners);
})->with([
'defaults' => [[], true, false, 'acme'],
'runners only' => [['previews' => '0', 'runners' => '1'], false, true, 'acme'],
'runners without organization' => [['runners' => '1'], true, false, null],
]);
});
+18
View File
@@ -2,6 +2,7 @@
use App\Models\InstanceSettings;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
@@ -84,3 +85,20 @@ it('runs the scheduled job dispatcher from the scheduler instead of a queue', fu
->and($event->runInBackground)->toBeTrue()
->and($events->contains(fn ($event) => str_contains((string) $event->description, 'ScheduledJobManager')))->toBeFalse();
});
it('schedules GitHub runner reconciliation every minute on Coolify Cloud', function () {
config()->set('constants.coolify.self_hosted', false);
$schedule = new Schedule;
$kernel = app(Kernel::class);
(fn () => $this->schedule($schedule))->call($kernel);
$event = collect($schedule->events())->first(
fn ($event) => str_contains((string) $event->description, 'ReconcileGithubRunnersJob')
);
expect(isCloud())->toBeTrue()
->and($event)->not->toBeNull()
->and($event->expression)->toBe('* * * * *')
->and($event->onOneServer)->toBeTrue();
});
@@ -1,5 +1,6 @@
<?php
use App\Livewire\Server\Show;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\ServerSetting;
@@ -8,6 +9,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Once;
use Livewire\Livewire;
uses(RefreshDatabase::class);
@@ -116,6 +118,20 @@ describe('ServerSetting::ensureValidSentinelToken', function () {
expect($settings->fresh()->sentinel_token)->toBe($token);
});
it('loads the server page when the stored token cannot be decrypted', function () {
$settings = $this->server->settings;
DB::table('server_settings')->where('id', $settings->id)->update(['sentinel_token' => 'not-encrypted-junk']);
$this->actingAs($this->team->members()->first());
session(['currentTeam' => $this->team]);
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->assertOk()
->assertSet('sentinelToken', $settings->fresh()->sentinel_token);
expect(ServerSetting::isValidSentinelToken($settings->fresh()->sentinel_token))->toBeTrue();
});
it('returns existing valid token without regenerating', function () {
$settings = $this->server->settings;
$original = $settings->sentinel_token;
+121
View File
@@ -0,0 +1,121 @@
<?php
use App\Models\GithubRunnerConfig;
use App\Models\GithubRunnerExecution;
use App\Models\Server;
use App\Services\GithubRunner\GithubRunnerContainer;
use Tests\TestCase;
uses(TestCase::class);
beforeEach(function () {
$this->execution = (new GithubRunnerExecution)->forceFill(['uuid' => 'abc123']);
$this->config = (new GithubRunnerConfig)->forceFill([
'docker_mode' => 'dind',
'cpu_limit' => '2',
'memory_limit' => '4g',
'runner_image' => null,
]);
$this->nonRootServer = Mockery::mock(Server::class)->makePartial();
$this->nonRootServer->shouldReceive('getAttribute')->with('user')->andReturn('ubuntu');
});
afterEach(function () {
Mockery::close();
});
it('starts a privileged Docker sidecar with limits on a private network', function () {
$commands = (new GithubRunnerContainer($this->execution, $this->config))->prepareCommands();
expect($commands[0])->toBe('docker network create --label coolify.githubRunner=true --label coolify.githubRunnerExecution=abc123 coolify-runner-abc123')
->and($commands)->toContain('docker volume create --label coolify.githubRunner=true --label coolify.githubRunnerExecution=abc123 coolify-runner-abc123-work')
->and(end($commands))
->toStartWith('docker run -d --privileged --name coolify-runner-abc123-dind --network coolify-runner-abc123')
->toContain("--pids-limit 4096 --cpus '2' --memory '4g' --memory-swap '4g'")
->toContain('-v coolify-runner-abc123-sock:/var/run -v coolify-runner-abc123-work:/home/runner/_work')
->toContain("'".config('constants.github_runner.dind_image')."' dockerd --host=unix:///var/run/docker.sock --group=123");
});
it('passes the JIT config through a root-only env file and never mounts the host Docker socket', function () {
$commands = (new GithubRunnerContainer($this->execution, $this->config))->startCommands('SECRET');
$all = implode("\n", $commands);
expect($all)->not->toContain('SECRET')
->toContain(base64_encode("ACTIONS_RUNNER_INPUT_JITCONFIG=SECRET\n"))
->not->toContain('docker.sock:/var/run/docker.sock')
->and($commands[2])->toBe('chmod 600 /data/coolify/github-runners/abc123.env')
->and($commands[4])
->toStartWith('docker run -d --name coolify-runner-abc123 --network coolify-runner-abc123')
->toContain('--env-file /data/coolify/github-runners/abc123.env -e DOCKER_HOST=unix:///var/run/docker.sock')
->toEndWith("'".config('constants.github_runner.image')."' /home/runner/run.sh")
->and(end($commands))->toBe('rm -f /data/coolify/github-runners/abc123.env');
});
it('starts the Docker sidecar with the Sysbox runtime instead of privileged mode', function () {
$this->config->forceFill(['docker_mode' => 'sysbox']);
$container = new GithubRunnerContainer($this->execution, $this->config);
$commands = [...$container->prepareCommands(), ...$container->startCommands('SECRET')];
$all = implode("\n", $commands);
expect($container->usesDind())->toBeTrue()
->and($all)->not->toContain('--privileged')
->toContain('docker run -d --runtime=sysbox-runc --name coolify-runner-abc123-dind --network coolify-runner-abc123')
->toContain('-e DOCKER_HOST=unix:///var/run/docker.sock');
});
it('starts only an unprivileged runner when Docker is disabled', function () {
$this->config->forceFill(['docker_mode' => 'none', 'cpu_limit' => null, 'memory_limit' => null]);
$container = new GithubRunnerContainer($this->execution, $this->config);
$all = implode("\n", [...$container->prepareCommands(), ...$container->startCommands('SECRET')]);
expect($all)->not->toContain('--privileged')
->not->toContain('DOCKER_HOST')
->not->toContain('-dind')
->not->toContain('--cpus');
});
it('uses the latest official runner image by default', function () {
expect(config('constants.github_runner.image'))->toBe('ghcr.io/actions/actions-runner:latest');
});
it('pulls the runner image every time so that a moving tag stays current', function () {
$commands = (new GithubRunnerContainer($this->execution, $this->config))->prepareCommands();
$image = "'".config('constants.github_runner.image')."'";
$dindImage = "'".config('constants.github_runner.dind_image')."'";
expect($commands)
// Falls back to the local image when the registry cannot be reached.
->toContain("docker pull {$image} || docker image inspect {$image} > /dev/null")
->toContain("docker image inspect {$dindImage} > /dev/null 2>&1 || docker pull {$dindImage}");
});
it('quotes a custom runner image', function () {
$this->config->forceFill(['runner_image' => 'ghcr.io/acme/runner:1.0']);
$commands = (new GithubRunnerContainer($this->execution, $this->config))->startCommands('SECRET');
expect($commands[4])->toEndWith("'ghcr.io/acme/runner:1.0' /home/runner/run.sh");
});
it('keeps every command valid for servers with a non-root user', function () {
$container = new GithubRunnerContainer($this->execution, $this->config);
$commands = collect([
...$container->prepareCommands(),
$container->dockerReadyCommand(),
...$container->startCommands('SECRET'),
...GithubRunnerContainer::cleanupCommands('abc123'),
GithubRunnerContainer::listCommand(),
]);
$parsed = parseCommandsByLineForSudo($commands, $this->nonRootServer);
expect($parsed)
->toContain('sudo docker pull '."'".config('constants.github_runner.image')."'".' || sudo docker image inspect '."'".config('constants.github_runner.image')."'".' > /dev/null')
->toContain('echo '.base64_encode("ACTIONS_RUNNER_INPUT_JITCONFIG=SECRET\n").' | sudo base64 -d | sudo tee /data/coolify/github-runners/abc123.env > /dev/null')
->toContain('sudo chmod 600 /data/coolify/github-runners/abc123.env')
->toContain('sudo docker rm -f -v coolify-runner-abc123 coolify-runner-abc123-dind > /dev/null 2>&1 || sudo true')
->toContain("sudo docker exec coolify-runner-abc123-dind docker version --format '{{.Server.Version}}'")
->toContain('sudo docker ps -a --filter label=coolify.githubRunner=true --format \'{{.Label "coolify.githubRunnerExecution"}} {{.Names}} {{.State}}\'');
$linesWithoutSudo = collect($parsed)->reject(fn (string $line) => str_starts_with($line, 'sudo ') || str_starts_with($line, 'echo '));
expect($linesWithoutSudo)->toBeEmpty();
});
+126
View File
@@ -0,0 +1,126 @@
<?php
use App\Actions\Server\InstallSysbox;
use App\Models\Server;
use Tests\TestCase;
uses(TestCase::class);
afterEach(function () {
Mockery::close();
});
describe('Docker network settings', function () {
it('adds the current bridge address and the Docker default pools, and keeps other settings', function () {
$config = InstallSysbox::daemonConfigWithNetworkSettings(
'{"log-driver": "json-file", "log-opts": {"max-size": "10m"}, "features": {}}',
'172.17.0.1 172.17.0.0/16',
);
$decoded = json_decode($config, true);
expect($decoded['bip'])->toBe('172.17.0.1/16')
->and($decoded['log-opts'])->toBe(['max-size' => '10m'])
->and($decoded['default-address-pools'][0])->toBe(['base' => '172.17.0.0/16', 'size' => 16])
->and(end($decoded['default-address-pools']))->toBe(['base' => '192.168.0.0/16', 'size' => 20])
->and($config)->toContain('"features": {}')
// Sysbox detects both keys with a regex that needs one indented key per line.
->toMatch('/^[ ]+"bip": "[0-9.]+.*"/m')
->toMatch('/^[ ]+"default-address-pools"/m');
});
it('keeps existing address pools and only adds the bridge address', function () {
$config = InstallSysbox::daemonConfigWithNetworkSettings(
'{"default-address-pools": [{"base": "10.0.0.0/8", "size": 24}]}',
'10.0.0.1 10.0.0.0/24',
);
expect(json_decode($config, true))->toBe([
'default-address-pools' => [['base' => '10.0.0.0/8', 'size' => 24]],
'bip' => '10.0.0.1/24',
]);
});
it('does not change a configuration that already has both settings', function () {
expect(InstallSysbox::daemonConfigWithNetworkSettings(
'{"bip": "172.17.0.1/16", "default-address-pools": [{"base": "172.17.0.0/12", "size": 16}]}',
'172.17.0.1 172.17.0.0/16',
))->toBeNull();
});
it('creates the configuration when the file does not exist', function () {
expect(json_decode(InstallSysbox::daemonConfigWithNetworkSettings('', '172.17.0.1 172.17.0.0/16'), true))
->toHaveKeys(['bip', 'default-address-pools']);
});
it('refuses an invalid configuration or bridge address', function (string $config, string $bridge) {
InstallSysbox::daemonConfigWithNetworkSettings($config, $bridge);
})->throws(RuntimeException::class)->with([
'broken JSON' => ['{"bip": ', '172.17.0.1 172.17.0.0/16'],
'JSON array' => ['[]', '172.17.0.1 172.17.0.0/16'],
'no bridge gateway' => ['{}', ''],
'bridge with shell characters' => ['{}', '172.17.0.1;reboot 172.17.0.0/16'],
]);
});
it('supports kernels from 5.12', function (string $kernel, bool $supported) {
expect(InstallSysbox::isKernelSupported($kernel))->toBe($supported);
})->with([
['6.8.0-139-generic', true],
['5.15.0-1-amd64', true],
['5.12.0', true],
['5.10.0-28-amd64', false],
['4.19.0', false],
['unknown', false],
]);
describe('install commands', function () {
it('verifies the pinned package before it installs it', function () {
$commands = InstallSysbox::installCommands('arm64', null);
$version = config('constants.github_runner.sysbox.version');
$package = "/tmp/sysbox-ce_{$version}_arm64.deb";
expect($commands)
->toContain("curl -fsSL -o {$package} https://github.com/nestybox/sysbox/releases/download/v{$version}/sysbox-ce_{$version}.linux_arm64.deb")
->toContain("echo '".config('constants.github_runner.sysbox.checksums.arm64')." {$package}' | sha256sum -c -")
// Sysbox needs fusermount3. Without fuse3, Debian 12 satisfies the "fuse" dependency with FUSE 2.
->toContain("DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=300 install -y fuse3 {$package}")
->toContain('test -x /usr/bin/fusermount3')
->and(implode("\n", $commands))->not->toContain('daemon.json')
->not->toContain('systemctl restart');
});
it('writes the Docker settings before the package and keeps a backup', function () {
$commands = InstallSysbox::installCommands('amd64', "{\n \"bip\": \"172.17.0.1/16\"\n}\n");
$write = collect($commands)->search(fn ($command) => str_contains($command, 'tee /etc/docker/daemon.json'));
$install = collect($commands)->search(fn ($command) => str_contains($command, 'install -y'));
expect($commands)->toContain('cp /etc/docker/daemon.json /etc/docker/daemon.json.before-sysbox 2>/dev/null || true')
->and($commands[$write])->toBe('echo '.base64_encode("{\n \"bip\": \"172.17.0.1/16\"\n}\n").' | base64 -d | tee /etc/docker/daemon.json > /dev/null')
->and($write)->toBeLessThan($install);
});
it('keeps every command valid for servers with a non-root user', function () {
$server = Mockery::mock(Server::class)->makePartial();
$server->shouldReceive('getAttribute')->with('user')->andReturn('ubuntu');
$parsed = parseCommandsByLineForSudo(collect(InstallSysbox::installCommands('amd64', '{}')), $server);
expect($parsed)
->toContain('echo '.base64_encode('{}').' | sudo base64 -d | sudo tee /etc/docker/daemon.json > /dev/null')
->toContain('sudo cp /etc/docker/daemon.json /etc/docker/daemon.json.before-sysbox 2>/dev/null || sudo true')
->toContain('sudo DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=300 install -y fuse3 /tmp/sysbox-ce_'.config('constants.github_runner.sysbox.version').'_amd64.deb')
->toContain('sudo systemctl is-active --quiet sysbox-mgr sysbox-fs')
->toContain('sudo test -x /usr/bin/fusermount3')
->toContain("sudo docker info --format '{{range \$name, \$runtime := .Runtimes}}{{\$name}} {{end}}' | sudo grep -qw sysbox-runc");
});
});
it('checks the installation with commands that work for servers with a non-root user', function () {
$server = Mockery::mock(Server::class)->makePartial();
$server->shouldReceive('getAttribute')->with('user')->andReturn('ubuntu');
// "sudo command -v" fails because command is a shell builtin, so the check must not use it.
expect(parseCommandsByLineForSudo(collect([InstallSysbox::installedCheckCommand()]), $server))->toBe([
"sudo systemctl is-active --quiet sysbox-mgr sysbox-fs && sudo test -x /usr/bin/fusermount3 && sudo docker info --format '{{range \$name, \$runtime := .Runtimes}}{{\$name}} {{end}}'",
]);
});