Files
coolify/app/Models/GithubRunnerExecution.php
Andras Bacsai 4227ec6ebf fix(storage): require deploy permission for external host writes
Require deploy or root token access when creating managed mounts outside
resource directories or changing their file contents through the API.

Synchronize KeyDB and Dragonfly passwords with local and shared
REDIS_PASSWORD values while preserving secret manager resolution.
2026-10-05 12:03:37 +02:00

187 lines
6.1 KiB
PHP

<?php
namespace App\Models;
use App\Enums\GithubRunnerStatus;
use App\Jobs\DeregisterGithubRunnerJob;
use App\Jobs\ProvisionGithubRunnerJob;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class GithubRunnerExecution extends BaseModel
{
protected $fillable = [
'github_app_id',
'github_runner_config_id',
'server_id',
'status',
'trigger_workflow_job_id',
'workflow_job_id',
'workflow_job_html_url',
'workflow_name',
'job_name',
'repository_full_name',
'labels',
'is_pull_request',
'runner_name',
'runner_id',
'conclusion',
'error_message',
'provision_attempts',
'queued_at',
'ready_at',
'started_at',
'completed_at',
];
protected function casts(): array
{
return [
'status' => GithubRunnerStatus::class,
'trigger_workflow_job_id' => 'integer',
'workflow_job_id' => 'integer',
'runner_id' => 'integer',
'provision_attempts' => 'integer',
'labels' => 'array',
'is_pull_request' => 'boolean',
'queued_at' => 'datetime',
'ready_at' => 'datetime',
'started_at' => 'datetime',
'completed_at' => 'datetime',
];
}
public function githubApp(): BelongsTo
{
return $this->belongsTo(GithubApp::class);
}
public function config(): BelongsTo
{
return $this->belongsTo(GithubRunnerConfig::class, 'github_runner_config_id');
}
public function server(): BelongsTo
{
return $this->belongsTo(Server::class);
}
/**
* Deregistering the runners from GitHub is best effort and never blocks the deletion.
*
* @param Builder<GithubRunnerExecution> $query
*/
public static function deleteAndDeregister(Builder $query): void
{
(clone $query)
->whereIn('status', GithubRunnerStatus::occupying())
->whereNotNull('runner_id')
->get(['github_app_id', 'runner_id'])
->each(fn (GithubRunnerExecution $execution) => DeregisterGithubRunnerJob::dispatch($execution->github_app_id, $execution->runner_id));
(clone $query)->delete();
}
/**
* Marks the runner running. When a job without a pending runner took it, returns a new queued execution for the original job.
*
* @param array<string, mixed> $jobDetails
*/
public function assignJob(int $jobId, array $jobDetails): ?self
{
$replacement = DB::transaction(function () use ($jobId, $jobDetails): ?self {
$execution = self::query()->whereKey($this->id)->lockForUpdate()->first();
if (! $execution || ! in_array($execution->status, [GithubRunnerStatus::Provisioning, GithubRunnerStatus::Idle], true)) {
return null;
}
$running = [...$jobDetails, 'workflow_job_id' => $jobId, 'status' => GithubRunnerStatus::Running, 'started_at' => now()];
$jobExecution = $execution->trigger_workflow_job_id === $jobId ? $execution : self::query()
->where('github_app_id', $execution->github_app_id)
->where('trigger_workflow_job_id', $jobId)
->lockForUpdate()
->first();
if ($jobExecution?->isActive()) {
$execution->update($running);
return null;
}
$replacement = [
...$execution->only(['github_app_id', 'trigger_workflow_job_id', 'workflow_job_html_url', 'workflow_name', 'job_name', 'repository_full_name', 'labels', 'is_pull_request', 'provision_attempts']),
'workflow_job_id' => $execution->trigger_workflow_job_id,
'status' => GithubRunnerStatus::Queued,
'queued_at' => now(),
];
$jobExecution?->delete();
$execution->update([...$running, 'trigger_workflow_job_id' => $jobId]);
if ($replacement['provision_attempts'] >= ProvisionGithubRunnerJob::MAX_ATTEMPTS) {
Log::warning('GitHub runner taken by another job too often; no new runner is started.', [
'github_app_id' => $execution->github_app_id,
'workflow_job_id' => $replacement['trigger_workflow_job_id'],
'attempts' => $replacement['provision_attempts'],
]);
return null;
}
$hasMatchingConfig = GithubRunnerConfig::query()
->where('github_app_id', $execution->github_app_id)
->where('is_enabled', true)
->get()
->contains(fn (GithubRunnerConfig $config) => $config->matchesLabels($replacement['labels'] ?? [])
&& ($config->allow_pull_requests || ! $replacement['is_pull_request']));
return $hasMatchingConfig ? self::create($replacement) : null;
});
$this->refresh();
return $replacement;
}
public function isActive(): bool
{
return $this->status->isActive();
}
/**
* Base name of the Docker resources (containers, network, volumes) of this runner.
*/
public function containerName(): string
{
return 'coolify-runner-'.$this->uuid;
}
/**
* Marks the runner as finished and keeps the first completion time.
*/
public function finish(GithubRunnerStatus $status, ?string $errorMessage = null): void
{
$this->update([
'status' => $status,
'error_message' => $errorMessage,
'completed_at' => $this->completed_at ?? now(),
]);
}
public function duration(): ?string
{
if (! $this->started_at) {
return null;
}
return $this->started_at->diffForHumans($this->completed_at ?? now(), true);
}
public function workflowJobUrl(): ?string
{
$url = trim((string) $this->workflow_job_html_url);
return str_starts_with($url, 'https://') ? $url : null;
}
}