feat(backups): support scheduled backups for application storage targets

Add polymorphic volume backup scheduling for persistent volumes and directories, expose schedule management via API, and reorganize backup configuration and execution views.
This commit is contained in:
Andras Bacsai
2026-07-15 17:34:22 +02:00
parent 995ec5fbb6
commit d7385ad0c4
60 changed files with 3570 additions and 985 deletions
@@ -0,0 +1,353 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\Project;
use App\Models\S3Storage;
use App\Models\ScheduledVolumeBackup;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user, ['role' => 'owner']);
$plainTextToken = Str::random(40);
$token = $this->user->tokens()->create([
'name' => 'volume-backup-api-test',
'token' => hash('sha256', $plainTextToken),
'abilities' => ['*'],
'team_id' => $this->team->id,
]);
$this->headers = [
'Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken,
'Content-Type' => 'application/json',
];
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$this->volume = LocalPersistentVolume::create([
'name' => 'api-volume',
'mount_path' => '/data',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
]);
$this->s3Storage = S3Storage::create([
'name' => 'volume-backup-s3',
'region' => 'us-east-1',
'key' => 'key',
'secret' => 'secret',
'bucket' => 'bucket',
'endpoint' => 'https://s3.example.com',
'team_id' => $this->team->id,
'is_usable' => true,
]);
});
function createVolumeBackupApiToken($context, User $user, array $abilities): string
{
$plainTextToken = Str::random(40);
$token = $user->tokens()->create([
'name' => 'volume-backup-api-permission-test',
'token' => hash('sha256', $plainTextToken),
'abilities' => $abilities,
'team_id' => $context->team->id,
]);
return $token->getKey().'|'.$plainTextToken;
}
it('sets an application volume backup schedule through the API', function () {
$response = $this->withHeaders($this->headers)
->putJson("/api/v1/applications/{$this->application->uuid}/storages/{$this->volume->uuid}/backups", [
'frequency' => '0 2 * * *',
'enabled' => true,
'save_s3' => true,
'disable_local_backup' => true,
'stop_during_backup' => true,
's3_storage_uuid' => $this->s3Storage->uuid,
'retention_amount_locally' => 3,
'retention_days_locally' => 4,
'retention_max_storage_locally' => 5.5,
'retention_amount_s3' => 6,
'retention_days_s3' => 7,
'retention_max_storage_s3' => 8.5,
'timeout' => 600,
]);
$response->assertCreated()->assertJsonStructure(['uuid', 'message']);
$backup = ScheduledVolumeBackup::query()->sole();
expect($backup->backupable->is($this->volume))->toBeTrue()
->and($backup->team_id)->toBe($this->team->id)
->and($backup->frequency)->toBe('0 2 * * *')
->and($backup->enabled)->toBeTrue()
->and($backup->save_s3)->toBeTrue()
->and($backup->disable_local_backup)->toBeTrue()
->and($backup->stop_during_backup)->toBeTrue()
->and($backup->s3_storage_id)->toBe($this->s3Storage->id)
->and($backup->retention_amount_locally)->toBe(3)
->and($backup->retention_days_locally)->toBe(4)
->and($backup->retention_max_storage_locally)->toBe(5.5)
->and($backup->retention_amount_s3)->toBe(6)
->and($backup->retention_days_s3)->toBe(7)
->and($backup->retention_max_storage_s3)->toBe(8.5)
->and($backup->timeout)->toBe(600);
});
it('updates the existing backup schedule instead of creating another one', function () {
$this->volume->scheduledBackups()->create([
'team_id' => $this->team->id,
'frequency' => 'daily',
'save_s3' => true,
'disable_local_backup' => true,
's3_storage_id' => $this->s3Storage->id,
]);
$response = $this->withHeaders($this->headers)
->putJson("/api/v1/applications/{$this->application->uuid}/storages/{$this->volume->uuid}/backups", [
'frequency' => 'hourly',
'enabled' => false,
'save_s3' => false,
]);
$response->assertOk();
$backup = ScheduledVolumeBackup::query()->sole();
expect($backup->frequency)->toBe('hourly')
->and($backup->enabled)->toBeFalse()
->and($backup->save_s3)->toBeFalse()
->and($backup->disable_local_backup)->toBeFalse()
->and($backup->s3_storage_id)->toBeNull();
});
it('sets a directory backup schedule through the API', function () {
$directory = LocalFileVolume::unguarded(fn () => LocalFileVolume::withoutEvents(fn () => LocalFileVolume::create([
'uuid' => new_public_id(),
'fs_path' => './uploads',
'mount_path' => '/app/uploads',
'is_directory' => true,
'is_host_file' => false,
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
])));
$this->withHeaders($this->headers)
->putJson("/api/v1/applications/{$this->application->uuid}/storages/{$directory->uuid}/backups", [
'frequency' => 'daily',
])
->assertCreated();
expect(ScheduledVolumeBackup::query()->sole()->backupable->is($directory))->toBeTrue();
});
it('sets database and service volume backup schedules through their storage APIs', function () {
$database = StandalonePostgresql::create([
'name' => 'api-postgres',
'image' => 'postgres:17-alpine',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'postgres',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$databaseVolume = $database->persistentStorages()->firstOrFail();
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$serviceApplication = ServiceApplication::create([
'uuid' => new_public_id(),
'name' => 'api-service-application',
'image' => 'nginx:alpine',
'service_id' => $service->id,
]);
$serviceVolume = LocalPersistentVolume::create([
'name' => 'service-api-volume',
'mount_path' => '/data',
'resource_id' => $serviceApplication->id,
'resource_type' => $serviceApplication->getMorphClass(),
]);
$this->withHeaders($this->headers)
->putJson("/api/v1/databases/{$database->uuid}/storages/{$databaseVolume->uuid}/backups", ['frequency' => 'daily'])
->assertCreated();
$this->withHeaders($this->headers)
->putJson("/api/v1/services/{$service->uuid}/storages/{$serviceVolume->uuid}/backups", ['frequency' => 'weekly'])
->assertCreated();
expect($databaseVolume->scheduledBackups()->sole()->frequency)->toBe('daily')
->and($serviceVolume->scheduledBackups()->sole()->frequency)->toBe('weekly');
});
it('rejects ineligible file storages and invalid schedule settings', function () {
$file = LocalFileVolume::unguarded(fn () => LocalFileVolume::withoutEvents(fn () => LocalFileVolume::create([
'uuid' => new_public_id(),
'fs_path' => '/tmp/config.json',
'mount_path' => '/app/config.json',
'is_directory' => false,
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
])));
$this->withHeaders($this->headers)
->putJson("/api/v1/applications/{$this->application->uuid}/storages/{$file->uuid}/backups", ['frequency' => 'daily'])
->assertUnprocessable()
->assertJsonValidationErrors(['storage_uuid']);
$this->withHeaders($this->headers)
->putJson("/api/v1/applications/{$this->application->uuid}/storages/{$this->volume->uuid}/backups", [
'frequency' => 'not-a-frequency',
'save_s3' => false,
'disable_local_backup' => true,
'unexpected' => true,
])
->assertUnprocessable()
->assertJsonValidationErrors(['frequency', 'disable_local_backup', 'unexpected']);
expect(ScheduledVolumeBackup::query()->count())->toBe(0);
});
it('rejects an s3 storage owned by another team', function () {
$otherTeam = Team::factory()->create();
$otherS3Storage = S3Storage::create([
'name' => 'other-team-s3',
'region' => 'us-east-1',
'key' => 'key',
'secret' => 'secret',
'bucket' => 'bucket',
'endpoint' => 'https://s3.example.com',
'team_id' => $otherTeam->id,
'is_usable' => true,
]);
$this->withHeaders($this->headers)
->putJson("/api/v1/applications/{$this->application->uuid}/storages/{$this->volume->uuid}/backups", [
'frequency' => 'daily',
'save_s3' => true,
's3_storage_uuid' => $otherS3Storage->uuid,
])
->assertUnprocessable()
->assertJsonValidationErrors(['s3_storage_uuid']);
expect(ScheduledVolumeBackup::query()->count())->toBe(0);
});
it('rejects schedule values larger than the database columns support', function () {
$this->withHeaders($this->headers)
->putJson("/api/v1/applications/{$this->application->uuid}/storages/{$this->volume->uuid}/backups", [
'frequency' => str_repeat('a', 256),
'retention_days_locally' => 2147483648,
'retention_days_s3' => 2147483648,
'retention_max_storage_locally' => 10000000000,
'retention_max_storage_s3' => 10000000000,
])
->assertUnprocessable()
->assertJsonValidationErrors([
'frequency',
'retention_days_locally',
'retention_days_s3',
'retention_max_storage_locally',
'retention_max_storage_s3',
]);
expect(ScheduledVolumeBackup::query()->count())->toBe(0);
});
it('requires authentication, write ability, and an admin team role', function () {
$endpoint = "/api/v1/applications/{$this->application->uuid}/storages/{$this->volume->uuid}/backups";
$this->putJson($endpoint, ['frequency' => 'daily'])->assertUnauthorized();
$readToken = createVolumeBackupApiToken($this, $this->user, ['read']);
$this->withToken($readToken)->putJson($endpoint, ['frequency' => 'daily'])->assertForbidden();
auth()->forgetGuards();
$member = User::factory()->create();
$this->team->members()->attach($member, ['role' => 'member']);
$memberWriteToken = createVolumeBackupApiToken($this, $member, ['write']);
$this->withToken($memberWriteToken)->putJson($endpoint, ['frequency' => 'daily'])->assertForbidden();
expect(ScheduledVolumeBackup::query()->count())->toBe(0);
});
it('does not set schedules through resources owned by another team', function () {
$otherTeam = Team::factory()->create();
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]);
$otherApplication = Application::factory()->create([
'environment_id' => $otherEnvironment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$otherDatabase = StandalonePostgresql::create([
'name' => 'other-team-postgres',
'image' => 'postgres:17-alpine',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'postgres',
'environment_id' => $otherEnvironment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$otherService = Service::factory()->create([
'environment_id' => $otherEnvironment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
foreach (['applications' => $otherApplication, 'databases' => $otherDatabase, 'services' => $otherService] as $type => $resource) {
$this->withHeaders($this->headers)
->putJson("/api/v1/{$type}/{$resource->uuid}/storages/{$this->volume->uuid}/backups", ['frequency' => 'daily'])
->assertNotFound();
}
expect(ScheduledVolumeBackup::query()->count())->toBe(0);
});
it('does not set a schedule for storage outside the scoped parent', function () {
$otherApplication = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$otherVolume = LocalPersistentVolume::create([
'name' => 'other-application-volume',
'mount_path' => '/data',
'resource_id' => $otherApplication->id,
'resource_type' => $otherApplication->getMorphClass(),
]);
$this->withHeaders($this->headers)
->putJson("/api/v1/applications/{$this->application->uuid}/storages/{$otherVolume->uuid}/backups", ['frequency' => 'daily'])
->assertNotFound();
expect(ScheduledVolumeBackup::query()->count())->toBe(0);
});
+137 -14
View File
@@ -73,12 +73,137 @@ beforeEach(function () {
});
it('renders a highlighted enable backup button and a regular disable backup button', function () {
$view = file_get_contents(resource_path('views/livewire/project/database/backup-edit.blade.php'));
$view = file_get_contents(resource_path('views/livewire/project/database/backup-edit/general.blade.php'));
$s3View = file_get_contents(resource_path('views/livewire/project/database/backup-edit/s3.blade.php'));
expect($view)
->toContain('wire:target="toggleEnabled" isHighlighted>Enable Backup</x-forms.button>')
->toContain('wire:target="toggleEnabled">Disable Backup</x-forms.button>')
->not->toContain('label="Backup Enabled"');
->not->toContain('label="Backup Enabled"')
->and($s3View)
->toContain('wire:target="toggleS3" isHighlighted')
->toContain('wire:target="toggleS3">Disable S3</x-forms.button>')
->not->toContain('label="S3 Enabled"');
});
it('enables and disables S3 backups from the S3 title action', function () {
$s3 = createS3StorageForBackupEditValidationTest($this->team);
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false,
's3_storage_id' => $s3->id,
]);
$component = Livewire::test(BackupEdit::class, [
'backup' => $backup->fresh(),
'availableS3Storages' => $this->team->s3s,
'section' => 's3',
])
->assertSee('Enable S3')
->call('toggleS3')
->assertSet('saveS3', true)
->assertSee('Disable S3');
expect($backup->refresh()->save_s3)->toBeTruthy();
$component->call('toggleS3')->assertSet('saveS3', false);
expect($backup->refresh()->save_s3)->toBeFalsy();
});
it('shows and saves S3 retention while S3 backups are disabled', function () {
$backup = createBackupForEditValidationTest($this->team, [
'enabled' => false,
'save_s3' => false,
'database_backup_retention_amount_locally' => 0,
'database_backup_retention_days_locally' => 0,
'database_backup_retention_max_storage_locally' => 0,
'database_backup_retention_amount_s3' => 0,
'database_backup_retention_days_s3' => 0,
'database_backup_retention_max_storage_s3' => 0,
'dump_all' => false,
'timeout' => 3600,
]);
Livewire::test(BackupEdit::class, [
'backup' => $backup,
'availableS3Storages' => $this->team->s3s,
'section' => 'retention',
])
->assertSee('S3 Storage Retention')
->set('databaseBackupRetentionAmountS3', 12)
->set('databaseBackupRetentionDaysS3', 30)
->set('databaseBackupRetentionMaxStorageS3', 4.5)
->call('submit')
->assertHasNoErrors();
expect($backup->refresh()->save_s3)->toBeFalsy()
->and($backup->database_backup_retention_amount_s3)->toBe(12)
->and($backup->database_backup_retention_days_s3)->toBe(30)
->and($backup->database_backup_retention_max_storage_s3)->toBe(4.5);
});
it('splits standalone database backup settings and executions across dedicated urls', function () {
config(['cache.default' => 'array', 'app.maintenance.driver' => 'file']);
$backup = createBackupForEditValidationTest($this->team);
$database = $backup->database;
$parameters = [
'project_uuid' => $database->project()->uuid,
'environment_uuid' => $database->environment->uuid,
'database_uuid' => $database->uuid,
'backup_uuid' => $backup->uuid,
];
$generalUrl = route('project.database.backup.execution', $parameters);
$this->get($generalUrl)
->assertOk()
->assertSee('General')
->assertSee('S3')
->assertSee('Retention')
->assertSee('Executions')
->assertSee('Danger Zone')
->assertSee('Frequency')
->assertDontSee('S3 Enabled')
->assertDontSee('Number of backups to keep')
->assertDontSee('Cleanup Failed Backups')
->assertDontSee('Delete Backups and Schedule');
$this->get($generalUrl.'/s3')
->assertOk()
->assertSeeInOrder(['S3 Storage', 'Disable Local Backup'])
->assertSee('S3 Storage')
->assertDontSee('S3 Storage Retention')
->assertDontSee('Local Backup Retention')
->assertDontSee('Frequency')
->assertDontSee('Cleanup Failed Backups');
$s3View = file_get_contents(resource_path('views/livewire/project/database/backup-edit/s3.blade.php'));
expect(strpos($s3View, '<span>S3 Storage</span>'))
->toBeLessThan(strpos($s3View, 'label="Disable Local Backup"'));
$this->get($generalUrl.'/retention')
->assertOk()
->assertSee('Local Backup Retention')
->assertSee('S3 Storage Retention')
->assertSee('Number of backups to keep')
->assertDontSee('Frequency')
->assertDontSee('Cleanup Failed Backups');
$this->get($generalUrl.'/executions')
->assertOk()
->assertSee('<h2 class="py-0">Executions</h2>', false)
->assertDontSee('Executions <span', false)
->assertSee('Cleanup Failed Backups')
->assertDontSee('Frequency')
->assertDontSee('Number of backups to keep');
$this->get($generalUrl.'/danger')
->assertOk()
->assertSee('Danger Zone')
->assertSee('Delete Scheduled Backup')
->assertSee('Delete Backups and Schedule')
->assertDontSee('Frequency')
->assertDontSee('Number of backups to keep')
->assertDontSee('Cleanup Failed Backups');
});
it('enables and disables a scheduled database backup from the title action', function () {
@@ -191,7 +316,7 @@ it('shows available S3 storages even when S3 backup is disabled', function () {
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, 'section' => 's3'])
->assertSee('First S3')
->assertSee('Second S3');
});
@@ -202,7 +327,7 @@ it('shows disabled S3 storage dropdown when no storages are available', function
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, 'section' => 's3'])
->assertSee('No S3 storage available');
});
@@ -215,19 +340,17 @@ it('allows S3 backups to be disabled when no usable storage remains', function (
$component = Livewire::test(BackupEdit::class, [
'backup' => $backup->fresh(),
'availableS3Storages' => collect(),
])->assertSet('saveS3', true);
preg_match('/<input\b(?=[^>]*wire:model=(?:"saveS3"|saveS3))[^>]*>/', $component->html(), $matches);
expect($matches[0] ?? null)->not->toBeNull()
->and(preg_match('/\sdisabled(?:\s|\/>)/', $matches[0]))->toBe(0);
$component->set('saveS3', false)->call('instantSave')->assertDispatched('success');
'section' => 's3',
])
->assertSet('saveS3', true)
->assertSee('Disable S3')
->call('toggleS3')
->assertDispatched('success')
->assertSee('Enable S3');
expect($backup->refresh()->save_s3)->toBeFalsy()
->and($backup->s3_storage_id)->toBeNull();
preg_match('/<input\b(?=[^>]*wire:model=(?:"saveS3"|saveS3))[^>]*>/', $component->html(), $matches);
expect(preg_match('/\sdisabled(?:\s|\/>)/', $matches[0]))->toBe(1);
});
it('shows when S3 backups are currently disabled', function () {
@@ -237,7 +360,7 @@ it('shows when S3 backups are currently disabled', function () {
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, 'section' => 's3'])
->assertSee('S3 Storage')
->assertSee('(currently disabled)');
});
+4 -5
View File
@@ -5,7 +5,6 @@ use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\LocalPersistentVolume;
use App\Models\Project;
use App\Models\ScheduledVolumeBackup;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
@@ -16,6 +15,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
config(['app.maintenance.driver' => 'file']);
InstanceSettings::forceCreate(['id' => 0]);
$this->team = Team::factory()->create();
@@ -30,13 +30,11 @@ it('renders frontend-only application backup search data for volume names and fr
$dailyVolume = createBackupSearchVolume($application, 'app-data');
$weeklyVolume = createBackupSearchVolume($application, 'Cache-Data');
ScheduledVolumeBackup::create([
'local_persistent_volume_id' => $dailyVolume->id,
$dailyVolume->scheduledBackups()->create([
'team_id' => $this->team->id,
'frequency' => 'daily',
]);
ScheduledVolumeBackup::create([
'local_persistent_volume_id' => $weeklyVolume->id,
$weeklyVolume->scheduledBackups()->create([
'team_id' => $this->team->id,
'frequency' => '0 4 * * 0',
]);
@@ -51,6 +49,7 @@ it('renders frontend-only application backup search data for volume names and fr
->assertOk()
->assertSee('Cache-Data')
->assertSee('Volume: app-data')
->assertSee("search: 'cache'", false)
->assertSee('x-model="search"', false)
->assertSee('x-show=', false)
->assertSee('No scheduled backups match your search.');
@@ -3,9 +3,10 @@
use App\Livewire\Project\Database\CreateScheduledBackup;
use App\Models\Environment;
use App\Models\Project;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceDatabase;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
@@ -15,124 +16,78 @@ use Livewire\Livewire;
uses(RefreshDatabase::class);
function createDatabaseForScheduledBackupTest(Team $team): StandalonePostgresql
{
$server = Server::factory()->create(['team_id' => $team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
return StandalonePostgresql::create([
'name' => 'pg-scheduled-backup-validation',
'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(),
]);
}
function createS3StorageForTeam(Team $team, string $name = 'Test S3'): S3Storage
{
return S3Storage::create([
'name' => $name,
'region' => 'us-east-1',
'key' => 'test-key',
'secret' => 'test-secret',
'bucket' => 'test-bucket',
'endpoint' => 'https://s3.example.com',
'is_usable' => true,
'team_id' => $team->id,
]);
}
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
it('rejects enabling S3 backup without a selected S3 storage', function () {
$database = createDatabaseForScheduledBackupTest($this->team);
Livewire::test(CreateScheduledBackup::class, ['database' => $database])
->set('frequency', '0 0 * * *')
->set('saveToS3', true)
->set('s3StorageId', null)
->call('submit')
->assertDispatched('error');
expect(ScheduledDatabaseBackup::count())->toBe(0);
});
it('rejects an S3 storage not owned by the current team', function () {
$database = createDatabaseForScheduledBackupTest($this->team);
$foreignS3 = createS3StorageForTeam(Team::factory()->create(), 'Foreign S3');
Livewire::test(CreateScheduledBackup::class, ['database' => $database])
->set('frequency', '0 0 * * *')
->set('saveToS3', true)
->set('s3StorageId', $foreignS3->id)
->call('submit')
->assertDispatched('error');
expect(ScheduledDatabaseBackup::count())->toBe(0);
});
it('rejects an S3 storage that is reassigned after the component is mounted', function () {
$database = createDatabaseForScheduledBackupTest($this->team);
$s3 = createS3StorageForTeam($this->team);
it('creates a standalone database backup without S3 and opens its configuration', function () {
$database = StandalonePostgresql::create([
'name' => 'postgres',
'image' => 'postgres:16-alpine',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'postgres',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$component = Livewire::test(CreateScheduledBackup::class, ['database' => $database])
->set('frequency', '0 0 * * *')
->set('saveToS3', true)
->set('s3StorageId', $s3->id);
->assertDontSee('Save to S3')
->set('frequency', 'daily')
->call('submit');
$s3->update(['team_id' => Team::factory()->create()->id]);
$backup = ScheduledDatabaseBackup::query()->sole();
$component
->call('submit')
->assertDispatched('error');
$component->assertRedirectToRoute('project.database.backup.execution', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'database_uuid' => $database->uuid,
'backup_uuid' => $backup->uuid,
]);
expect(ScheduledDatabaseBackup::count())->toBe(0);
expect($backup->save_s3)->toBeFalsy()
->and($backup->s3_storage_id)->toBeNull();
});
it('rejects an S3 storage that becomes unusable after the component is mounted', function () {
$database = createDatabaseForScheduledBackupTest($this->team);
$s3 = createS3StorageForTeam($this->team);
it('creates a service database backup without S3 and opens its configuration', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
$database = ServiceDatabase::create([
'service_id' => $service->id,
'name' => 'postgres',
'image' => 'postgres:16-alpine',
'custom_type' => 'postgresql',
]);
$component = Livewire::test(CreateScheduledBackup::class, ['database' => $database])
->set('frequency', '0 0 * * *')
->set('saveToS3', true)
->set('s3StorageId', $s3->id);
->assertDontSee('Save to S3')
->set('frequency', 'daily')
->call('submit');
$s3->update(['is_usable' => false]);
$backup = ScheduledDatabaseBackup::query()->sole();
$component
->call('submit')
->assertDispatched('error');
$component->assertRedirectToRoute('project.service.database.backup.show', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'service_uuid' => $service->uuid,
'stack_service_uuid' => $database->uuid,
'backup_uuid' => $backup->uuid,
]);
expect(ScheduledDatabaseBackup::count())->toBe(0);
});
it('creates a scheduled backup with a valid team-owned S3 storage', function () {
$database = createDatabaseForScheduledBackupTest($this->team);
$s3 = createS3StorageForTeam($this->team);
Livewire::test(CreateScheduledBackup::class, ['database' => $database])
->set('frequency', '0 0 * * *')
->set('saveToS3', true)
->set('s3StorageId', $s3->id)
->call('submit')
->assertDispatched('refreshScheduledBackups');
$backup = ScheduledDatabaseBackup::first();
expect($backup)->not->toBeNull();
expect($backup->save_s3)->toBeTruthy();
expect($backup->s3_storage_id)->toBe($s3->id);
expect($backup->save_s3)->toBeFalsy()
->and($backup->s3_storage_id)->toBeNull();
});
@@ -0,0 +1,26 @@
<?php
it('uses the simplified database backup execution heading and spacing', function () {
$view = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
expect($view)
->toContain('<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">')
->toContain('<h2 class="py-0">Executions</h2>')
->not->toContain('Executions <span')
->toContain('class="flex flex-col gap-4 pt-2"');
});
it('renders scheduled task executions as selectable status cards with expandable logs', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/scheduled-task/executions.blade.php'));
expect($view)
->toContain('<div class="flex flex-col gap-2" wire:poll.5000ms="refreshExecutions"')
->toContain('<a wire:click="selectTask({{ data_get($execution, \'id\') }})"')
->toContain('border-l-2 transition-colors p-4 cursor-pointer')
->toContain("'success' => 'Success'")
->toContain("'running' => 'In Progress'")
->toContain("'failed' => 'Failed'")
->toContain("data_get(\$execution, 'id') == \$selectedKey")
->toContain('max-h-[600px] overflow-y-auto')
->toContain('No executions found.');
});
+86 -2
View File
@@ -1,14 +1,19 @@
<?php
use App\Jobs\ServerStorageSaveJob;
use App\Livewire\Project\Service\FileStorage;
use App\Livewire\Project\Service\Storage;
use App\Livewire\Project\Shared\Storages\All;
use App\Livewire\Project\Shared\Storages\Show;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\Project;
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;
@@ -95,7 +100,8 @@ test('livewire file storage stores safe file mounts under the application config
->set('file_storage_path', '/etc/nginx/nginx.conf')
->set('file_storage_content', 'server {}')
->call('submitFileStorage')
->assertDispatched('success');
->assertDispatched('success')
->assertDispatched('configurationChanged');
$volume = LocalFileVolume::query()->sole();
@@ -109,7 +115,8 @@ test('livewire host file storage stores an existing host file path without manag
->set('host_file_storage_source', '/etc/nginx/nginx.conf')
->set('host_file_storage_destination', '/etc/nginx/nginx.conf')
->call('submitHostFileStorage')
->assertDispatched('success');
->assertDispatched('success')
->assertDispatched('configurationChanged');
$volume = LocalFileVolume::query()->sole();
@@ -121,3 +128,80 @@ test('livewire host file storage stores an existing host file path without manag
Bus::assertNotDispatched(ServerStorageSaveJob::class);
});
test('livewire volume storage refreshes the storage list and configuration warning', function () {
Livewire::test(Storage::class, ['resource' => $this->application])
->set('name', 'data')
->set('mount_path', '/app/data')
->call('submitPersistentVolume')
->assertDispatched('success')
->assertDispatched('refreshStorages')
->assertDispatched('configurationChanged');
});
test('volume storage list shows volumes added after it was mounted', function () {
$firstVolume = LocalPersistentVolume::create([
'name' => $this->application->uuid.'-first',
'mount_path' => '/app/first',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
]);
$storageList = Livewire::test(All::class, ['resource' => $this->application])
->assertSee($firstVolume->name);
$secondVolume = LocalPersistentVolume::create([
'name' => $this->application->uuid.'-second',
'mount_path' => '/app/second',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
]);
$storageList
->assertDontSee($secondVolume->name)
->dispatch('refreshStorages')
->assertSee($secondVolume->name);
});
test('deleting a file mount refreshes the configuration warning', function () {
$file = LocalFileVolume::create([
'fs_path' => '/etc/nginx/nginx.conf',
'mount_path' => '/etc/nginx/nginx.conf',
'is_host_file' => true,
'is_based_on_git' => false,
'is_preview_suffix_enabled' => true,
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
]);
Livewire::test(FileStorage::class, ['fileStorage' => $file])
->call('delete', 'password')
->assertDispatched('configurationChanged');
expect($file->fresh())->toBeNull();
});
test('deleting a volume mount refreshes the configuration warning', function () {
$database = StandalonePostgresql::create([
'name' => 'test-postgres',
'image' => 'postgres:15-alpine',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'postgres',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$volume = LocalPersistentVolume::create([
'name' => $database->uuid.'-data',
'mount_path' => '/var/lib/postgresql/data',
'resource_id' => $database->id,
'resource_type' => $database->getMorphClass(),
]);
Livewire::test(Show::class, ['storage' => $volume, 'resource' => $database])
->call('delete', 'password')
->assertDispatched('configurationChanged');
expect($volume->fresh())->toBeNull();
});
@@ -5,6 +5,7 @@ use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\LocalFileVolume;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
@@ -87,6 +88,30 @@ it('refreshes configuration changes when the event is received', function () {
->assertSee('Build command');
});
it('shows an unapplied configuration warning after a directory mount is added', function () {
$application = configurationCheckerApplication($this->environment);
markConfigurationCheckerApplicationDeployed($application);
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
->assertSet('isConfigurationChanged', false);
LocalFileVolume::withoutEvents(fn () => LocalFileVolume::forceCreate([
'uuid' => (string) Str::uuid(),
'fs_path' => application_configuration_dir().'/'.$application->uuid.'/data',
'mount_path' => '/app/data',
'is_directory' => true,
'resource_id' => $application->id,
'resource_type' => $application->getMorphClass(),
]));
$component
->dispatch('configurationChanged')
->assertSet('isConfigurationChanged', true)
->assertSee('The latest configuration has not been applied')
->assertSee('Directory mount')
->assertSee('Please redeploy to apply the new configuration.');
});
it('refreshes stale modal configuration diff before opening changes', function () {
$application = configurationCheckerApplication($this->environment);
markConfigurationCheckerApplicationDeployed($application);
@@ -5,6 +5,7 @@ use App\Livewire\Project\Service\Heading;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\ScheduledDatabaseBackup;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
@@ -139,3 +140,65 @@ test('owner can still hydrate service heading with own service', function () {
])
->assertOk();
});
test('service database backup schedules use dedicated general retention and executions urls', function () {
$backup = ScheduledDatabaseBackup::create([
'team_id' => $this->teamA->id,
'frequency' => 'daily',
'database_id' => $this->ownServiceDatabase->id,
'database_type' => $this->ownServiceDatabase->getMorphClass(),
'save_s3' => true,
]);
$listUrl = route('project.service.database.backups', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
'stack_service_uuid' => $this->ownServiceDatabase->uuid,
]);
$generalUrl = $listUrl.'/'.$backup->uuid;
$this->get($listUrl)
->assertOk()
->assertSee('href="'.$generalUrl.'"', false);
$this->get($generalUrl)
->assertOk()
->assertSee('Frequency')
->assertDontSee('S3 Enabled')
->assertDontSee('Number of backups to keep')
->assertDontSee('Cleanup Failed Backups')
->assertDontSee('Delete Backups and Schedule');
$this->get($generalUrl.'/s3')
->assertOk()
->assertSee('S3 Storage')
->assertDontSee('S3 Storage Retention')
->assertDontSee('Local Backup Retention')
->assertDontSee('Frequency')
->assertDontSee('Cleanup Failed Backups');
$this->get($generalUrl.'/retention')
->assertOk()
->assertSee('Local Backup Retention')
->assertSee('S3 Storage Retention')
->assertSee('Number of backups to keep')
->assertDontSee('Frequency')
->assertDontSee('Cleanup Failed Backups');
$this->get($generalUrl.'/executions')
->assertOk()
->assertSee('<h2 class="py-0">Executions</h2>', false)
->assertDontSee('Executions <span', false)
->assertSee('Cleanup Failed Backups')
->assertDontSee('Frequency')
->assertDontSee('Number of backups to keep');
$this->get($generalUrl.'/danger')
->assertOk()
->assertSee('Danger Zone')
->assertSee('Delete Scheduled Backup')
->assertSee('Delete Backups and Schedule')
->assertDontSee('Frequency')
->assertDontSee('Number of backups to keep')
->assertDontSee('Cleanup Failed Backups');
});
File diff suppressed because it is too large Load Diff
@@ -4,6 +4,8 @@ use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\Project;
use App\Models\Team;
use App\Services\DeploymentConfiguration\ConfigurationDiffer;
@@ -79,6 +81,35 @@ it('detects redeploy-only domain changes', function () {
->and($change['new_full_value'])->toBe($domains);
});
it('detects added storage mounts as redeploy-only changes', function (string $type) {
$application = snapshotTestApplication();
markSnapshotTestApplicationDeployed($application);
if ($type === 'volume') {
LocalPersistentVolume::create([
'name' => $application->uuid.'-data',
'mount_path' => '/app/data',
'resource_id' => $application->id,
'resource_type' => $application->getMorphClass(),
]);
} else {
LocalFileVolume::withoutEvents(fn () => LocalFileVolume::forceCreate([
'uuid' => (string) Str::uuid(),
'fs_path' => application_configuration_dir().'/'.$application->uuid.'/data',
'mount_path' => '/app/data',
'is_directory' => $type === 'directory',
'resource_id' => $application->id,
'resource_type' => $application->getMorphClass(),
]));
}
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
expect($diff->isChanged())->toBeTrue()
->and($diff->requiresBuild())->toBeFalse()
->and(collect($diff->changes())->pluck('section'))->toContain('storage');
})->with(['volume', 'directory', 'file']);
it('detects Docker image reference changes as redeploy-only changes', function (string $field, string $label, string $newValue) {
$application = snapshotTestApplication([
'build_pack' => 'dockerimage',