mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
feat(github-runners): allow public repositories and refuse pull request jobs by default
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.
This commit is contained in:
1 parent
d1cf3e6ef1
commit
bcc07dd47e
8 files changed
+194
-14
No files matched your search
@@ -53,6 +53,8 @@ class GithubRunners extends Component
|
||||
|
||||
public bool $isDedicated = false;
|
||||
|
||||
public bool $allowPullRequests = false;
|
||||
|
||||
/** Null until checked over SSH. */
|
||||
public ?bool $isSysboxInstalled = null;
|
||||
|
||||
@@ -87,6 +89,7 @@ class GithubRunners extends Component
|
||||
'idleTimeout' => ['required', 'integer', 'min:1', 'max:1440'],
|
||||
'jobTimeout' => ['required', 'integer', 'min:1', 'max:7200'],
|
||||
'isDedicated' => ['required', 'boolean'],
|
||||
'allowPullRequests' => ['required', 'boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -139,6 +142,7 @@ class GithubRunners extends Component
|
||||
$this->idleTimeout = $config->idle_timeout;
|
||||
$this->jobTimeout = $config->job_timeout;
|
||||
$this->isDedicated = $config->is_dedicated;
|
||||
$this->allowPullRequests = $config->allow_pull_requests;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -199,6 +203,7 @@ class GithubRunners extends Component
|
||||
'idle_timeout' => $this->idleTimeout,
|
||||
'job_timeout' => $this->jobTimeout,
|
||||
'is_dedicated' => $this->isDedicated,
|
||||
'allow_pull_requests' => $this->allowPullRequests,
|
||||
]);
|
||||
unset($this->config);
|
||||
$this->syncData();
|
||||
|
||||
@@ -20,6 +20,7 @@ class GithubRunnerConfig extends BaseModel
|
||||
'labels',
|
||||
'is_enabled',
|
||||
'is_dedicated',
|
||||
'allow_pull_requests',
|
||||
'max_runners',
|
||||
'docker_mode',
|
||||
'runner_image',
|
||||
@@ -36,6 +37,7 @@ class GithubRunnerConfig extends BaseModel
|
||||
'labels' => 'array',
|
||||
'is_enabled' => 'boolean',
|
||||
'is_dedicated' => 'boolean',
|
||||
'allow_pull_requests' => 'boolean',
|
||||
'max_runners' => 'integer',
|
||||
'docker_mode' => GithubRunnerDockerMode::class,
|
||||
'capacity_wait_timeout' => 'integer',
|
||||
|
||||
@@ -16,7 +16,9 @@ class GithubRunnerApi
|
||||
public function __construct(private GithubApp $githubApp) {}
|
||||
|
||||
/**
|
||||
* Finds or creates the runner group of the App. The group is limited to private repositories.
|
||||
* Finds or creates the runner group of the App. Every repository of the organization, public ones too,
|
||||
* can use it; runners refuse pull request jobs unless the runner config allows them.
|
||||
* Groups created before public repositories were allowed are updated.
|
||||
* 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.
|
||||
*
|
||||
@@ -29,6 +31,10 @@ class GithubRunnerApi
|
||||
if ($this->githubApp->runner_group_id) {
|
||||
$response = $this->client()->get("/orgs/{$org}/actions/runner-groups/{$this->githubApp->runner_group_id}");
|
||||
if ($response->successful()) {
|
||||
if (! $response->json('default') && ($response->json('visibility') !== 'all' || $response->json('allows_public_repositories') !== true)) {
|
||||
$this->allowAllRepositories((int) $this->githubApp->runner_group_id);
|
||||
}
|
||||
|
||||
return ['id' => (int) $this->githubApp->runner_group_id, 'is_default' => (bool) $response->json('default')];
|
||||
}
|
||||
if ($response->status() !== 404) {
|
||||
@@ -38,8 +44,8 @@ class GithubRunnerApi
|
||||
|
||||
$response = $this->client()->post("/orgs/{$org}/actions/runner-groups", [
|
||||
'name' => 'Coolify '.$this->githubApp->uuid,
|
||||
'visibility' => 'private',
|
||||
'allows_public_repositories' => false,
|
||||
'visibility' => 'all',
|
||||
'allows_public_repositories' => true,
|
||||
]);
|
||||
if (! $response->successful() || ! $response->json('id')) {
|
||||
throw new RuntimeException('Could not create a runner group: '.$this->errorMessage($response));
|
||||
@@ -51,6 +57,17 @@ class GithubRunnerApi
|
||||
return $group;
|
||||
}
|
||||
|
||||
private function allowAllRepositories(int $runnerGroupId): void
|
||||
{
|
||||
$response = $this->client()->patch("/orgs/{$this->organization()}/actions/runner-groups/{$runnerGroupId}", [
|
||||
'visibility' => 'all',
|
||||
'allows_public_repositories' => true,
|
||||
]);
|
||||
if (! $response->successful()) {
|
||||
throw new RuntimeException('Could not update the runner group: '.$this->errorMessage($response));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $labels
|
||||
* @return array{runner_id: int, encoded_jit_config: string}
|
||||
|
||||
@@ -10,6 +10,7 @@ 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
|
||||
@@ -27,6 +28,8 @@ class GithubRunnerContainer
|
||||
|
||||
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
|
||||
@@ -110,6 +113,12 @@ class GithubRunnerContainer
|
||||
];
|
||||
|
||||
$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();
|
||||
@@ -133,10 +142,35 @@ class GithubRunnerContainer
|
||||
"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",
|
||||
'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.
|
||||
*/
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Runners refuse pull request jobs unless this is enabled.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('github_runner_configs', function (Blueprint $table) {
|
||||
$table->boolean('allow_pull_requests')->default(false)->after('is_dedicated');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('github_runner_configs', function (Blueprint $table) {
|
||||
$table->dropColumn('allow_pull_requests');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -50,7 +50,7 @@
|
||||
@else
|
||||
<form wire:submit="submit" class="contents">
|
||||
<x-unsaved-bar action="submit"
|
||||
targets="githubAppId,labels,maxRunners,dockerMode,runnerImage,cpuLimit,memoryLimit,capacityWaitTimeout,idleTimeout,jobTimeout,isDedicated" />
|
||||
targets="githubAppId,labels,maxRunners,dockerMode,runnerImage,cpuLimit,memoryLimit,capacityWaitTimeout,idleTimeout,jobTimeout,isDedicated,allowPullRequests" />
|
||||
<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>
|
||||
@@ -64,10 +64,17 @@
|
||||
</x-slot:actions>
|
||||
|
||||
<div x-cloak x-show="$wire.dockerMode === 'dind'">
|
||||
<x-callout type="warning" title="Use only for trusted, private repositories">
|
||||
<x-callout type="warning" title="Use only for trusted 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.
|
||||
Use Isolated Docker (Sysbox) or No Docker to keep jobs away from the server. Every
|
||||
repository of the organization can use these runners, public ones too.
|
||||
</x-callout>
|
||||
</div>
|
||||
<div x-cloak x-show="$wire.allowPullRequests" class="mt-4">
|
||||
<x-callout type="warning" title="Pull request jobs run on this server">
|
||||
In a public repository, anyone can open a pull request and change its workflow. Turn
|
||||
on approval for fork pull request workflows in the organization's Actions settings on
|
||||
GitHub, and do not use the privileged Docker mode.
|
||||
</x-callout>
|
||||
</div>
|
||||
|
||||
@@ -98,6 +105,12 @@
|
||||
['value' => true, 'label' => 'Dedicated to runners'],
|
||||
]"
|
||||
helper="Dedicated servers are not used for application builds while runners are enabled." />
|
||||
<x-forms.listbox id="allowPullRequests" label="Pull request jobs" canGate="update"
|
||||
:canResource="$server" :options="[
|
||||
['value' => false, 'label' => 'Refuse pull request jobs'],
|
||||
['value' => true, 'label' => 'Run pull request jobs'],
|
||||
]"
|
||||
helper="Runners fail jobs that a pull request started (pull_request, pull_request_target, and review events) before any step runs. Push, schedule, and manual jobs always run. Applies to runners that start after you save." />
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ function fakeRunnerGroupApi(GithubApp $githubApp, int $createStatus = 201): void
|
||||
}
|
||||
|
||||
describe('runner settings page', function () {
|
||||
it('saves the configuration and creates a runner group for private repositories only', function () {
|
||||
it('saves the configuration and creates a runner group that public repositories can use', function () {
|
||||
fakeRunnerGroupApi($this->githubApp);
|
||||
|
||||
Livewire::actingAs($this->owner)
|
||||
@@ -128,8 +128,46 @@ describe('runner settings page', function () {
|
||||
->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');
|
||||
&& $request['allows_public_repositories'] === true
|
||||
&& $request['visibility'] === 'all');
|
||||
expect($config->allow_pull_requests)->toBeFalse();
|
||||
});
|
||||
|
||||
it('opens an existing private runner group to public repositories', function () {
|
||||
$this->githubApp->update(['runner_group_id' => 77]);
|
||||
Http::fake([
|
||||
'https://api.github.com/zen' => Http::response('ok', 200, ['Date' => now()->toRfc7231String()]),
|
||||
"https://api.github.com/app/installations/{$this->githubApp->installation_id}/access_tokens" => Http::response(['token' => 'installation-token'], 201),
|
||||
'https://api.github.com/orgs/acme/actions/runner-groups/77' => fn ($request) => $request->method() === 'PATCH'
|
||||
? Http::response(['id' => 77, 'default' => false, 'visibility' => 'all', 'allows_public_repositories' => true])
|
||||
: Http::response(['id' => 77, 'default' => false, 'visibility' => 'private', 'allows_public_repositories' => false]),
|
||||
]);
|
||||
|
||||
Livewire::actingAs($this->owner)
|
||||
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
||||
->set('githubAppId', $this->githubApp->id)
|
||||
->call('submit')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('success');
|
||||
|
||||
Http::assertSent(fn ($request) => $request->method() === 'PATCH'
|
||||
&& str_ends_with($request->url(), '/orgs/acme/actions/runner-groups/77')
|
||||
&& $request['allows_public_repositories'] === true
|
||||
&& $request['visibility'] === 'all');
|
||||
});
|
||||
|
||||
it('saves whether runners take pull request jobs', function () {
|
||||
fakeRunnerGroupApi($this->githubApp);
|
||||
|
||||
Livewire::actingAs($this->owner)
|
||||
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
||||
->set('githubAppId', $this->githubApp->id)
|
||||
->set('allowPullRequests', true)
|
||||
->call('submit')
|
||||
->assertHasNoErrors()
|
||||
->assertSet('allowPullRequests', true);
|
||||
|
||||
expect(GithubRunnerConfig::sole()->allow_pull_requests)->toBeTrue();
|
||||
});
|
||||
|
||||
it('does not fall back to the Default runner group when group creation fails', function (int $status) {
|
||||
|
||||
@@ -4,6 +4,7 @@ use App\Models\GithubRunnerConfig;
|
||||
use App\Models\GithubRunnerExecution;
|
||||
use App\Models\Server;
|
||||
use App\Services\GithubRunner\GithubRunnerContainer;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
@@ -44,9 +45,10 @@ it('passes the JIT config through a root-only env file and never mounts the host
|
||||
->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])
|
||||
->and(collect($commands)->first(fn (string $command) => str_starts_with($command, 'docker run -d --name coolify-runner-abc123 ')))
|
||||
->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')
|
||||
->toContain('--env-file /data/coolify/github-runners/abc123.env ')
|
||||
->toContain('-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');
|
||||
});
|
||||
@@ -93,7 +95,8 @@ 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");
|
||||
expect(collect($commands)->first(fn (string $command) => str_starts_with($command, 'docker run -d --name coolify-runner-abc123 ')))
|
||||
->toEndWith("'ghcr.io/acme/runner:1.0' /home/runner/run.sh");
|
||||
});
|
||||
|
||||
it('keeps every command valid for servers with a non-root user', function () {
|
||||
@@ -112,6 +115,8 @@ it('keeps every command valid for servers with a non-root user', function () {
|
||||
->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('echo '.base64_encode(GithubRunnerContainer::pullRequestHookScript()).' | sudo base64 -d | sudo tee /data/coolify/github-runners/abc123-job-started.sh > /dev/null')
|
||||
->toContain('sudo chmod 644 /data/coolify/github-runners/abc123-job-started.sh')
|
||||
->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}}\'');
|
||||
@@ -119,3 +124,44 @@ it('keeps every command valid for servers with a non-root user', function () {
|
||||
$linesWithoutSudo = collect($parsed)->reject(fn (string $line) => str_starts_with($line, 'sudo ') || str_starts_with($line, 'echo '));
|
||||
expect($linesWithoutSudo)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('installs a job-started hook that refuses pull request jobs by default', function () {
|
||||
$commands = (new GithubRunnerContainer($this->execution, $this->config))->startCommands('SECRET');
|
||||
$hookFile = '/data/coolify/github-runners/abc123-job-started.sh';
|
||||
|
||||
expect($commands)->toContain('echo '.base64_encode(GithubRunnerContainer::pullRequestHookScript()).' | base64 -d | tee '.$hookFile.' > /dev/null')
|
||||
->toContain("chmod 644 {$hookFile}")
|
||||
->and(collect($commands)->first(fn (string $command) => str_starts_with($command, 'docker run -d --name coolify-runner-abc123 ')))
|
||||
->toContain("-v {$hookFile}:/home/runner/coolify-job-started.sh:ro -e ACTIONS_RUNNER_HOOK_JOB_STARTED=/home/runner/coolify-job-started.sh")
|
||||
->and(GithubRunnerContainer::cleanupCommands('abc123'))->toContain("rm -f /data/coolify/github-runners/abc123.env {$hookFile}");
|
||||
});
|
||||
|
||||
it('does not install the pull request hook when pull request jobs are allowed', function () {
|
||||
$this->config->forceFill(['allow_pull_requests' => true]);
|
||||
$all = implode("\n", (new GithubRunnerContainer($this->execution, $this->config))->startCommands('SECRET'));
|
||||
|
||||
expect($all)->not->toContain('job-started')
|
||||
->not->toContain('ACTIONS_RUNNER_HOOK_JOB_STARTED');
|
||||
});
|
||||
|
||||
it('fails pull request jobs in the job-started hook and lets other jobs run', function (?string $eventName, int $expectedExitCode) {
|
||||
$hook = tempnam(sys_get_temp_dir(), 'hook');
|
||||
file_put_contents($hook, GithubRunnerContainer::pullRequestHookScript());
|
||||
|
||||
$environment = $eventName === null ? [] : ['GITHUB_EVENT_NAME' => $eventName];
|
||||
$process = new Process(['bash', $hook], env: ['PATH' => getenv('PATH'), ...$environment]);
|
||||
$process->run();
|
||||
unlink($hook);
|
||||
|
||||
expect($process->getExitCode())->toBe($expectedExitCode);
|
||||
})->with([
|
||||
'pull_request' => ['pull_request', 1],
|
||||
'pull_request_target' => ['pull_request_target', 1],
|
||||
'pull_request_review' => ['pull_request_review', 1],
|
||||
'pull_request_review_comment' => ['pull_request_review_comment', 1],
|
||||
'unknown event fails closed' => [null, 1],
|
||||
'push' => ['push', 0],
|
||||
'workflow_dispatch' => ['workflow_dispatch', 0],
|
||||
'schedule' => ['schedule', 0],
|
||||
'merge_group' => ['merge_group', 0],
|
||||
]);
|
||||
Reference in new issue
Block a user