mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
The runner group was limited to private repositories, so jobs from public repositories never got a runner: Coolify still started a runner for each matching job, which then waited idle until its timeout. The runner group now allows every repository of the organization, public ones too; existing private groups are updated. GitHub gives a runner any queued job with matching labels, so pull request jobs are refused inside the runner: a job-started hook fails jobs from pull_request, pull_request_target and review events before any step runs, and fails closed when the event is unknown. A new runner setting, off by default, allows pull request jobs; the settings page then warns to require approval for fork pull request workflows on GitHub.
231 lines
8.1 KiB
PHP
231 lines
8.1 KiB
PHP
<?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.
|
|
* Unless the config allows pull request jobs, a job-started hook fails them before any step runs.
|
|
* 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;
|
|
|
|
private const HOOK_PATH = '/home/runner/coolify-job-started.sh';
|
|
|
|
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->config->allow_pull_requests) {
|
|
$hookFile = self::hookFileFor($this->execution->uuid);
|
|
$commands[] = 'echo '.base64_encode(self::pullRequestHookScript())." | base64 -d | tee {$hookFile} > /dev/null";
|
|
$commands[] = "chmod 644 {$hookFile}";
|
|
$runnerFlags .= " -v {$hookFile}:".self::HOOK_PATH.':ro -e ACTIONS_RUNNER_HOOK_JOB_STARTED='.self::HOOK_PATH;
|
|
}
|
|
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 ".self::hookFileFor($executionUuid),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Job-started hook that fails pull request jobs before any step runs. GitHub gives a runner
|
|
* any queued job with matching labels, so the check must run in the runner, not when Coolify
|
|
* starts it. An unknown event fails closed.
|
|
*/
|
|
public static function pullRequestHookScript(): string
|
|
{
|
|
return <<<'BASH'
|
|
#!/bin/bash
|
|
case "${GITHUB_EVENT_NAME:-}" in
|
|
pull_request|pull_request_target|pull_request_review|pull_request_review_comment|"")
|
|
echo "::error::This Coolify runner does not run pull request jobs. Allow pull request jobs in the GitHub runner settings of the build server to run them."
|
|
exit 1
|
|
;;
|
|
esac
|
|
exit 0
|
|
|
|
BASH;
|
|
}
|
|
|
|
private static function hookFileFor(string $executionUuid): string
|
|
{
|
|
return self::SECRETS_DIRECTORY."/{$executionUuid}-job-started.sh";
|
|
}
|
|
|
|
/**
|
|
* 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}";
|
|
}
|
|
}
|