From 6d739cfebc13acda5bbb82638f2cb8b153750843 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Tue, 2 Jun 2026 11:25:00 +0200 Subject: [PATCH] Add configuration for GitHub self-hosted runners --- .../nginx/app/classes/coolify_manager.php | 258 ++++++++++++++++++ .../app/classes/coolify_schema_bootstrap.php | 6 + ...ify_github_runner_backend_repository_c.php | 25 ++ ...coolify_github_runner_count_per_repo_c.php | 25 ++ ...fy_github_runner_frontend_repository_c.php | 25 ++ .../config/coolify_github_runner_labels_c.php | 25 ++ .../coolify_github_runner_service_uuid_c.php | 25 ++ .../config/coolify_github_runner_token_c.php | 38 +++ 8 files changed, 427 insertions(+) create mode 100644 services/nginx/app/modules/coolify/config/coolify_github_runner_backend_repository_c.php create mode 100644 services/nginx/app/modules/coolify/config/coolify_github_runner_count_per_repo_c.php create mode 100644 services/nginx/app/modules/coolify/config/coolify_github_runner_frontend_repository_c.php create mode 100644 services/nginx/app/modules/coolify/config/coolify_github_runner_labels_c.php create mode 100644 services/nginx/app/modules/coolify/config/coolify_github_runner_service_uuid_c.php create mode 100644 services/nginx/app/modules/coolify/config/coolify_github_runner_token_c.php diff --git a/services/nginx/app/classes/coolify_manager.php b/services/nginx/app/classes/coolify_manager.php index f174ac1f..97609f5a 100644 --- a/services/nginx/app/classes/coolify_manager.php +++ b/services/nginx/app/classes/coolify_manager.php @@ -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']); diff --git a/services/nginx/app/classes/coolify_schema_bootstrap.php b/services/nginx/app/classes/coolify_schema_bootstrap.php index 1d349625..ef5e9b67 100644 --- a/services/nginx/app/classes/coolify_schema_bootstrap.php +++ b/services/nginx/app/classes/coolify_schema_bootstrap.php @@ -154,6 +154,12 @@ class coolify_schema_bootstrap self::ensureModuleConfigDefault('Coolify', 'hetzner_cloud_api_token', '', 'string'); self::ensureModuleConfigDefault('Coolify', 'public_gateway_host', 'api-v2.truckwash.io', 'string'); self::ensureModuleConfigDefault('Coolify', 'public_gateway_probe_path', '', 'string'); + self::ensureModuleConfigDefault('Coolify', 'github_runner_token', '', 'string'); + self::ensureModuleConfigDefault('Coolify', 'github_runner_service_uuid', '', 'string'); + self::ensureModuleConfigDefault('Coolify', 'github_runner_frontend_repository', 'copenhagentruckwash/pleno-vue', 'string'); + self::ensureModuleConfigDefault('Coolify', 'github_runner_backend_repository', 'copenhagentruckwash/api', 'string'); + self::ensureModuleConfigDefault('Coolify', 'github_runner_labels', 'self-hosted,Linux,X64,default', 'string'); + self::ensureModuleConfigDefault('Coolify', 'github_runner_count_per_repo', '1', 'int'); self::ensureDefaultGateway('node1.truckwash.io', '94.130.142.41', 10); self::ensureDefaultGateway('node2.truckwash.io', '65.21.214.30', 20); diff --git a/services/nginx/app/modules/coolify/config/coolify_github_runner_backend_repository_c.php b/services/nginx/app/modules/coolify/config/coolify_github_runner_backend_repository_c.php new file mode 100644 index 00000000..c50c837e --- /dev/null +++ b/services/nginx/app/modules/coolify/config/coolify_github_runner_backend_repository_c.php @@ -0,0 +1,25 @@ +setupConfigVariable( + 'Coolify', + 'github_runner_backend_repository', + 'string', + false, + null, + 'GitHub backend repository that receives Coolify-managed self-hosted runners.', + 'copenhagentruckwash/api', + false, + 'copenhagentruckwash/api' + ); + } +} diff --git a/services/nginx/app/modules/coolify/config/coolify_github_runner_count_per_repo_c.php b/services/nginx/app/modules/coolify/config/coolify_github_runner_count_per_repo_c.php new file mode 100644 index 00000000..55ecbbaa --- /dev/null +++ b/services/nginx/app/modules/coolify/config/coolify_github_runner_count_per_repo_c.php @@ -0,0 +1,25 @@ +setupConfigVariable( + 'Coolify', + 'github_runner_count_per_repo', + 'int', + false, + null, + 'Number of self-hosted GitHub runner containers to deploy per repository.', + '1', + false, + '1' + ); + } +} diff --git a/services/nginx/app/modules/coolify/config/coolify_github_runner_frontend_repository_c.php b/services/nginx/app/modules/coolify/config/coolify_github_runner_frontend_repository_c.php new file mode 100644 index 00000000..9f8e229d --- /dev/null +++ b/services/nginx/app/modules/coolify/config/coolify_github_runner_frontend_repository_c.php @@ -0,0 +1,25 @@ +setupConfigVariable( + 'Coolify', + 'github_runner_frontend_repository', + 'string', + false, + null, + 'GitHub frontend repository that receives Coolify-managed self-hosted runners.', + 'copenhagentruckwash/pleno-vue', + false, + 'copenhagentruckwash/pleno-vue' + ); + } +} diff --git a/services/nginx/app/modules/coolify/config/coolify_github_runner_labels_c.php b/services/nginx/app/modules/coolify/config/coolify_github_runner_labels_c.php new file mode 100644 index 00000000..cb7355a3 --- /dev/null +++ b/services/nginx/app/modules/coolify/config/coolify_github_runner_labels_c.php @@ -0,0 +1,25 @@ +setupConfigVariable( + 'Coolify', + 'github_runner_labels', + 'string', + false, + null, + 'Comma-separated GitHub Actions runner labels registered on each Coolify-managed runner.', + 'self-hosted,Linux,X64,default', + false, + 'self-hosted,Linux,X64,default' + ); + } +} diff --git a/services/nginx/app/modules/coolify/config/coolify_github_runner_service_uuid_c.php b/services/nginx/app/modules/coolify/config/coolify_github_runner_service_uuid_c.php new file mode 100644 index 00000000..7027a9b6 --- /dev/null +++ b/services/nginx/app/modules/coolify/config/coolify_github_runner_service_uuid_c.php @@ -0,0 +1,25 @@ +setupConfigVariable( + 'Coolify', + 'github_runner_service_uuid', + 'string', + false, + null, + 'Coolify service UUID for the managed GitHub self-hosted runner stack.', + 'abc123...', + false, + '' + ); + } +} diff --git a/services/nginx/app/modules/coolify/config/coolify_github_runner_token_c.php b/services/nginx/app/modules/coolify/config/coolify_github_runner_token_c.php new file mode 100644 index 00000000..ab4d0c02 --- /dev/null +++ b/services/nginx/app/modules/coolify/config/coolify_github_runner_token_c.php @@ -0,0 +1,38 @@ +setupConfigVariable( + 'Coolify', + 'github_runner_token', + 'string', + false, + null, + 'GitHub PAT used to register Coolify-managed self-hosted repository runners.', + 'github_pat_...', + true, + '' + ); + } + + public function setVariableValue(mixed $value): void + { + $value = trim((string)($value ?? '')); + if ($value !== '' && !str_starts_with($value, 'twsec:v1:')) { + $value = replication_secret_box::encrypt($value); + } + + $this->traitSetVariableValue($value); + } +}