mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Co-authored-by: Aditya Tripathi <aditya@climactic.co>
350 lines
16 KiB
PHP
350 lines
16 KiB
PHP
<?php
|
|
|
|
use App\Enums\ApplicationDeploymentStatus;
|
|
use App\Jobs\GithubAppPermissionJob;
|
|
use App\Jobs\SyncGithubDeploymentJob;
|
|
use App\Livewire\Source\Github\Change;
|
|
use App\Models\Application;
|
|
use App\Models\ApplicationDeploymentQueue;
|
|
use App\Models\ApplicationPreview;
|
|
use App\Models\Environment;
|
|
use App\Models\GithubApp;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\PrivateKey;
|
|
use App\Models\Project;
|
|
use App\Models\Server;
|
|
use App\Models\StandaloneDocker;
|
|
use App\Models\Team;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Http\Client\Request;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Notification;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Illuminate\Support\Str;
|
|
use Livewire\Livewire;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0], ['fqdn' => 'https://coolify.example.com']));
|
|
|
|
$this->team = Team::factory()->create();
|
|
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
|
$destination = StandaloneDocker::where('server_id', $this->server->id)->first();
|
|
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
|
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
|
|
|
openssl_pkey_export(openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]), $pemKey);
|
|
$this->githubApp = GithubApp::create([
|
|
'name' => 'Private GitHub App',
|
|
'api_url' => 'https://api.github.com',
|
|
'html_url' => 'https://github.com',
|
|
'app_id' => 12345,
|
|
'installation_id' => 67890,
|
|
'private_key_id' => PrivateKey::create(['name' => 'Key', 'private_key' => $pemKey, 'team_id' => $this->team->id])->id,
|
|
'is_public' => false,
|
|
'deployments' => 'write',
|
|
'team_id' => $this->team->id,
|
|
]);
|
|
|
|
$this->application = Application::factory()->create([
|
|
'name' => 'web',
|
|
'environment_id' => $environment->id,
|
|
'destination_id' => $destination->id,
|
|
'destination_type' => $destination->getMorphClass(),
|
|
'source_type' => GithubApp::class,
|
|
'source_id' => $this->githubApp->id,
|
|
'git_repository' => 'acme/shop',
|
|
'git_branch' => 'main',
|
|
'fqdn' => 'https://shop.example.com,https://www.shop.example.com',
|
|
]);
|
|
|
|
// The model hook would run the job on the sync queue; the tests run it explicitly.
|
|
Queue::fake([SyncGithubDeploymentJob::class]);
|
|
|
|
Http::fake([
|
|
'https://api.github.com/zen' => Http::response('ok', 200, ['Date' => now()->toRfc7231String()]),
|
|
'https://api.github.com/app/installations/*/access_tokens' => Http::response(['token' => 'token'], 201),
|
|
'https://api.github.com/repos/acme/shop/pulls/7' => Http::response([
|
|
'head' => ['ref' => 'feature/cart', 'sha' => 'aaa111', 'repo' => ['full_name' => 'acme/shop']],
|
|
'base' => ['repo' => ['full_name' => 'acme/shop']],
|
|
]),
|
|
'https://api.github.com/repos/acme/shop/pulls/8' => Http::response([
|
|
'head' => ['ref' => 'main', 'sha' => 'bbb222', 'repo' => ['full_name' => 'someone/shop']],
|
|
'base' => ['repo' => ['full_name' => 'acme/shop']],
|
|
]),
|
|
'https://api.github.com/repos/acme/shop/deployments' => fn () => $this->createDeploymentResponse ?? Http::response(['id' => 555], 201),
|
|
'https://api.github.com/repos/acme/shop/deployments/*/statuses' => Http::response(['id' => 1], 201),
|
|
]);
|
|
});
|
|
|
|
function githubDeployment(Application $application, array $attributes = []): ApplicationDeploymentQueue
|
|
{
|
|
return ApplicationDeploymentQueue::create([
|
|
'application_id' => $application->id,
|
|
'deployment_uuid' => (string) Str::uuid(),
|
|
'pull_request_id' => 0,
|
|
'commit' => 'HEAD',
|
|
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
|
|
'server_id' => $application->destination->server->id,
|
|
...$attributes,
|
|
]);
|
|
}
|
|
|
|
function syncGithubDeployment(ApplicationDeploymentQueue $deployment, bool $deactivate = false): void
|
|
{
|
|
(new SyncGithubDeploymentJob($deployment->id, $deactivate))->handle();
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
function githubRequests(string $suffix): array
|
|
{
|
|
return Http::recorded(fn (Request $request): bool => $request->method() === 'POST' && str_ends_with($request->url(), $suffix))
|
|
->map(fn (array $pair): array => $pair[0]->data())
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
it('queues a sync when the status of a deployment changes', function () {
|
|
$deployment = githubDeployment($this->application, ['status' => ApplicationDeploymentStatus::QUEUED->value]);
|
|
|
|
$deployment->update(['status' => ApplicationDeploymentStatus::IN_PROGRESS->value]);
|
|
|
|
Queue::assertPushed(SyncGithubDeploymentJob::class, fn (SyncGithubDeploymentJob $job): bool => $job->deploymentQueueId === $deployment->id);
|
|
});
|
|
|
|
it('queues the sync on the default queue, not on the deployment queue', function () {
|
|
$deployment = githubDeployment($this->application, ['status' => ApplicationDeploymentStatus::QUEUED->value]);
|
|
|
|
$deployment->update(['status' => ApplicationDeploymentStatus::IN_PROGRESS->value]);
|
|
|
|
Queue::assertPushedOn('default', SyncGithubDeploymentJob::class);
|
|
expect(deployment_queue())->not->toBe('default');
|
|
});
|
|
|
|
it('gives up on a slow GitHub request after a short timeout', function () {
|
|
$timeouts = [];
|
|
Http::fake(function (Request $request, array $options) use (&$timeouts) {
|
|
$timeouts[$request->url()] = [$options['timeout'] ?? null, $options['connect_timeout'] ?? null];
|
|
|
|
return null;
|
|
});
|
|
|
|
syncGithubDeployment(githubDeployment($this->application));
|
|
|
|
expect($timeouts)->not->toBeEmpty();
|
|
foreach ($timeouts as $url => [$timeout, $connectTimeout]) {
|
|
expect($timeout)->toBeLessThanOrEqual(SyncGithubDeploymentJob::HTTP_TIMEOUT_SECONDS, $url)
|
|
->and($connectTimeout)->toBeLessThanOrEqual(SyncGithubDeploymentJob::HTTP_TIMEOUT_SECONDS, $url);
|
|
}
|
|
expect((new SyncGithubDeploymentJob(1))->timeout)->toBeLessThan(120);
|
|
});
|
|
|
|
it('does not queue a sync when the GitHub App has no deployments permission', function () {
|
|
$this->githubApp->update(['deployments' => null]);
|
|
$deployment = githubDeployment($this->application, ['status' => ApplicationDeploymentStatus::QUEUED->value]);
|
|
|
|
$deployment->update(['status' => ApplicationDeploymentStatus::IN_PROGRESS->value]);
|
|
|
|
Queue::assertNotPushed(SyncGithubDeploymentJob::class);
|
|
});
|
|
|
|
it('does not queue a sync for the deployment to an additional server', function () {
|
|
$deployment = githubDeployment($this->application, [
|
|
'status' => ApplicationDeploymentStatus::QUEUED->value,
|
|
'parent_deployment_uuid' => 'main-deployment',
|
|
]);
|
|
|
|
$deployment->update(['status' => ApplicationDeploymentStatus::IN_PROGRESS->value]);
|
|
|
|
Queue::assertNotPushed(SyncGithubDeploymentJob::class);
|
|
});
|
|
|
|
it('queues a sync for a deployment that fails because its job cannot be queued', function () {
|
|
Notification::fake();
|
|
$deployment = githubDeployment($this->application);
|
|
|
|
fail_undispatchable_deployment($deployment, new RuntimeException('queue connection refused'));
|
|
|
|
expect($deployment->fresh()->status)->toBe(ApplicationDeploymentStatus::FAILED->value);
|
|
Queue::assertPushed(SyncGithubDeploymentJob::class, fn (SyncGithubDeploymentJob $job): bool => $job->deploymentQueueId === $deployment->id);
|
|
});
|
|
|
|
it('queues a sync for the deployments that a newer webhook deployment cancels', function () {
|
|
Queue::fake();
|
|
$this->application->settings->update(['is_auto_cancel_building_deployments_enabled' => true]);
|
|
$building = githubDeployment($this->application, ['is_webhook' => true]);
|
|
$queued = githubDeployment($this->application, ['is_webhook' => true, 'status' => ApplicationDeploymentStatus::QUEUED->value]);
|
|
|
|
queue_application_deployment($this->application, 'newer-webhook-deployment', commit: 'ddd444', is_webhook: true);
|
|
|
|
expect($building->fresh()->status)->toBe(ApplicationDeploymentStatus::CANCELLED_BY_USER->value)
|
|
->and($queued->fresh()->status)->toBe(ApplicationDeploymentStatus::CANCELLED_BY_USER->value);
|
|
foreach ([$building, $queued] as $cancelled) {
|
|
Queue::assertPushed(SyncGithubDeploymentJob::class, fn (SyncGithubDeploymentJob $job): bool => $job->deploymentQueueId === $cancelled->id);
|
|
}
|
|
});
|
|
|
|
it('creates one GitHub deployment for the branch and sends each status', function () {
|
|
$deployment = githubDeployment($this->application);
|
|
|
|
syncGithubDeployment($deployment);
|
|
syncGithubDeployment($deployment);
|
|
$deployment->update(['status' => ApplicationDeploymentStatus::FINISHED->value]);
|
|
syncGithubDeployment($deployment);
|
|
|
|
expect(githubRequests('/deployments'))->toBe([[
|
|
'ref' => 'main',
|
|
'environment' => 'web',
|
|
'description' => 'Coolify deployment',
|
|
'auto_merge' => false,
|
|
'required_contexts' => [],
|
|
'transient_environment' => false,
|
|
'production_environment' => true,
|
|
]]);
|
|
|
|
$statuses = githubRequests('/deployments/555/statuses');
|
|
$logUrl = "https://coolify.example.com/project/{$this->application->environment->project->uuid}/environment/{$this->application->environment->uuid}/application/{$this->application->uuid}/deployment/{$deployment->deployment_uuid}";
|
|
expect($statuses)->toBe([
|
|
['state' => 'in_progress', 'description' => 'Deploying with Coolify.', 'log_url' => $logUrl],
|
|
['state' => 'success', 'description' => 'Deployed with Coolify.', 'log_url' => $logUrl, 'environment_url' => 'https://shop.example.com'],
|
|
])->and($deployment->fresh()->only(['github_deployment_id', 'github_deployment_state']))
|
|
->toBe(['github_deployment_id' => 555, 'github_deployment_state' => 'success']);
|
|
});
|
|
|
|
it('uses the head branch of a pull request from the same repository', function () {
|
|
ApplicationPreview::create(['application_id' => $this->application->id, 'pull_request_id' => 7, 'pull_request_html_url' => 'https://github.com/acme/shop/pull/7', 'fqdn' => 'https://pr-7.shop.example.com']);
|
|
$deployment = githubDeployment($this->application, [
|
|
'pull_request_id' => 7,
|
|
'commit' => 'aaa111',
|
|
'status' => ApplicationDeploymentStatus::FINISHED->value,
|
|
]);
|
|
|
|
syncGithubDeployment($deployment);
|
|
|
|
expect(githubRequests('/deployments')[0])->toMatchArray([
|
|
'ref' => 'feature/cart',
|
|
'environment' => 'web (preview)',
|
|
'transient_environment' => true,
|
|
'production_environment' => false,
|
|
])->and(githubRequests('/statuses')[0]['environment_url'])->toBe('https://pr-7.shop.example.com');
|
|
});
|
|
|
|
it('uses the deployed commit when the head branch of a pull request moved', function () {
|
|
$deployment = githubDeployment($this->application, ['pull_request_id' => 7, 'commit' => 'old999']);
|
|
|
|
syncGithubDeployment($deployment);
|
|
|
|
expect(githubRequests('/deployments')[0]['ref'])->toBe('old999');
|
|
});
|
|
|
|
it('uses the deployed commit for a pull request from a fork', function () {
|
|
$deployment = githubDeployment($this->application, ['pull_request_id' => 8, 'commit' => 'bbb222']);
|
|
|
|
syncGithubDeployment($deployment);
|
|
|
|
expect(githubRequests('/deployments')[0]['ref'])->toBe('bbb222');
|
|
});
|
|
|
|
it('uses the deployed commit of a branch deployment', function () {
|
|
$deployment = githubDeployment($this->application, ['commit' => 'ccc333']);
|
|
|
|
syncGithubDeployment($deployment);
|
|
|
|
expect(githubRequests('/deployments')[0]['ref'])->toBe('ccc333');
|
|
});
|
|
|
|
it('does not create a GitHub deployment for a deployment that was cancelled before the sync', function () {
|
|
$deployment = githubDeployment($this->application, ['status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value]);
|
|
|
|
syncGithubDeployment($deployment);
|
|
|
|
expect(githubRequests('/deployments'))->toBe([])
|
|
->and($deployment->fresh()->github_deployment_id)->toBeNull();
|
|
});
|
|
|
|
it('marks the GitHub deployments of a closed pull request inactive and keeps them inactive', function () {
|
|
$deployment = githubDeployment($this->application, ['pull_request_id' => 7]);
|
|
syncGithubDeployment($deployment);
|
|
$other = githubDeployment($this->application, ['pull_request_id' => 9]);
|
|
|
|
SyncGithubDeploymentJob::deactivatePullRequest($this->application, 7);
|
|
|
|
Queue::assertPushed(SyncGithubDeploymentJob::class, 1);
|
|
Queue::assertPushed(SyncGithubDeploymentJob::class, fn (SyncGithubDeploymentJob $job): bool => $job->deploymentQueueId === $deployment->id && $job->deactivate);
|
|
Queue::assertNotPushed(SyncGithubDeploymentJob::class, fn (SyncGithubDeploymentJob $job): bool => $job->deploymentQueueId === $other->id);
|
|
|
|
syncGithubDeployment($deployment, deactivate: true);
|
|
$deployment->update(['status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value]);
|
|
syncGithubDeployment($deployment);
|
|
|
|
expect(collect(githubRequests('/deployments/555/statuses'))->pluck('state')->all())->toBe(['in_progress', 'inactive']);
|
|
});
|
|
|
|
it('does not create a GitHub deployment when the pull request closed before the first sync', function () {
|
|
$deployment = githubDeployment($this->application, ['pull_request_id' => 7]);
|
|
|
|
SyncGithubDeploymentJob::deactivatePullRequest($this->application, 7);
|
|
|
|
Queue::assertPushed(SyncGithubDeploymentJob::class, fn (SyncGithubDeploymentJob $job): bool => $job->deploymentQueueId === $deployment->id && $job->deactivate);
|
|
|
|
syncGithubDeployment($deployment, deactivate: true);
|
|
$deployment->update(['status' => ApplicationDeploymentStatus::FINISHED->value]);
|
|
syncGithubDeployment($deployment);
|
|
|
|
expect(githubRequests('/deployments'))->toBe([])
|
|
->and(githubRequests('/statuses'))->toBe([])
|
|
->and($deployment->fresh()->github_deployment_state)->toBe('inactive');
|
|
});
|
|
|
|
it('fails the sync job, not the deployment, when GitHub rejects the request', function () {
|
|
$this->createDeploymentResponse = Http::response(['message' => 'Resource not accessible by integration'], 403);
|
|
$deployment = githubDeployment($this->application);
|
|
|
|
expect(fn () => syncGithubDeployment($deployment))->toThrow(Exception::class, 'Resource not accessible by integration')
|
|
->and($deployment->fresh()->status)->toBe(ApplicationDeploymentStatus::IN_PROGRESS->value)
|
|
->and($deployment->fresh()->github_deployment_id)->toBeNull();
|
|
});
|
|
|
|
it('shows an existing App how to add the deployments permission, and hides it after Refetch', function () {
|
|
$this->withoutVite();
|
|
$this->githubApp->update(['deployments' => null]);
|
|
$owner = User::factory()->create();
|
|
$this->team->members()->attach($owner->id, ['role' => 'owner']);
|
|
$this->actingAs($owner);
|
|
session(['currentTeam' => $this->team]);
|
|
|
|
$page = fn () => Livewire::withQueryParams(['github_app_uuid' => $this->githubApp->uuid])
|
|
->test(Change::class)
|
|
->set('activeTab', 'permissions');
|
|
|
|
$page()->assertSee('Not ready for GitHub deployments')
|
|
->assertSee('Repository permission "Deployments": write', false)
|
|
->assertSee(getPermissionsPath($this->githubApp));
|
|
|
|
Http::fake([
|
|
'https://api.github.com/app' => Http::response(['permissions' => ['contents' => 'read', 'deployments' => 'write'], 'events' => ['push']]),
|
|
'https://api.github.com/app/installations/67890' => Http::response(['permissions' => ['contents' => 'read', 'deployments' => 'write']]),
|
|
]);
|
|
GithubAppPermissionJob::dispatchSync($this->githubApp);
|
|
|
|
expect($this->githubApp->fresh()->canSyncDeployments())->toBeTrue();
|
|
$page()->assertSee('GitHub deployments')->assertDontSee('Not ready for GitHub deployments');
|
|
});
|
|
|
|
it('stays not ready until the installation approves the deployments permission', function () {
|
|
$this->githubApp->update(['deployments' => null]);
|
|
Http::fake([
|
|
'https://api.github.com/app' => Http::response(['permissions' => ['contents' => 'read', 'deployments' => 'write']]),
|
|
'https://api.github.com/app/installations/67890' => Http::response(['permissions' => ['contents' => 'read']]),
|
|
]);
|
|
|
|
GithubAppPermissionJob::dispatchSync($this->githubApp);
|
|
|
|
expect($this->githubApp->fresh()->deployments)->toBeNull()
|
|
->and($this->githubApp->fresh()->canSyncDeployments())->toBeFalse();
|
|
});
|