Add configuration for GitHub self-hosted runners

This commit is contained in:
Jeppe Bundgaard
2026-06-02 11:25:00 +02:00
parent 20071166f8
commit 6d739cfebc
8 changed files with 427 additions and 0 deletions
@@ -1371,6 +1371,129 @@ class coolify_manager
];
}
public function deployGithubRunners(array $input, ?int $actorUserId = null): array
{
$this->ensureSchema();
$dryRun = $this->toBool($input['dry_run'] ?? null, false);
$instanceId = (int)($input['instance_id'] ?? 0);
if ($instanceId <= 0) {
$instanceId = $this->defaultInstanceId();
}
$instance = $this->getInstance($instanceId);
$repositories = $this->githubRunnerRepositories($input);
$labels = $this->githubRunnerLabels($input['labels'] ?? null);
$countPerRepo = $this->githubRunnerCount($input['count_per_repo'] ?? $input['runner_count_per_repo'] ?? null);
$serviceName = $this->githubRunnerServiceName($input['service_name'] ?? null);
$resourceUuid = $this->nullableString($input['service_uuid'] ?? null)
?? $this->nullableString($this->coolifyConfigValue('github_runner_service_uuid', ''));
$token = $this->githubRunnerToken($input);
$template = $this->githubRunnerComposeTemplate($repositories, $labels, $countPerRepo);
$hash = $this->composeHash($template);
$plan = [
'type' => 'deploy_github_runners',
'instance_id' => $instanceId,
'service_uuid' => $resourceUuid,
'service_name' => $serviceName,
'repositories' => $repositories,
'labels' => $labels,
'count_per_repo' => $countPerRepo,
'compose_hash' => $hash,
'action' => $resourceUuid === null ? 'create' : 'update',
'token_set' => $token !== '',
'token_source' => trim((string)($input['github_token'] ?? $input['token'] ?? '')) !== '' ? 'request' : 'config',
];
if ($dryRun) {
$this->audit(null, $instanceId, null, 'github_runners_planned', $actorUserId, 'info', $plan);
return [
'ok' => true,
'dry_run' => true,
'mutated' => false,
'planned' => [$plan],
'applied' => [],
'errors' => [],
'service_uuid' => $resourceUuid,
'service_name' => $serviceName,
'compose_hash' => $hash,
'repositories' => $repositories,
'labels' => $labels,
'count_per_repo' => $countPerRepo,
];
}
if ($token === '') {
throw new RuntimeException('GitHub runner token is required to deploy self-hosted runners.');
}
$client = $this->clientForInstance($instance);
$apiResult = [];
$action = $resourceUuid === null ? 'created' : 'updated';
if ($resourceUuid === null) {
$apiResult = $client->createService($this->githubRunnerServicePayload($instance, $input, $serviceName, $template, false));
$resourceUuid = trim((string)($apiResult['uuid'] ?? ''));
if ($resourceUuid === '') {
throw new RuntimeException('Coolify did not return a GitHub runner service UUID.');
}
} else {
try {
$apiResult = $client->updateService($resourceUuid, $this->githubRunnerServicePayload($instance, $input, $serviceName, $template, true));
} catch (Throwable $throwable) {
if (!str_contains(strtolower($throwable->getMessage()), '404')
&& !str_contains(strtolower($throwable->getMessage()), 'not found')) {
throw $throwable;
}
$apiResult = $client->createService($this->githubRunnerServicePayload($instance, $input, $serviceName, $template, false));
$resourceUuid = trim((string)($apiResult['uuid'] ?? ''));
if ($resourceUuid === '') {
throw new RuntimeException('Coolify did not return a GitHub runner service UUID.');
}
$action = 'created';
}
}
$client->updateServiceEnvsBulk($resourceUuid, ['GITHUB_RUNNER_TOKEN' => $token]);
$start = $this->startOrRestartService($client, $resourceUuid, $action === 'updated');
$deployment = $client->deployResource($resourceUuid, false);
$this->setModuleConfigValue('Coolify', 'github_runner_service_uuid', $resourceUuid, 'string');
$this->setModuleConfigValue('Coolify', 'github_runner_frontend_repository', $repositories['frontend'], 'string');
$this->setModuleConfigValue('Coolify', 'github_runner_backend_repository', $repositories['backend'], 'string');
$this->setModuleConfigValue('Coolify', 'github_runner_labels', implode(',', $labels), 'string');
$this->setModuleConfigValue('Coolify', 'github_runner_count_per_repo', (string)$countPerRepo, 'int');
if ($this->toBool($input['persist_token'] ?? null, false)) {
$this->setModuleConfigValue('Coolify', 'github_runner_token', replication_secret_box::encrypt($token), 'string');
}
$applied = array_replace($plan, [
'action' => $action,
'service_uuid' => $resourceUuid,
'coolify' => self::redactCoolifyResponse($apiResult),
'start' => self::redactCoolifyResponse(is_array($start) ? $start : []),
'deployment' => self::redactCoolifyResponse($deployment),
]);
$this->audit(null, $instanceId, null, 'github_runners_deployed', $actorUserId, 'info', $applied);
return [
'ok' => true,
'dry_run' => false,
'mutated' => true,
'planned' => [$plan],
'applied' => [$applied],
'errors' => [],
'action' => $action,
'service_uuid' => $resourceUuid,
'service_name' => $serviceName,
'compose_hash' => $hash,
'repositories' => $repositories,
'labels' => $labels,
'count_per_repo' => $countPerRepo,
'deployment' => self::redactCoolifyResponse($deployment),
];
}
private function gatewayApiCodeVersionLabel(array $target): string
{
$channelSlug = trim((string)($target['channel_slug'] ?? 'gateway'));
@@ -3266,6 +3389,141 @@ class coolify_manager
];
}
private function githubRunnerRepositories(array $input): array
{
return [
'frontend' => $this->normalizeGithubRepository(
$input['frontend_repository'] ?? $input['frontend_repo'] ?? $this->coolifyConfigValue('github_runner_frontend_repository', 'copenhagentruckwash/pleno-vue'),
'frontend'
),
'backend' => $this->normalizeGithubRepository(
$input['backend_repository'] ?? $input['backend_repo'] ?? $this->coolifyConfigValue('github_runner_backend_repository', 'copenhagentruckwash/api'),
'backend'
),
];
}
private function normalizeGithubRepository(mixed $value, string $label): string
{
$repository = trim((string)$value);
$repository = preg_replace('#^https://github\.com/#i', '', $repository) ?? $repository;
$repository = preg_replace('#^git@github\.com:#i', '', $repository) ?? $repository;
$repository = preg_replace('#\.git$#i', '', $repository) ?? $repository;
$repository = trim($repository, " \t\n\r\0\x0B/");
if (!preg_match('#^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$#', $repository)) {
throw new RuntimeException('GitHub ' . $label . ' repository must be in owner/repo format.');
}
return $repository;
}
private function githubRunnerLabels(mixed $value): array
{
$raw = trim((string)($value ?? ''));
if ($raw === '') {
$raw = $this->coolifyConfigValue('github_runner_labels', 'self-hosted,Linux,X64,default');
}
$labels = array_values(array_unique(array_filter(array_map(
static fn(string $label): string => trim($label),
preg_split('/[,\s]+/', $raw) ?: []
))));
return $labels !== [] ? $labels : ['self-hosted', 'Linux', 'X64', 'default'];
}
private function githubRunnerCount(mixed $value): int
{
$count = (int)($value ?? 0);
if ($count <= 0) {
$count = (int)$this->coolifyConfigValue('github_runner_count_per_repo', '1');
}
return max(1, min(10, $count));
}
private function githubRunnerServiceName(mixed $value): string
{
$name = strtolower(trim((string)($value ?? 'truckwash-github-runners')));
$name = preg_replace('/[^a-z0-9-]+/', '-', $name) ?: '';
$name = trim($name, '-') ?: 'truckwash-github-runners';
return substr($name, 0, 120);
}
private function githubRunnerToken(array $input): string
{
$token = trim((string)($input['github_token'] ?? $input['token'] ?? ''));
if ($token !== '') {
return $token;
}
$envToken = trim((string)(getenv('GITHUB_RUNNER_TOKEN') ?: getenv('GITHUB_TOKEN') ?: ''));
if ($envToken !== '') {
return $envToken;
}
return replication_secret_box::decrypt($this->coolifyConfigValue('github_runner_token', ''));
}
private function githubRunnerComposeTemplate(array $repositories, array $labels, int $countPerRepo): array
{
$lines = ['services:'];
foreach ($repositories as $key => $repository) {
for ($index = 1; $index <= $countPerRepo; $index++) {
$service = 'github-runner-' . $key . '-' . $index;
$runnerName = 'truckwash-' . $key . '-' . $index;
$runnerLabels = array_values(array_unique(array_merge($labels, [$key])));
$lines = array_merge($lines, [
' ' . $service . ':',
' image: myoung34/github-runner:latest',
' restart: unless-stopped',
' environment:',
' REPO_URL: ' . self::yamlScalar('https://github.com/' . $repository),
' RUNNER_NAME: ' . self::yamlScalar($runnerName),
' RUNNER_SCOPE: repo',
' RUNNER_WORKDIR: /tmp/runner/work',
' LABELS: ' . self::yamlScalar(implode(',', $runnerLabels)),
' EPHEMERAL: "false"',
' RUN_AS_ROOT: "true"',
' ACCESS_TOKEN: ${GITHUB_RUNNER_TOKEN}',
' volumes:',
' - /var/run/docker.sock:/var/run/docker.sock',
]);
}
}
return [
'compose' => implode("\n", $lines) . "\n",
'env' => 'GITHUB_RUNNER_TOKEN=${GITHUB_RUNNER_TOKEN}',
];
}
private function githubRunnerServicePayload(array $instance, array $input, string $serviceName, array $template, bool $update): array
{
$payload = [
'name' => $serviceName,
'description' => 'Truckwash GitHub self-hosted runners for frontend and backend workflows.',
'instant_deploy' => false,
'docker_compose_raw' => $this->encodedDockerCompose($template),
'force_domain_override' => false,
];
if (!$update) {
$payload = array_replace($payload, [
'project_uuid' => $this->targetMapping($input, $instance, 'project_uuid'),
'environment_name' => $this->targetMapping($input, $instance, 'environment_name') ?: 'production',
'environment_uuid' => $this->targetMapping($input, $instance, 'environment_uuid'),
'server_uuid' => $this->targetMapping($input, $instance, 'server_uuid'),
'destination_uuid' => $this->targetMapping($input, $instance, 'destination_uuid'),
]);
}
return array_filter($payload, static fn($value): bool => $value !== null && $value !== '');
}
private static function yamlScalar(string $value): string
{
return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $value) . '"';
}
private function publicLoadBalancerConfig(array $config): array
{
unset($config['token']);