mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-29 10:04:36 +08:00
Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Co-authored-by: Aditya Tripathi <aditya@climactic.co> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
763 lines
34 KiB
PHP
763 lines
34 KiB
PHP
<?php
|
|
|
|
use App\Enums\GithubRunnerDockerMode;
|
|
use App\Enums\GithubRunnerStatus;
|
|
use App\Jobs\CleanupGithubRunnerJob;
|
|
use App\Jobs\GithubAppPermissionJob;
|
|
use App\Jobs\ProvisionGithubRunnerJob;
|
|
use App\Livewire\Server\GithubRunnerExecutions;
|
|
use App\Livewire\Server\GithubRunners;
|
|
use App\Livewire\Server\Show;
|
|
use App\Livewire\Source\Github\Change;
|
|
use App\Livewire\Source\Github\Create;
|
|
use App\Models\GithubApp;
|
|
use App\Models\GithubRunnerConfig;
|
|
use App\Models\GithubRunnerExecution;
|
|
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\Http;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Livewire\Livewire;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
$this->withoutVite();
|
|
Server::flushIdentityMap();
|
|
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
|
|
|
$this->team = Team::factory()->create();
|
|
$this->owner = User::factory()->create();
|
|
$this->member = User::factory()->create();
|
|
$this->team->members()->attach($this->owner->id, ['role' => 'owner']);
|
|
$this->team->members()->attach($this->member->id, ['role' => 'member']);
|
|
session(['currentTeam' => $this->team]);
|
|
|
|
$this->server = settingsTestServer($this->team, 'build');
|
|
$this->githubApp = settingsTestGithubApp($this->team, 3333);
|
|
});
|
|
|
|
afterEach(function () {
|
|
Server::flushIdentityMap();
|
|
});
|
|
|
|
function settingsTestServer(Team $team, string $role): Server
|
|
{
|
|
$server = Server::factory()->create(['team_id' => $team->id]);
|
|
$server->settings()->update([
|
|
'is_reachable' => true,
|
|
'is_usable' => true,
|
|
'server_role' => $role,
|
|
'is_build_server' => $role === 'build',
|
|
'is_swarm_worker' => false,
|
|
'force_disabled' => false,
|
|
]);
|
|
|
|
return $server->fresh();
|
|
}
|
|
|
|
function settingsTestGithubApp(Team $team, int $appId, array $attributes = []): GithubApp
|
|
{
|
|
$rsaKey = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
|
|
openssl_pkey_export($rsaKey, $pemKey);
|
|
$privateKey = PrivateKey::create(['name' => 'Runner key', 'private_key' => $pemKey, 'team_id' => $team->id]);
|
|
|
|
return GithubApp::create([
|
|
'name' => "settings-app-{$appId}",
|
|
'organization' => 'acme',
|
|
'api_url' => 'https://api.github.com',
|
|
'html_url' => 'https://github.com',
|
|
'custom_user' => 'git',
|
|
'custom_port' => 22,
|
|
'app_id' => $appId,
|
|
'installation_id' => $appId + 1,
|
|
'webhook_secret' => 'secret',
|
|
'private_key_id' => $privateKey->id,
|
|
'team_id' => $team->id,
|
|
'is_system_wide' => false,
|
|
'organization_self_hosted_runners' => 'write',
|
|
'actions' => 'read',
|
|
'webhook_events' => ['workflow_job'],
|
|
...$attributes,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Attributes of a GitHub App that is missing every runner permission.
|
|
*/
|
|
function unreadyRunnerApp(): array
|
|
{
|
|
return ['organization_self_hosted_runners' => null, 'actions' => null, 'webhook_events' => ['push']];
|
|
}
|
|
|
|
function fakeRunnerGroupApi(GithubApp $githubApp, int $createStatus = 201): void
|
|
{
|
|
Http::fake([
|
|
'https://api.github.com/zen' => Http::response('ok', 200, ['Date' => now()->toRfc7231String()]),
|
|
"https://api.github.com/app/installations/{$githubApp->installation_id}/access_tokens" => Http::response(['token' => 'installation-token'], 201),
|
|
'https://api.github.com/orgs/acme/actions/runner-groups' => fn ($request) => $request->method() === 'POST'
|
|
? Http::response($createStatus === 201 ? ['id' => 55, 'default' => false] : ['message' => 'Upgrade to create runner groups'], $createStatus)
|
|
: Http::response(['runner_groups' => [['id' => 1, 'name' => 'Default', 'default' => true]]], 200),
|
|
]);
|
|
}
|
|
|
|
describe('runner settings page', function () {
|
|
it('saves the configuration and creates a runner group that public repositories can use', function () {
|
|
fakeRunnerGroupApi($this->githubApp);
|
|
|
|
Livewire::actingAs($this->owner)
|
|
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
|
->set('githubAppId', $this->githubApp->id)
|
|
->set('labels', 'Coolify, self-hosted, gpu')
|
|
->set('memoryLimit', '4G')
|
|
->call('submit')
|
|
->assertHasNoErrors()
|
|
->assertDispatched('success')
|
|
->assertSee('Recent runners');
|
|
|
|
$config = GithubRunnerConfig::sole();
|
|
expect($config->server_id)->toBe($this->server->id)
|
|
->and($config->labels)->toBe(['coolify', 'gpu'])
|
|
->and($config->memory_limit)->toBe('4g')
|
|
->and($config->docker_mode)->toBe(GithubRunnerDockerMode::None)
|
|
->and($this->githubApp->fresh()->runner_group_id)->toBe(55);
|
|
Http::assertSent(fn ($request) => $request->method() === 'POST'
|
|
&& str_ends_with($request->url(), '/orgs/acme/actions/runner-groups')
|
|
&& $request['allows_public_repositories'] === true
|
|
&& $request['visibility'] === 'all');
|
|
expect($config->allow_pull_requests)->toBeFalse();
|
|
});
|
|
|
|
it('keeps the restrictions an organization admin set on an existing runner group', function () {
|
|
$this->githubApp->update(['runner_group_id' => 77]);
|
|
Http::fake([
|
|
'https://api.github.com/zen' => Http::response('ok', 200, ['Date' => now()->toRfc7231String()]),
|
|
"https://api.github.com/app/installations/{$this->githubApp->installation_id}/access_tokens" => Http::response(['token' => 'installation-token'], 201),
|
|
'https://api.github.com/orgs/acme/actions/runner-groups/77' => Http::response(['id' => 77, 'default' => false, 'visibility' => 'selected', 'allows_public_repositories' => false]),
|
|
]);
|
|
|
|
Livewire::actingAs($this->owner)
|
|
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
|
->set('githubAppId', $this->githubApp->id)
|
|
->call('submit')
|
|
->assertHasNoErrors()
|
|
->assertDispatched('success');
|
|
|
|
expect($this->githubApp->fresh()->runner_group_id)->toBe(77)
|
|
->and(GithubRunnerConfig::count())->toBe(1);
|
|
Http::assertNotSent(fn ($request) => in_array($request->method(), ['PATCH', 'POST'], true)
|
|
&& str_contains($request->url(), '/runner-groups'));
|
|
});
|
|
|
|
it('saves whether runners take pull request jobs', function () {
|
|
fakeRunnerGroupApi($this->githubApp);
|
|
|
|
Livewire::actingAs($this->owner)
|
|
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
|
->set('githubAppId', $this->githubApp->id)
|
|
->set('allowPullRequests', true)
|
|
->call('submit')
|
|
->assertHasNoErrors()
|
|
->assertSet('allowPullRequests', true);
|
|
|
|
expect(GithubRunnerConfig::sole()->allow_pull_requests)->toBeTrue();
|
|
});
|
|
|
|
it('does not fall back to the Default runner group when group creation fails', function (int $status) {
|
|
fakeRunnerGroupApi($this->githubApp, $status);
|
|
|
|
Livewire::actingAs($this->owner)
|
|
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
|
->set('githubAppId', $this->githubApp->id)
|
|
->call('submit')
|
|
->assertNotDispatched('success');
|
|
|
|
expect($this->githubApp->fresh()->runner_group_id)->toBeNull()
|
|
->and(GithubRunnerConfig::count())->toBe(0);
|
|
Http::assertNotSent(fn ($request) => $request->method() === 'GET'
|
|
&& str_ends_with($request->url(), '/orgs/acme/actions/runner-groups'));
|
|
})->with([403, 401, 422, 429, 500]);
|
|
|
|
it('requires a custom label with safe characters', function (string $labels) {
|
|
Http::fake();
|
|
|
|
Livewire::actingAs($this->owner)
|
|
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
|
->set('githubAppId', $this->githubApp->id)
|
|
->set('labels', $labels)
|
|
->call('submit')
|
|
->assertHasErrors('labels');
|
|
|
|
expect(GithubRunnerConfig::count())->toBe(0);
|
|
})->with([
|
|
'only reserved labels' => ['self-hosted, linux, x64'],
|
|
'shell characters' => ['coolify;rm -rf /'],
|
|
]);
|
|
|
|
it('rejects resource limits that are not plain values', function (string $field, string $value) {
|
|
Http::fake();
|
|
|
|
Livewire::actingAs($this->owner)
|
|
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
|
->set('githubAppId', $this->githubApp->id)
|
|
->set($field, $value)
|
|
->call('submit')
|
|
->assertHasErrors($field);
|
|
})->with([
|
|
'cpu' => ['cpuLimit', '2 --privileged'],
|
|
'memory' => ['memoryLimit', '4g; reboot'],
|
|
'image' => ['runnerImage', 'bad image; reboot'],
|
|
]);
|
|
|
|
it('rejects a GitHub App of another team', function () {
|
|
$otherApp = settingsTestGithubApp(Team::factory()->create(), 4444);
|
|
Http::fake();
|
|
|
|
Livewire::actingAs($this->owner)
|
|
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
|
->set('githubAppId', $otherApp->id)
|
|
->call('submit')
|
|
->assertHasErrors('githubAppId');
|
|
|
|
expect(GithubRunnerConfig::count())->toBe(0);
|
|
Http::assertNothingSent();
|
|
});
|
|
|
|
it('does not let members change the configuration', function () {
|
|
Livewire::actingAs($this->member)
|
|
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
|
->set('githubAppId', $this->githubApp->id)
|
|
->call('submit')
|
|
->assertForbidden();
|
|
|
|
expect(GithubRunnerConfig::count())->toBe(0);
|
|
});
|
|
|
|
it('opens on Coolify Cloud because runners run on the user\'s own servers', function () {
|
|
config()->set('constants.coolify.self_hosted', false);
|
|
|
|
Livewire::actingAs($this->owner)
|
|
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
|
->assertNoRedirect()
|
|
->assertSee('GitHub Actions runners');
|
|
});
|
|
|
|
it('does not open the page for the Coolify host', function () {
|
|
$server = settingsTestServer($this->team, 'build');
|
|
$server->update(['ip' => 'host.docker.internal']);
|
|
|
|
Livewire::actingAs($this->owner)
|
|
->test(GithubRunners::class, ['server_uuid' => $server->uuid])
|
|
->assertRedirect(route('server.show', ['server_uuid' => $server->uuid]));
|
|
|
|
$this->actingAs($this->owner)
|
|
->get(route('server.show', ['server_uuid' => $server->uuid]))
|
|
->assertDontSee(route('server.github-runners', ['server_uuid' => $server->uuid]));
|
|
});
|
|
|
|
it('does not open the page for a server of another team', function () {
|
|
$otherServer = settingsTestServer(Team::factory()->create(), 'build');
|
|
|
|
Livewire::actingAs($this->owner)
|
|
->test(GithubRunners::class, ['server_uuid' => $otherServer->uuid])
|
|
->assertRedirect(route('server.index'));
|
|
});
|
|
|
|
it('asks for confirmation before it enables runners on a server that also runs resources', function () {
|
|
$server = settingsTestServer($this->team, 'both');
|
|
fakeRunnerGroupApi($this->githubApp);
|
|
|
|
Livewire::actingAs($this->owner)
|
|
->test(GithubRunners::class, ['server_uuid' => $server->uuid])
|
|
->assertSee('Enable runners on this server?')
|
|
->assertDontSee('Application builds')
|
|
->call('toggleEnabled')
|
|
->assertDispatched('success')
|
|
->assertSee('Runners share this server with your resources');
|
|
|
|
expect(GithubRunnerConfig::sole())->server_id->toBe($server->id)->is_enabled->toBeTrue();
|
|
});
|
|
|
|
it('refuses runners on a deployments only server', function () {
|
|
$server = settingsTestServer($this->team, 'deployment');
|
|
Http::fake();
|
|
|
|
Livewire::actingAs($this->owner)
|
|
->test(GithubRunners::class, ['server_uuid' => $server->uuid])
|
|
->assertSee('This server is not enabled for builds')
|
|
->assertDontSee('Recent runners')
|
|
->set('githubAppId', $this->githubApp->id)
|
|
->call('toggleEnabled')
|
|
->assertDispatched('error');
|
|
|
|
expect(GithubRunnerConfig::count())->toBe(0);
|
|
Http::assertNothingSent();
|
|
});
|
|
|
|
it('enables runners on a build server without a confirmation', function () {
|
|
fakeRunnerGroupApi($this->githubApp);
|
|
|
|
Livewire::actingAs($this->owner)
|
|
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
|
->assertDontSee('Enable runners on this server?')
|
|
->call('toggleEnabled')
|
|
->assertDispatched('success')
|
|
->assertSee('Application builds')
|
|
->assertDontSee('Runners share this server with your resources');
|
|
});
|
|
|
|
it('disables runners and removes only the idle ones', function () {
|
|
Queue::fake();
|
|
$config = GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify']]);
|
|
$idle = GithubRunnerExecution::create(['github_app_id' => $this->githubApp->id, 'github_runner_config_id' => $config->id, 'server_id' => $this->server->id, 'trigger_workflow_job_id' => 1, 'status' => GithubRunnerStatus::Idle]);
|
|
$running = GithubRunnerExecution::create(['github_app_id' => $this->githubApp->id, 'github_runner_config_id' => $config->id, 'server_id' => $this->server->id, 'trigger_workflow_job_id' => 2, 'status' => GithubRunnerStatus::Running]);
|
|
|
|
Livewire::actingAs($this->owner)
|
|
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
|
->call('toggleEnabled')
|
|
->assertDispatched('success')
|
|
->assertSee('Runners are disabled')
|
|
->assertDontSee('Parallel runners')
|
|
->assertDontSee('Recent runners');
|
|
|
|
expect($config->fresh()->is_enabled)->toBeFalse()
|
|
->and($idle->fresh()->status)->toBe(GithubRunnerStatus::Cancelled)
|
|
->and($running->fresh()->status)->toBe(GithubRunnerStatus::Running);
|
|
Queue::assertPushed(CleanupGithubRunnerJob::class, 1);
|
|
Queue::assertPushed(CleanupGithubRunnerJob::class, fn ($job) => $job->executionId === $idle->id);
|
|
});
|
|
|
|
it('enables disabled runners and starts the queued jobs', function () {
|
|
Queue::fake();
|
|
fakeRunnerGroupApi($this->githubApp);
|
|
$config = GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify'], 'is_enabled' => false]);
|
|
GithubRunnerExecution::create(['github_app_id' => $this->githubApp->id, 'trigger_workflow_job_id' => 1, 'status' => GithubRunnerStatus::Queued]);
|
|
|
|
Livewire::actingAs($this->owner)
|
|
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
|
->call('toggleEnabled')
|
|
->assertDispatched('success')
|
|
->assertDontSee('Runners are disabled')
|
|
->assertSee('Parallel runners');
|
|
|
|
expect($config->fresh()->is_enabled)->toBeTrue();
|
|
Queue::assertPushed(ProvisionGithubRunnerJob::class);
|
|
});
|
|
|
|
it('shows only the enable button before runners are enabled', function () {
|
|
fakeRunnerGroupApi($this->githubApp);
|
|
|
|
Livewire::actingAs($this->owner)
|
|
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
|
->assertSee('Runners are disabled')
|
|
->assertSee('Enable runners')
|
|
->assertDontSee('Parallel runners')
|
|
->assertDontSee('Recent runners')
|
|
->call('toggleEnabled')
|
|
->assertHasNoErrors()
|
|
->assertDispatched('success')
|
|
->assertSee('Parallel runners');
|
|
|
|
$config = GithubRunnerConfig::sole();
|
|
expect($config->is_enabled)->toBeTrue()
|
|
->and($config->github_app_id)->toBe($this->githubApp->id)
|
|
->and($config->labels)->toBe(['coolify']);
|
|
});
|
|
|
|
it('enables runners with an App that is not ready without calling GitHub', function () {
|
|
Http::fake();
|
|
$this->githubApp->update(unreadyRunnerApp());
|
|
|
|
Livewire::actingAs($this->owner)
|
|
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
|
->call('toggleEnabled')
|
|
->assertHasNoErrors()
|
|
->assertDispatched('success')
|
|
->assertSee('Parallel runners')
|
|
->assertSee('The GitHub App is not ready');
|
|
|
|
expect(GithubRunnerConfig::sole()->is_enabled)->toBeTrue();
|
|
Http::assertNothingSent();
|
|
});
|
|
|
|
it('selects the first App that is ready for runners', function () {
|
|
$this->githubApp->update(['name' => 'a-first', ...unreadyRunnerApp()]);
|
|
$readyApp = settingsTestGithubApp($this->team, 4444, ['name' => 'b-ready']);
|
|
|
|
Livewire::actingAs($this->owner)
|
|
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
|
->assertSet('githubAppId', $readyApp->id);
|
|
});
|
|
|
|
it('saves settings with an App that is not ready without calling GitHub', function () {
|
|
Http::fake();
|
|
$this->githubApp->update(unreadyRunnerApp());
|
|
$config = GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify']]);
|
|
|
|
Livewire::actingAs($this->owner)
|
|
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
|
->set('maxRunners', 4)
|
|
->call('submit')
|
|
->assertHasNoErrors()
|
|
->assertDispatched('success');
|
|
|
|
expect($config->fresh()->max_runners)->toBe(4);
|
|
Http::assertNothingSent();
|
|
});
|
|
|
|
it('keeps runners disabled when saving other settings', function () {
|
|
fakeRunnerGroupApi($this->githubApp);
|
|
$config = GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify'], 'is_enabled' => false]);
|
|
|
|
Livewire::actingAs($this->owner)
|
|
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
|
->set('maxRunners', 4)
|
|
->call('submit')
|
|
->assertDispatched('success');
|
|
|
|
expect($config->fresh()->is_enabled)->toBeFalse()
|
|
->and($config->fresh()->max_runners)->toBe(4);
|
|
});
|
|
|
|
it('does not let members disable runners', function () {
|
|
$config = GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify']]);
|
|
|
|
Livewire::actingAs($this->member)
|
|
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
|
->call('toggleEnabled')
|
|
->assertForbidden();
|
|
|
|
expect($config->fresh()->is_enabled)->toBeTrue();
|
|
});
|
|
});
|
|
|
|
describe('isolated Docker with Sysbox', function () {
|
|
it('does not save Isolated Docker while Sysbox is missing', function () {
|
|
fakeRunnerGroupApi($this->githubApp);
|
|
Process::fake(['*' => Process::result(output: 'io.containerd.runc.v2 runc')]);
|
|
|
|
Livewire::actingAs($this->owner)
|
|
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
|
->set('githubAppId', $this->githubApp->id)
|
|
->set('dockerMode', 'sysbox')
|
|
->call('submit')
|
|
->assertHasErrors('dockerMode')
|
|
->assertSet('isSysboxInstalled', false);
|
|
|
|
expect(GithubRunnerConfig::count())->toBe(0);
|
|
});
|
|
|
|
it('saves Isolated Docker when Sysbox is installed', function () {
|
|
fakeRunnerGroupApi($this->githubApp);
|
|
Process::fake(['*' => Process::result(output: 'io.containerd.runc.v2 runc sysbox-runc ')]);
|
|
|
|
Livewire::actingAs($this->owner)
|
|
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
|
->set('githubAppId', $this->githubApp->id)
|
|
->set('dockerMode', 'sysbox')
|
|
->call('submit')
|
|
->assertHasNoErrors();
|
|
|
|
expect(GithubRunnerConfig::sole()->docker_mode->value)->toBe('sysbox');
|
|
});
|
|
|
|
it('checks that Sysbox can mount its FUSE file system', function () {
|
|
Process::fake(['*' => Process::result(output: '')]);
|
|
|
|
Livewire::actingAs($this->owner)
|
|
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
|
->call('checkSysbox');
|
|
|
|
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'test -x /usr/bin/fusermount3'));
|
|
});
|
|
|
|
it('shows the install button when Sysbox is missing', function () {
|
|
Process::fake(['*' => Process::result(output: '')]);
|
|
GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify']]);
|
|
|
|
Livewire::actingAs($this->owner)
|
|
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
|
->call('checkSysbox')
|
|
->assertSet('isSysboxInstalled', false)
|
|
->assertSee('Install Sysbox');
|
|
});
|
|
|
|
it('stops before the installation on servers without apt', function () {
|
|
Process::fake(['*' => Process::result(output: '')]);
|
|
|
|
Livewire::actingAs($this->owner)
|
|
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
|
->call('installSysbox')
|
|
->assertNotDispatched('activityMonitor');
|
|
|
|
Process::assertRanTimes(fn ($process) => str_contains(remoteProcessScript($process), 'apt-get'), 1);
|
|
});
|
|
|
|
it('does not let members install Sysbox', function () {
|
|
Process::fake();
|
|
|
|
Livewire::actingAs($this->member)
|
|
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
|
|
->call('installSysbox')
|
|
->assertForbidden();
|
|
|
|
Process::assertNothingRan();
|
|
});
|
|
});
|
|
|
|
describe('runner executions list', function () {
|
|
beforeEach(function () {
|
|
Queue::fake();
|
|
$config = GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify']]);
|
|
$this->running = GithubRunnerExecution::create(['github_app_id' => $this->githubApp->id, 'github_runner_config_id' => $config->id, 'server_id' => $this->server->id, 'trigger_workflow_job_id' => 1, 'status' => GithubRunnerStatus::Running, 'repository_full_name' => 'acme/api']);
|
|
$this->queued = GithubRunnerExecution::create(['github_app_id' => $this->githubApp->id, 'trigger_workflow_job_id' => 2, 'status' => GithubRunnerStatus::Queued, 'repository_full_name' => 'acme/web']);
|
|
});
|
|
|
|
it('shows runners of the server and queued jobs of its GitHub App', function () {
|
|
$otherServer = settingsTestServer($this->team, 'build');
|
|
GithubRunnerExecution::create(['github_app_id' => $this->githubApp->id, 'server_id' => $otherServer->id, 'trigger_workflow_job_id' => 3, 'status' => GithubRunnerStatus::Running, 'repository_full_name' => 'acme/other']);
|
|
|
|
Livewire::actingAs($this->owner)
|
|
->test(GithubRunnerExecutions::class, ['server' => $this->server])
|
|
->assertSee('acme/api')
|
|
->assertSee('acme/web')
|
|
->assertDontSee('acme/other');
|
|
});
|
|
|
|
it('cancels a running runner and queues its cleanup', function () {
|
|
Livewire::actingAs($this->owner)
|
|
->test(GithubRunnerExecutions::class, ['server' => $this->server])
|
|
->call('cancel', $this->running->id)
|
|
->assertDispatched('success');
|
|
|
|
expect($this->running->fresh()->status)->toBe(GithubRunnerStatus::Cancelled);
|
|
Queue::assertPushed(CleanupGithubRunnerJob::class, fn ($job) => $job->executionId === $this->running->id);
|
|
});
|
|
|
|
it('does not let members cancel runners', function () {
|
|
Livewire::actingAs($this->member)
|
|
->test(GithubRunnerExecutions::class, ['server' => $this->server])
|
|
->call('cancel', $this->running->id)
|
|
->assertForbidden();
|
|
|
|
expect($this->running->fresh()->status)->toBe(GithubRunnerStatus::Running);
|
|
});
|
|
|
|
it('does not cancel runners of another server', function () {
|
|
$otherServer = settingsTestServer($this->team, 'build');
|
|
$other = GithubRunnerExecution::create(['github_app_id' => $this->githubApp->id, 'server_id' => $otherServer->id, 'trigger_workflow_job_id' => 4, 'status' => GithubRunnerStatus::Running]);
|
|
|
|
Livewire::actingAs($this->owner)
|
|
->test(GithubRunnerExecutions::class, ['server' => $this->server])
|
|
->call('cancel', $other->id);
|
|
|
|
expect($other->fresh()->status)->toBe(GithubRunnerStatus::Running);
|
|
});
|
|
});
|
|
|
|
describe('build server role', function () {
|
|
beforeEach(function () {
|
|
GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify']]);
|
|
});
|
|
|
|
it('changes the role in the UI while runners are enabled', function () {
|
|
Livewire::actingAs($this->owner)
|
|
->test(Show::class, ['server_uuid' => $this->server->uuid])
|
|
->set('serverRole', 'both')
|
|
->call('requestServerRoleChange')
|
|
->assertNotDispatched('error')
|
|
->assertDispatched('open-server-role-confirmation')
|
|
->call('confirmServerRoleChange');
|
|
|
|
expect($this->server->settings->fresh()->server_role->value)->toBe('both');
|
|
});
|
|
|
|
it('blocks a change to deployments only in the UI while runners are enabled', function () {
|
|
Livewire::actingAs($this->owner)
|
|
->test(Show::class, ['server_uuid' => $this->server->uuid])
|
|
->set('serverRole', 'deployment')
|
|
->call('requestServerRoleChange')
|
|
->assertSet('serverRole', 'build')
|
|
->assertDispatched('error');
|
|
|
|
expect($this->server->settings->fresh()->server_role->value)->toBe('build');
|
|
});
|
|
|
|
it('blocks a change to deployments only through the API while runners are enabled', function () {
|
|
$token = $this->owner->createToken('runner-test', ['*']);
|
|
$token->accessToken->forceFill(['team_id' => $this->team->id])->save();
|
|
settingsTestServer($this->team, 'build');
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$token->plainTextToken])
|
|
->patchJson('/api/v1/servers/'.$this->server->uuid, ['server_role' => 'deployment'])
|
|
->assertStatus(422)
|
|
->assertJsonPath('errors.server_role.0', 'Disable the GitHub runners before you set this server to deployments only.');
|
|
});
|
|
|
|
it('changes the role through the API while runners are enabled', function () {
|
|
$token = $this->owner->createToken('runner-test', ['*']);
|
|
$token->accessToken->forceFill(['team_id' => $this->team->id])->save();
|
|
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$token->plainTextToken])
|
|
->patchJson('/api/v1/servers/'.$this->server->uuid, ['server_role' => 'both'])
|
|
->assertSuccessful();
|
|
|
|
expect($this->server->settings->fresh()->server_role->value)->toBe('both');
|
|
});
|
|
|
|
it('leaves dedicated runner servers out of application builds', function () {
|
|
expect(Server::buildServers($this->team->id)->pluck('id'))->toContain($this->server->id);
|
|
|
|
$this->server->githubRunnerConfig->update(['is_dedicated' => true]);
|
|
expect(Server::buildServers($this->team->id)->pluck('id'))->not->toContain($this->server->id);
|
|
|
|
$this->server->githubRunnerConfig->update(['is_enabled' => false]);
|
|
expect(Server::buildServers($this->team->id)->pluck('id'))->toContain($this->server->id);
|
|
});
|
|
});
|
|
|
|
describe('GitHub App runner permissions', function () {
|
|
it('stores runner permissions and webhook events from GitHub', function () {
|
|
Http::fake([
|
|
'https://api.github.com/zen' => Http::response('ok', 200, ['Date' => now()->toRfc7231String()]),
|
|
'https://api.github.com/app' => Http::response([
|
|
'permissions' => ['contents' => 'read', 'metadata' => 'read', 'organization_self_hosted_runners' => 'write', 'actions' => 'read'],
|
|
'events' => ['push', 'workflow_job'],
|
|
]),
|
|
]);
|
|
|
|
GithubAppPermissionJob::dispatchSync($this->githubApp);
|
|
|
|
$githubApp = $this->githubApp->fresh();
|
|
expect($githubApp->organization_self_hosted_runners)->toBe('write')
|
|
->and($githubApp->actions)->toBe('read')
|
|
->and($githubApp->webhook_events)->toBe(['push', 'workflow_job'])
|
|
->and($githubApp->missingRunnerRequirements())->toBe([]);
|
|
});
|
|
|
|
it('lists what an App needs before it can run jobs', function () {
|
|
$githubApp = settingsTestGithubApp($this->team, 5555, [...unreadyRunnerApp(), 'organization' => null]);
|
|
|
|
expect($githubApp->missingRunnerRequirements())->toHaveCount(4);
|
|
});
|
|
|
|
it('shows the runner permissions on the GitHub App permissions tab', function () {
|
|
$this->githubApp->update(['organization_self_hosted_runners' => 'write', 'webhook_events' => ['push']]);
|
|
|
|
Livewire::withQueryParams(['github_app_uuid' => $this->githubApp->uuid])
|
|
->actingAs($this->owner)
|
|
->test(Change::class)
|
|
->set('activeTab', 'permissions')
|
|
->assertSee('GitHub Actions runners')
|
|
->assertSee('Not ready for runners')
|
|
->assertSee('Webhook event "Workflow job"')
|
|
->assertDontSee('Organization permission "Self-hosted runners": write');
|
|
});
|
|
|
|
it('shows the runner option disabled with a reason when the App has no organization', function () {
|
|
$personalApp = GithubApp::create(['name' => 'personal', 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com', 'custom_user' => 'git', 'custom_port' => 22, 'team_id' => $this->team->id, 'is_system_wide' => false]);
|
|
|
|
Livewire::withQueryParams(['github_app_uuid' => $personalApp->uuid])
|
|
->actingAs($this->owner)
|
|
->test(Change::class)
|
|
->assertSee('GitHub Actions runners')
|
|
->assertSee('Only GitHub Apps that belong to an organization can run workflow jobs.');
|
|
});
|
|
|
|
it('survives the confirmation modal refresh after deleting an App', function () {
|
|
$unregistered = GithubApp::create(['name' => 'to-delete', 'organization' => 'acme', 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com', 'custom_user' => 'git', 'custom_port' => 22, 'team_id' => $this->team->id, 'is_system_wide' => false]);
|
|
|
|
Livewire::withQueryParams(['github_app_uuid' => $unregistered->uuid])
|
|
->actingAs($this->owner)
|
|
->test(Change::class)
|
|
->call('delete')
|
|
->assertRedirect(route('source.all'))
|
|
->call('$refresh')
|
|
->assertOk();
|
|
|
|
expect(GithubApp::find($unregistered->id))->toBeNull();
|
|
});
|
|
|
|
it('blocks deleting an App while servers have enabled runners', function () {
|
|
GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify']]);
|
|
|
|
expect(fn () => $this->githubApp->delete())->toThrow(Exception::class, 'GitHub Actions runners');
|
|
expect(GithubApp::find($this->githubApp->id))->not->toBeNull()
|
|
->and(GithubRunnerConfig::count())->toBe(1);
|
|
});
|
|
|
|
it('blocks deleting an App while disabled runners still run jobs', function () {
|
|
$config = GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify'], 'is_enabled' => false]);
|
|
GithubRunnerExecution::create(['github_app_id' => $this->githubApp->id, 'github_runner_config_id' => $config->id, 'server_id' => $this->server->id, 'trigger_workflow_job_id' => 1, 'status' => GithubRunnerStatus::Running]);
|
|
|
|
expect(fn () => $this->githubApp->delete())->toThrow(Exception::class, 'GitHub Actions runners');
|
|
expect(GithubApp::find($this->githubApp->id))->not->toBeNull();
|
|
});
|
|
|
|
it('deletes an App after its runners are disabled and finished', function () {
|
|
$config = GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify'], 'is_enabled' => false]);
|
|
GithubRunnerExecution::create(['github_app_id' => $this->githubApp->id, 'github_runner_config_id' => $config->id, 'server_id' => $this->server->id, 'trigger_workflow_job_id' => 1, 'status' => GithubRunnerStatus::Completed]);
|
|
|
|
$this->githubApp->delete();
|
|
|
|
expect(GithubApp::find($this->githubApp->id))->toBeNull()
|
|
->and(GithubRunnerConfig::count())->toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('new GitHub App purpose', function () {
|
|
it('requires an organization when the App is for GitHub Actions runners', function () {
|
|
Livewire::actingAs($this->owner)
|
|
->test(Create::class)
|
|
->set('use_for_github_runners', true)
|
|
->set('organization', '')
|
|
->call('createGitHubApp')
|
|
->assertHasErrors(['organization' => 'required']);
|
|
|
|
expect(GithubApp::where('team_id', $this->team->id)->count())->toBe(1);
|
|
});
|
|
|
|
it('marks the organization as required when GitHub Actions runners are selected', function () {
|
|
Livewire::actingAs($this->owner)
|
|
->test(Create::class)
|
|
->assertSee('Personal account when empty')
|
|
->set('use_for_github_runners', true)
|
|
->assertSee('GitHub organization, for example coollabsio')
|
|
->assertSeeHtml('required');
|
|
});
|
|
|
|
it('passes the selected purposes to the registration page', function () {
|
|
Livewire::actingAs($this->owner)
|
|
->test(Create::class)
|
|
->set('name', 'runner-purpose-app')
|
|
->set('organization', 'acme')
|
|
->set('use_for_pull_request_previews', false)
|
|
->set('use_for_github_runners', true)
|
|
->call('createGitHubApp')
|
|
->assertHasNoErrors()
|
|
->assertRedirectContains('previews=0&runners=1');
|
|
|
|
$githubApp = GithubApp::where('name', 'runner-purpose-app')->sole();
|
|
expect($githubApp->organization)->toBe('acme');
|
|
});
|
|
|
|
it('prefills the registration options from the selected purposes', function (array $query, bool $previews, bool $runners, ?string $organization) {
|
|
$unregistered = GithubApp::create(['name' => 'prefill', 'organization' => $organization, 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com', 'custom_user' => 'git', 'custom_port' => 22, 'team_id' => $this->team->id, 'is_system_wide' => false]);
|
|
|
|
Livewire::withQueryParams(['github_app_uuid' => $unregistered->uuid, ...$query])
|
|
->actingAs($this->owner)
|
|
->test(Change::class)
|
|
->assertSet('preview_deployment_permissions', $previews)
|
|
->assertSet('github_runners', $runners);
|
|
})->with([
|
|
'defaults' => [[], true, false, 'acme'],
|
|
'runners only' => [['previews' => '0', 'runners' => '1'], false, true, 'acme'],
|
|
'runners without organization' => [['runners' => '1'], true, false, null],
|
|
]);
|
|
});
|