mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-15 20:15:45 +00:00
Merge remote-tracking branch 'origin/next' into pr-7774-fix-upgrade-notification-modal
This commit is contained in:
commit
02558d8672
99 files changed
+1169
-424
No files matched your search
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\CleanupInstanceStuffsJob;
|
||||
use App\Jobs\DatabaseBackupJob;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
// --- WithoutOverlapping middleware on DatabaseBackupJob ---
|
||||
|
||||
test('database backup job has WithoutOverlapping middleware keyed by backup id', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => false,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
$job = new DatabaseBackupJob($backup);
|
||||
$middleware = $job->middleware();
|
||||
|
||||
expect($middleware)->toHaveCount(1);
|
||||
expect($middleware[0])->toBeInstanceOf(WithoutOverlapping::class);
|
||||
});
|
||||
|
||||
test('database backup job middleware uses custom timeout plus buffer', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => false,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
'team_id' => $team->id,
|
||||
'timeout' => 7200,
|
||||
]);
|
||||
|
||||
$job = new DatabaseBackupJob($backup);
|
||||
$middleware = $job->middleware();
|
||||
|
||||
$reflection = new ReflectionClass($middleware[0]);
|
||||
$expiresAfterProp = $reflection->getProperty('expiresAfter');
|
||||
$releaseAfterProp = $reflection->getProperty('releaseAfter');
|
||||
|
||||
expect($expiresAfterProp->getValue($middleware[0]))->toBe(7500); // 7200 + 300
|
||||
expect($releaseAfterProp->getValue($middleware[0]))->toBeNull(); // dontRelease sets to null
|
||||
});
|
||||
|
||||
// --- Stale backup detection in DatabaseBackupJob ---
|
||||
|
||||
test('markStaleExecutionsAsFailed marks stale running executions as failed', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => false,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
'team_id' => $team->id,
|
||||
'timeout' => 3600,
|
||||
]);
|
||||
|
||||
// Create a stale execution (3 hours old, timeout is 1h, threshold is 2h)
|
||||
$staleExecution = ScheduledDatabaseBackupExecution::create([
|
||||
'uuid' => 'stale-exec-uuid',
|
||||
'database_name' => 'test_db',
|
||||
'scheduled_database_backup_id' => $backup->id,
|
||||
'status' => 'running',
|
||||
]);
|
||||
ScheduledDatabaseBackupExecution::where('id', $staleExecution->id)
|
||||
->update(['created_at' => now()->subHours(3)]);
|
||||
|
||||
// Call the private method via reflection
|
||||
$job = new DatabaseBackupJob($backup);
|
||||
$reflection = new ReflectionClass($job);
|
||||
$method = $reflection->getMethod('markStaleExecutionsAsFailed');
|
||||
$method->invoke($job);
|
||||
|
||||
$staleExecution->refresh();
|
||||
expect($staleExecution->status)->toBe('failed');
|
||||
expect($staleExecution->message)->toContain('exceeded maximum allowed time');
|
||||
expect($staleExecution->finished_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('markStaleExecutionsAsFailed does not mark recent running executions as failed', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => false,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
'team_id' => $team->id,
|
||||
'timeout' => 3600,
|
||||
]);
|
||||
|
||||
// Create a recent execution (30 minutes old, threshold is 2 hours)
|
||||
$recentExecution = ScheduledDatabaseBackupExecution::create([
|
||||
'uuid' => 'recent-exec-uuid',
|
||||
'database_name' => 'test_db',
|
||||
'scheduled_database_backup_id' => $backup->id,
|
||||
'status' => 'running',
|
||||
]);
|
||||
ScheduledDatabaseBackupExecution::where('id', $recentExecution->id)
|
||||
->update(['created_at' => now()->subMinutes(30)]);
|
||||
|
||||
$job = new DatabaseBackupJob($backup);
|
||||
$reflection = new ReflectionClass($job);
|
||||
$method = $reflection->getMethod('markStaleExecutionsAsFailed');
|
||||
$method->invoke($job);
|
||||
|
||||
$recentExecution->refresh();
|
||||
expect($recentExecution->status)->toBe('running');
|
||||
expect($recentExecution->finished_at)->toBeNull();
|
||||
});
|
||||
|
||||
// --- Decimal cast on ScheduledDatabaseBackup model ---
|
||||
|
||||
test('scheduled database backup model casts max storage fields to float', function () {
|
||||
$model = new ScheduledDatabaseBackup;
|
||||
$casts = $model->getCasts();
|
||||
|
||||
expect($casts)->toHaveKey('database_backup_retention_max_storage_locally');
|
||||
expect($casts['database_backup_retention_max_storage_locally'])->toBe('float');
|
||||
expect($casts)->toHaveKey('database_backup_retention_max_storage_s3');
|
||||
expect($casts['database_backup_retention_max_storage_s3'])->toBe('float');
|
||||
});
|
||||
|
||||
test('max storage retention field returns float zero that passes strict comparison', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => false,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
'team_id' => $team->id,
|
||||
'database_backup_retention_max_storage_locally' => 0,
|
||||
]);
|
||||
|
||||
$backup->refresh();
|
||||
|
||||
// With the float cast, strict comparison to 0.0 works
|
||||
expect($backup->database_backup_retention_max_storage_locally)->toBe(0.0);
|
||||
// And loose comparison to int 0 also works
|
||||
expect($backup->database_backup_retention_max_storage_locally == 0)->toBeTrue();
|
||||
});
|
||||
|
||||
// --- Periodic retention enforcement in CleanupInstanceStuffsJob ---
|
||||
|
||||
test('cleanup instance stuffs job calls retention enforcement without errors', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => false,
|
||||
'enabled' => true,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
'team_id' => $team->id,
|
||||
'database_backup_retention_amount_locally' => 1,
|
||||
]);
|
||||
|
||||
// Create 3 successful executions
|
||||
foreach (range(1, 3) as $i) {
|
||||
ScheduledDatabaseBackupExecution::create([
|
||||
'uuid' => "exec-uuid-{$i}",
|
||||
'database_name' => 'test_db',
|
||||
'filename' => "/backup/test_{$i}.dmp",
|
||||
'scheduled_database_backup_id' => $backup->id,
|
||||
'status' => 'success',
|
||||
'size' => 1024,
|
||||
'local_storage_deleted' => false,
|
||||
'created_at' => now()->subDays($i),
|
||||
]);
|
||||
}
|
||||
|
||||
Cache::forget('backup-retention-enforcement');
|
||||
|
||||
// Should not throw — the job gracefully handles missing servers
|
||||
$job = new CleanupInstanceStuffsJob;
|
||||
$job->handle();
|
||||
|
||||
// All 3 executions should still exist (no server to delete files from)
|
||||
expect($backup->executions()->count())->toBe(3);
|
||||
});
|
||||
|
||||
test('deleteOldBackupsLocally identifies correct backups for deletion by retention count', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => false,
|
||||
'enabled' => true,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
'team_id' => $team->id,
|
||||
'database_backup_retention_amount_locally' => 1,
|
||||
]);
|
||||
|
||||
foreach (range(1, 3) as $i) {
|
||||
$exec = ScheduledDatabaseBackupExecution::create([
|
||||
'uuid' => "exec-uuid-{$i}",
|
||||
'database_name' => 'test_db',
|
||||
'filename' => "/backup/test_{$i}.dmp",
|
||||
'scheduled_database_backup_id' => $backup->id,
|
||||
'status' => 'success',
|
||||
'size' => 1024,
|
||||
'local_storage_deleted' => false,
|
||||
]);
|
||||
ScheduledDatabaseBackupExecution::where('id', $exec->id)
|
||||
->update(['created_at' => now()->subDays($i)]);
|
||||
}
|
||||
|
||||
// Test the selection logic directly
|
||||
$successfulBackups = $backup->executions()
|
||||
->where('status', 'success')
|
||||
->where('local_storage_deleted', false)
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
$retentionAmount = $backup->database_backup_retention_amount_locally;
|
||||
$backupsToDelete = $successfulBackups->skip($retentionAmount);
|
||||
|
||||
// exec-uuid-1 is newest (1 day ago), exec-uuid-2 and exec-uuid-3 should be deleted
|
||||
expect($backupsToDelete)->toHaveCount(2);
|
||||
expect($successfulBackups->first()->uuid)->toBe('exec-uuid-1');
|
||||
});
|
||||
|
||||
test('cleanup instance stuffs job throttles retention enforcement via cache', function () {
|
||||
Cache::forget('backup-retention-enforcement');
|
||||
|
||||
$job = new CleanupInstanceStuffsJob;
|
||||
$job->handle();
|
||||
|
||||
// The cache key should now exist
|
||||
expect(Cache::has('backup-retention-enforcement'))->toBeTrue();
|
||||
|
||||
// Running again should skip retention enforcement (cache hit)
|
||||
// We verify by checking the lock is still held — no error thrown
|
||||
$job->handle();
|
||||
|
||||
expect(Cache::has('backup-retention-enforcement'))->toBeTrue();
|
||||
});
|
||||
@@ -512,6 +512,9 @@ describe('custom_docker_run_options validation', function () {
|
||||
'--cap-add=NET_ADMIN --cap-add=NET_RAW',
|
||||
'--privileged --init',
|
||||
'--memory=512m --cpus=2',
|
||||
'--entrypoint "sh -c \'npm start\'"',
|
||||
'--entrypoint "sh -c \'php artisan schedule:work\'"',
|
||||
'--hostname "my-host"',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -20,3 +20,12 @@ it('uses distinct keyed branches for the edit value field modes', function () {
|
||||
->toContain('wire:key="env-show-value-textarea-{{ $env->id }}"')
|
||||
->toContain('wire:key="env-show-value-input-{{ $env->id }}"');
|
||||
});
|
||||
|
||||
it('uses sans font for the developer bulk environment variable editor', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('class="whitespace-pre-wrap font-sans"')
|
||||
->not->toContain('wire:model="variables" monospace')
|
||||
->not->toContain('wire:model="variablesPreview" monospace');
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
use Spatie\Url\Url;
|
||||
|
||||
/**
|
||||
* Tests for branch name parsing from Git repository URLs.
|
||||
* Verifies that branch names containing slashes (e.g., fix/something)
|
||||
* are correctly extracted from URLs like /tree/fix/something.
|
||||
*/
|
||||
function parseBranchFromUrl(string $url): array
|
||||
{
|
||||
$parsed = Url::fromString($url);
|
||||
$branch = 'main';
|
||||
$baseDirectory = '/';
|
||||
|
||||
if ($parsed->getSegment(3) === 'tree') {
|
||||
$path = str($parsed->getPath())->trim('/');
|
||||
$branch = str($path)->after('tree/')->value();
|
||||
$baseDirectory = '/';
|
||||
}
|
||||
|
||||
return [
|
||||
'branch' => $branch,
|
||||
'base_directory' => $baseDirectory,
|
||||
'repository' => $parsed->getSegment(1).'/'.$parsed->getSegment(2),
|
||||
];
|
||||
}
|
||||
|
||||
test('parses simple branch from GitHub URL', function () {
|
||||
$result = parseBranchFromUrl('https://github.com/andrasbacsai/coolify-examples/tree/main');
|
||||
|
||||
expect($result['branch'])->toBe('main');
|
||||
expect($result['base_directory'])->toBe('/');
|
||||
expect($result['repository'])->toBe('andrasbacsai/coolify-examples');
|
||||
});
|
||||
|
||||
test('parses branch with slash from GitHub URL', function () {
|
||||
$result = parseBranchFromUrl('https://github.com/andrasbacsai/coolify-examples-1/tree/fix/8854-env-var-fallback-volume');
|
||||
|
||||
expect($result['branch'])->toBe('fix/8854-env-var-fallback-volume');
|
||||
expect($result['base_directory'])->toBe('/');
|
||||
expect($result['repository'])->toBe('andrasbacsai/coolify-examples-1');
|
||||
});
|
||||
|
||||
test('parses branch with multiple slashes from GitHub URL', function () {
|
||||
$result = parseBranchFromUrl('https://github.com/user/repo/tree/feature/team/new-widget');
|
||||
|
||||
expect($result['branch'])->toBe('feature/team/new-widget');
|
||||
expect($result['base_directory'])->toBe('/');
|
||||
});
|
||||
|
||||
test('defaults to main branch when no tree segment in URL', function () {
|
||||
$result = parseBranchFromUrl('https://github.com/andrasbacsai/coolify-examples');
|
||||
|
||||
expect($result['branch'])->toBe('main');
|
||||
expect($result['base_directory'])->toBe('/');
|
||||
});
|
||||
|
||||
test('parses version-style branch with slash from GitHub URL', function () {
|
||||
$result = parseBranchFromUrl('https://github.com/coollabsio/coolify-examples/tree/release/v2.0');
|
||||
|
||||
expect($result['branch'])->toBe('release/v2.0');
|
||||
expect($result['base_directory'])->toBe('/');
|
||||
});
|
||||
|
||||
test('parses branch from non-GitHub URL with tree segment', function () {
|
||||
$result = parseBranchFromUrl('https://gitlab.com/user/repo/tree/hotfix/critical-bug');
|
||||
|
||||
expect($result['branch'])->toBe('hotfix/critical-bug');
|
||||
expect($result['base_directory'])->toBe('/');
|
||||
});
|
||||
Reference in new issue
Block a user