mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-29 10:04:36 +08:00
Add a /dashboard/canvas page that renders every team server with its resources and their running or queued deployments, backed by a new ServerCanvasController::dashboard endpoint and a per-server usage endpoint for zone headers. The dashboard remembers the list/canvas choice and each section (projects, servers) its own table/grid view. - Show Deploying/Queued pills on canvas cards and a foldable recent deployments panel; add shared CanvasControls component - Allow backup downloads from the server host via the S3 download storage (PrepareFileDownloadJob accepts a null container) - Extract backup toggle/run actions into ManagesDatabaseBackup and surface them in backup execution headers; merge S3 and retention sections into the general backup settings - Move deployments indicator to the top bar and add a main nav resource menu with submenus; simplify breadcrumbs and settings layouts - Add shared variables overview canvas and scheduled task modals - Add feature tests for dashboard canvas, view modes, navbar submenus, backup S3 downloads and scheduled task modals
184 lines
8.0 KiB
PHP
184 lines
8.0 KiB
PHP
<?php
|
|
|
|
use App\Models\Application;
|
|
use App\Models\Environment;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\LocalPersistentVolume;
|
|
use App\Models\Project;
|
|
use App\Models\S3Storage;
|
|
use App\Models\ScheduledDatabaseBackup;
|
|
use App\Models\ScheduledDatabaseBackupExecution;
|
|
use App\Models\ScheduledVolumeBackupExecution;
|
|
use App\Models\Server;
|
|
use App\Models\StandaloneDocker;
|
|
use App\Models\StandalonePostgresql;
|
|
use App\Models\Team;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
config(['app.maintenance.driver' => 'file']);
|
|
Server::flushIdentityMap();
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
|
|
|
|
$this->team = Team::factory()->create();
|
|
$this->otherTeam = Team::factory()->create();
|
|
|
|
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
|
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
|
$environment = Environment::factory()->create([
|
|
'project_id' => Project::factory()->create(['team_id' => $this->team->id])->id,
|
|
]);
|
|
$database = StandalonePostgresql::create([
|
|
'name' => 's3-download-pg',
|
|
'image' => 'postgres:16-alpine',
|
|
'postgres_user' => 'postgres',
|
|
'postgres_password' => 'password',
|
|
'postgres_db' => 'postgres',
|
|
'environment_id' => $environment->id,
|
|
'destination_id' => $destination->id,
|
|
'destination_type' => $destination->getMorphClass(),
|
|
]);
|
|
$application = Application::factory()->create([
|
|
'environment_id' => $environment->id,
|
|
'destination_id' => $destination->id,
|
|
'destination_type' => $destination->getMorphClass(),
|
|
]);
|
|
$volume = LocalPersistentVolume::create([
|
|
'name' => 'app-data',
|
|
'mount_path' => '/data',
|
|
'resource_id' => $application->id,
|
|
'resource_type' => $application->getMorphClass(),
|
|
]);
|
|
|
|
$this->primaryS3 = createS3ForDownloadFallbackTest($this->team, 'primary');
|
|
$this->replicaS3 = createS3ForDownloadFallbackTest($this->team, 'replica');
|
|
|
|
$this->databaseBackup = ScheduledDatabaseBackup::create([
|
|
'team_id' => $this->team->id,
|
|
'frequency' => 'daily',
|
|
'save_s3' => true,
|
|
'disable_local_backup' => true,
|
|
's3_storage_id' => $this->primaryS3->id,
|
|
'database_id' => $database->id,
|
|
'database_type' => $database->getMorphClass(),
|
|
]);
|
|
$this->databaseExecution = ScheduledDatabaseBackupExecution::create([
|
|
'scheduled_database_backup_id' => $this->databaseBackup->id,
|
|
'status' => 'success',
|
|
'database_name' => 'postgres',
|
|
'filename' => '/data/coolify/backups/databases/team/pg/pg-dump-postgres-1.dmp',
|
|
'local_storage_deleted' => true,
|
|
]);
|
|
|
|
$this->volumeBackup = $volume->scheduledBackups()->create([
|
|
'team_id' => $this->team->id,
|
|
'frequency' => 'daily',
|
|
]);
|
|
$this->volumeExecution = ScheduledVolumeBackupExecution::create([
|
|
'scheduled_volume_backup_id' => $this->volumeBackup->id,
|
|
's3_storage_id' => $this->primaryS3->id,
|
|
'status' => 'success',
|
|
'filename' => '/data/coolify/backups/volumes/team/app/volume-app-data-1.tar.gz',
|
|
'local_storage_deleted' => true,
|
|
]);
|
|
});
|
|
|
|
function createS3ForDownloadFallbackTest(Team $team, string $name, bool $isUsable = true): S3Storage
|
|
{
|
|
return S3Storage::create([
|
|
'name' => $name,
|
|
'region' => 'us-east-1',
|
|
'key' => 'key',
|
|
'secret' => 'secret',
|
|
'bucket' => "{$name}-{$team->id}-bucket",
|
|
'endpoint' => 'https://1.1.1.1',
|
|
'team_id' => $team->id,
|
|
'is_usable' => $isUsable,
|
|
]);
|
|
}
|
|
|
|
function signInForDownloadFallbackTest($testCase, Team $team, string $role = 'owner'): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$user->teams()->attach($team, ['role' => $role]);
|
|
$testCase->actingAs($user);
|
|
session(['currentTeam' => $team]);
|
|
}
|
|
|
|
/**
|
|
* @return array{0: string, 1: ScheduledDatabaseBackupExecution|ScheduledVolumeBackupExecution}
|
|
*/
|
|
function downloadFallbackTarget($testCase, string $type): array
|
|
{
|
|
return $type === 'database'
|
|
? ['download.backup', $testCase->databaseExecution]
|
|
: ['download.volume-backup', $testCase->volumeExecution];
|
|
}
|
|
|
|
function assertPresignedBackupRedirect($response, S3Storage $storage, string $filename): void
|
|
{
|
|
$location = $response->assertRedirect()->headers->get('Location');
|
|
$url = parse_url($location);
|
|
parse_str($url['query'], $query);
|
|
|
|
expect("{$url['scheme']}://{$url['host']}{$url['path']}")->toBe("https://1.1.1.1/{$storage->bucket}".$filename)
|
|
->and($query)->toHaveKeys(['X-Amz-Signature', 'X-Amz-Expires'])
|
|
->and((int) $query['X-Amz-Expires'])->toBeLessThanOrEqual(15 * 60)
|
|
->and($query['response-content-disposition'])->toContain('attachment; filename="'.basename($filename).'"');
|
|
}
|
|
|
|
it('redirects to a presigned URL of the primary S3 copy when no local copy exists', function (string $type) {
|
|
[$route, $execution] = downloadFallbackTarget($this, $type);
|
|
$execution->s3Replicas()->create(['s3_storage_id' => $this->replicaS3->id, 's3_uploaded' => true]);
|
|
$execution->s3Replicas()->create(['s3_storage_id' => $this->primaryS3->id, 's3_uploaded' => true]);
|
|
signInForDownloadFallbackTest($this, $this->team);
|
|
|
|
assertPresignedBackupRedirect($this->get(route($route, $execution->id)), $this->primaryS3, $execution->filename);
|
|
})->with(['database', 'volume']);
|
|
|
|
it('uses a replica when the primary S3 copy is deleted or its storage is unusable', function (string $type, bool $primaryDeleted) {
|
|
[$route, $execution] = downloadFallbackTarget($this, $type);
|
|
if (! $primaryDeleted) {
|
|
$this->primaryS3->update(['is_usable' => false]);
|
|
}
|
|
$execution->s3Replicas()->create(['s3_storage_id' => $this->primaryS3->id, 's3_uploaded' => true, 's3_storage_deleted' => $primaryDeleted]);
|
|
$execution->s3Replicas()->create(['s3_storage_id' => $this->replicaS3->id, 's3_uploaded' => true]);
|
|
signInForDownloadFallbackTest($this, $this->team);
|
|
|
|
assertPresignedBackupRedirect($this->get(route($route, $execution->id)), $this->replicaS3, $execution->filename);
|
|
})->with(['database', 'volume'])->with(['primary deleted' => true, 'primary unusable' => false]);
|
|
|
|
it('keeps the not found message when no usable S3 copy exists', function (string $type, string $message) {
|
|
[$route, $execution] = downloadFallbackTarget($this, $type);
|
|
$execution->s3Replicas()->create(['s3_storage_id' => $this->primaryS3->id, 's3_uploaded' => false]);
|
|
$execution->s3Replicas()->create(['s3_storage_id' => $this->replicaS3->id, 's3_uploaded' => true, 's3_storage_deleted' => true]);
|
|
signInForDownloadFallbackTest($this, $this->team);
|
|
|
|
$this->get(route($route, $execution->id))->assertNotFound()->assertJson(['message' => $message]);
|
|
})->with([
|
|
'database' => ['database', 'Backup not available locally, but available on S3.'],
|
|
'volume' => ['volume', 'Backup not found locally on the server.'],
|
|
]);
|
|
|
|
it('never uses an S3 storage of another team', function (string $type) {
|
|
[$route, $execution] = downloadFallbackTarget($this, $type);
|
|
$foreignS3 = createS3ForDownloadFallbackTest($this->otherTeam, 'foreign');
|
|
$execution->s3Replicas()->create(['s3_storage_id' => $foreignS3->id, 's3_uploaded' => true]);
|
|
signInForDownloadFallbackTest($this, $this->team);
|
|
|
|
$this->get(route($route, $execution->id))->assertNotFound();
|
|
})->with(['database', 'volume']);
|
|
|
|
it('forbids members and other teams from getting a presigned URL', function (string $type, string $user) {
|
|
[$route, $execution] = downloadFallbackTarget($this, $type);
|
|
$execution->s3Replicas()->create(['s3_storage_id' => $this->primaryS3->id, 's3_uploaded' => true]);
|
|
$user === 'member'
|
|
? signInForDownloadFallbackTest($this, $this->team, 'member')
|
|
: signInForDownloadFallbackTest($this, $this->otherTeam);
|
|
|
|
$this->get(route($route, $execution->id))->assertForbidden();
|
|
})->with(['database', 'volume'])->with(['member', 'other team owner']);
|