mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
235 lines
9.0 KiB
PHP
235 lines
9.0 KiB
PHP
<?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 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);
|
|
}
|
|
|
|
if ($this->stopIfFinished($execution)) {
|
|
return;
|
|
}
|
|
|
|
$runner = $api->generateJitConfig($container->name(), $config->registeredLabels(), $runnerGroupId);
|
|
$execution->update(['runner_id' => $runner['runner_id']]);
|
|
if ($this->stopIfFinished($execution)) {
|
|
return;
|
|
}
|
|
// stopIfFinished() reloaded the server, so a transfer that just finished stops the start command.
|
|
instant_remote_process($container->startCommands($runner['encoded_jit_config']), $execution->server);
|
|
|
|
if ($this->stopIfFinished($execution)) {
|
|
return;
|
|
}
|
|
GithubRunnerExecution::query()
|
|
->whereKey($execution->id)
|
|
->whereIn('status', GithubRunnerStatus::occupying())
|
|
->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
|
|
{
|
|
if ($this->stopIfFinished($execution)) {
|
|
return;
|
|
}
|
|
|
|
$attempt = $execution->provision_attempts;
|
|
if ($attempt >= self::MAX_ATTEMPTS) {
|
|
GithubRunnerExecution::query()
|
|
->whereKey($execution->id)
|
|
->where('status', GithubRunnerStatus::Provisioning)
|
|
->update([
|
|
'status' => GithubRunnerStatus::Failed,
|
|
'error_message' => remove_iip("Could not start a runner after {$attempt} attempts: {$e->getMessage()}"),
|
|
'completed_at' => now(),
|
|
]);
|
|
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();
|
|
$retried = GithubRunnerExecution::query()
|
|
->whereKey($execution->id)
|
|
->where('status', GithubRunnerStatus::Provisioning)
|
|
->update([
|
|
'status' => GithubRunnerStatus::Queued,
|
|
'github_runner_config_id' => null,
|
|
'server_id' => null,
|
|
'runner_name' => null,
|
|
'runner_id' => null,
|
|
'ready_at' => null,
|
|
'error_message' => remove_iip("Attempt {$attempt} of ".self::MAX_ATTEMPTS." failed: {$e->getMessage()}"),
|
|
]);
|
|
if (! $retried) {
|
|
return;
|
|
}
|
|
self::dispatch($execution->id)->delay(now()->addSeconds(self::RETRY_DELAY_SECONDS * $attempt));
|
|
}
|
|
|
|
private function stopIfFinished(GithubRunnerExecution $execution): bool
|
|
{
|
|
// The execution is deleted together with its server.
|
|
if (! GithubRunnerExecution::query()->whereKey($execution->id)->exists()) {
|
|
if ($execution->runner_id) {
|
|
DeregisterGithubRunnerJob::dispatch($execution->github_app_id, $execution->runner_id);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// Reloads the server too: a transfer to another instance can finish while this job runs.
|
|
$execution->refresh();
|
|
if ($execution->isActive() && ! $execution->server?->isTransferredAway()) {
|
|
return false;
|
|
}
|
|
|
|
if ($execution->isActive()) {
|
|
$execution->finish(GithubRunnerStatus::Cancelled, 'The server was transferred to another Coolify instance.');
|
|
}
|
|
CleanupGithubRunnerJob::dispatch($execution->id);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 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->allow_pull_requests || ! $execution->is_pull_request)
|
|
&& $config->server->canBuildApplications()
|
|
&& ! $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.');
|
|
}
|
|
}
|