mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
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:
co-authored by
Claude Opus 5.5
parent
03a056709d
commit
fd85b740f5
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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.',
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
+28
@@ -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>
|
||||
|
||||
@@ -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
@@ -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)" \
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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}}'",
|
||||
]);
|
||||
});
|
||||
Reference in New Issue
Block a user