mirror of
https://github.com/coollabsio/coolify.git
synced 2026-03-11 08:55:47 +00:00
feat(github-runners): improve runner lifecycle and GitHub sync
Add runner execution observability and lifecycle hardening for self-hosted GitHub Actions runners, including: - scheduled artifact cleanup job for cached runner tarballs/templates - workflow_job in_progress handling to mark executions as running - safer cleanup failure handling and non-functional server fallback - persisted workflow job HTML URLs with execution "Open" links in UI - configurable runner group name sync (UI + provisioning + GitHub API) - webhook events persistence and auto-fix for missing required events - strict enum/status checks and related model cast/query improvements Also includes migrations and expanded feature/unit coverage for webhook, provisioning, cleanup, and runner group/event sync behaviors.
This commit is contained in:
parent
cec2f388df
commit
5c50ab6162
33 changed files with 1813 additions and 120 deletions
|
|
@ -5,6 +5,7 @@ namespace App\Console;
|
|||
use App\Jobs\CheckForUpdatesJob;
|
||||
use App\Jobs\CheckHelperImageJob;
|
||||
use App\Jobs\CheckTraefikVersionJob;
|
||||
use App\Jobs\CleanupGithubRunnerArtifactsJob;
|
||||
use App\Jobs\CleanupInstanceStuffsJob;
|
||||
use App\Jobs\CleanupOrphanedPreviewContainersJob;
|
||||
use App\Jobs\CleanupStaleGithubRunnersJob;
|
||||
|
|
@ -57,6 +58,7 @@ class Kernel extends ConsoleKernel
|
|||
|
||||
$this->scheduleInstance->command('uploads:clear')->everyTwoMinutes();
|
||||
$this->scheduleInstance->job(new CleanupStaleGithubRunnersJob)->everyFiveMinutes()->onOneServer();
|
||||
$this->scheduleInstance->job(new CleanupGithubRunnerArtifactsJob)->dailyAt('02:00')->onOneServer();
|
||||
|
||||
} else {
|
||||
// Instance Jobs
|
||||
|
|
@ -89,6 +91,7 @@ class Kernel extends ConsoleKernel
|
|||
|
||||
// Cleanup stale GitHub Actions runners
|
||||
$this->scheduleInstance->job(new CleanupStaleGithubRunnersJob)->everyFiveMinutes()->onOneServer();
|
||||
$this->scheduleInstance->job(new CleanupGithubRunnerArtifactsJob)->dailyAt('02:00')->onOneServer();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,6 @@ enum GithubRunnerStatus: string
|
|||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return in_array($this, [self::Queued, self::Provisioning, self::Running, self::Cleaning]);
|
||||
return in_array($this, [self::Queued, self::Provisioning, self::Running, self::Cleaning], true);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Http\Controllers\Webhook;
|
||||
|
||||
use App\Enums\GithubRunnerStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\CleanupGithubRunnerJob;
|
||||
use App\Jobs\GithubAppPermissionJob;
|
||||
|
|
@ -9,6 +10,7 @@ use App\Jobs\ProcessGithubPullRequestWebhook;
|
|||
use App\Jobs\ProvisionGithubRunnerJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\GithubRunnerExecution;
|
||||
use App\Models\PrivateKey;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
|
|
@ -229,6 +231,14 @@ class Github extends Controller
|
|||
if ($x_github_event === 'workflow_job') {
|
||||
$action = data_get($payload, 'action');
|
||||
$workflowJob = data_get($payload, 'workflow_job');
|
||||
$workflowJobId = (int) data_get($workflowJob, 'id', 0);
|
||||
|
||||
ray("[webhook] workflow_job.{$action} received", [
|
||||
'workflow_job_id' => $workflowJobId,
|
||||
'runner_name' => data_get($workflowJob, 'runner_name'),
|
||||
'workflow_name' => data_get($workflowJob, 'workflow_name'),
|
||||
'conclusion' => data_get($workflowJob, 'conclusion'),
|
||||
]);
|
||||
|
||||
if ($action === 'queued' && $workflowJob) {
|
||||
ProvisionGithubRunnerJob::dispatch(
|
||||
|
|
@ -236,14 +246,32 @@ class Github extends Controller
|
|||
workflowJobPayload: collect($workflowJob)->toArray(),
|
||||
organizationLogin: data_get($payload, 'organization.login', ''),
|
||||
repositoryId: (int) data_get($payload, 'repository.id', 0),
|
||||
repositoryFullName: data_get($payload, 'repository.full_name'),
|
||||
);
|
||||
|
||||
return response('Runner provisioning queued.');
|
||||
}
|
||||
|
||||
if ($action === 'in_progress' && $workflowJobId > 0) {
|
||||
$execution = GithubRunnerExecution::query()
|
||||
->where('workflow_job_id', $workflowJobId)
|
||||
->whereIn('status', [GithubRunnerStatus::Queued, GithubRunnerStatus::Provisioning])
|
||||
->first();
|
||||
|
||||
if ($execution) {
|
||||
$execution->update([
|
||||
'status' => GithubRunnerStatus::Running,
|
||||
'started_at' => $execution->started_at ?? now(),
|
||||
'runner_name' => data_get($workflowJob, 'runner_name') ?: $execution->runner_name,
|
||||
]);
|
||||
}
|
||||
|
||||
return response('Runner marked running.');
|
||||
}
|
||||
|
||||
if ($action === 'completed' && $workflowJob) {
|
||||
CleanupGithubRunnerJob::dispatch(
|
||||
workflowJobId: (int) data_get($workflowJob, 'id'),
|
||||
workflowJobId: $workflowJobId,
|
||||
);
|
||||
|
||||
return response('Runner cleanup queued.');
|
||||
|
|
|
|||
57
app/Jobs/CleanupGithubRunnerArtifactsJob.php
Normal file
57
app/Jobs/CleanupGithubRunnerArtifactsJob.php
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\GithubRunnerConfig;
|
||||
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;
|
||||
|
||||
class CleanupGithubRunnerArtifactsJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $timeout = 300;
|
||||
|
||||
public $tries = 1;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->onQueue('high');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$targets = GithubRunnerConfig::query()
|
||||
->where('is_enabled', true)
|
||||
->with('server')
|
||||
->get()
|
||||
->filter(fn (GithubRunnerConfig $config) => $config->server?->isFunctional())
|
||||
->map(fn (GithubRunnerConfig $config): array => [
|
||||
'server' => $config->server,
|
||||
'base_dir' => $config->runner_base_dir,
|
||||
])
|
||||
->unique(fn (array $target): string => "{$target['server']->id}:{$target['base_dir']}")
|
||||
->values();
|
||||
|
||||
foreach ($targets as $target) {
|
||||
$baseDir = validateShellSafePath($target['base_dir'], 'runner base directory');
|
||||
|
||||
instant_remote_process(static::buildCleanupCommands($baseDir), $target['server'], throwError: false);
|
||||
}
|
||||
}
|
||||
|
||||
public static function buildCleanupCommands(string $baseDir): array
|
||||
{
|
||||
$quotedBaseDir = escapeshellarg($baseDir);
|
||||
|
||||
return [
|
||||
"if [ -d {$quotedBaseDir}/.cache ]; then for arch in x64 arm64; do ls -1t {$quotedBaseDir}/.cache/actions-runner-linux-\${arch}-*.tar.gz 2>/dev/null | tail -n +3 | xargs -r rm -f; done; fi",
|
||||
"if [ -d {$quotedBaseDir}/.templates ]; then for arch in x64 arm64; do ls -1td {$quotedBaseDir}/.templates/runner-\${arch}-* 2>/dev/null | tail -n +3 | xargs -r rm -rf; done; fi",
|
||||
"if [ -d {$quotedBaseDir}/.template ]; then for arch in x64 arm64; do ls -1td {$quotedBaseDir}/.template/runner-\${arch}-* 2>/dev/null | tail -n +3 | xargs -r rm -rf; done; fi",
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -33,24 +33,44 @@ class CleanupGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
|
|||
|
||||
public function handle(): void
|
||||
{
|
||||
ray("[cleanup] Starting cleanup for workflow_job_id {$this->workflowJobId}");
|
||||
|
||||
$execution = GithubRunnerExecution::where('workflow_job_id', $this->workflowJobId)
|
||||
->with('config.githubApp')
|
||||
->first();
|
||||
|
||||
if (! $execution) {
|
||||
ray("[cleanup] No execution found for workflow_job_id {$this->workflowJobId} — skipping");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Already cleaned up
|
||||
if (in_array($execution->status, [GithubRunnerStatus::Completed, GithubRunnerStatus::Failed])) {
|
||||
ray("[cleanup] Execution {$execution->id} already in {$execution->status->value} — skipping");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
ray("[cleanup] Execution {$execution->id} transitioning from {$execution->status->value} → cleaning");
|
||||
$execution->update(['status' => GithubRunnerStatus::Cleaning]);
|
||||
|
||||
try {
|
||||
$server = $execution->server;
|
||||
|
||||
if (! $server || ! $server->isFunctional()) {
|
||||
ray("[cleanup] Server not functional for execution {$execution->id}; marking failed to release capacity");
|
||||
$this->deregisterFromGithub($execution);
|
||||
|
||||
$execution->update([
|
||||
'status' => GithubRunnerStatus::Failed,
|
||||
'error_message' => 'Cleanup skipped: server is not functional.',
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($execution->pid) {
|
||||
instant_remote_process([
|
||||
"kill {$execution->pid} 2>/dev/null || true",
|
||||
|
|
@ -69,7 +89,10 @@ class CleanupGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
|
|||
'status' => GithubRunnerStatus::Completed,
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
ray("[cleanup] Execution {$execution->id} completed successfully");
|
||||
} catch (\Throwable $e) {
|
||||
ray("[cleanup] Execution {$execution->id} cleanup failed: {$e->getMessage()}");
|
||||
$execution->update([
|
||||
'status' => GithubRunnerStatus::Failed,
|
||||
'error_message' => 'Cleanup failed: '.$e->getMessage(),
|
||||
|
|
@ -78,6 +101,23 @@ class CleanupGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
|
|||
}
|
||||
}
|
||||
|
||||
public function failed(?\Throwable $exception): void
|
||||
{
|
||||
GithubRunnerExecution::query()
|
||||
->where('workflow_job_id', $this->workflowJobId)
|
||||
->whereIn('status', [
|
||||
GithubRunnerStatus::Queued,
|
||||
GithubRunnerStatus::Provisioning,
|
||||
GithubRunnerStatus::Running,
|
||||
GithubRunnerStatus::Cleaning,
|
||||
])
|
||||
->update([
|
||||
'status' => GithubRunnerStatus::Failed,
|
||||
'error_message' => 'Cleanup failed: '.($exception?->getMessage() ?? 'unknown error'),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function deregisterFromGithub(GithubRunnerExecution $execution): void
|
||||
{
|
||||
if (! $execution->runner_id) {
|
||||
|
|
|
|||
|
|
@ -46,8 +46,11 @@ class GithubAppPermissionJob implements ShouldBeEncrypted, ShouldQueue
|
|||
$this->github_app->pull_requests = data_get($permissions, 'pull_requests');
|
||||
$this->github_app->administration = data_get($permissions, 'administration');
|
||||
$this->github_app->organization_self_hosted_runners = data_get($permissions, 'organization_self_hosted_runners');
|
||||
$this->github_app->webhook_events = data_get($response, 'events', []);
|
||||
|
||||
$this->github_app->save();
|
||||
|
||||
$this->autoFixMissingEvents($github_access_token);
|
||||
$this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret');
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
|
|
@ -55,4 +58,29 @@ class GithubAppPermissionJob implements ShouldBeEncrypted, ShouldQueue
|
|||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function autoFixMissingEvents(string $github_access_token): void
|
||||
{
|
||||
$missing = $this->github_app->missingWebhookEvents();
|
||||
|
||||
if (empty($missing)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$updatedEvents = array_values(array_unique(
|
||||
array_merge($this->github_app->webhook_events ?? [], $missing)
|
||||
));
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => "Bearer $github_access_token",
|
||||
'Accept' => 'application/vnd.github+json',
|
||||
])->patch("{$this->github_app->api_url}/app", [
|
||||
'events' => $updatedEvents,
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$this->github_app->webhook_events = data_get($response->json(), 'events', $updatedEvents);
|
||||
$this->github_app->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
|
|||
public array $workflowJobPayload,
|
||||
public string $organizationLogin,
|
||||
public int $repositoryId = 0,
|
||||
public ?string $repositoryFullName = null,
|
||||
public ?string $capacityWaitStartedAt = null,
|
||||
) {
|
||||
$this->onQueue('high');
|
||||
|
|
@ -40,7 +41,11 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
|
|||
|
||||
public function handle(): void
|
||||
{
|
||||
$workflowJobId = data_get($this->workflowJobPayload, 'id');
|
||||
$workflowJobId = (int) data_get($this->workflowJobPayload, 'id');
|
||||
|
||||
if ($workflowJobId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Idempotency: skip if already provisioning for this job
|
||||
if (GithubRunnerExecution::where('workflow_job_id', $workflowJobId)->exists()) {
|
||||
|
|
@ -52,6 +57,10 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
|
|||
return;
|
||||
}
|
||||
|
||||
if (! $this->shouldContinueProvisioning($githubApp, $workflowJobId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$requestedLabels = data_get($this->workflowJobPayload, 'labels', []);
|
||||
|
||||
// Step 1: find configs that match labels (ignoring capacity)
|
||||
|
|
@ -75,8 +84,15 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
|
|||
? \Carbon\Carbon::parse($this->capacityWaitStartedAt)
|
||||
: now();
|
||||
|
||||
if (now()->diffInMinutes($waitStartedAt) >= $timeoutMinutes) {
|
||||
$waitedMinutes = $waitStartedAt->diffInMinutes(now(), absolute: true);
|
||||
|
||||
if ($waitedMinutes >= $timeoutMinutes) {
|
||||
// Gave up waiting — log and drop so GitHub eventually cancels the job
|
||||
ray("[provision] Gave up waiting for capacity after {$waitedMinutes}m", [
|
||||
'workflow_job_id' => $workflowJobId,
|
||||
'labels' => $requestedLabels,
|
||||
'timeout_minutes' => $timeoutMinutes,
|
||||
]);
|
||||
logger()->warning('ProvisionGithubRunnerJob: gave up waiting for capacity', [
|
||||
'workflow_job_id' => $workflowJobId,
|
||||
'labels' => $requestedLabels,
|
||||
|
|
@ -86,12 +102,21 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
|
|||
return;
|
||||
}
|
||||
|
||||
$firstConfig = $matchingConfigs->first();
|
||||
ray("[provision] At capacity for workflow_job_id {$workflowJobId} — retrying in 15s", [
|
||||
'active' => $firstConfig->activeRunnerCount(),
|
||||
'max' => $firstConfig->max_runners,
|
||||
'waited_minutes' => $waitedMinutes,
|
||||
'timeout_minutes' => $timeoutMinutes,
|
||||
]);
|
||||
|
||||
// Dispatch a new job in 15 seconds carrying the wait start timestamp
|
||||
static::dispatch(
|
||||
githubAppId: $this->githubAppId,
|
||||
workflowJobPayload: $this->workflowJobPayload,
|
||||
organizationLogin: $this->organizationLogin,
|
||||
repositoryId: $this->repositoryId,
|
||||
repositoryFullName: $this->repositoryFullNameOrNull(),
|
||||
capacityWaitStartedAt: $this->capacityWaitStartedAt ?? now()->toIso8601String(),
|
||||
)->delay(15);
|
||||
|
||||
|
|
@ -108,9 +133,10 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
|
|||
'runner_name' => $runnerName,
|
||||
'runner_dir' => $runnerDir,
|
||||
'workflow_job_id' => $workflowJobId,
|
||||
'workflow_job_html_url' => data_get($this->workflowJobPayload, 'html_url'),
|
||||
'workflow_name' => data_get($this->workflowJobPayload, 'workflow_name'),
|
||||
'repository_full_name' => data_get($this->workflowJobPayload, 'repository.full_name',
|
||||
data_get($this->workflowJobPayload, 'head_repository.full_name')
|
||||
data_get($this->workflowJobPayload, 'head_repository.full_name', $this->repositoryFullNameOrNull())
|
||||
),
|
||||
]);
|
||||
|
||||
|
|
@ -168,21 +194,32 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
|
|||
{
|
||||
$token = generateGithubInstallationToken($githubApp);
|
||||
$apiUrl = $githubApp->api_url ?? 'https://api.github.com';
|
||||
$groupName = $this->resolveRunnerGroupName($githubApp);
|
||||
|
||||
if ($githubApp->runner_group_id) {
|
||||
// Ensure existing group allows public repos
|
||||
Http::withHeaders([
|
||||
// Keep existing group settings and name in sync with Coolify.
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => "Bearer {$token}",
|
||||
'Accept' => 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version' => '2022-11-28',
|
||||
])->patch("{$apiUrl}/orgs/{$githubApp->organization}/actions/runner-groups/{$githubApp->runner_group_id}", [
|
||||
'name' => $groupName,
|
||||
'allows_public_repositories' => true,
|
||||
]);
|
||||
|
||||
return $githubApp->runner_group_id;
|
||||
}
|
||||
if ($response->successful()) {
|
||||
return $githubApp->runner_group_id;
|
||||
}
|
||||
|
||||
$groupName = 'Coolify-'.((string) new Cuid2(7));
|
||||
if ($response->status() !== 404) {
|
||||
throw new \RuntimeException(
|
||||
'Failed to sync runner group: '.data_get($response->json(), 'message', $response->body())
|
||||
);
|
||||
}
|
||||
|
||||
$githubApp->update(['runner_group_id' => null]);
|
||||
$githubApp->refresh();
|
||||
}
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => "Bearer {$token}",
|
||||
|
|
@ -201,11 +238,26 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
|
|||
}
|
||||
|
||||
$runnerGroupId = (int) data_get($response->json(), 'id');
|
||||
$githubApp->update(['runner_group_id' => $runnerGroupId]);
|
||||
$githubApp->update([
|
||||
'runner_group_id' => $runnerGroupId,
|
||||
'runner_group_name' => $groupName,
|
||||
]);
|
||||
|
||||
return $runnerGroupId;
|
||||
}
|
||||
|
||||
private function resolveRunnerGroupName(GithubApp $githubApp): string
|
||||
{
|
||||
$groupName = trim((string) $githubApp->runner_group_name);
|
||||
|
||||
if ($groupName === '') {
|
||||
$groupName = 'Coolify-'.((string) new Cuid2(7));
|
||||
$githubApp->update(['runner_group_name' => $groupName]);
|
||||
}
|
||||
|
||||
return preg_replace('/\s+/', ' ', $groupName) ?? $groupName;
|
||||
}
|
||||
|
||||
private function ensureRepositoryInRunnerGroup(GithubApp $githubApp, int $runnerGroupId): void
|
||||
{
|
||||
if ($this->repositoryId <= 0) {
|
||||
|
|
@ -286,6 +338,7 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
|
|||
"if [ ! -f {$cacheDir}/{$tarball} ]; then curl -sL https://github.com/actions/runner/releases/download/v{$version}/{$tarball} -o {$cacheDir}/{$tarball}; fi",
|
||||
"if [ ! -d {$templateDir} ]; then mkdir -p {$templateDir} && tar xzf {$cacheDir}/{$tarball} -C {$templateDir} && chown -R {$user}:{$user} {$templateDir}; fi",
|
||||
"cp -r {$templateDir}/. {$runnerDir}",
|
||||
"touch {$cacheDir}/{$tarball} {$templateDir}",
|
||||
"chown -R {$user}:{$user} {$runnerDir}",
|
||||
], $server);
|
||||
|
||||
|
|
@ -333,4 +386,60 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
|
|||
|
||||
return '2.321.0';
|
||||
}
|
||||
|
||||
private function repositoryFullNameOrNull(): ?string
|
||||
{
|
||||
// Backward compatibility: older serialized jobs may not have this promoted property initialized.
|
||||
return isset($this->repositoryFullName) ? $this->repositoryFullName : null;
|
||||
}
|
||||
|
||||
private function shouldContinueProvisioning(GithubApp $githubApp, int $workflowJobId): bool
|
||||
{
|
||||
$repositoryFullName = $this->repositoryFullNameOrNull()
|
||||
?? data_get($this->workflowJobPayload, 'repository.full_name')
|
||||
?? data_get($this->workflowJobPayload, 'head_repository.full_name');
|
||||
|
||||
if (! is_string($repositoryFullName) || trim($repositoryFullName) === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$apiUrl = $githubApp->api_url ?? 'https://api.github.com';
|
||||
$headers = [
|
||||
'Accept' => 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version' => '2022-11-28',
|
||||
];
|
||||
if (! $githubApp->is_public) {
|
||||
$token = generateGithubInstallationToken($githubApp);
|
||||
$headers['Authorization'] = "Bearer {$token}";
|
||||
}
|
||||
|
||||
$response = Http::withHeaders($headers)
|
||||
->get("{$apiUrl}/repos/{$repositoryFullName}/actions/jobs/{$workflowJobId}");
|
||||
|
||||
if ($response->status() === 404) {
|
||||
ray("[provision] workflow_job_id {$workflowJobId} no longer exists — skipping provisioning");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$status = data_get($response->json(), 'status');
|
||||
$conclusion = data_get($response->json(), 'conclusion');
|
||||
$isDone = $status === 'completed' || $conclusion === 'cancelled';
|
||||
|
||||
if ($isDone) {
|
||||
ray("[provision] workflow_job_id {$workflowJobId} is {$status} ({$conclusion}) — skipping provisioning");
|
||||
|
||||
return false;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,10 +37,10 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue
|
|||
public function failed(?\Throwable $exception): void
|
||||
{
|
||||
if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) {
|
||||
Log::warning('ServerCheckJob timed out', [
|
||||
'server_id' => $this->server->id,
|
||||
'server_name' => $this->server->name,
|
||||
]);
|
||||
// Log::warning('ServerCheckJob timed out', [
|
||||
// 'server_id' => $this->server->id,
|
||||
// 'server_name' => $this->server->name,
|
||||
// ]);
|
||||
|
||||
// Delete the queue job so it doesn't appear in Horizon's failed list.
|
||||
$this->job?->delete();
|
||||
|
|
|
|||
|
|
@ -108,10 +108,10 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
|
|||
public function failed(?\Throwable $exception): void
|
||||
{
|
||||
if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) {
|
||||
Log::warning('ServerConnectionCheckJob timed out', [
|
||||
'server_id' => $this->server->id,
|
||||
'server_name' => $this->server->name,
|
||||
]);
|
||||
// Log::warning('ServerConnectionCheckJob timed out', [
|
||||
// 'server_id' => $this->server->id,
|
||||
// 'server_name' => $this->server->name,
|
||||
// ]);
|
||||
$this->server->settings->update([
|
||||
'is_reachable' => false,
|
||||
'is_usable' => false,
|
||||
|
|
|
|||
|
|
@ -32,10 +32,10 @@ class ServerStorageCheckJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||
public function failed(?\Throwable $exception): void
|
||||
{
|
||||
if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) {
|
||||
Log::warning('ServerStorageCheckJob timed out', [
|
||||
'server_id' => $this->server->id,
|
||||
'server_name' => $this->server->name,
|
||||
]);
|
||||
// Log::warning('ServerStorageCheckJob timed out', [
|
||||
// 'server_id' => $this->server->id,
|
||||
// 'server_name' => $this->server->name,
|
||||
// ]);
|
||||
|
||||
// Delete the queue job so it doesn't appear in Horizon's failed list.
|
||||
$this->job?->delete();
|
||||
|
|
|
|||
34
app/Livewire/Server/GithubRunnerExecutions.php
Normal file
34
app/Livewire/Server/GithubRunnerExecutions.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Server;
|
||||
|
||||
use App\Models\GithubRunnerExecution;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
|
||||
class GithubRunnerExecutions extends Component
|
||||
{
|
||||
public Server $server;
|
||||
|
||||
public function mount(Server $server): void
|
||||
{
|
||||
$this->server = $server;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function recentExecutions(): Collection
|
||||
{
|
||||
return GithubRunnerExecution::query()
|
||||
->where('server_id', $this->server->id)
|
||||
->orderByDesc('created_at')
|
||||
->limit(25)
|
||||
->get();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.server.github-runner-executions');
|
||||
}
|
||||
}
|
||||
|
|
@ -10,8 +10,10 @@ use App\Models\Server;
|
|||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
class GithubRunners extends Component
|
||||
{
|
||||
|
|
@ -23,6 +25,9 @@ class GithubRunners extends Component
|
|||
|
||||
public ?int $selectedGithubAppId = null;
|
||||
|
||||
#[Validate(['nullable', 'string', 'max:255'])]
|
||||
public ?string $runnerGroupName = null;
|
||||
|
||||
#[Validate(['required', 'string', 'min:1'])]
|
||||
public string $labels = 'self-hosted,coolify';
|
||||
|
||||
|
|
@ -53,6 +58,8 @@ class GithubRunners extends Component
|
|||
|
||||
public bool $skipNextSelectedAppReload = false;
|
||||
|
||||
public ?string $originalRunnerGroupName = null;
|
||||
|
||||
public function mount(string $server_uuid): void
|
||||
{
|
||||
try {
|
||||
|
|
@ -86,15 +93,6 @@ class GithubRunners extends Component
|
|||
return $this->config?->activeRunnerCount() ?? 0;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function recentExecutions()
|
||||
{
|
||||
return GithubRunnerExecution::where('server_id', $this->server->id)
|
||||
->orderByDesc('created_at')
|
||||
->limit(25)
|
||||
->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function selectedApp(): ?GithubApp
|
||||
{
|
||||
|
|
@ -116,6 +114,8 @@ class GithubRunners extends Component
|
|||
$config = $this->server->githubRunnerConfig;
|
||||
if ($config) {
|
||||
$this->selectedGithubAppId = $config->github_app_id;
|
||||
$this->runnerGroupName = $config->githubApp?->runner_group_name;
|
||||
$this->originalRunnerGroupName = $this->normalizeRunnerGroupName($this->runnerGroupName);
|
||||
$this->skipNextSelectedAppReload = true;
|
||||
$this->labels = implode(',', $config->labels ?? []);
|
||||
$this->maxRunners = $config->max_runners;
|
||||
|
|
@ -144,6 +144,8 @@ class GithubRunners extends Component
|
|||
return;
|
||||
}
|
||||
|
||||
$this->runnerGroupName = $this->selectedApp?->runner_group_name;
|
||||
$this->originalRunnerGroupName = $this->normalizeRunnerGroupName($this->runnerGroupName);
|
||||
$this->repositoriesLoaded = true;
|
||||
$this->loadAccessibleRepositories();
|
||||
}
|
||||
|
|
@ -199,6 +201,27 @@ class GithubRunners extends Component
|
|||
throw new \Exception('Please select a GitHub App.');
|
||||
}
|
||||
|
||||
$runnerGroupName = $this->normalizeRunnerGroupName($this->runnerGroupName);
|
||||
if ($runnerGroupName === null) {
|
||||
$runnerGroupName = $this->generateDefaultRunnerGroupName();
|
||||
$this->runnerGroupName = $runnerGroupName;
|
||||
}
|
||||
$runnerGroupNameIsDirty = $runnerGroupName !== $this->originalRunnerGroupName;
|
||||
|
||||
if ($runnerGroupNameIsDirty) {
|
||||
GithubApp::query()
|
||||
->whereKey($this->selectedGithubAppId)
|
||||
->update(['runner_group_name' => $runnerGroupName]);
|
||||
$this->runnerGroupName = $runnerGroupName;
|
||||
|
||||
$selectedGithubApp = GithubApp::query()->find($this->selectedGithubAppId);
|
||||
if ($selectedGithubApp) {
|
||||
$this->syncRunnerGroupNameToGithub($selectedGithubApp);
|
||||
}
|
||||
}
|
||||
|
||||
$this->originalRunnerGroupName = $runnerGroupName;
|
||||
|
||||
$labelsArray = array_map('trim', explode(',', $this->labels));
|
||||
$labelsArray = array_values(array_filter($labelsArray));
|
||||
|
||||
|
|
@ -240,6 +263,90 @@ class GithubRunners extends Component
|
|||
}
|
||||
}
|
||||
|
||||
private function normalizeRunnerGroupName(?string $runnerGroupName): ?string
|
||||
{
|
||||
if (! is_string($runnerGroupName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$trimmedName = trim($runnerGroupName);
|
||||
|
||||
if ($trimmedName === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return preg_replace('/\s+/', ' ', $trimmedName) ?? $trimmedName;
|
||||
}
|
||||
|
||||
private function generateDefaultRunnerGroupName(): string
|
||||
{
|
||||
return 'Coolify-'.((string) new Cuid2(7));
|
||||
}
|
||||
|
||||
private function syncRunnerGroupNameToGithub(GithubApp $githubApp): void
|
||||
{
|
||||
$desiredRunnerGroupName = $this->normalizeRunnerGroupName($githubApp->runner_group_name);
|
||||
|
||||
if ($desiredRunnerGroupName === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $githubApp->installation_id || ! $githubApp->organization) {
|
||||
return;
|
||||
}
|
||||
|
||||
$token = generateGithubInstallationToken($githubApp);
|
||||
$apiUrl = $githubApp->api_url ?? 'https://api.github.com';
|
||||
$headers = [
|
||||
'Authorization' => "Bearer {$token}",
|
||||
'Accept' => 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version' => '2022-11-28',
|
||||
];
|
||||
|
||||
if ($githubApp->runner_group_id) {
|
||||
$patchResponse = Http::withHeaders($headers)->patch(
|
||||
"{$apiUrl}/orgs/{$githubApp->organization}/actions/runner-groups/{$githubApp->runner_group_id}",
|
||||
[
|
||||
'name' => $desiredRunnerGroupName,
|
||||
'allows_public_repositories' => true,
|
||||
]
|
||||
);
|
||||
|
||||
if ($patchResponse->successful()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($patchResponse->status() !== 404) {
|
||||
throw new \RuntimeException(
|
||||
'Failed to sync runner group: '.data_get($patchResponse->json(), 'message', $patchResponse->body())
|
||||
);
|
||||
}
|
||||
|
||||
$githubApp->update(['runner_group_id' => null]);
|
||||
$githubApp->refresh();
|
||||
}
|
||||
|
||||
$createResponse = Http::withHeaders($headers)->post(
|
||||
"{$apiUrl}/orgs/{$githubApp->organization}/actions/runner-groups",
|
||||
[
|
||||
'name' => $desiredRunnerGroupName,
|
||||
'visibility' => 'selected',
|
||||
'allows_public_repositories' => true,
|
||||
]
|
||||
);
|
||||
|
||||
if (! $createResponse->successful()) {
|
||||
throw new \RuntimeException(
|
||||
'Failed to create runner group: '.data_get($createResponse->json(), 'message', $createResponse->body())
|
||||
);
|
||||
}
|
||||
|
||||
$githubApp->update([
|
||||
'runner_group_id' => (int) data_get($createResponse->json(), 'id'),
|
||||
'runner_group_name' => $desiredRunnerGroupName,
|
||||
]);
|
||||
}
|
||||
|
||||
public function toggleEnabled()
|
||||
{
|
||||
try {
|
||||
|
|
@ -279,6 +386,7 @@ class GithubRunners extends Component
|
|||
}
|
||||
}
|
||||
|
||||
#[On('cancel-github-runner-execution')]
|
||||
public function cancelExecution(int $executionId)
|
||||
{
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -69,6 +69,8 @@ class Change extends Component
|
|||
|
||||
public ?string $organizationSelfHostedRunners = null;
|
||||
|
||||
public ?array $webhookEvents = null;
|
||||
|
||||
public $applications;
|
||||
|
||||
public $privateKeys;
|
||||
|
|
@ -126,6 +128,7 @@ class Change extends Component
|
|||
$this->github_app->metadata = $this->metadata;
|
||||
$this->github_app->pull_requests = $this->pullRequests;
|
||||
$this->github_app->organization_self_hosted_runners = $this->organizationSelfHostedRunners;
|
||||
$this->github_app->webhook_events = $this->webhookEvents;
|
||||
} else {
|
||||
// Sync FROM model (on load/refresh)
|
||||
$this->name = $this->github_app->name;
|
||||
|
|
@ -145,6 +148,7 @@ class Change extends Component
|
|||
$this->metadata = $this->github_app->metadata;
|
||||
$this->pullRequests = $this->github_app->pull_requests;
|
||||
$this->organizationSelfHostedRunners = $this->github_app->organization_self_hosted_runners;
|
||||
$this->webhookEvents = $this->github_app->webhook_events;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -178,10 +182,17 @@ class Change extends Component
|
|||
return;
|
||||
}
|
||||
|
||||
$previousEvents = $this->github_app->webhook_events ?? [];
|
||||
GithubAppPermissionJob::dispatchSync($this->github_app);
|
||||
$this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret');
|
||||
$this->syncData(false);
|
||||
$this->dispatch('success', 'Github App permissions updated.');
|
||||
|
||||
$addedEvents = array_diff($this->github_app->webhook_events ?? [], $previousEvents);
|
||||
if (! empty($addedEvents)) {
|
||||
$this->dispatch('success', 'Permissions updated. Auto-enabled missing events: '.implode(', ', $addedEvents));
|
||||
} else {
|
||||
$this->dispatch('success', 'Github App permissions updated.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Provide better error message for unsupported key formats
|
||||
$errorMessage = $e->getMessage();
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ class GithubApp extends BaseModel
|
|||
'is_system_wide' => 'boolean',
|
||||
'type' => 'string',
|
||||
'runner_group_id' => 'integer',
|
||||
'runner_group_name' => 'string',
|
||||
'webhook_events' => 'array',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
|
|
@ -94,6 +96,28 @@ class GithubApp extends BaseModel
|
|||
return $this->hasMany(GithubRunnerConfig::class);
|
||||
}
|
||||
|
||||
public function requiredWebhookEvents(): array
|
||||
{
|
||||
$events = ['push'];
|
||||
|
||||
if ($this->pull_requests === 'write') {
|
||||
$events[] = 'pull_request';
|
||||
}
|
||||
|
||||
if ($this->runnerConfigs()->exists()) {
|
||||
$events[] = 'workflow_job';
|
||||
}
|
||||
|
||||
return $events;
|
||||
}
|
||||
|
||||
public function missingWebhookEvents(): array
|
||||
{
|
||||
$current = $this->webhook_events ?? [];
|
||||
|
||||
return array_values(array_diff($this->requiredWebhookEvents(), $current));
|
||||
}
|
||||
|
||||
public function type(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\GithubRunnerStatus;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
|
@ -45,7 +46,12 @@ class GithubRunnerConfig extends BaseModel
|
|||
public function activeRunnerCount(): int
|
||||
{
|
||||
return $this->executions()
|
||||
->whereIn('status', ['queued', 'provisioning', 'running', 'cleaning'])
|
||||
->whereIn('status', [
|
||||
GithubRunnerStatus::Queued->value,
|
||||
GithubRunnerStatus::Provisioning->value,
|
||||
GithubRunnerStatus::Running->value,
|
||||
GithubRunnerStatus::Cleaning->value,
|
||||
])
|
||||
->count();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,4 +46,19 @@ class GithubRunnerExecution extends BaseModel
|
|||
|
||||
return $this->started_at->diffForHumans($end, true);
|
||||
}
|
||||
|
||||
public function workflowJobUrl(): ?string
|
||||
{
|
||||
$directUrl = trim((string) $this->workflow_job_html_url);
|
||||
if ($directUrl !== '') {
|
||||
return $directUrl;
|
||||
}
|
||||
|
||||
$repositoryFullName = trim((string) $this->repository_full_name);
|
||||
if ($repositoryFullName === '' || ! $this->workflow_job_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return "https://github.com/{$repositoryFullName}/actions?query=".urlencode((string) $this->workflow_job_id);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ class ServerSetting extends Model
|
|||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'force_disabled' => 'boolean',
|
||||
'force_docker_cleanup' => 'boolean',
|
||||
'docker_cleanup_threshold' => 'integer',
|
||||
'sentinel_token' => 'encrypted',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('github_apps', function (Blueprint $table) {
|
||||
$table->string('runner_group_name')->nullable()->after('runner_group_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('github_apps', function (Blueprint $table) {
|
||||
$table->dropColumn('runner_group_name');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('github_runner_executions', function (Blueprint $table) {
|
||||
$table->string('workflow_job_html_url')->nullable()->after('workflow_job_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('github_runner_executions', function (Blueprint $table) {
|
||||
$table->dropColumn('workflow_job_html_url');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('github_apps', function (Blueprint $table) {
|
||||
$table->json('webhook_events')->nullable()->after('organization_self_hosted_runners');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('github_apps', function (Blueprint $table) {
|
||||
$table->dropColumn('webhook_events');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
<div class="mt-8" wire:poll.10s>
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<h3>Recent Executions</h3>
|
||||
<x-forms.button type="button" wire:click="$refresh" class="!py-1 !px-3 !text-xs">
|
||||
Refresh
|
||||
</x-forms.button>
|
||||
</div>
|
||||
@if ($this->recentExecutions->isEmpty())
|
||||
<div class="text-sm text-neutral-500">No runner executions yet. When a workflow job matches this server's labels, it will appear here.</div>
|
||||
@else
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left border-b border-neutral-700">
|
||||
<th class="pb-2 pr-4">Runner</th>
|
||||
<th class="pb-2 pr-4">Workflow</th>
|
||||
<th class="pb-2 pr-4">Repository</th>
|
||||
<th class="pb-2 pr-4">Status</th>
|
||||
<th class="pb-2 pr-4">Duration</th>
|
||||
<th class="pb-2 pr-4">Started</th>
|
||||
<th class="pb-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($this->recentExecutions as $execution)
|
||||
<tr class="border-b border-neutral-800">
|
||||
<td class="py-2 pr-4 font-mono text-xs">{{ $execution->runner_name }}</td>
|
||||
<td class="py-2 pr-4">{{ $execution->workflow_name ?? '-' }}</td>
|
||||
<td class="py-2 pr-4">{{ $execution->repository_full_name ?? '-' }}</td>
|
||||
<td class="py-2 pr-4">
|
||||
@switch($execution->status->value)
|
||||
@case('queued')
|
||||
<span class="px-2 py-0.5 text-xs rounded-full bg-warning/20 text-warning">Queued</span>
|
||||
@break
|
||||
@case('provisioning')
|
||||
<span class="px-2 py-0.5 text-xs rounded-full bg-blue-500/20 text-blue-400">Provisioning</span>
|
||||
@break
|
||||
@case('running')
|
||||
<span class="px-2 py-0.5 text-xs rounded-full bg-success/20 text-success">Running</span>
|
||||
@break
|
||||
@case('completed')
|
||||
<span class="px-2 py-0.5 text-xs rounded-full bg-neutral-500/20 text-neutral-400">Completed</span>
|
||||
@break
|
||||
@case('failed')
|
||||
<span class="px-2 py-0.5 text-xs rounded-full bg-error/20 text-error">Failed</span>
|
||||
@break
|
||||
@case('timed_out')
|
||||
<span class="px-2 py-0.5 text-xs rounded-full bg-orange-500/20 text-orange-400">Timed Out</span>
|
||||
@break
|
||||
@case('cleaning')
|
||||
<span class="px-2 py-0.5 text-xs rounded-full bg-blue-500/20 text-blue-400">Cleaning</span>
|
||||
@break
|
||||
@endswitch
|
||||
</td>
|
||||
<td class="py-2 pr-4">{{ $execution->duration() ?? '-' }}</td>
|
||||
<td class="py-2 pr-4">{{ $execution->started_at?->diffForHumans() ?? $execution->created_at->diffForHumans() }}</td>
|
||||
<td class="py-2 whitespace-nowrap">
|
||||
<div class="flex items-center gap-2 flex-nowrap whitespace-nowrap">
|
||||
@if ($execution->workflowJobUrl())
|
||||
<a href="{{ $execution->workflowJobUrl() }}" target="_blank" rel="noopener noreferrer"
|
||||
class="flex hover:no-underline">
|
||||
<x-forms.button type="button" class="!py-0.5 !px-2 !text-xs">
|
||||
Open
|
||||
<x-external-link />
|
||||
</x-forms.button>
|
||||
</a>
|
||||
@endif
|
||||
@if ($execution->isActive())
|
||||
<x-forms.button
|
||||
wire:click="$dispatch('cancel-github-runner-execution', { executionId: {{ $execution->id }} })"
|
||||
wire:confirm="Cancel this runner? This will kill the process, remove the runner directory, and deregister from GitHub."
|
||||
canGate="update" :canResource="$server"
|
||||
class="!py-0.5 !px-2 !text-xs">
|
||||
Cancel
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -117,6 +117,12 @@
|
|||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<x-forms.input canGate="update" :canResource="$server" id="runnerGroupName"
|
||||
label="Runner Group Name (optional)"
|
||||
helper="If set, Coolify will enforce this GitHub organization runner group name during sync." />
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<x-forms.input canGate="update" :canResource="$server" id="labels"
|
||||
label="Labels (comma-separated)" required
|
||||
|
|
@ -176,76 +182,8 @@
|
|||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Executions --}}
|
||||
@if ($this->config)
|
||||
<div class="mt-8" wire:poll.10s>
|
||||
<h3 class="mb-4">Recent Executions</h3>
|
||||
@if ($this->recentExecutions->isEmpty())
|
||||
<div class="text-sm text-neutral-500">No runner executions yet. When a workflow job matches this server's labels, it will appear here.</div>
|
||||
@else
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left border-b border-neutral-700">
|
||||
<th class="pb-2 pr-4">Runner</th>
|
||||
<th class="pb-2 pr-4">Workflow</th>
|
||||
<th class="pb-2 pr-4">Repository</th>
|
||||
<th class="pb-2 pr-4">Status</th>
|
||||
<th class="pb-2 pr-4">Duration</th>
|
||||
<th class="pb-2 pr-4">Started</th>
|
||||
<th class="pb-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($this->recentExecutions as $execution)
|
||||
<tr class="border-b border-neutral-800">
|
||||
<td class="py-2 pr-4 font-mono text-xs">{{ $execution->runner_name }}</td>
|
||||
<td class="py-2 pr-4">{{ $execution->workflow_name ?? '-' }}</td>
|
||||
<td class="py-2 pr-4">{{ $execution->repository_full_name ?? '-' }}</td>
|
||||
<td class="py-2 pr-4">
|
||||
@switch($execution->status->value)
|
||||
@case('queued')
|
||||
<span class="px-2 py-0.5 text-xs rounded-full bg-warning/20 text-warning">Queued</span>
|
||||
@break
|
||||
@case('provisioning')
|
||||
<span class="px-2 py-0.5 text-xs rounded-full bg-blue-500/20 text-blue-400">Provisioning</span>
|
||||
@break
|
||||
@case('running')
|
||||
<span class="px-2 py-0.5 text-xs rounded-full bg-success/20 text-success">Running</span>
|
||||
@break
|
||||
@case('completed')
|
||||
<span class="px-2 py-0.5 text-xs rounded-full bg-neutral-500/20 text-neutral-400">Completed</span>
|
||||
@break
|
||||
@case('failed')
|
||||
<span class="px-2 py-0.5 text-xs rounded-full bg-error/20 text-error">Failed</span>
|
||||
@break
|
||||
@case('timed_out')
|
||||
<span class="px-2 py-0.5 text-xs rounded-full bg-orange-500/20 text-orange-400">Timed Out</span>
|
||||
@break
|
||||
@case('cleaning')
|
||||
<span class="px-2 py-0.5 text-xs rounded-full bg-blue-500/20 text-blue-400">Cleaning</span>
|
||||
@break
|
||||
@endswitch
|
||||
</td>
|
||||
<td class="py-2 pr-4">{{ $execution->duration() ?? '-' }}</td>
|
||||
<td class="py-2 pr-4">{{ $execution->started_at?->diffForHumans() ?? $execution->created_at->diffForHumans() }}</td>
|
||||
<td class="py-2">
|
||||
@if ($execution->isActive())
|
||||
<x-forms.button wire:click="cancelExecution({{ $execution->id }})"
|
||||
wire:confirm="Cancel this runner? This will kill the process, remove the runner directory, and deregister from GitHub."
|
||||
canGate="update" :canResource="$server"
|
||||
class="!py-0.5 !px-2 !text-xs">
|
||||
Cancel
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<livewire:server.github-runner-executions :server="$server" />
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -140,6 +140,26 @@
|
|||
helper="write access needed to use GitHub Actions self-hosted runners."
|
||||
label="Runners" readonly placeholder="N/A" />
|
||||
</div>
|
||||
<h3 class="pt-4">Webhook Events</h3>
|
||||
@if ($webhookEvents)
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@foreach ($webhookEvents as $event)
|
||||
<span class="px-2 py-1 text-xs font-mono rounded dark:bg-coolgray-200 bg-neutral-200">{{ $event }}</span>
|
||||
@endforeach
|
||||
</div>
|
||||
@php
|
||||
$missingEvents = $github_app->missingWebhookEvents();
|
||||
@endphp
|
||||
@if (!empty($missingEvents))
|
||||
<div class="text-xs text-warning">
|
||||
Missing required events (will be auto-enabled on Refetch): {{ implode(', ', $missingEvents) }}
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
<div class="text-xs opacity-70">
|
||||
No webhook event data yet. Click Refetch above to fetch current events.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</form>
|
||||
|
|
@ -323,6 +343,7 @@
|
|||
}
|
||||
if (administration) {
|
||||
default_permissions.administration = 'write';
|
||||
default_events.push('workflow_job');
|
||||
}
|
||||
|
||||
const data = {
|
||||
|
|
|
|||
183
tests/Feature/CleanupGithubRunnerJobFailureTest.php
Normal file
183
tests/Feature/CleanupGithubRunnerJobFailureTest.php
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\GithubRunnerStatus;
|
||||
use App\Jobs\CleanupGithubRunnerJob;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\GithubRunnerConfig;
|
||||
use App\Models\GithubRunnerExecution;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('marks cleaning execution as failed when cleanup job fails', function () {
|
||||
$team = Team::factory()->create();
|
||||
$privateKeyId = \Illuminate\Support\Facades\DB::table('private_keys')->insertGetId([
|
||||
'uuid' => fake()->uuid(),
|
||||
'name' => 'test-key',
|
||||
'private_key' => encrypt('test'),
|
||||
'team_id' => $team->id,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
$server = Server::factory()->create(['private_key_id' => $privateKeyId, 'team_id' => $team->id]);
|
||||
$server->settings()->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]);
|
||||
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'test-app',
|
||||
'app_id' => fake()->unique()->randomNumber(6, true),
|
||||
'installation_id' => 789,
|
||||
'client_id' => 'Iv1.abc123',
|
||||
'client_secret' => 'secret',
|
||||
'webhook_secret' => 'test-secret',
|
||||
'private_key_id' => $privateKeyId,
|
||||
'team_id' => $team->id,
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'organization' => 'test-org',
|
||||
]);
|
||||
|
||||
$config = GithubRunnerConfig::create([
|
||||
'server_id' => $server->id,
|
||||
'github_app_id' => $githubApp->id,
|
||||
'labels' => ['self-hosted', 'coolify'],
|
||||
'max_runners' => 1,
|
||||
'capacity_wait_timeout' => 60,
|
||||
]);
|
||||
|
||||
$execution = GithubRunnerExecution::create([
|
||||
'server_id' => $server->id,
|
||||
'github_runner_config_id' => $config->id,
|
||||
'status' => GithubRunnerStatus::Cleaning,
|
||||
'runner_name' => 'coolify-cleaning',
|
||||
'runner_dir' => '/opt/github-runners/coolify-cleaning',
|
||||
'workflow_job_id' => 123456,
|
||||
'started_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$job = new CleanupGithubRunnerJob(workflowJobId: 123456);
|
||||
$job->failed(new RuntimeException('simulated cleanup crash'));
|
||||
|
||||
$execution->refresh();
|
||||
|
||||
expect($execution->status)->toBe(GithubRunnerStatus::Failed)
|
||||
->and($execution->error_message)->toContain('simulated cleanup crash')
|
||||
->and($execution->completed_at)->not->toBeNull()
|
||||
->and($config->fresh()->activeRunnerCount())->toBe(0)
|
||||
->and($config->fresh()->hasCapacity())->toBeTrue();
|
||||
});
|
||||
|
||||
it('marks running execution as failed when cleanup job fails before cleaning transition', function () {
|
||||
$team = Team::factory()->create();
|
||||
$privateKeyId = \Illuminate\Support\Facades\DB::table('private_keys')->insertGetId([
|
||||
'uuid' => fake()->uuid(),
|
||||
'name' => 'test-key',
|
||||
'private_key' => encrypt('test'),
|
||||
'team_id' => $team->id,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
$server = Server::factory()->create(['private_key_id' => $privateKeyId, 'team_id' => $team->id]);
|
||||
$server->settings()->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]);
|
||||
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'test-app',
|
||||
'app_id' => fake()->unique()->randomNumber(6, true),
|
||||
'installation_id' => 789,
|
||||
'client_id' => 'Iv1.abc123',
|
||||
'client_secret' => 'secret',
|
||||
'webhook_secret' => 'test-secret',
|
||||
'private_key_id' => $privateKeyId,
|
||||
'team_id' => $team->id,
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'organization' => 'test-org',
|
||||
]);
|
||||
|
||||
$config = GithubRunnerConfig::create([
|
||||
'server_id' => $server->id,
|
||||
'github_app_id' => $githubApp->id,
|
||||
'labels' => ['self-hosted', 'coolify'],
|
||||
'max_runners' => 1,
|
||||
'capacity_wait_timeout' => 60,
|
||||
]);
|
||||
|
||||
$execution = GithubRunnerExecution::create([
|
||||
'server_id' => $server->id,
|
||||
'github_runner_config_id' => $config->id,
|
||||
'status' => GithubRunnerStatus::Running,
|
||||
'runner_name' => 'coolify-running',
|
||||
'runner_dir' => '/opt/github-runners/coolify-running',
|
||||
'workflow_job_id' => 123457,
|
||||
'started_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$job = new CleanupGithubRunnerJob(workflowJobId: 123457);
|
||||
$job->failed(new RuntimeException('simulated early cleanup crash'));
|
||||
|
||||
$execution->refresh();
|
||||
|
||||
expect($execution->status)->toBe(GithubRunnerStatus::Failed)
|
||||
->and($execution->error_message)->toContain('simulated early cleanup crash')
|
||||
->and($execution->completed_at)->not->toBeNull()
|
||||
->and($config->fresh()->activeRunnerCount())->toBe(0)
|
||||
->and($config->fresh()->hasCapacity())->toBeTrue();
|
||||
});
|
||||
|
||||
it('marks execution as failed when cleanup runs on a non-functional server', function () {
|
||||
$team = Team::factory()->create();
|
||||
$privateKeyId = \Illuminate\Support\Facades\DB::table('private_keys')->insertGetId([
|
||||
'uuid' => fake()->uuid(),
|
||||
'name' => 'test-key',
|
||||
'private_key' => encrypt('test'),
|
||||
'team_id' => $team->id,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
$server = Server::factory()->create(['private_key_id' => $privateKeyId, 'team_id' => $team->id]);
|
||||
$server->settings()->update(['is_reachable' => false, 'is_usable' => false, 'force_disabled' => false]);
|
||||
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'test-app',
|
||||
'app_id' => fake()->unique()->randomNumber(6, true),
|
||||
'installation_id' => 789,
|
||||
'client_id' => 'Iv1.abc123',
|
||||
'client_secret' => 'secret',
|
||||
'webhook_secret' => 'test-secret',
|
||||
'private_key_id' => $privateKeyId,
|
||||
'team_id' => $team->id,
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'organization' => 'test-org',
|
||||
]);
|
||||
|
||||
$config = GithubRunnerConfig::create([
|
||||
'server_id' => $server->id,
|
||||
'github_app_id' => $githubApp->id,
|
||||
'labels' => ['self-hosted', 'coolify'],
|
||||
'max_runners' => 1,
|
||||
'capacity_wait_timeout' => 60,
|
||||
]);
|
||||
|
||||
$execution = GithubRunnerExecution::create([
|
||||
'server_id' => $server->id,
|
||||
'github_runner_config_id' => $config->id,
|
||||
'status' => GithubRunnerStatus::Running,
|
||||
'runner_name' => 'coolify-running',
|
||||
'runner_dir' => '/opt/github-runners/coolify-running',
|
||||
'workflow_job_id' => 999001,
|
||||
'started_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$job = new CleanupGithubRunnerJob(workflowJobId: 999001);
|
||||
$job->handle();
|
||||
|
||||
$execution->refresh();
|
||||
|
||||
expect($execution->status)->toBe(GithubRunnerStatus::Failed)
|
||||
->and($execution->error_message)->toBe('Cleanup skipped: server is not functional.')
|
||||
->and($execution->completed_at)->not->toBeNull()
|
||||
->and($config->fresh()->activeRunnerCount())->toBe(0)
|
||||
->and($config->fresh()->hasCapacity())->toBeTrue();
|
||||
});
|
||||
101
tests/Feature/GithubAppWebhookEventsSyncTest.php
Normal file
101
tests/Feature/GithubAppWebhookEventsSyncTest.php
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<?php
|
||||
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function setupGithubAppWithKey(array $appAttributes = []): GithubApp
|
||||
{
|
||||
$team = Team::factory()->create();
|
||||
$privateKeyId = DB::table('private_keys')->insertGetId([
|
||||
'uuid' => fake()->uuid(),
|
||||
'name' => 'test-key',
|
||||
'private_key' => 'test-key-value',
|
||||
'team_id' => $team->id,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return GithubApp::create(array_merge([
|
||||
'uuid' => fake()->uuid(),
|
||||
'name' => 'test-app',
|
||||
'html_url' => 'https://github.com',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'team_id' => $team->id,
|
||||
'app_id' => 12345,
|
||||
'private_key_id' => $privateKeyId,
|
||||
], $appAttributes));
|
||||
}
|
||||
|
||||
it('stores webhook events from github api response', function () {
|
||||
$app = setupGithubAppWithKey();
|
||||
|
||||
Http::fake([
|
||||
'*/app' => Http::response([
|
||||
'permissions' => ['contents' => 'read', 'metadata' => 'read'],
|
||||
'events' => ['push', 'installation', 'pull_request'],
|
||||
]),
|
||||
]);
|
||||
|
||||
$response = Http::get('https://api.github.com/app')->json();
|
||||
$app->webhook_events = data_get($response, 'events', []);
|
||||
$app->save();
|
||||
$app->refresh();
|
||||
|
||||
expect($app->webhook_events)->toBe(['push', 'installation', 'pull_request']);
|
||||
});
|
||||
|
||||
it('auto-fixes missing events by patching github api', function () {
|
||||
$app = setupGithubAppWithKey([
|
||||
'webhook_events' => ['push'],
|
||||
'pull_requests' => 'write',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'api.github.com/app' => Http::response([
|
||||
'events' => ['push', 'pull_request'],
|
||||
]),
|
||||
]);
|
||||
|
||||
$missing = $app->missingWebhookEvents();
|
||||
expect($missing)->toContain('pull_request');
|
||||
|
||||
$updatedEvents = array_values(array_unique(
|
||||
array_merge($app->webhook_events ?? [], $missing)
|
||||
));
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => 'Bearer fake-jwt',
|
||||
'Accept' => 'application/vnd.github+json',
|
||||
])->patch('https://api.github.com/app', [
|
||||
'events' => $updatedEvents,
|
||||
]);
|
||||
|
||||
expect($response->successful())->toBeTrue();
|
||||
|
||||
$app->webhook_events = data_get($response->json(), 'events', $updatedEvents);
|
||||
$app->save();
|
||||
$app->refresh();
|
||||
|
||||
expect($app->webhook_events)
|
||||
->toContain('push')
|
||||
->toContain('pull_request');
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return $request->method() === 'PATCH'
|
||||
&& str_contains($request->url(), '/app')
|
||||
&& in_array('pull_request', $request['events']);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not patch when no events are missing', function () {
|
||||
$app = setupGithubAppWithKey([
|
||||
'webhook_events' => ['push', 'installation'],
|
||||
]);
|
||||
|
||||
expect($app->missingWebhookEvents())->toBe([]);
|
||||
});
|
||||
90
tests/Feature/GithubAppWebhookEventsTest.php
Normal file
90
tests/Feature/GithubAppWebhookEventsTest.php
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<?php
|
||||
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\GithubRunnerConfig;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createGithubAppForEvents(array $attributes = []): GithubApp
|
||||
{
|
||||
$team = Team::factory()->create();
|
||||
|
||||
return GithubApp::create(array_merge([
|
||||
'uuid' => fake()->uuid(),
|
||||
'name' => 'test-app',
|
||||
'html_url' => 'https://github.com',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'team_id' => $team->id,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
it('returns base required events when no special config exists', function () {
|
||||
$app = createGithubAppForEvents();
|
||||
|
||||
expect($app->requiredWebhookEvents())
|
||||
->toBe(['push']);
|
||||
});
|
||||
|
||||
it('includes pull_request when pull_requests permission is write', function () {
|
||||
$app = createGithubAppForEvents(['pull_requests' => 'write']);
|
||||
|
||||
expect($app->requiredWebhookEvents())
|
||||
->toContain('pull_request');
|
||||
});
|
||||
|
||||
it('does not include pull_request when pull_requests permission is read', function () {
|
||||
$app = createGithubAppForEvents(['pull_requests' => 'read']);
|
||||
|
||||
expect($app->requiredWebhookEvents())
|
||||
->not->toContain('pull_request');
|
||||
});
|
||||
|
||||
it('includes workflow_job when runner configs exist', function () {
|
||||
$app = createGithubAppForEvents();
|
||||
$server = Server::factory()->create(['team_id' => $app->team_id]);
|
||||
|
||||
GithubRunnerConfig::create([
|
||||
'server_id' => $server->id,
|
||||
'github_app_id' => $app->id,
|
||||
'labels' => ['self-hosted'],
|
||||
]);
|
||||
|
||||
expect($app->requiredWebhookEvents())
|
||||
->toContain('workflow_job');
|
||||
});
|
||||
|
||||
it('returns missing events correctly', function () {
|
||||
$app = createGithubAppForEvents([
|
||||
'webhook_events' => ['push'],
|
||||
]);
|
||||
|
||||
$missing = $app->missingWebhookEvents();
|
||||
|
||||
expect($missing)->toBe([]);
|
||||
});
|
||||
|
||||
it('returns no missing events when all required events are present', function () {
|
||||
$app = createGithubAppForEvents([
|
||||
'webhook_events' => ['push'],
|
||||
]);
|
||||
|
||||
expect($app->missingWebhookEvents())->toBe([]);
|
||||
});
|
||||
|
||||
it('returns missing workflow_job when runner config exists but event is not subscribed', function () {
|
||||
$app = createGithubAppForEvents([
|
||||
'webhook_events' => ['push', 'installation'],
|
||||
]);
|
||||
$server = Server::factory()->create(['team_id' => $app->team_id]);
|
||||
|
||||
GithubRunnerConfig::create([
|
||||
'server_id' => $server->id,
|
||||
'github_app_id' => $app->id,
|
||||
'labels' => ['self-hosted'],
|
||||
]);
|
||||
|
||||
expect($app->missingWebhookEvents())->toContain('workflow_job');
|
||||
});
|
||||
|
|
@ -6,9 +6,9 @@ use App\Models\GithubApp;
|
|||
use App\Models\GithubRunnerConfig;
|
||||
use App\Models\GithubRunnerExecution;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerSetting;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
|
@ -26,11 +26,10 @@ function makeRunnerSetup(array $configOverrides = []): array
|
|||
]);
|
||||
$server = Server::factory()->create(['private_key_id' => $privateKeyId, 'team_id' => $team->id]);
|
||||
|
||||
// Create functional server settings so isFunctional() returns true
|
||||
ServerSetting::updateOrCreate(
|
||||
['server_id' => $server->id],
|
||||
['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false],
|
||||
);
|
||||
// Make server functional — Server::created() auto-creates settings with is_reachable=false.
|
||||
// force_disabled must be explicitly set because it's not cast to boolean on ServerSetting.
|
||||
$server->settings()->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]);
|
||||
$server->refresh();
|
||||
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'test-app',
|
||||
|
|
@ -67,6 +66,7 @@ function makeJob(GithubApp $githubApp, array $overrides = []): ProvisionGithubRu
|
|||
], $overrides['payload'] ?? []),
|
||||
organizationLogin: 'test-org',
|
||||
repositoryId: 0,
|
||||
repositoryFullName: $overrides['repositoryFullName'] ?? null,
|
||||
capacityWaitStartedAt: $overrides['capacityWaitStartedAt'] ?? null,
|
||||
);
|
||||
}
|
||||
|
|
@ -224,6 +224,24 @@ it('still re-dispatches when wait time is within the custom timeout', function (
|
|||
Queue::assertPushed(ProvisionGithubRunnerJob::class, fn ($j) => $j->workflowJobPayload['id'] === 44002);
|
||||
});
|
||||
|
||||
it('counts cleaning executions as active capacity', function () {
|
||||
['config' => $config, 'server' => $server] = makeRunnerSetup(['max_runners' => 1]);
|
||||
|
||||
GithubRunnerExecution::create([
|
||||
'server_id' => $server->id,
|
||||
'github_runner_config_id' => $config->id,
|
||||
'status' => GithubRunnerStatus::Cleaning,
|
||||
'runner_name' => 'coolify-cleaning',
|
||||
'runner_dir' => '/opt/github-runners/coolify-cleaning',
|
||||
'workflow_job_id' => 41001,
|
||||
'pid' => 12345,
|
||||
'started_at' => now()->subMinutes(1),
|
||||
]);
|
||||
|
||||
expect($config->fresh()->activeRunnerCount())->toBe(1)
|
||||
->and($config->fresh()->hasCapacity())->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not re-dispatch when the job has already been provisioned (idempotency)', function () {
|
||||
Queue::fake();
|
||||
['githubApp' => $githubApp, 'config' => $config, 'server' => $server] = makeRunnerSetup();
|
||||
|
|
@ -246,3 +264,66 @@ it('does not re-dispatch when the job has already been provisioned (idempotency)
|
|||
expect(GithubRunnerExecution::where('workflow_job_id', 33001)->count())->toBe(1);
|
||||
Queue::assertNotPushed(ProvisionGithubRunnerJob::class);
|
||||
});
|
||||
|
||||
it('does not re-dispatch when github reports the workflow job as cancelled', function () {
|
||||
Queue::fake();
|
||||
Http::fake([
|
||||
'https://api.github.com/repos/test-org/test-repo/actions/jobs/21002' => Http::response([
|
||||
'status' => 'completed',
|
||||
'conclusion' => 'cancelled',
|
||||
], 200),
|
||||
]);
|
||||
|
||||
['githubApp' => $githubApp, 'config' => $config, 'server' => $server] = makeRunnerSetup(['max_runners' => 1]);
|
||||
$githubApp->update(['is_public' => true]);
|
||||
|
||||
GithubRunnerExecution::create([
|
||||
'server_id' => $server->id,
|
||||
'github_runner_config_id' => $config->id,
|
||||
'status' => GithubRunnerStatus::Running,
|
||||
'runner_name' => 'coolify-existing',
|
||||
'runner_dir' => '/opt/github-runners/coolify-existing',
|
||||
'workflow_job_id' => 21001,
|
||||
'pid' => 12345,
|
||||
'started_at' => now()->subMinutes(2),
|
||||
]);
|
||||
|
||||
$job = makeJob($githubApp, [
|
||||
'payload' => ['id' => 21002, 'labels' => ['self-hosted', 'coolify'], 'workflow_name' => 'CI'],
|
||||
'repositoryFullName' => 'test-org/test-repo',
|
||||
]);
|
||||
$job->handle();
|
||||
|
||||
expect(GithubRunnerExecution::where('workflow_job_id', 21002)->exists())->toBeFalse();
|
||||
Queue::assertNotPushed(ProvisionGithubRunnerJob::class);
|
||||
});
|
||||
|
||||
it('does not re-dispatch when github reports the workflow job as missing', function () {
|
||||
Queue::fake();
|
||||
Http::fake([
|
||||
'https://api.github.com/repos/test-org/test-repo/actions/jobs/20002' => Http::response([], 404),
|
||||
]);
|
||||
|
||||
['githubApp' => $githubApp, 'config' => $config, 'server' => $server] = makeRunnerSetup(['max_runners' => 1]);
|
||||
$githubApp->update(['is_public' => true]);
|
||||
|
||||
GithubRunnerExecution::create([
|
||||
'server_id' => $server->id,
|
||||
'github_runner_config_id' => $config->id,
|
||||
'status' => GithubRunnerStatus::Running,
|
||||
'runner_name' => 'coolify-existing',
|
||||
'runner_dir' => '/opt/github-runners/coolify-existing',
|
||||
'workflow_job_id' => 20001,
|
||||
'pid' => 12345,
|
||||
'started_at' => now()->subMinutes(2),
|
||||
]);
|
||||
|
||||
$job = makeJob($githubApp, [
|
||||
'payload' => ['id' => 20002, 'labels' => ['self-hosted', 'coolify'], 'workflow_name' => 'CI'],
|
||||
'repositoryFullName' => 'test-org/test-repo',
|
||||
]);
|
||||
$job->handle();
|
||||
|
||||
expect(GithubRunnerExecution::where('workflow_job_id', 20002)->exists())->toBeFalse();
|
||||
Queue::assertNotPushed(ProvisionGithubRunnerJob::class);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
<?php
|
||||
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\GithubRunnerConfig;
|
||||
use App\Models\GithubRunnerExecution;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('returns pong for ping events on the webhook endpoint', function () {
|
||||
$response = $this->postJson('/source/github/events', [], [
|
||||
$response = $this->postJson('/webhooks/source/github/events', [], [
|
||||
'X-GitHub-Event' => 'ping',
|
||||
]);
|
||||
|
||||
|
|
@ -30,7 +33,7 @@ it('returns nothing to do when no github app found for workflow_job event', func
|
|||
$body = json_encode($payload);
|
||||
$signature = hash_hmac('sha256', $body, $secret);
|
||||
|
||||
$response = $this->postJson('/source/github/events', $payload, [
|
||||
$response = $this->postJson('/webhooks/source/github/events', $payload, [
|
||||
'X-GitHub-Event' => 'workflow_job',
|
||||
'X-GitHub-Hook-Installation-Target-Id' => '999999',
|
||||
'X-Hub-Signature-256' => 'sha256='.$signature,
|
||||
|
|
@ -42,10 +45,13 @@ it('returns nothing to do when no github app found for workflow_job event', func
|
|||
|
||||
it('dispatches provisioning job for queued workflow_job event', function () {
|
||||
$team = \App\Models\Team::factory()->create();
|
||||
$privateKey = \App\Models\PrivateKey::create([
|
||||
$privateKeyId = \Illuminate\Support\Facades\DB::table('private_keys')->insertGetId([
|
||||
'uuid' => fake()->uuid(),
|
||||
'name' => 'test-key',
|
||||
'private_key' => 'test',
|
||||
'private_key' => encrypt('test'),
|
||||
'team_id' => $team->id,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$githubApp = GithubApp::create([
|
||||
|
|
@ -55,7 +61,7 @@ it('dispatches provisioning job for queued workflow_job event', function () {
|
|||
'client_id' => 'Iv1.abc123',
|
||||
'client_secret' => 'secret',
|
||||
'webhook_secret' => 'test-webhook-secret',
|
||||
'private_key_id' => $privateKey->id,
|
||||
'private_key_id' => $privateKeyId,
|
||||
'team_id' => $team->id,
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
|
|
@ -76,13 +82,14 @@ it('dispatches provisioning job for queued workflow_job event', function () {
|
|||
],
|
||||
'repository' => [
|
||||
'id' => 123456789,
|
||||
'full_name' => 'test-org/repo-one',
|
||||
],
|
||||
];
|
||||
|
||||
$body = json_encode($payload);
|
||||
$signature = hash_hmac('sha256', $body, 'test-webhook-secret');
|
||||
|
||||
$response = $this->postJson('/source/github/events', $payload, [
|
||||
$response = $this->postJson('/webhooks/source/github/events', $payload, [
|
||||
'X-GitHub-Event' => 'workflow_job',
|
||||
'X-GitHub-Hook-Installation-Target-Id' => '123456',
|
||||
'X-Hub-Signature-256' => 'sha256='.$signature,
|
||||
|
|
@ -93,16 +100,20 @@ it('dispatches provisioning job for queued workflow_job event', function () {
|
|||
$response->assertSee('Runner provisioning queued.');
|
||||
|
||||
\Illuminate\Support\Facades\Queue::assertPushed(\App\Jobs\ProvisionGithubRunnerJob::class, function ($job) {
|
||||
return $job->repositoryId === 123456789;
|
||||
return $job->repositoryId === 123456789
|
||||
&& $job->repositoryFullName === 'test-org/repo-one';
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches cleanup job for completed workflow_job event', function () {
|
||||
$team = \App\Models\Team::factory()->create();
|
||||
$privateKey = \App\Models\PrivateKey::create([
|
||||
$privateKeyId = \Illuminate\Support\Facades\DB::table('private_keys')->insertGetId([
|
||||
'uuid' => fake()->uuid(),
|
||||
'name' => 'test-key',
|
||||
'private_key' => 'test',
|
||||
'private_key' => encrypt('test'),
|
||||
'team_id' => $team->id,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$githubApp = GithubApp::create([
|
||||
|
|
@ -112,7 +123,7 @@ it('dispatches cleanup job for completed workflow_job event', function () {
|
|||
'client_id' => 'Iv1.abc123',
|
||||
'client_secret' => 'secret',
|
||||
'webhook_secret' => 'cleanup-secret',
|
||||
'private_key_id' => $privateKey->id,
|
||||
'private_key_id' => $privateKeyId,
|
||||
'team_id' => $team->id,
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
|
|
@ -135,7 +146,7 @@ it('dispatches cleanup job for completed workflow_job event', function () {
|
|||
$body = json_encode($payload);
|
||||
$signature = hash_hmac('sha256', $body, 'cleanup-secret');
|
||||
|
||||
$response = $this->postJson('/source/github/events', $payload, [
|
||||
$response = $this->postJson('/webhooks/source/github/events', $payload, [
|
||||
'X-GitHub-Event' => 'workflow_job',
|
||||
'X-GitHub-Hook-Installation-Target-Id' => '654321',
|
||||
'X-Hub-Signature-256' => 'sha256='.$signature,
|
||||
|
|
@ -147,3 +158,80 @@ it('dispatches cleanup job for completed workflow_job event', function () {
|
|||
|
||||
\Illuminate\Support\Facades\Queue::assertPushed(\App\Jobs\CleanupGithubRunnerJob::class);
|
||||
});
|
||||
|
||||
it('marks execution as running for in_progress workflow_job event', function () {
|
||||
$team = \App\Models\Team::factory()->create();
|
||||
$privateKeyId = \Illuminate\Support\Facades\DB::table('private_keys')->insertGetId([
|
||||
'uuid' => fake()->uuid(),
|
||||
'name' => 'test-key',
|
||||
'private_key' => encrypt('test'),
|
||||
'team_id' => $team->id,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$server = Server::factory()->create([
|
||||
'private_key_id' => $privateKeyId,
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'test-app',
|
||||
'app_id' => 112233,
|
||||
'installation_id' => 789,
|
||||
'client_id' => 'Iv1.abc123',
|
||||
'client_secret' => 'secret',
|
||||
'webhook_secret' => 'inprogress-secret',
|
||||
'private_key_id' => $privateKeyId,
|
||||
'team_id' => $team->id,
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'organization' => 'test-org',
|
||||
]);
|
||||
|
||||
$config = GithubRunnerConfig::create([
|
||||
'server_id' => $server->id,
|
||||
'github_app_id' => $githubApp->id,
|
||||
'labels' => ['self-hosted', 'coolify'],
|
||||
'max_runners' => 1,
|
||||
'capacity_wait_timeout' => 60,
|
||||
]);
|
||||
|
||||
$execution = GithubRunnerExecution::create([
|
||||
'server_id' => $server->id,
|
||||
'github_runner_config_id' => $config->id,
|
||||
'status' => \App\Enums\GithubRunnerStatus::Provisioning,
|
||||
'runner_name' => 'coolify-preprovision',
|
||||
'runner_dir' => '/opt/github-runners/coolify-preprovision',
|
||||
'workflow_job_id' => 998877,
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'action' => 'in_progress',
|
||||
'workflow_job' => [
|
||||
'id' => 998877,
|
||||
'runner_name' => 'coolify-live-runner',
|
||||
],
|
||||
'organization' => [
|
||||
'login' => 'test-org',
|
||||
],
|
||||
];
|
||||
|
||||
$body = json_encode($payload);
|
||||
$signature = hash_hmac('sha256', $body, 'inprogress-secret');
|
||||
|
||||
$response = $this->postJson('/webhooks/source/github/events', $payload, [
|
||||
'X-GitHub-Event' => 'workflow_job',
|
||||
'X-GitHub-Hook-Installation-Target-Id' => '112233',
|
||||
'X-Hub-Signature-256' => 'sha256='.$signature,
|
||||
'Content-Type' => 'application/json',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('Runner marked running.');
|
||||
|
||||
$execution->refresh();
|
||||
expect($execution->status->value)->toBe('running')
|
||||
->and($execution->runner_name)->toBe('coolify-live-runner')
|
||||
->and($execution->started_at)->not->toBeNull();
|
||||
});
|
||||
|
|
|
|||
228
tests/Feature/GithubRunnersRunnerGroupNameTest.php
Normal file
228
tests/Feature/GithubRunnersRunnerGroupNameTest.php
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Server\GithubRunners;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\GithubRunnerConfig;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
$validKey = "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDMflQ+H/XBxrhK\n3etBe1c4NzjOFcFp0EXbdhnZCPvkd7PE706osqnfTYxT5I2HYeBiXN20NVhxwhZy\nf8K8sLuITPqjNfLkpwPwbHn5WAy4VgFOxrrVHlNo0jWYSLuNRQPtOgUBJc/WzDi7\nPLPauCLE+sIK7i1dGf8f1UzBLJsNEKuGOq4uAhG8pjpkKY+vSFwgHNTK8qOtoauG\nw+rz6fqzCJ9RLPo/SL7mXardeypg3roQZ9RNfCt50E4H+lP7+hLaDQk5IXBPpGZc\n1ZpvQvAu+e+N62up4KwGFhxL3ziyr3djb7nmJpADwRbzKSl1ry50cpWFbgv9NOYO\nlwfij9ErAgMBAAECggEACPfbWvQiM4gzCeQso+0JrgdMoEvM9TEzTG95V7mF+TGU\nuo93htIlvWDUcCjHBN0dLu3SsqC09cbkyXW3782HvppdqEMT7sTdA9zGBqeUEJDZ\nCCroA7O2Rb5o/Po88MefkfZS74dzKNZBAK57VsgaN5hQYpP/0k7zD42BCxHD5QaL\njuEbQHl7/gthGZBez2IhuH3JcLRgLCXS9cEVCA7229uv0mNtFejZSbypIeq07qQf\niJgsaODtqL5avLj4JSxqjYUwv6oxkKDOK/XXurV2RQ0cV1upuV0Js0HgdQN2K1QL\nh7VA2oO0K5++BoEX5Tn5aEvp0WVQF52wQ8w8pQ3TxQKBgQDqI6Tix5dUoLxLRbFZ\nGjutQOOUpnmFqz/EioCs1Ll95tHC+qi/vyov1efWoufOR1CnLjrTE5Yls1FTYjpp\nwTboxBmDYe473jqaZ4oKLZpXgN+Er6l4ktlw9m9MGx8/U891IKxYfdETj63yQOZK\n4rQ4QS3qbY6N95H9T10azzG8zwKBgQDflhjZKz0ykvOV0TgvAOrqpC9TTteKqCue\nq0Pma6utfWnhoYFwo7kmlBCRoLU4NB9UibJbIxERwTXEDlQMica0/rZStoB7UELn\n9i9AlFPZUEO17TxYggG/TYDdj4MUNsoj3KZS1fGE4sQYi81pKsuy0y2tokZptKmG\nmAVSKIJU5QKBgB7lZTSnschxDWfBYo2ncIiEL4PGE/MXjeqZfDFSQMfkVXmtKedj\nimWVjGo+ROhrcLEe4JRJ2V5QM0MViy+5V02P0u4LViyAPqtxTj3ZlqxFTTltFKfc\neOT3H+ijC5SHsrB6B0QGFjjGlOWKutjW4YEq2Kw+mLkTGiia+GY5QQ7xAoGBAJs/\nm61fyrSNOTnz9nEc0AFxU7Mi8aNDtlYMUa9zX9etV5HmFPzjkjJpaT/VOT/3YTHQ\nEtoZdUbAw9aIpG+4UxNmMa8pLflx96MdXB4ZYEdq5jkyq05Bp3jwFeTCO6ATkzRn\nh83I5FUDKGpq2IyHvL1EyVjhbscDPRtJ/5fWrPjJAoGBAN2Ejrbz3kIyJhf/m7Dq\nJR7zmeeQmK/tAdG9mtIbPGZPUxQd7MOq2z02y3ZX5FJcWPFAuWTNFgs68T4CkeY4\n8TUIdKEwhvkB0uR/alJVTLyaaGU8IOk7Rw6Otu9wlvjqy+Nqoy2GRS4VPLK9dePs\nNwAXUicFB5gVAWeyU+C6Xjn1\n-----END PRIVATE KEY-----";
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::create(['id' => 0]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$privateKeyId = DB::table('private_keys')->insertGetId([
|
||||
'uuid' => fake()->uuid(),
|
||||
'name' => 'test-key',
|
||||
'private_key' => encrypt('test-key-content'),
|
||||
'team_id' => $this->team->id,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->githubApp = GithubApp::create([
|
||||
'name' => 'Test App',
|
||||
'app_id' => 123456,
|
||||
'installation_id' => null,
|
||||
'client_id' => 'Iv1.abc',
|
||||
'client_secret' => 'secret',
|
||||
'webhook_secret' => 'hook-secret',
|
||||
'private_key_id' => $privateKeyId,
|
||||
'team_id' => $this->team->id,
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'organization' => 'test-org',
|
||||
'organization_self_hosted_runners' => 'write',
|
||||
]);
|
||||
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $privateKeyId,
|
||||
]);
|
||||
});
|
||||
|
||||
it('stores the normalized runner group name when saving github runner config', function () {
|
||||
Livewire::test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
||||
->set('selectedGithubAppId', $this->githubApp->id)
|
||||
->set('runnerGroupName', ' Team Runners Primary ')
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$this->githubApp->refresh();
|
||||
|
||||
expect($this->githubApp->runner_group_name)->toBe('Team Runners Primary');
|
||||
});
|
||||
|
||||
it('generates a default runner group name when the field is empty', function () {
|
||||
$this->githubApp->update(['runner_group_name' => 'Existing Name']);
|
||||
|
||||
Livewire::test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
||||
->set('selectedGithubAppId', $this->githubApp->id)
|
||||
->set('runnerGroupName', ' ')
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$this->githubApp->refresh();
|
||||
|
||||
expect($this->githubApp->runner_group_name)->toStartWith('Coolify-');
|
||||
});
|
||||
|
||||
it('syncs runner group name to github api when saving from ui', function () use ($validKey) {
|
||||
$validPrivateKey = PrivateKey::create([
|
||||
'name' => 'valid-runner-key',
|
||||
'private_key' => $validKey,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$this->githubApp->update([
|
||||
'installation_id' => 222,
|
||||
'private_key_id' => $validPrivateKey->id,
|
||||
'runner_group_id' => 88,
|
||||
'runner_group_name' => 'Old Name',
|
||||
]);
|
||||
|
||||
GithubRunnerConfig::create([
|
||||
'server_id' => $this->server->id,
|
||||
'github_app_id' => $this->githubApp->id,
|
||||
'labels' => ['self-hosted', 'coolify'],
|
||||
'max_runners' => 4,
|
||||
'capacity_wait_timeout' => 60,
|
||||
'runner_user' => 'runner',
|
||||
'runner_base_dir' => '/opt/github-runners',
|
||||
'is_enabled' => true,
|
||||
]);
|
||||
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, ['Date' => now()->toRfc7231String()]),
|
||||
'https://api.github.com/app/installations/222/access_tokens' => Http::response(['token' => 'ghs_test_token'], 200),
|
||||
'https://api.github.com/orgs/test-org/actions/runner-groups/88' => Http::response([], 200),
|
||||
]);
|
||||
|
||||
Livewire::test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
||||
->assertSet('selectedGithubAppId', $this->githubApp->id)
|
||||
->set('selectedGithubAppId', $this->githubApp->id)
|
||||
->set('runnerGroupName', 'New Synced Name')
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$this->githubApp->refresh();
|
||||
expect($this->githubApp->runner_group_name)->toBe('New Synced Name');
|
||||
|
||||
$recordedRequests = collect(Http::recorded())
|
||||
->map(fn (array $entry) => $entry[0]->method().' '.$entry[0]->url())
|
||||
->values()
|
||||
->all();
|
||||
|
||||
\PHPUnit\Framework\Assert::assertContains(
|
||||
'PATCH https://api.github.com/orgs/test-org/actions/runner-groups/88',
|
||||
$recordedRequests,
|
||||
'Recorded requests: '.json_encode($recordedRequests)
|
||||
);
|
||||
});
|
||||
|
||||
it('does not sync runner group name to github api when field is not dirty', function () use ($validKey) {
|
||||
$validPrivateKey = PrivateKey::create([
|
||||
'name' => 'valid-runner-key-no-dirty',
|
||||
'private_key' => $validKey,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$this->githubApp->update([
|
||||
'installation_id' => 222,
|
||||
'private_key_id' => $validPrivateKey->id,
|
||||
'runner_group_id' => 88,
|
||||
'runner_group_name' => 'Same Name',
|
||||
]);
|
||||
|
||||
GithubRunnerConfig::create([
|
||||
'server_id' => $this->server->id,
|
||||
'github_app_id' => $this->githubApp->id,
|
||||
'labels' => ['self-hosted', 'coolify'],
|
||||
'max_runners' => 4,
|
||||
'capacity_wait_timeout' => 60,
|
||||
'runner_user' => 'runner',
|
||||
'runner_base_dir' => '/opt/github-runners',
|
||||
'is_enabled' => true,
|
||||
]);
|
||||
|
||||
Http::preventStrayRequests();
|
||||
Http::fake();
|
||||
|
||||
Livewire::test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
||||
->assertSet('selectedGithubAppId', $this->githubApp->id)
|
||||
->assertSet('runnerGroupName', 'Same Name')
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('generates and syncs a default runner group name to github when field is empty', function () use ($validKey) {
|
||||
$validPrivateKey = PrivateKey::create([
|
||||
'name' => 'valid-runner-key-empty-sync',
|
||||
'private_key' => $validKey,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$this->githubApp->update([
|
||||
'installation_id' => 222,
|
||||
'private_key_id' => $validPrivateKey->id,
|
||||
'runner_group_id' => 88,
|
||||
'runner_group_name' => 'Existing Name',
|
||||
]);
|
||||
|
||||
GithubRunnerConfig::create([
|
||||
'server_id' => $this->server->id,
|
||||
'github_app_id' => $this->githubApp->id,
|
||||
'labels' => ['self-hosted', 'coolify'],
|
||||
'max_runners' => 4,
|
||||
'capacity_wait_timeout' => 60,
|
||||
'runner_user' => 'runner',
|
||||
'runner_base_dir' => '/opt/github-runners',
|
||||
'is_enabled' => true,
|
||||
]);
|
||||
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, ['Date' => now()->toRfc7231String()]),
|
||||
'https://api.github.com/app/installations/222/access_tokens' => Http::response(['token' => 'ghs_test_token'], 200),
|
||||
'https://api.github.com/orgs/test-org/actions/runner-groups/88' => Http::response([], 200),
|
||||
]);
|
||||
|
||||
Livewire::test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
||||
->assertSet('selectedGithubAppId', $this->githubApp->id)
|
||||
->set('runnerGroupName', ' ')
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$this->githubApp->refresh();
|
||||
expect($this->githubApp->runner_group_name)->toStartWith('Coolify-');
|
||||
|
||||
$recordedRequests = collect(Http::recorded())
|
||||
->map(fn (array $entry) => $entry[0]->method().' '.$entry[0]->url())
|
||||
->values()
|
||||
->all();
|
||||
|
||||
\PHPUnit\Framework\Assert::assertContains(
|
||||
'PATCH https://api.github.com/orgs/test-org/actions/runner-groups/88',
|
||||
$recordedRequests,
|
||||
'Recorded requests: '.json_encode($recordedRequests)
|
||||
);
|
||||
});
|
||||
194
tests/Feature/ProvisionGithubRunnerRunnerGroupSyncTest.php
Normal file
194
tests/Feature/ProvisionGithubRunnerRunnerGroupSyncTest.php
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\ProvisionGithubRunnerJob;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function makeGithubAppForRunnerGroupTests(): GithubApp
|
||||
{
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$privateKey = PrivateKey::create([
|
||||
'name' => 'runner-group-key',
|
||||
'private_key' => <<<'KEY'
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDMflQ+H/XBxrhK
|
||||
3etBe1c4NzjOFcFp0EXbdhnZCPvkd7PE706osqnfTYxT5I2HYeBiXN20NVhxwhZy
|
||||
f8K8sLuITPqjNfLkpwPwbHn5WAy4VgFOxrrVHlNo0jWYSLuNRQPtOgUBJc/WzDi7
|
||||
PLPauCLE+sIK7i1dGf8f1UzBLJsNEKuGOq4uAhG8pjpkKY+vSFwgHNTK8qOtoauG
|
||||
w+rz6fqzCJ9RLPo/SL7mXardeypg3roQZ9RNfCt50E4H+lP7+hLaDQk5IXBPpGZc
|
||||
1ZpvQvAu+e+N62up4KwGFhxL3ziyr3djb7nmJpADwRbzKSl1ry50cpWFbgv9NOYO
|
||||
lwfij9ErAgMBAAECggEACPfbWvQiM4gzCeQso+0JrgdMoEvM9TEzTG95V7mF+TGU
|
||||
uo93htIlvWDUcCjHBN0dLu3SsqC09cbkyXW3782HvppdqEMT7sTdA9zGBqeUEJDZ
|
||||
CCroA7O2Rb5o/Po88MefkfZS74dzKNZBAK57VsgaN5hQYpP/0k7zD42BCxHD5QaL
|
||||
juEbQHl7/gthGZBez2IhuH3JcLRgLCXS9cEVCA7229uv0mNtFejZSbypIeq07qQf
|
||||
iJgsaODtqL5avLj4JSxqjYUwv6oxkKDOK/XXurV2RQ0cV1upuV0Js0HgdQN2K1QL
|
||||
h7VA2oO0K5++BoEX5Tn5aEvp0WVQF52wQ8w8pQ3TxQKBgQDqI6Tix5dUoLxLRbFZ
|
||||
GjutQOOUpnmFqz/EioCs1Ll95tHC+qi/vyov1efWoufOR1CnLjrTE5Yls1FTYjpp
|
||||
wTboxBmDYe473jqaZ4oKLZpXgN+Er6l4ktlw9m9MGx8/U891IKxYfdETj63yQOZK
|
||||
4rQ4QS3qbY6N95H9T10azzG8zwKBgQDflhjZKz0ykvOV0TgvAOrqpC9TTteKqCue
|
||||
q0Pma6utfWnhoYFwo7kmlBCRoLU4NB9UibJbIxERwTXEDlQMica0/rZStoB7UELn
|
||||
9i9AlFPZUEO17TxYggG/TYDdj4MUNsoj3KZS1fGE4sQYi81pKsuy0y2tokZptKmG
|
||||
mAVSKIJU5QKBgB7lZTSnschxDWfBYo2ncIiEL4PGE/MXjeqZfDFSQMfkVXmtKedj
|
||||
imWVjGo+ROhrcLEe4JRJ2V5QM0MViy+5V02P0u4LViyAPqtxTj3ZlqxFTTltFKfc
|
||||
eOT3H+ijC5SHsrB6B0QGFjjGlOWKutjW4YEq2Kw+mLkTGiia+GY5QQ7xAoGBAJs/
|
||||
m61fyrSNOTnz9nEc0AFxU7Mi8aNDtlYMUa9zX9etV5HmFPzjkjJpaT/VOT/3YTHQ
|
||||
EtoZdUbAw9aIpG+4UxNmMa8pLflx96MdXB4ZYEdq5jkyq05Bp3jwFeTCO6ATkzRn
|
||||
h83I5FUDKGpq2IyHvL1EyVjhbscDPRtJ/5fWrPjJAoGBAN2Ejrbz3kIyJhf/m7Dq
|
||||
JR7zmeeQmK/tAdG9mtIbPGZPUxQd7MOq2z02y3ZX5FJcWPFAuWTNFgs68T4CkeY4
|
||||
8TUIdKEwhvkB0uR/alJVTLyaaGU8IOk7Rw6Otu9wlvjqy+Nqoy2GRS4VPLK9dePs
|
||||
NwAXUicFB5gVAWeyU+C6Xjn1
|
||||
-----END PRIVATE KEY-----
|
||||
KEY,
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
return GithubApp::create([
|
||||
'name' => 'Runner App',
|
||||
'app_id' => fake()->unique()->numberBetween(100000, 999999),
|
||||
'installation_id' => 222,
|
||||
'client_id' => 'Iv1.runner',
|
||||
'client_secret' => 'secret',
|
||||
'webhook_secret' => 'hook-secret',
|
||||
'private_key_id' => $privateKey->id,
|
||||
'team_id' => $team->id,
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'organization' => 'test-org',
|
||||
]);
|
||||
}
|
||||
|
||||
function callEnsureRunnerGroup(ProvisionGithubRunnerJob $job, GithubApp $githubApp): int
|
||||
{
|
||||
$method = new ReflectionMethod(ProvisionGithubRunnerJob::class, 'ensureRunnerGroup');
|
||||
$method->setAccessible(true);
|
||||
|
||||
return $method->invoke($job, $githubApp);
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
Http::preventStrayRequests();
|
||||
});
|
||||
|
||||
it('creates a runner group with the custom name when no runner group exists', function () {
|
||||
$githubApp = makeGithubAppForRunnerGroupTests();
|
||||
$githubApp->update(['runner_group_name' => 'Team Runner Group']);
|
||||
|
||||
Http::fake([
|
||||
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, ['Date' => now()->toRfc7231String()]),
|
||||
'https://api.github.com/app/installations/222/access_tokens' => Http::response(['token' => 'ghs_test'], 200),
|
||||
'https://api.github.com/orgs/test-org/actions/runner-groups' => Http::response(['id' => 9001], 201),
|
||||
]);
|
||||
|
||||
$job = new ProvisionGithubRunnerJob($githubApp->id, ['id' => 1], 'test-org');
|
||||
$runnerGroupId = callEnsureRunnerGroup($job, $githubApp->fresh());
|
||||
|
||||
expect($runnerGroupId)->toBe(9001);
|
||||
|
||||
$githubApp->refresh();
|
||||
expect($githubApp->runner_group_id)->toBe(9001)
|
||||
->and($githubApp->runner_group_name)->toBe('Team Runner Group');
|
||||
|
||||
Http::assertSent(function (Request $request) {
|
||||
return $request->method() === 'POST'
|
||||
&& $request->url() === 'https://api.github.com/orgs/test-org/actions/runner-groups'
|
||||
&& $request['name'] === 'Team Runner Group';
|
||||
});
|
||||
});
|
||||
|
||||
it('syncs the custom name to github when runner group id already exists', function () {
|
||||
$githubApp = makeGithubAppForRunnerGroupTests();
|
||||
$githubApp->update([
|
||||
'runner_group_id' => 42,
|
||||
'runner_group_name' => 'Synced Name',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, ['Date' => now()->toRfc7231String()]),
|
||||
'https://api.github.com/app/installations/222/access_tokens' => Http::response(['token' => 'ghs_test'], 200),
|
||||
'https://api.github.com/orgs/test-org/actions/runner-groups/42' => Http::response([], 200),
|
||||
]);
|
||||
|
||||
$job = new ProvisionGithubRunnerJob($githubApp->id, ['id' => 2], 'test-org');
|
||||
$runnerGroupId = callEnsureRunnerGroup($job, $githubApp->fresh());
|
||||
|
||||
expect($runnerGroupId)->toBe(42);
|
||||
|
||||
Http::assertSent(function (Request $request) {
|
||||
return $request->method() === 'PATCH'
|
||||
&& $request->url() === 'https://api.github.com/orgs/test-org/actions/runner-groups/42'
|
||||
&& $request['name'] === 'Synced Name';
|
||||
});
|
||||
|
||||
Http::assertNotSent(function (Request $request) {
|
||||
return $request->method() === 'POST'
|
||||
&& $request->url() === 'https://api.github.com/orgs/test-org/actions/runner-groups';
|
||||
});
|
||||
});
|
||||
|
||||
it('recreates the runner group when the stored runner group id no longer exists on github', function () {
|
||||
$githubApp = makeGithubAppForRunnerGroupTests();
|
||||
$githubApp->update([
|
||||
'runner_group_id' => 42,
|
||||
'runner_group_name' => 'Recover Name',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, ['Date' => now()->toRfc7231String()]),
|
||||
'https://api.github.com/app/installations/222/access_tokens' => Http::response(['token' => 'ghs_test'], 200),
|
||||
'https://api.github.com/orgs/test-org/actions/runner-groups/42' => Http::response(['message' => 'Not Found'], 404),
|
||||
'https://api.github.com/orgs/test-org/actions/runner-groups' => Http::response(['id' => 96], 201),
|
||||
]);
|
||||
|
||||
$job = new ProvisionGithubRunnerJob($githubApp->id, ['id' => 3], 'test-org');
|
||||
$runnerGroupId = callEnsureRunnerGroup($job, $githubApp->fresh());
|
||||
|
||||
expect($runnerGroupId)->toBe(96);
|
||||
|
||||
$githubApp->refresh();
|
||||
expect($githubApp->runner_group_id)->toBe(96)
|
||||
->and($githubApp->runner_group_name)->toBe('Recover Name');
|
||||
|
||||
Http::assertSent(function (Request $request) {
|
||||
return $request->method() === 'PATCH'
|
||||
&& $request->url() === 'https://api.github.com/orgs/test-org/actions/runner-groups/42';
|
||||
});
|
||||
|
||||
Http::assertSent(function (Request $request) {
|
||||
return $request->method() === 'POST'
|
||||
&& $request->url() === 'https://api.github.com/orgs/test-org/actions/runner-groups'
|
||||
&& $request['name'] === 'Recover Name';
|
||||
});
|
||||
});
|
||||
|
||||
it('generates and stores a fallback name when no custom runner group name is set', function () {
|
||||
$githubApp = makeGithubAppForRunnerGroupTests();
|
||||
|
||||
Http::fake([
|
||||
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, ['Date' => now()->toRfc7231String()]),
|
||||
'https://api.github.com/app/installations/222/access_tokens' => Http::response(['token' => 'ghs_test'], 200),
|
||||
'https://api.github.com/orgs/test-org/actions/runner-groups' => Http::response(['id' => 77], 201),
|
||||
]);
|
||||
|
||||
$job = new ProvisionGithubRunnerJob($githubApp->id, ['id' => 4], 'test-org');
|
||||
$runnerGroupId = callEnsureRunnerGroup($job, $githubApp->fresh());
|
||||
|
||||
expect($runnerGroupId)->toBe(77);
|
||||
|
||||
$githubApp->refresh();
|
||||
expect($githubApp->runner_group_name)->toStartWith('Coolify-')
|
||||
->and($githubApp->runner_group_id)->toBe(77);
|
||||
|
||||
Http::assertSent(function (Request $request) {
|
||||
return $request->method() === 'POST'
|
||||
&& $request->url() === 'https://api.github.com/orgs/test-org/actions/runner-groups'
|
||||
&& is_string($request['name'])
|
||||
&& str_starts_with($request['name'], 'Coolify-');
|
||||
});
|
||||
});
|
||||
84
tests/Feature/Server/GithubRunnerExecutionsTest.php
Normal file
84
tests/Feature/Server/GithubRunnerExecutionsTest.php
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\GithubRunnerStatus;
|
||||
use App\Livewire\Server\GithubRunnerExecutions;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\GithubRunnerConfig;
|
||||
use App\Models\GithubRunnerExecution;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('renders recent github runner executions inside the polled child component', function () {
|
||||
$team = Team::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
$team->members()->attach($user->id, ['role' => 'owner']);
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => $team]);
|
||||
|
||||
$privateKeyId = DB::table('private_keys')->insertGetId([
|
||||
'uuid' => fake()->uuid(),
|
||||
'name' => 'test-key',
|
||||
'private_key' => encrypt('test'),
|
||||
'team_id' => $team->id,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'private_key_id' => $privateKeyId,
|
||||
]);
|
||||
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'Test App',
|
||||
'app_id' => 123456,
|
||||
'installation_id' => 789,
|
||||
'client_id' => 'Iv1.abc',
|
||||
'client_secret' => 'secret',
|
||||
'webhook_secret' => 'hook-secret',
|
||||
'private_key_id' => $privateKeyId,
|
||||
'team_id' => $team->id,
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'organization' => 'test-org',
|
||||
]);
|
||||
|
||||
$config = GithubRunnerConfig::create([
|
||||
'server_id' => $server->id,
|
||||
'github_app_id' => $githubApp->id,
|
||||
'labels' => ['self-hosted', 'coolify'],
|
||||
'max_runners' => 4,
|
||||
'capacity_wait_timeout' => 60,
|
||||
'runner_user' => 'runner',
|
||||
'runner_base_dir' => '/opt/github-runners',
|
||||
'is_enabled' => true,
|
||||
]);
|
||||
|
||||
GithubRunnerExecution::create([
|
||||
'server_id' => $server->id,
|
||||
'github_runner_config_id' => $config->id,
|
||||
'status' => GithubRunnerStatus::Running,
|
||||
'runner_name' => 'coolify-test-runner',
|
||||
'runner_dir' => '/opt/github-runners/coolify-test-runner',
|
||||
'workflow_job_id' => 987654,
|
||||
'workflow_job_html_url' => 'https://github.com/test-org/test-repo/actions/runs/111/job/987654',
|
||||
'repository_full_name' => 'test-org/test-repo',
|
||||
'started_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$component = Livewire::test(GithubRunnerExecutions::class, ['server' => $server])
|
||||
->assertSee('Recent Executions')
|
||||
->assertSee('Refresh')
|
||||
->assertSee('coolify-test-runner')
|
||||
->assertSee('Running')
|
||||
->assertSee('Open');
|
||||
|
||||
expect($component->html())->toContain('wire:poll.10s');
|
||||
expect($component->html())->toContain('https://github.com/test-org/test-repo/actions/runs/111/job/987654');
|
||||
});
|
||||
28
tests/Unit/CleanupGithubRunnerArtifactsJobTest.php
Normal file
28
tests/Unit/CleanupGithubRunnerArtifactsJobTest.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\CleanupGithubRunnerArtifactsJob;
|
||||
|
||||
it('builds cleanup commands for cached runner tarballs and template directories', function () {
|
||||
$commands = CleanupGithubRunnerArtifactsJob::buildCleanupCommands('/opt/github-runners');
|
||||
|
||||
expect($commands)->toHaveCount(3);
|
||||
expect($commands[0])->toContain('.cache/actions-runner-linux-${arch}-*.tar.gz');
|
||||
expect($commands[1])->toContain('.templates/runner-${arch}-*');
|
||||
expect($commands[2])->toContain('.template/runner-${arch}-*');
|
||||
expect($commands[0])->toContain('tail -n +3');
|
||||
expect($commands[1])->toContain('tail -n +3');
|
||||
expect($commands[2])->toContain('tail -n +3');
|
||||
});
|
||||
|
||||
it('schedules github runner artifact cleanup daily at 2am', function () {
|
||||
$kernelFile = file_get_contents(__DIR__.'/../../app/Console/Kernel.php');
|
||||
|
||||
expect($kernelFile)->toContain('use App\\Jobs\\CleanupGithubRunnerArtifactsJob;');
|
||||
expect($kernelFile)->toContain("->job(new CleanupGithubRunnerArtifactsJob)->dailyAt('02:00')->onOneServer();");
|
||||
});
|
||||
|
||||
it('updates runner cache and template timestamps during provisioning', function () {
|
||||
$provisionFile = file_get_contents(__DIR__.'/../../app/Jobs/ProvisionGithubRunnerJob.php');
|
||||
|
||||
expect($provisionFile)->toContain('touch {$cacheDir}/{$tarball} {$templateDir}');
|
||||
});
|
||||
33
tests/Unit/GithubRunnerExecutionWorkflowUrlTest.php
Normal file
33
tests/Unit/GithubRunnerExecutionWorkflowUrlTest.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
use App\Models\GithubRunnerExecution;
|
||||
|
||||
it('prefers the direct workflow job html url when available', function () {
|
||||
$execution = new GithubRunnerExecution([
|
||||
'workflow_job_id' => 987654,
|
||||
'repository_full_name' => 'test-org/test-repo',
|
||||
'workflow_job_html_url' => 'https://github.com/test-org/test-repo/actions/runs/111/job/987654',
|
||||
]);
|
||||
|
||||
expect($execution->workflowJobUrl())->toBe('https://github.com/test-org/test-repo/actions/runs/111/job/987654');
|
||||
});
|
||||
|
||||
it('builds a fallback github actions search url when direct url is missing', function () {
|
||||
$execution = new GithubRunnerExecution([
|
||||
'workflow_job_id' => 987654,
|
||||
'repository_full_name' => 'test-org/test-repo',
|
||||
'workflow_job_html_url' => null,
|
||||
]);
|
||||
|
||||
expect($execution->workflowJobUrl())->toBe('https://github.com/test-org/test-repo/actions?query=987654');
|
||||
});
|
||||
|
||||
it('returns null when there is not enough data to build a workflow url', function () {
|
||||
$execution = new GithubRunnerExecution([
|
||||
'workflow_job_id' => null,
|
||||
'repository_full_name' => null,
|
||||
'workflow_job_html_url' => null,
|
||||
]);
|
||||
|
||||
expect($execution->workflowJobUrl())->toBeNull();
|
||||
});
|
||||
Loading…
Reference in a new issue