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>
325 lines
12 KiB
PHP
325 lines
12 KiB
PHP
<?php
|
|
|
|
use App\Enums\ApplicationDeploymentStatus;
|
|
use App\Jobs\SyncGithubDeploymentJob;
|
|
use App\Models\Application;
|
|
use App\Models\ApplicationDeploymentQueue;
|
|
use App\Models\Environment;
|
|
use App\Models\GithubApp;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\PrivateKey;
|
|
use App\Models\Project;
|
|
use App\Models\ScheduledDatabaseBackup;
|
|
use App\Models\ScheduledDatabaseBackupExecution;
|
|
use App\Models\ScheduledTask;
|
|
use App\Models\ScheduledTaskExecution;
|
|
use App\Models\Server;
|
|
use App\Models\StandaloneDocker;
|
|
use App\Models\StandalonePostgresql;
|
|
use App\Models\Team;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Contracts\Console\Kernel;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Artisan;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Notification;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Illuminate\Support\Str;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
// Freeze time for consistent testing
|
|
Carbon::setTestNow('2025-01-15 12:00:00');
|
|
|
|
// Fake notifications to ensure none are sent
|
|
Notification::fake();
|
|
// app:init pulls the service templates and writes the real repository file,
|
|
// so answer with its current content instead of an empty body.
|
|
Http::fake([
|
|
config('constants.services.official') => Http::response(file_get_contents(service_templates_path())),
|
|
'*' => Http::response(),
|
|
]);
|
|
Process::fake();
|
|
Queue::fake();
|
|
|
|
InstanceSettings::forceCreate(['id' => 0, 'do_not_track' => true]);
|
|
|
|
// app:init runs `optimize`, which boots a fresh application with a new in-memory
|
|
// database and caches config into bootstrap/cache. Replace both with no-ops here.
|
|
foreach (['optimize', 'optimize:clear'] as $commandName) {
|
|
app(Kernel::class)->registerCommand(new class($commandName) extends Command
|
|
{
|
|
public function __construct(string $commandName)
|
|
{
|
|
$this->signature = $commandName;
|
|
|
|
parent::__construct();
|
|
}
|
|
|
|
public function handle(): int
|
|
{
|
|
return self::SUCCESS;
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
afterEach(function () {
|
|
Carbon::setTestNow();
|
|
});
|
|
|
|
test('app:init marks stuck scheduled task executions as failed', function () {
|
|
// Create a team for the scheduled task
|
|
$team = Team::factory()->create();
|
|
|
|
// Create a scheduled task
|
|
$scheduledTask = ScheduledTask::factory()->create([
|
|
'team_id' => $team->id,
|
|
]);
|
|
|
|
// Create multiple task executions with 'running' status
|
|
$runningExecution1 = ScheduledTaskExecution::create([
|
|
'scheduled_task_id' => $scheduledTask->id,
|
|
'status' => 'running',
|
|
'started_at' => Carbon::now()->subMinutes(10),
|
|
]);
|
|
|
|
$runningExecution2 = ScheduledTaskExecution::create([
|
|
'scheduled_task_id' => $scheduledTask->id,
|
|
'status' => 'running',
|
|
'started_at' => Carbon::now()->subMinutes(5),
|
|
]);
|
|
|
|
// Create a completed execution (should not be affected)
|
|
$completedExecution = ScheduledTaskExecution::create([
|
|
'scheduled_task_id' => $scheduledTask->id,
|
|
'status' => 'success',
|
|
'started_at' => Carbon::now()->subMinutes(15),
|
|
'finished_at' => Carbon::now()->subMinutes(14),
|
|
]);
|
|
|
|
// Run the app:init command
|
|
Artisan::call('app:init');
|
|
|
|
// Refresh models from database
|
|
$runningExecution1->refresh();
|
|
$runningExecution2->refresh();
|
|
$completedExecution->refresh();
|
|
|
|
// Assert running executions are now failed
|
|
expect($runningExecution1->status)->toBe('failed')
|
|
->and($runningExecution1->message)->toBe('Marked as failed during Coolify startup - job was interrupted')
|
|
->and($runningExecution1->finished_at)->not->toBeNull()
|
|
->and($runningExecution1->finished_at->toDateTimeString())->toBe('2025-01-15 12:00:00');
|
|
|
|
expect($runningExecution2->status)->toBe('failed')
|
|
->and($runningExecution2->message)->toBe('Marked as failed during Coolify startup - job was interrupted')
|
|
->and($runningExecution2->finished_at)->not->toBeNull();
|
|
|
|
// Assert completed execution is unchanged
|
|
expect($completedExecution->status)->toBe('success')
|
|
->and($completedExecution->message)->toBeNull();
|
|
|
|
// Assert NO notifications were sent
|
|
Notification::assertNothingSent();
|
|
});
|
|
|
|
test('app:init marks stuck database backup executions as failed', function () {
|
|
// Create a team for the scheduled backup
|
|
$team = Team::factory()->create();
|
|
|
|
// Create a database
|
|
$server = Server::factory()->create(['team_id' => $team->id]);
|
|
$destination = StandaloneDocker::firstOrCreate(
|
|
['server_id' => $server->id, 'network' => 'coolify'],
|
|
['uuid' => (string) Str::uuid(), 'name' => 'docker']
|
|
);
|
|
$project = Project::factory()->create(['team_id' => $team->id]);
|
|
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
|
$database = StandalonePostgresql::create([
|
|
'name' => 'db',
|
|
'postgres_user' => 'postgres',
|
|
'postgres_password' => 'password',
|
|
'postgres_db' => 'db',
|
|
'image' => 'postgres:17',
|
|
'environment_id' => $environment->id,
|
|
'destination_id' => $destination->id,
|
|
'destination_type' => $destination->getMorphClass(),
|
|
]);
|
|
|
|
// Create a scheduled backup
|
|
$scheduledBackup = ScheduledDatabaseBackup::create([
|
|
'frequency' => '0 0 * * *',
|
|
'team_id' => $team->id,
|
|
'database_id' => $database->id,
|
|
'database_type' => $database->getMorphClass(),
|
|
]);
|
|
|
|
// Create multiple backup executions with 'running' status
|
|
$runningBackup1 = ScheduledDatabaseBackupExecution::create([
|
|
'scheduled_database_backup_id' => $scheduledBackup->id,
|
|
'status' => 'running',
|
|
'database_name' => 'test_db',
|
|
]);
|
|
|
|
$runningBackup2 = ScheduledDatabaseBackupExecution::create([
|
|
'scheduled_database_backup_id' => $scheduledBackup->id,
|
|
'status' => 'running',
|
|
'database_name' => 'test_db_2',
|
|
]);
|
|
|
|
// Create a successful backup (should not be affected)
|
|
$successfulBackup = ScheduledDatabaseBackupExecution::create([
|
|
'scheduled_database_backup_id' => $scheduledBackup->id,
|
|
'status' => 'success',
|
|
'database_name' => 'test_db_3',
|
|
'finished_at' => Carbon::now()->subMinutes(20),
|
|
]);
|
|
|
|
// Run the app:init command
|
|
Artisan::call('app:init');
|
|
|
|
// Refresh models from database
|
|
$runningBackup1->refresh();
|
|
$runningBackup2->refresh();
|
|
$successfulBackup->refresh();
|
|
|
|
// Assert running backups are now failed
|
|
expect($runningBackup1->status)->toBe('failed')
|
|
->and($runningBackup1->message)->toBe('Marked as failed during Coolify startup - job was interrupted')
|
|
->and($runningBackup1->finished_at)->not->toBeNull()
|
|
->and($runningBackup1->finished_at->toDateTimeString())->toBe('2025-01-15 12:00:00');
|
|
|
|
expect($runningBackup2->status)->toBe('failed')
|
|
->and($runningBackup2->message)->toBe('Marked as failed during Coolify startup - job was interrupted')
|
|
->and($runningBackup2->finished_at)->not->toBeNull();
|
|
|
|
// Assert successful backup is unchanged
|
|
expect($successfulBackup->status)->toBe('success')
|
|
->and($successfulBackup->message)->toBeNull();
|
|
|
|
// Assert NO notifications were sent
|
|
Notification::assertNothingSent();
|
|
});
|
|
|
|
test('app:init handles cleanup when no stuck executions exist', function () {
|
|
// Create a team
|
|
$team = Team::factory()->create();
|
|
|
|
// Create a scheduled task
|
|
$scheduledTask = ScheduledTask::factory()->create([
|
|
'team_id' => $team->id,
|
|
]);
|
|
|
|
// Create only completed executions
|
|
ScheduledTaskExecution::create([
|
|
'scheduled_task_id' => $scheduledTask->id,
|
|
'status' => 'success',
|
|
'started_at' => Carbon::now()->subMinutes(10),
|
|
'finished_at' => Carbon::now()->subMinutes(9),
|
|
]);
|
|
|
|
ScheduledTaskExecution::create([
|
|
'scheduled_task_id' => $scheduledTask->id,
|
|
'status' => 'failed',
|
|
'started_at' => Carbon::now()->subMinutes(20),
|
|
'finished_at' => Carbon::now()->subMinutes(19),
|
|
]);
|
|
|
|
// Run the app:init command (should not fail)
|
|
$exitCode = Artisan::call('app:init');
|
|
|
|
// Assert command succeeded
|
|
expect($exitCode)->toBe(0);
|
|
|
|
// Assert all executions remain unchanged
|
|
expect(ScheduledTaskExecution::where('status', 'running')->count())->toBe(0)
|
|
->and(ScheduledTaskExecution::where('status', 'success')->count())->toBe(1)
|
|
->and(ScheduledTaskExecution::where('status', 'failed')->count())->toBe(1);
|
|
|
|
// Assert NO notifications were sent
|
|
Notification::assertNothingSent();
|
|
});
|
|
|
|
test('cleanup does not send notifications even when team has notification settings', function () {
|
|
// Create a team with notification settings enabled
|
|
$team = Team::factory()->create();
|
|
$team->emailNotificationSettings->update([
|
|
'smtp_enabled' => true,
|
|
'smtp_from_address' => 'test@example.com',
|
|
]);
|
|
|
|
// Create a scheduled task
|
|
$scheduledTask = ScheduledTask::factory()->create([
|
|
'team_id' => $team->id,
|
|
]);
|
|
|
|
// Create a running execution
|
|
$runningExecution = ScheduledTaskExecution::create([
|
|
'scheduled_task_id' => $scheduledTask->id,
|
|
'status' => 'running',
|
|
'started_at' => Carbon::now()->subMinutes(5),
|
|
]);
|
|
|
|
// Run the app:init command
|
|
Artisan::call('app:init');
|
|
|
|
// Refresh model
|
|
$runningExecution->refresh();
|
|
|
|
// Assert execution is failed
|
|
expect($runningExecution->status)->toBe('failed');
|
|
|
|
// Assert NO notifications were sent despite team having notification settings
|
|
Notification::assertNothingSent();
|
|
});
|
|
|
|
test('app:init queues a GitHub deployment sync for each interrupted deployment', function () {
|
|
$team = Team::factory()->create();
|
|
$server = Server::factory()->create(['team_id' => $team->id]);
|
|
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
|
$project = Project::factory()->create(['team_id' => $team->id]);
|
|
$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::factory()->create(['team_id' => $team->id])->id,
|
|
'is_public' => false,
|
|
'deployments' => 'write',
|
|
'team_id' => $team->id,
|
|
]);
|
|
$application = Application::factory()->create([
|
|
'environment_id' => Environment::factory()->create(['project_id' => $project->id])->id,
|
|
'destination_id' => $destination->id,
|
|
'destination_type' => $destination->getMorphClass(),
|
|
'source_type' => GithubApp::class,
|
|
'source_id' => $githubApp->id,
|
|
]);
|
|
$deploy = fn (ApplicationDeploymentStatus $status): ApplicationDeploymentQueue => ApplicationDeploymentQueue::create([
|
|
'application_id' => $application->id,
|
|
'deployment_uuid' => (string) Str::uuid(),
|
|
'pull_request_id' => 0,
|
|
'commit' => 'HEAD',
|
|
'status' => $status->value,
|
|
'server_id' => $server->id,
|
|
]);
|
|
$inProgress = $deploy(ApplicationDeploymentStatus::IN_PROGRESS);
|
|
$queued = $deploy(ApplicationDeploymentStatus::QUEUED);
|
|
$finished = $deploy(ApplicationDeploymentStatus::FINISHED);
|
|
|
|
Artisan::call('app:init');
|
|
|
|
expect($inProgress->fresh()->status)->toBe(ApplicationDeploymentStatus::FAILED->value)
|
|
->and($queued->fresh()->status)->toBe(ApplicationDeploymentStatus::FAILED->value)
|
|
->and($finished->fresh()->status)->toBe(ApplicationDeploymentStatus::FINISHED->value);
|
|
Queue::assertPushed(SyncGithubDeploymentJob::class, 2);
|
|
foreach ([$inProgress, $queued] as $failed) {
|
|
Queue::assertPushed(SyncGithubDeploymentJob::class, fn (SyncGithubDeploymentJob $job): bool => $job->deploymentQueueId === $failed->id);
|
|
}
|
|
});
|