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

View file

@ -0,0 +1,314 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Application;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\S3Storage;
use App\Models\ScheduledVolumeBackup;
use App\Models\Service;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
#[OA\Schema(
schema: 'VolumeBackupScheduleRequest',
required: ['frequency'],
properties: [
new OA\Property(property: 'frequency', type: 'string', maxLength: 255, example: '0 2 * * *'),
new OA\Property(property: 'enabled', type: 'boolean', default: true),
new OA\Property(property: 'save_s3', type: 'boolean', default: false),
new OA\Property(property: 'disable_local_backup', type: 'boolean', default: false),
new OA\Property(property: 'stop_during_backup', type: 'boolean', default: false),
new OA\Property(property: 's3_storage_uuid', type: 'string', nullable: true),
new OA\Property(property: 'retention_amount_locally', type: 'integer', default: 7, minimum: 0, maximum: 10000),
new OA\Property(property: 'retention_days_locally', type: 'integer', default: 0, maximum: 2147483647, minimum: 0),
new OA\Property(property: 'retention_max_storage_locally', type: 'number', format: 'float', default: 0, maximum: 9999999999, minimum: 0),
new OA\Property(property: 'retention_amount_s3', type: 'integer', default: 7, minimum: 0, maximum: 10000),
new OA\Property(property: 'retention_days_s3', type: 'integer', default: 0, maximum: 2147483647, minimum: 0),
new OA\Property(property: 'retention_max_storage_s3', type: 'number', format: 'float', default: 0, maximum: 9999999999, minimum: 0),
new OA\Property(property: 'timeout', type: 'integer', default: 3600, minimum: 60, maximum: 36000),
],
type: 'object',
additionalProperties: false,
)]
#[OA\Schema(
schema: 'VolumeBackupScheduleResponse',
required: ['uuid', 'message', 'storage_uuid', 'storage_type', 'frequency', 'enabled', 'save_s3', 'disable_local_backup', 'stop_during_backup', 'retention_amount_locally', 'retention_days_locally', 'retention_max_storage_locally', 'retention_amount_s3', 'retention_days_s3', 'retention_max_storage_s3', 'timeout'],
properties: [
new OA\Property(property: 'uuid', type: 'string'),
new OA\Property(property: 'message', type: 'string'),
new OA\Property(property: 'storage_uuid', type: 'string'),
new OA\Property(property: 'storage_type', type: 'string', enum: ['persistent', 'directory']),
new OA\Property(property: 'frequency', type: 'string'),
new OA\Property(property: 'enabled', type: 'boolean'),
new OA\Property(property: 'save_s3', type: 'boolean'),
new OA\Property(property: 'disable_local_backup', type: 'boolean'),
new OA\Property(property: 'stop_during_backup', type: 'boolean'),
new OA\Property(property: 's3_storage_uuid', type: 'string', nullable: true),
new OA\Property(property: 'retention_amount_locally', type: 'integer'),
new OA\Property(property: 'retention_days_locally', type: 'integer'),
new OA\Property(property: 'retention_max_storage_locally', type: 'number', format: 'float'),
new OA\Property(property: 'retention_amount_s3', type: 'integer'),
new OA\Property(property: 'retention_days_s3', type: 'integer'),
new OA\Property(property: 'retention_max_storage_s3', type: 'number', format: 'float'),
new OA\Property(property: 'timeout', type: 'integer'),
],
type: 'object',
)]
class VolumeBackupsController extends Controller
{
#[OA\Put(
summary: 'Set application storage backup schedule',
description: 'Create or replace the backup schedule for an application persistent volume or directory storage.',
path: '/applications/{uuid}/storages/{storage_uuid}/backups',
operationId: 'set-application-storage-backup-schedule',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleRequest')),
tags: ['Applications'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the application.', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'storage_uuid', in: 'path', required: true, description: 'UUID of the persistent volume or directory storage.', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(response: 200, description: 'Backup schedule replaced.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')),
new OA\Response(response: 201, description: 'Backup schedule created.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, description: 'Forbidden.'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
],
)]
#[OA\Put(
summary: 'Set database storage backup schedule',
description: 'Create or replace the backup schedule for a database persistent volume or directory storage.',
path: '/databases/{uuid}/storages/{storage_uuid}/backups',
operationId: 'set-database-storage-backup-schedule',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleRequest')),
tags: ['Databases'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the database.', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'storage_uuid', in: 'path', required: true, description: 'UUID of the persistent volume or directory storage.', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(response: 200, description: 'Backup schedule replaced.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')),
new OA\Response(response: 201, description: 'Backup schedule created.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, description: 'Forbidden.'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
],
)]
#[OA\Put(
summary: 'Set service storage backup schedule',
description: 'Create or replace the backup schedule for a service persistent volume or directory storage.',
path: '/services/{uuid}/storages/{storage_uuid}/backups',
operationId: 'set-service-storage-backup-schedule',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleRequest')),
tags: ['Services'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the service.', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'storage_uuid', in: 'path', required: true, description: 'UUID of the persistent volume or directory storage.', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(response: 200, description: 'Backup schedule replaced.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')),
new OA\Response(response: 201, description: 'Backup schedule created.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, description: 'Forbidden.'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
],
)]
public function upsert(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$invalidRequest = validateIncomingRequest($request);
if ($invalidRequest instanceof JsonResponse) {
return $invalidRequest;
}
$resourceType = $request->route('resource_type');
$resource = $this->findResource($resourceType, $request->route('uuid'), $teamId);
if (! $resource) {
return response()->json([
'message' => match ($resourceType) {
'application' => 'Application not found.',
'database' => 'Database not found.',
'service' => 'Service not found.',
default => 'Resource not found.',
},
], 404);
}
$this->authorize('update', $resource);
$storage = $this->findStorage($resource, $request->route('storage_uuid'));
if (! $storage) {
return response()->json(['message' => 'Storage not found.'], 404);
}
$validator = customApiValidator($request->all(), [
'frequency' => 'required|string|max:255',
'enabled' => 'boolean',
'save_s3' => 'boolean',
'disable_local_backup' => 'boolean',
'stop_during_backup' => 'boolean',
's3_storage_uuid' => 'nullable|string',
'retention_amount_locally' => 'integer|min:0|max:10000',
'retention_days_locally' => 'integer|min:0|max:2147483647',
'retention_max_storage_locally' => 'numeric|min:0|max:9999999999',
'retention_amount_s3' => 'integer|min:0|max:10000',
'retention_days_s3' => 'integer|min:0|max:2147483647',
'retention_max_storage_s3' => 'numeric|min:0|max:9999999999',
'timeout' => 'integer|min:60|max:36000',
]);
$errors = $validator->errors();
$allowedFields = [
'frequency',
'enabled',
'save_s3',
'disable_local_backup',
'stop_during_backup',
's3_storage_uuid',
'retention_amount_locally',
'retention_days_locally',
'retention_max_storage_locally',
'retention_amount_s3',
'retention_days_s3',
'retention_max_storage_s3',
'timeout',
];
foreach (array_diff(array_keys($request->all()), $allowedFields) as $field) {
$errors->add($field, 'This field is not allowed.');
}
if (! $errors->has('frequency') && ! validate_cron_expression($request->string('frequency')->toString())) {
$errors->add('frequency', 'The frequency must be a valid cron or human expression.');
}
$saveToS3 = $request->boolean('save_s3');
if ($request->boolean('disable_local_backup') && ! $saveToS3) {
$errors->add('disable_local_backup', 'Local backups can only be disabled when S3 backups are enabled.');
}
$s3Storage = null;
if ($saveToS3) {
$s3Storage = S3Storage::query()
->where('team_id', $teamId)
->where('is_usable', true)
->where('uuid', $request->input('s3_storage_uuid'))
->first();
if (! $s3Storage) {
$errors->add('s3_storage_uuid', 'Select a usable S3 storage owned by your team.');
}
}
if ($storage instanceof LocalFileVolume && (! $storage->is_directory || $storage->is_host_file)) {
$errors->add('storage_uuid', 'Only directory file storages can be backed up.');
}
if ($errors->isNotEmpty()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$backup = $storage->scheduledBackups()->updateOrCreate([], [
'team_id' => $teamId,
'frequency' => $request->string('frequency')->toString(),
'enabled' => $request->boolean('enabled', true),
'save_s3' => $saveToS3,
'disable_local_backup' => $saveToS3 && $request->boolean('disable_local_backup'),
'stop_during_backup' => $request->boolean('stop_during_backup'),
's3_storage_id' => $s3Storage?->id,
'retention_amount_locally' => $request->integer('retention_amount_locally', 7),
'retention_days_locally' => $request->integer('retention_days_locally'),
'retention_max_storage_locally' => $request->float('retention_max_storage_locally'),
'retention_amount_s3' => $request->integer('retention_amount_s3', 7),
'retention_days_s3' => $request->integer('retention_days_s3'),
'retention_max_storage_s3' => $request->float('retention_max_storage_s3'),
'timeout' => $request->integer('timeout', 3600),
]);
$created = $backup->wasRecentlyCreated;
auditLog('api.volume_backup.schedule_set', [
'team_id' => $teamId,
'resource_type' => $resourceType,
'resource_uuid' => $resource->uuid,
'storage_uuid' => $storage->uuid,
'backup_uuid' => $backup->uuid,
]);
return response()->json($this->responseData($backup, $storage, $s3Storage, $created), $created ? 201 : 200);
}
private function findResource(string $resourceType, string $uuid, int|string $teamId): ?Model
{
return match ($resourceType) {
'application' => Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first(),
'database' => queryDatabaseByUuidWithinTeam($uuid, $teamId),
'service' => Service::query()->whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $uuid)->first(),
default => null,
};
}
private function findStorage(Model $resource, string $storageUuid): LocalPersistentVolume|LocalFileVolume|null
{
if ($resource instanceof Service) {
foreach ($resource->applications->concat($resource->databases) as $serviceResource) {
$storage = $this->findStorage($serviceResource, $storageUuid);
if ($storage) {
return $storage;
}
}
return null;
}
$storage = $resource->persistentStorages()->where('uuid', $storageUuid)->first();
return $storage ?? $resource->fileStorages()->where('uuid', $storageUuid)->first();
}
private function responseData(
ScheduledVolumeBackup $backup,
LocalPersistentVolume|LocalFileVolume $storage,
?S3Storage $s3Storage,
bool $created,
): array {
return [
'uuid' => $backup->uuid,
'message' => $created ? 'Storage backup schedule created.' : 'Storage backup schedule updated.',
'storage_uuid' => $storage->uuid,
'storage_type' => $storage instanceof LocalFileVolume ? 'directory' : 'persistent',
'frequency' => $backup->frequency,
'enabled' => $backup->enabled,
'save_s3' => $backup->save_s3,
'disable_local_backup' => $backup->disable_local_backup,
'stop_during_backup' => $backup->stop_during_backup,
's3_storage_uuid' => $s3Storage?->uuid,
'retention_amount_locally' => $backup->retention_amount_locally,
'retention_days_locally' => $backup->retention_days_locally,
'retention_max_storage_locally' => $backup->retention_max_storage_locally,
'retention_amount_s3' => $backup->retention_amount_s3,
'retention_days_s3' => $backup->retention_days_s3,
'retention_max_storage_s3' => $backup->retention_max_storage_s3,
'timeout' => $backup->timeout,
];
}
}

View file

@ -356,7 +356,7 @@ private function processScheduledTask(ScheduledTask $task, ?Server $precheckedSe
private function processScheduledVolumeBackups(): void
{
ScheduledVolumeBackup::query()
->with(['volume', 'team.subscription'])
->with(['backupable', 'team.subscription'])
->where('enabled', true)
->chunkById(self::CHUNK_SIZE, function ($backups): void {
foreach ($backups as $backup) {
@ -397,7 +397,7 @@ private function processScheduledVolumeBackup(ScheduledVolumeBackup $backup): vo
return;
}
if (! $backup->volume || ! $server) {
if (! $backup->backupable || ! $server) {
$backup->delete();
$this->skippedCount++;
$this->logSkip('volume_backup', 'resource_deleted', [
@ -435,7 +435,8 @@ private function processScheduledVolumeBackup(ScheduledVolumeBackup $backup): vo
$this->dispatchedCount++;
Log::channel('scheduled')->info('Volume backup dispatched', [
'backup_id' => $backup->id,
'volume_id' => $backup->local_persistent_volume_id,
'backupable_type' => $backup->backupable_type,
'backupable_id' => $backup->backupable_id,
'team_id' => $backup->team_id,
'server_id' => $server->id,
]);

View file

@ -3,6 +3,7 @@
namespace App\Jobs;
use App\Events\BackupCreated;
use App\Models\LocalPersistentVolume;
use App\Models\ScheduledVolumeBackup;
use App\Models\ScheduledVolumeBackupExecution;
use App\Models\Server;
@ -53,30 +54,30 @@ public static function lockKey(int $backupId): string
public function handle(): void
{
$this->backup->loadMissing(['volume.resource', 'team', 's3']);
$this->backup->loadMissing(['backupable.resource', 'team', 's3']);
$server = $this->backup->server();
$volume = $this->backup->volume;
$target = $this->backup->backupable;
$team = $this->backup->team;
if (! $server || ! $volume || ! $team) {
throw new \RuntimeException('The volume backup resource, team, or server no longer exists.');
if (! $server || ! $target || ! $team) {
throw new \RuntimeException('The storage backup resource, team, or server no longer exists.');
}
$this->execution = $this->backup->executions()->create();
BackupCreated::dispatch($team->id);
$backupDirectory = backup_dir().'/volumes/'.str($team->name)->slug().'-'.$team->id.'/'.$volume->uuid;
$filename = 'volume-'.str($volume->name)->slug().'-'.Carbon::now()->timestamp.'.tar.gz';
$backupDirectory = backup_dir().'/volumes/'.str($team->name)->slug().'-'.$team->id.'/'.$target->uuid;
$filename = str($this->backup->targetType())->lower().'-'.str($this->backup->targetName())->slug().'-'.Carbon::now()->timestamp.'.tar.gz';
$backupLocation = $backupDirectory.'/'.$filename;
$this->execution->update(['filename' => $backupLocation]);
try {
$source = filled($volume->host_path) ? $volume->host_path : $volume->name;
$source = $this->backup->sourcePath();
$containerName = 'volume-backup-'.$this->execution->uuid;
$image = coolifyHelperImage().':'.getHelperVersion();
$verifySourceCommand = filled($volume->host_path)
? 'test -d '.escapeshellarg($source)
: 'docker volume inspect '.escapeshellarg($source).' >/dev/null';
$verifySourceCommand = $target instanceof LocalPersistentVolume && blank($target->host_path)
? 'docker volume inspect '.escapeshellarg($source).' >/dev/null'
: 'test -d '.escapeshellarg($source);
$archiveCommand = 'docker run --rm --name '.escapeshellarg($containerName)
.' -v '.escapeshellarg($source.':/volume:ro')
@ -112,7 +113,7 @@ public function handle(): void
);
if ($size <= 0) {
throw new \RuntimeException('The volume backup archive is empty or was not created.');
throw new \RuntimeException('The storage backup archive is empty or was not created.');
}
$warning = null;

View file

@ -46,7 +46,7 @@ public function handle(): void
public static function recover(ScheduledVolumeBackupExecution $execution): void
{
$execution->loadMissing('scheduledVolumeBackup.volume.resource');
$execution->loadMissing('scheduledVolumeBackup.backupable.resource');
if ($execution->stop_recovery_pending) {
self::recoverContainers($execution);

View file

@ -3,8 +3,8 @@
namespace App\Livewire\Project\Application\Backup;
use App\Models\Application;
use App\Models\S3Storage;
use App\Models\ScheduledVolumeBackup;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Attributes\Locked;
@ -17,44 +17,52 @@ class Create extends Component
#[Locked]
public Application $application;
public ?int $selectedVolumeId = null;
public ?string $selectedTargetKey = null;
public ?int $volumeId = null;
public ?string $targetKey = null;
public bool $volumeLocked = false;
public bool $targetLocked = false;
public string $frequency = 'daily';
public bool $saveToS3 = false;
public ?int $s3StorageId = null;
public Collection $volumes;
public Collection $definedS3s;
public Collection $targets;
protected function rules(): array
{
return [
'volumeId' => ['required', 'integer'],
'targetKey' => ['required', 'string', 'regex:/^(volume|directory):[1-9][0-9]*$/'],
'frequency' => ['required', 'string'],
'saveToS3' => ['required', 'boolean'],
's3StorageId' => ['nullable', 'integer'],
];
}
public function mount(): void
{
$this->authorize('view', $this->application);
$this->volumes = $this->application->persistentStorages()->orderBy('name')->get();
$this->definedS3s = S3Storage::ownedByCurrentTeam()->where('is_usable', true)->get();
$this->s3StorageId = $this->definedS3s->first()?->id;
$this->volumeLocked = $this->selectedVolumeId !== null;
$this->volumeId = $this->selectedVolumeId ?? $this->volumes->first()?->id;
$volumes = $this->application->persistentStorages()
->orderBy('name')
->get()
->map(fn (LocalPersistentVolume $volume): array => [
'key' => 'volume:'.$volume->id,
'type' => 'Volume',
'name' => $volume->name,
]);
$directories = $this->application->fileStorages()
->where('is_directory', true)
->where('is_host_file', false)
->orderBy('fs_path')
->get()
->map(fn (LocalFileVolume $directory): array => [
'key' => 'directory:'.$directory->id,
'type' => 'Directory',
'name' => $directory->fs_path,
]);
$this->targets = $volumes->concat($directories)->values();
$this->targetLocked = $this->selectedTargetKey !== null;
$this->targetKey = $this->selectedTargetKey ?? data_get($this->targets->first(), 'key');
$this->loadSelectedBackup();
}
public function updatedVolumeId(): void
public function updatedTargetKey(): void
{
$this->loadSelectedBackup();
}
@ -64,9 +72,9 @@ public function submit(): void
$this->authorize('update', $this->application);
$this->validate();
$volume = $this->application->persistentStorages()->whereKey($this->volumeId)->first();
if (! $volume) {
$this->addError('volumeId', 'Select a volume owned by this application.');
$target = $this->selectedTarget();
if (! $target) {
$this->addError('targetKey', 'Select a volume or directory owned by this application.');
return;
}
@ -77,26 +85,22 @@ public function submit(): void
return;
}
if ($this->saveToS3 && ! $this->validS3StorageExists()) {
$this->addError('s3StorageId', 'Select a usable S3 storage owned by your team.');
return;
}
$backup = ScheduledVolumeBackup::query()->updateOrCreate(
['local_persistent_volume_id' => $volume->id],
$backup = $target->scheduledBackups()->updateOrCreate(
[],
[
'team_id' => currentTeam()->id,
'frequency' => $this->frequency,
'enabled' => true,
'save_s3' => $this->saveToS3,
's3_storage_id' => $this->saveToS3 ? $this->s3StorageId : null,
],
);
$this->dispatch('success', $backup->wasRecentlyCreated ? 'Scheduled volume backup created.' : 'Scheduled volume backup updated.');
$this->dispatch('refreshVolumeBackups');
$this->dispatch('close-modal');
$this->dispatch('success', $backup->wasRecentlyCreated ? 'Scheduled storage backup created.' : 'Scheduled storage backup updated.');
$this->redirectRoute('project.application.backup.show', [
'project_uuid' => $this->application->project()->uuid,
'environment_uuid' => $this->application->environment->uuid,
'application_uuid' => $this->application->uuid,
'backup_uuid' => $backup->uuid,
], navigate: true);
}
public function render()
@ -106,25 +110,32 @@ public function render()
private function loadSelectedBackup(): void
{
$volume = $this->volumes->firstWhere('id', $this->volumeId);
if (! $volume) {
$target = $this->selectedTarget();
if (! $target) {
return;
}
$backup = $volume->scheduledBackups()->first();
$backup = $target->scheduledBackups()->first();
if ($backup) {
$this->frequency = $backup->frequency;
$this->saveToS3 = $backup->save_s3;
$this->s3StorageId = $backup->s3_storage_id ?? $this->definedS3s->first()?->id;
}
}
private function validS3StorageExists(): bool
private function selectedTarget(): LocalPersistentVolume|LocalFileVolume|null
{
return $this->s3StorageId !== null
&& S3Storage::ownedByCurrentTeam()
->where('is_usable', true)
->whereKey($this->s3StorageId)
->exists();
[$type, $id] = array_pad(explode(':', (string) $this->targetKey, 2), 2, null);
if (! ctype_digit((string) $id)) {
return null;
}
return match ($type) {
'volume' => $this->application->persistentStorages()->whereKey((int) $id)->first(),
'directory' => $this->application->fileStorages()
->whereKey((int) $id)
->where('is_directory', true)
->where('is_host_file', false)
->first(),
default => null,
};
}
}

View file

@ -16,6 +16,8 @@ class Index extends Component
public array $parameters;
public string $search = '';
protected $listeners = ['refreshVolumeBackups' => '$refresh'];
public function mount(): void
@ -23,15 +25,15 @@ public function mount(): void
$this->application = $this->findApplication();
$this->authorize('view', $this->application);
$this->parameters = get_route_parameters();
$this->search = request()->string('search')->toString();
}
public function render(): View
{
$volumeIds = $this->application->persistentStorages()->pluck('id');
$backups = ScheduledVolumeBackup::query()
->with(['volume', 'latestExecution'])
->with(['backupable', 'latestExecution'])
->withCount('executions')
->whereIn('local_persistent_volume_id', $volumeIds)
->forApplication($this->application)
->latest()
->get();

View file

@ -17,6 +17,8 @@ class Show extends Component
public array $parameters;
public string $section = 'general';
public function mount(): void
{
$project = currentTeam()->projects()
@ -32,11 +34,18 @@ public function mount(): void
$this->authorize('view', $this->application);
$this->backup = ScheduledVolumeBackup::query()
->with('volume')
->with('backupable')
->where('uuid', request()->route('backup_uuid'))
->whereIn('local_persistent_volume_id', $this->application->persistentStorages()->pluck('id'))
->forApplication($this->application)
->firstOrFail();
$this->parameters = get_route_parameters();
$this->section = match (request()->route()?->getName()) {
'project.application.backup.s3' => 's3',
'project.application.backup.retention' => 'retention',
'project.application.backup.executions' => 'executions',
'project.application.backup.danger' => 'danger',
default => 'general',
};
}
public function render()

View file

@ -15,6 +15,10 @@ class Execution extends Component
public $s3s;
public array $parameters = [];
public string $section = 'general';
public function mount()
{
$backup_uuid = request()->route('backup_uuid');
@ -39,6 +43,14 @@ public function mount()
$this->backup = $backup;
$this->executions = $executions;
$this->s3s = currentTeam()->s3s;
$this->parameters = get_route_parameters();
$this->section = match (request()->route()?->getName()) {
'project.database.backup.s3' => 's3',
'project.database.backup.retention' => 'retention',
'project.database.backup.executions' => 'executions',
'project.database.backup.danger' => 'danger',
default => 'general',
};
}
public function render()

View file

@ -19,6 +19,8 @@ class BackupEdit extends Component
public ScheduledDatabaseBackup $backup;
public string $section = 'general';
#[Locked]
public $availableS3Storages;
@ -184,7 +186,11 @@ public function delete($password, $selectedActions = [])
'stack_service_uuid' => $serviceDatabase->uuid,
]);
} else {
return redirect()->route('project.database.backup.index', $this->parameters);
return redirect()->route('project.database.backup.index', [
'project_uuid' => $this->parameters['project_uuid'],
'environment_uuid' => $this->parameters['environment_uuid'],
'database_uuid' => $this->parameters['database_uuid'],
]);
}
} catch (Exception $e) {
$this->dispatch('error', 'Failed to delete backup: '.$e->getMessage());
@ -235,6 +241,19 @@ public function updatedS3StorageId(): void
$this->instantSave();
}
public function toggleS3(): void
{
if (! $this->saveS3 && $this->availableS3StorageIds()->isEmpty()) {
$this->dispatch('error', 'Select a usable S3 storage before enabling S3 backups.');
return;
}
$this->saveS3 = ! $this->saveS3;
$this->disableLocalBackup = $this->saveS3 && $this->disableLocalBackup;
$this->instantSave();
}
private function customValidate()
{
if (! is_numeric($this->backup->s3_storage_id)) {

View file

@ -2,11 +2,9 @@
namespace App\Livewire\Project\Database;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
use Livewire\Component;
@ -18,31 +16,11 @@ class CreateScheduledBackup extends Component
#[Validate(['required', 'string'])]
public $frequency;
#[Validate(['required', 'boolean'])]
public bool $saveToS3 = false;
#[Locked]
public $database;
public bool $enabled = true;
#[Validate(['nullable', 'integer'])]
public ?int $s3StorageId = null;
public Collection $definedS3s;
public function mount()
{
try {
$this->definedS3s = currentTeam()->s3s;
if ($this->definedS3s->count() > 0) {
$this->s3StorageId = $this->definedS3s->first()->id;
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function submit()
{
try {
@ -50,20 +28,6 @@ public function submit()
$this->validate();
if ($this->saveToS3) {
$s3StorageExists = ! is_null($this->s3StorageId)
&& S3Storage::where('team_id', currentTeam()->id)
->where('is_usable', true)
->whereKey($this->s3StorageId)
->exists();
if (! $s3StorageExists) {
$this->dispatch('error', 'Please select a valid S3 storage to enable S3 backups.');
return;
}
}
$isValid = validate_cron_expression($this->frequency);
if (! $isValid) {
$this->dispatch('error', 'Invalid Cron / Human expression.');
@ -74,8 +38,8 @@ public function submit()
$payload = [
'enabled' => true,
'frequency' => $this->frequency,
'save_s3' => $this->saveToS3,
's3_storage_id' => $this->s3StorageId,
'save_s3' => false,
's3_storage_id' => null,
'database_id' => $this->database->id,
'database_type' => $this->database->getMorphClass(),
'team_id' => currentTeam()->id,
@ -91,11 +55,22 @@ public function submit()
$databaseBackup = ScheduledDatabaseBackup::create($payload);
if ($this->database->getMorphClass() === ServiceDatabase::class) {
$this->dispatch('refreshScheduledBackups', $databaseBackup->id);
$service = $this->database->service;
$this->redirectRoute('project.service.database.backup.show', [
'project_uuid' => $service->project()->uuid,
'environment_uuid' => $service->environment->uuid,
'service_uuid' => $service->uuid,
'stack_service_uuid' => $this->database->uuid,
'backup_uuid' => $databaseBackup->uuid,
], navigate: true);
} else {
$this->dispatch('refreshScheduledBackups');
$this->redirectRoute('project.database.backup.execution', [
'project_uuid' => $this->database->project()->uuid,
'environment_uuid' => $this->database->environment->uuid,
'database_uuid' => $this->database->uuid,
'backup_uuid' => $databaseBackup->uuid,
], navigate: true);
}
} catch (\Throwable $e) {
return handleError($e, $this);
} finally {

View file

@ -2,7 +2,6 @@
namespace App\Livewire\Project\Database;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@ -17,42 +16,18 @@ class ScheduledBackups extends Component
public $type;
public ?ScheduledDatabaseBackup $selectedBackup;
public $selectedBackupId;
public $s3s;
public string $custom_type = 'mysql';
protected $listeners = ['refreshScheduledBackups'];
protected $queryString = ['selectedBackupId'];
public function mount(): void
{
if ($this->selectedBackupId) {
$this->setSelectedBackup($this->selectedBackupId, true);
}
$this->parameters = get_route_parameters();
if ($this->database->getMorphClass() === ServiceDatabase::class) {
$this->type = 'service-database';
} else {
$this->type = 'database';
}
$this->s3s = currentTeam()->s3s;
}
public function setSelectedBackup($backupId, $force = false)
{
if ($this->selectedBackupId === $backupId && ! $force) {
return;
}
$this->selectedBackupId = $backupId;
$this->selectedBackup = $this->database->scheduledBackups->find($backupId);
if (is_null($this->selectedBackup)) {
$this->selectedBackupId = null;
}
}
public function setCustomType()
@ -86,9 +61,6 @@ public function delete($scheduled_backup_id): void
public function refreshScheduledBackups(?int $id = null): void
{
$this->database->refresh();
if ($id) {
$this->setSelectedBackup($id);
}
$this->dispatch('refreshScheduledBackups');
}
}

View file

@ -2,6 +2,7 @@
namespace App\Livewire\Project\Service;
use App\Models\ScheduledDatabaseBackup;
use App\Models\Service;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@ -17,16 +18,28 @@ class DatabaseBackups extends Component
public array $parameters;
public array $backupParameters = [];
public array $query;
public bool $isImportSupported = false;
public ?ScheduledDatabaseBackup $backup = null;
public string $section = 'index';
public $s3s;
protected $listeners = ['refreshScheduledBackups' => '$refresh'];
public function mount()
{
try {
$this->parameters = get_route_parameters();
$this->parameters = array_filter(
get_route_parameters(),
fn (string $key): bool => $key !== 'backup_uuid',
ARRAY_FILTER_USE_KEY,
);
$this->query = request()->query();
$project = currentTeam()
->projects()
@ -58,6 +71,21 @@ public function mount()
$dbType = $this->serviceDatabase->databaseType();
$supportedTypes = ['mysql', 'mariadb', 'postgres', 'mongo'];
$this->isImportSupported = collect($supportedTypes)->contains(fn ($type) => str_contains($dbType, $type));
if (request()->route('backup_uuid')) {
$this->backup = $this->serviceDatabase->scheduledBackups()
->where('uuid', request()->route('backup_uuid'))
->firstOrFail();
$this->s3s = currentTeam()->s3s;
$this->backupParameters = [...$this->parameters, 'backup_uuid' => $this->backup->uuid];
$this->section = match (request()->route()?->getName()) {
'project.service.database.backup.s3' => 's3',
'project.service.database.backup.retention' => 'retention',
'project.service.database.backup.executions' => 'executions',
'project.service.database.backup.danger' => 'danger',
default => 'general',
};
}
} catch (\Throwable $e) {
return handleError($e, $this);
}

View file

@ -4,6 +4,7 @@
use App\Models\Application;
use App\Models\LocalFileVolume;
use App\Models\ScheduledVolumeBackup;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\StandaloneClickhouse;
@ -15,6 +16,7 @@
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\On;
use Livewire\Attributes\Validate;
use Livewire\Component;
@ -34,6 +36,10 @@ class FileStorage extends Component
public bool $isReadOnly = false;
public bool $hasEnabledBackup = false;
public ?string $backupUrl = null;
#[Validate(['nullable'])]
public ?string $content = null;
@ -65,6 +71,36 @@ public function mount()
$this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI() || $this->fileStorage->is_too_large;
$this->syncData();
$this->refreshBackupStatus();
}
#[On('refreshVolumeBackups')]
public function refreshBackupStatus(): void
{
$backup = $this->fileStorage->is_directory
? $this->fileStorage->scheduledBackups()->first()
: null;
$this->hasEnabledBackup = $backup?->enabled ?? false;
$this->backupUrl = null;
if (! $this->hasEnabledBackup || ! $this->resource instanceof Application) {
return;
}
$parameters = [
'project_uuid' => $this->resource->project()->uuid,
'environment_uuid' => $this->resource->environment->uuid,
'application_uuid' => $this->resource->uuid,
];
$hasOtherBackups = ScheduledVolumeBackup::query()
->forApplication($this->resource)
->where('id', '!=', $backup->id)
->exists();
$this->backupUrl = $hasOtherBackups
? route('project.application.backup.index', [...$parameters, 'search' => $this->fileStorage->fs_path])
: route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]);
}
public function syncData(bool $toModel = false): void
@ -135,6 +171,10 @@ public function convertToFile()
try {
$this->authorize('update', $this->resource);
if ($this->fileStorage->scheduledBackups()->exists()) {
throw new \RuntimeException('Delete this directory backup schedule and its archives before converting it to a file.');
}
if ($this->fileStorage->is_host_file) {
throw new \Exception('Host file mounts are bind-only and cannot be converted.');
}
@ -162,6 +202,12 @@ public function delete($password, $selectedActions = [])
return 'The provided password is incorrect.';
}
if ($this->fileStorage->scheduledBackups()->exists()) {
$this->dispatch('error', 'Delete this directory backup schedule and its archives before deleting the directory.');
return false;
}
try {
$message = 'File deleted.';
if ($this->fileStorage->is_directory) {
@ -174,6 +220,7 @@ public function delete($password, $selectedActions = [])
$this->fileStorage->deleteStorageOnServer();
}
$this->fileStorage->delete();
$this->dispatch('configurationChanged');
$this->dispatch('success', $message);
} catch (\Throwable $e) {
return handleError($e, $this);

View file

@ -131,10 +131,12 @@ public function submitPersistentVolume()
'resource_type' => $this->resource->getMorphClass(),
]);
$this->resource->refresh();
$this->dispatch('configurationChanged');
$this->dispatch('success', 'Volume added successfully');
$this->dispatch('closeStorageModal', 'volume');
$this->clearForm();
$this->refreshStorages();
$this->dispatch('refreshStorages');
} catch (\Throwable $e) {
return handleError($e, $this);
}
@ -163,6 +165,7 @@ public function submitFileStorage()
'resource_type' => get_class($this->resource),
]);
$this->dispatch('configurationChanged');
$this->dispatch('success', 'File mount added successfully');
$this->dispatch('closeStorageModal', 'file');
$this->clearForm();
@ -195,6 +198,7 @@ public function submitHostFileStorage()
'resource_type' => get_class($this->resource),
]);
$this->dispatch('configurationChanged');
$this->dispatch('success', 'Host file mount added successfully');
$this->dispatch('closeStorageModal', 'host-file');
$this->clearForm();
@ -231,6 +235,7 @@ public function submitFileStorageDirectory()
'resource_type' => get_class($this->resource),
]);
$this->dispatch('configurationChanged');
$this->dispatch('success', 'Directory mount added successfully');
$this->dispatch('closeStorageModal', 'directory');
$this->clearForm();

View file

@ -2,7 +2,9 @@
namespace App\Livewire\Project\Shared\Storages;
use App\Models\Application;
use App\Models\LocalPersistentVolume;
use App\Models\ScheduledVolumeBackup;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\On;
@ -35,6 +37,8 @@ class Show extends Component
public bool $hasEnabledBackup = false;
public ?string $backupUrl = null;
protected $validationAttributes = [
'name' => 'name',
'mountPath' => 'mount',
@ -94,9 +98,28 @@ public function mount(): void
#[On('refreshVolumeBackups')]
public function refreshBackupStatus(): void
{
$this->hasEnabledBackup = $this->storage->scheduledBackups()
->where('enabled', true)
$backup = $this->storage->scheduledBackups()->first();
$this->hasEnabledBackup = $backup?->enabled ?? false;
$this->backupUrl = null;
if (! $this->hasEnabledBackup || ! $this->resource instanceof Application) {
return;
}
$parameters = [
'project_uuid' => $this->resource->project()->uuid,
'environment_uuid' => $this->resource->environment->uuid,
'application_uuid' => $this->resource->uuid,
];
$hasOtherBackups = ScheduledVolumeBackup::query()
->forApplication($this->resource)
->where('id', '!=', $backup->id)
->exists();
$this->backupUrl = $hasOtherBackups
? route('project.application.backup.index', [...$parameters, 'search' => $this->storage->name])
: route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]);
}
public function instantSave(): void
@ -135,6 +158,7 @@ public function delete($password, $selectedActions = [])
$this->storage->delete();
$this->dispatch('refreshStorages');
$this->dispatch('configurationChanged');
return true;
}

View file

@ -3,6 +3,7 @@
namespace App\Livewire\Project\Shared\Storages;
use App\Jobs\VolumeBackupJob;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\S3Storage;
use App\Models\ScheduledVolumeBackup;
@ -18,12 +19,14 @@ class VolumeBackups extends Component
use AuthorizesRequests;
use WithPagination;
public LocalPersistentVolume $storage;
public LocalPersistentVolume|LocalFileVolume $storage;
public $resource;
public ?ScheduledVolumeBackup $backup = null;
public string $section = 'general';
public string $frequency = 'daily';
public bool $enabled = false;
@ -113,7 +116,7 @@ public function save(): void
}
$this->backup = $this->persistBackup($this->enabled);
$this->dispatch('success', 'Volume backup schedule saved.');
$this->dispatch('success', 'Storage backup schedule saved.');
}
public function instantSave(): void
@ -123,9 +126,37 @@ public function instantSave(): void
public function updatedS3StorageId(): void
{
if ($this->saveToS3) {
$this->save();
$this->authorize('update', $this->resource);
if (! $this->hasValidS3Storage()) {
$this->addError('s3StorageId', 'Select a usable S3 storage owned by your team.');
return;
}
$this->resetErrorBag('s3StorageId');
$this->backup?->update(['s3_storage_id' => $this->s3StorageId]);
$this->dispatch('success', 'S3 storage updated.');
}
public function toggleS3(): void
{
$this->authorize('update', $this->resource);
if (! $this->saveToS3 && ! $this->hasValidS3Storage()) {
$this->dispatch('error', 'Select a usable S3 storage before enabling S3 backups.');
return;
}
$this->saveToS3 = ! $this->saveToS3;
$this->disableLocalBackup = $this->saveToS3 && $this->disableLocalBackup;
$this->backup?->update([
'save_s3' => $this->saveToS3,
'disable_local_backup' => $this->disableLocalBackup,
's3_storage_id' => $this->s3StorageId,
]);
$this->dispatch('success', $this->saveToS3 ? 'S3 backups enabled.' : 'S3 backups disabled.');
}
public function toggleEnabled(): void
@ -144,7 +175,7 @@ public function toggleEnabled(): void
$this->backup->update(['enabled' => $this->enabled]);
}
$this->dispatch('success', $this->enabled ? 'Volume backups enabled.' : 'Volume backups disabled.');
$this->dispatch('success', $this->enabled ? 'Storage backups enabled.' : 'Storage backups disabled.');
}
public function backupNow(): void
@ -161,7 +192,7 @@ public function backupNow(): void
$this->backup = $this->persistBackup($this->enabled);
VolumeBackupJob::dispatch($this->backup);
$this->dispatch('success', 'Volume backup queued.');
$this->dispatch('success', 'Storage backup queued.');
}
public function delete(?string $password = null, array $selectedActions = [])
@ -179,7 +210,7 @@ public function delete(?string $password = null, array $selectedActions = [])
$lock = Cache::lock(VolumeBackupJob::lockKey($this->backup->id), $this->backup->timeout + 300);
if (! $lock->get()) {
$this->dispatch('error', 'Wait for the queued or running volume backup to finish before deleting this schedule.');
$this->dispatch('error', 'Wait for the queued or running storage backup to finish before deleting this schedule.');
return false;
}
@ -191,7 +222,7 @@ public function delete(?string $password = null, array $selectedActions = [])
->orWhere('stop_recovery_pending', true)
->orWhere('s3_cleanup_pending', true))
->exists()) {
$this->dispatch('error', 'Wait for the running volume backup and container recovery to finish before deleting this schedule.');
$this->dispatch('error', 'Wait for the running storage backup and container recovery to finish before deleting this schedule.');
return false;
}
@ -228,7 +259,12 @@ public function delete(?string $password = null, array $selectedActions = [])
$this->backup->delete();
$this->backup = null;
$this->dispatch('success', 'Volume backup schedule and archives deleted.');
$this->dispatch('success', 'Storage backup schedule and archives deleted.');
$this->redirectRoute('project.application.backup.index', [
'project_uuid' => $this->resource->project()->uuid,
'environment_uuid' => $this->resource->environment->uuid,
'application_uuid' => $this->resource->uuid,
], navigate: true);
return true;
} catch (Throwable $exception) {
@ -361,8 +397,8 @@ private function validateSettings(): bool
private function persistBackup(bool $enabled): ScheduledVolumeBackup
{
return ScheduledVolumeBackup::query()->updateOrCreate(
['local_persistent_volume_id' => $this->storage->id],
return $this->storage->scheduledBackups()->updateOrCreate(
[],
[
'team_id' => currentTeam()->id,
'frequency' => $this->frequency,
@ -370,7 +406,7 @@ private function persistBackup(bool $enabled): ScheduledVolumeBackup
'save_s3' => $this->saveToS3,
'disable_local_backup' => $this->disableLocalBackup,
'stop_during_backup' => $this->stopDuringBackup,
's3_storage_id' => $this->saveToS3 ? $this->s3StorageId : null,
's3_storage_id' => $this->hasValidS3Storage() ? $this->s3StorageId : $this->backup?->s3_storage_id,
'retention_amount_locally' => $this->retentionAmountLocally,
'retention_days_locally' => $this->retentionDaysLocally,
'retention_max_storage_locally' => $this->retentionMaxStorageLocally,

View file

@ -6,6 +6,8 @@
use App\Jobs\ServerStorageSaveJob;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Symfony\Component\Yaml\Yaml;
class LocalFileVolume extends BaseModel
@ -57,6 +59,12 @@ protected static function booted()
$fileVolume->load(['service']);
dispatch(new ServerStorageSaveJob($fileVolume));
});
static::deleting(function (LocalFileVolume $fileVolume): void {
if ($fileVolume->scheduledBackups()->exists()) {
throw new \RuntimeException('Delete this directory backup schedule and its archives before deleting the directory.');
}
});
}
protected function isBinary(): Attribute
@ -73,11 +81,21 @@ protected function isTooLarge(): Attribute
);
}
public function service()
public function resource(): MorphTo
{
return $this->morphTo();
}
public function service(): MorphTo
{
return $this->morphTo('resource');
}
public function scheduledBackups(): MorphMany
{
return $this->morphMany(ScheduledVolumeBackup::class, 'backupable');
}
public function loadStorageOnServer()
{
if ($this->is_host_file) {

View file

@ -3,11 +3,20 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Symfony\Component\Yaml\Yaml;
class LocalPersistentVolume extends BaseModel
{
protected static function booted(): void
{
static::deleting(function (LocalPersistentVolume $volume): void {
if ($volume->scheduledBackups()->exists()) {
throw new \RuntimeException('Delete this volume backup schedule and its archives before deleting the volume.');
}
});
}
protected $fillable = [
'name',
'mount_path',
@ -42,9 +51,9 @@ public function database()
return $this->morphTo('resource');
}
public function scheduledBackups(): HasMany
public function scheduledBackups(): MorphMany
{
return $this->hasMany(ScheduledVolumeBackup::class, 'local_persistent_volume_id');
return $this->morphMany(ScheduledVolumeBackup::class, 'backupable');
}
protected function customizeName($value)

View file

@ -2,15 +2,19 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class ScheduledVolumeBackup extends BaseModel
{
protected $fillable = [
'uuid',
'local_persistent_volume_id',
'backupable_type',
'backupable_id',
'team_id',
's3_storage_id',
'frequency',
@ -44,9 +48,28 @@ protected function casts(): array
];
}
public function volume(): BelongsTo
public function scopeForApplication(Builder $query, Application $application): Builder
{
return $this->belongsTo(LocalPersistentVolume::class, 'local_persistent_volume_id');
$volumeIds = $application->persistentStorages()->pluck('id');
$directoryIds = $application->fileStorages()
->where('is_directory', true)
->where('is_host_file', false)
->pluck('id');
return $query->where(function (Builder $query) use ($volumeIds, $directoryIds): void {
$query->where(function (Builder $query) use ($volumeIds): void {
$query->where('backupable_type', (new LocalPersistentVolume)->getMorphClass())
->whereIn('backupable_id', $volumeIds);
})->orWhere(function (Builder $query) use ($directoryIds): void {
$query->where('backupable_type', (new LocalFileVolume)->getMorphClass())
->whereIn('backupable_id', $directoryIds);
});
});
}
public function backupable(): MorphTo
{
return $this->morphTo();
}
public function team(): BelongsTo
@ -71,7 +94,7 @@ public function latestExecution(): HasOne
public function server(): ?Server
{
$resource = $this->volume?->resource;
$resource = $this->targetResource();
if ($resource instanceof ServiceApplication || $resource instanceof ServiceDatabase) {
return $resource->service?->server?->fresh();
@ -79,4 +102,48 @@ public function server(): ?Server
return $resource?->destination?->server?->fresh();
}
public function targetResource(): ?Model
{
return $this->backupable?->resource;
}
public function targetType(): string
{
return $this->backupable instanceof LocalFileVolume ? 'Directory' : 'Volume';
}
public function targetName(): string
{
return match (true) {
$this->backupable instanceof LocalFileVolume => $this->backupable->fs_path,
$this->backupable instanceof LocalPersistentVolume => $this->backupable->name,
default => 'Unknown storage',
};
}
public function sourcePath(): string
{
$target = $this->backupable;
if ($target instanceof LocalPersistentVolume) {
return filled($target->host_path) ? $target->host_path : $target->name;
}
if (! $target instanceof LocalFileVolume || ! $target->is_directory) {
throw new \RuntimeException('The backup target is not a directory or persistent volume.');
}
$path = str($target->fs_path);
if ($path->startsWith('.')) {
$resource = $this->targetResource();
if (! $resource || ! method_exists($resource, 'workdir')) {
throw new \RuntimeException('The directory backup workdir is unavailable.');
}
return $resource->workdir().$path->after('.')->toString();
}
return $path->toString();
}
}

View file

@ -4,6 +4,8 @@
use App\Models\Application;
use App\Models\EnvironmentVariable;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Services\DeploymentConfiguration\Concerns\SummarizesDiffText;
use Illuminate\Support\Arr;
@ -47,6 +49,10 @@ public function toArray(): array
'label' => 'Environment Variables',
'items' => $this->environmentItems(),
],
'storage' => [
'label' => 'Storage',
'items' => $this->storageItems(),
],
],
];
}
@ -214,6 +220,40 @@ private function environmentItems(): array
->all();
}
/**
* @return array<int, array<string, mixed>>
*/
private function storageItems(): array
{
$volumes = $this->application->persistentStorages()
->orderBy('id')
->get(['id', 'name', 'mount_path', 'host_path'])
->map(function (LocalPersistentVolume $volume): array {
$source = $volume->host_path ?: $volume->name;
return $this->item(
key: 'volume_'.$volume->id,
label: 'Volume mount',
value: ['source' => $source, 'destination' => $volume->mount_path],
impact: 'redeploy',
displayValue: "{$source}{$volume->mount_path}",
);
});
$fileMounts = $this->application->fileStorages()
->orderBy('id')
->get(['id', 'fs_path', 'mount_path', 'is_directory'])
->map(fn (LocalFileVolume $file): array => $this->item(
key: 'file_'.$file->id,
label: $file->is_directory ? 'Directory mount' : 'File mount',
value: ['source' => $file->fs_path, 'destination' => $file->mount_path],
impact: 'redeploy',
displayValue: "{$file->fs_path}{$file->mount_path}",
));
return collect($volumes->all())->merge($fileMounts->all())->values()->all();
}
/**
* @return array<int, array<string, mixed>>
*/

View file

@ -14,7 +14,8 @@ public function up(): void
Schema::create('scheduled_volume_backups', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->foreignId('local_persistent_volume_id')->unique()->constrained()->restrictOnDelete();
$table->string('backupable_type');
$table->unsignedBigInteger('backupable_id');
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
$table->foreignId('s3_storage_id')->nullable()->constrained()->nullOnDelete();
$table->string('frequency');
@ -26,6 +27,8 @@ public function up(): void
$table->unsignedInteger('retention_amount_s3')->default(7);
$table->unsignedInteger('timeout')->default(3600);
$table->timestamps();
$table->unique(['backupable_type', 'backupable_id']);
});
}

View file

@ -15716,6 +15716,252 @@
]
}
},
"\/applications\/{uuid}\/storages\/{storage_uuid}\/backups": {
"put": {
"tags": [
"Applications"
],
"summary": "Set application storage backup schedule",
"description": "Create or replace the backup schedule for an application persistent volume or directory storage.",
"operationId": "set-application-storage-backup-schedule",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the application.",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "storage_uuid",
"in": "path",
"description": "UUID of the persistent volume or directory storage.",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application\/json": {
"schema": {
"$ref": "#\/components\/schemas\/VolumeBackupScheduleRequest"
}
}
}
},
"responses": {
"200": {
"description": "Backup schedule replaced.",
"content": {
"application\/json": {
"schema": {
"$ref": "#\/components\/schemas\/VolumeBackupScheduleResponse"
}
}
}
},
"201": {
"description": "Backup schedule created.",
"content": {
"application\/json": {
"schema": {
"$ref": "#\/components\/schemas\/VolumeBackupScheduleResponse"
}
}
}
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"403": {
"description": "Forbidden."
},
"404": {
"$ref": "#\/components\/responses\/404"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/databases\/{uuid}\/storages\/{storage_uuid}\/backups": {
"put": {
"tags": [
"Databases"
],
"summary": "Set database storage backup schedule",
"description": "Create or replace the backup schedule for a database persistent volume or directory storage.",
"operationId": "set-database-storage-backup-schedule",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the database.",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "storage_uuid",
"in": "path",
"description": "UUID of the persistent volume or directory storage.",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application\/json": {
"schema": {
"$ref": "#\/components\/schemas\/VolumeBackupScheduleRequest"
}
}
}
},
"responses": {
"200": {
"description": "Backup schedule replaced.",
"content": {
"application\/json": {
"schema": {
"$ref": "#\/components\/schemas\/VolumeBackupScheduleResponse"
}
}
}
},
"201": {
"description": "Backup schedule created.",
"content": {
"application\/json": {
"schema": {
"$ref": "#\/components\/schemas\/VolumeBackupScheduleResponse"
}
}
}
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"403": {
"description": "Forbidden."
},
"404": {
"$ref": "#\/components\/responses\/404"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/services\/{uuid}\/storages\/{storage_uuid}\/backups": {
"put": {
"tags": [
"Services"
],
"summary": "Set service storage backup schedule",
"description": "Create or replace the backup schedule for a service persistent volume or directory storage.",
"operationId": "set-service-storage-backup-schedule",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the service.",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "storage_uuid",
"in": "path",
"description": "UUID of the persistent volume or directory storage.",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application\/json": {
"schema": {
"$ref": "#\/components\/schemas\/VolumeBackupScheduleRequest"
}
}
}
},
"responses": {
"200": {
"description": "Backup schedule replaced.",
"content": {
"application\/json": {
"schema": {
"$ref": "#\/components\/schemas\/VolumeBackupScheduleResponse"
}
}
}
},
"201": {
"description": "Backup schedule created.",
"content": {
"application\/json": {
"schema": {
"$ref": "#\/components\/schemas\/VolumeBackupScheduleResponse"
}
}
}
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"403": {
"description": "Forbidden."
},
"404": {
"$ref": "#\/components\/responses\/404"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/vultr\/regions": {
"get": {
"tags": [
@ -15852,6 +16098,169 @@
},
"components": {
"schemas": {
"VolumeBackupScheduleRequest": {
"required": [
"frequency"
],
"properties": {
"frequency": {
"type": "string",
"maxLength": 255,
"example": "0 2 * * *"
},
"enabled": {
"type": "boolean",
"default": true
},
"save_s3": {
"type": "boolean",
"default": false
},
"disable_local_backup": {
"type": "boolean",
"default": false
},
"stop_during_backup": {
"type": "boolean",
"default": false
},
"s3_storage_uuid": {
"type": [
"string",
"null"
]
},
"retention_amount_locally": {
"type": "integer",
"default": 7,
"maximum": 10000,
"minimum": 0
},
"retention_days_locally": {
"type": "integer",
"default": 0,
"maximum": 2147483647,
"minimum": 0
},
"retention_max_storage_locally": {
"type": "number",
"format": "float",
"default": 0,
"maximum": 9999999999,
"minimum": 0
},
"retention_amount_s3": {
"type": "integer",
"default": 7,
"maximum": 10000,
"minimum": 0
},
"retention_days_s3": {
"type": "integer",
"default": 0,
"maximum": 2147483647,
"minimum": 0
},
"retention_max_storage_s3": {
"type": "number",
"format": "float",
"default": 0,
"maximum": 9999999999,
"minimum": 0
},
"timeout": {
"type": "integer",
"default": 3600,
"maximum": 36000,
"minimum": 60
}
},
"type": "object",
"additionalProperties": false
},
"VolumeBackupScheduleResponse": {
"required": [
"uuid",
"message",
"storage_uuid",
"storage_type",
"frequency",
"enabled",
"save_s3",
"disable_local_backup",
"stop_during_backup",
"retention_amount_locally",
"retention_days_locally",
"retention_max_storage_locally",
"retention_amount_s3",
"retention_days_s3",
"retention_max_storage_s3",
"timeout"
],
"properties": {
"uuid": {
"type": "string"
},
"message": {
"type": "string"
},
"storage_uuid": {
"type": "string"
},
"storage_type": {
"type": "string",
"enum": [
"persistent",
"directory"
]
},
"frequency": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"save_s3": {
"type": "boolean"
},
"disable_local_backup": {
"type": "boolean"
},
"stop_during_backup": {
"type": "boolean"
},
"s3_storage_uuid": {
"type": [
"string",
"null"
]
},
"retention_amount_locally": {
"type": "integer"
},
"retention_days_locally": {
"type": "integer"
},
"retention_max_storage_locally": {
"type": "number",
"format": "float"
},
"retention_amount_s3": {
"type": "integer"
},
"retention_days_s3": {
"type": "integer"
},
"retention_max_storage_s3": {
"type": "number",
"format": "float"
},
"timeout": {
"type": "integer"
}
},
"type": "object"
},
"Application": {
"description": "Application model",
"properties": {

View file

@ -10047,6 +10047,168 @@ paths:
security:
-
bearerAuth: []
'/applications/{uuid}/storages/{storage_uuid}/backups':
put:
tags:
- Applications
summary: 'Set application storage backup schedule'
description: 'Create or replace the backup schedule for an application persistent volume or directory storage.'
operationId: set-application-storage-backup-schedule
parameters:
-
name: uuid
in: path
description: 'UUID of the application.'
required: true
schema:
type: string
-
name: storage_uuid
in: path
description: 'UUID of the persistent volume or directory storage.'
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/VolumeBackupScheduleRequest'
responses:
'200':
description: 'Backup schedule replaced.'
content:
application/json:
schema:
$ref: '#/components/schemas/VolumeBackupScheduleResponse'
'201':
description: 'Backup schedule created.'
content:
application/json:
schema:
$ref: '#/components/schemas/VolumeBackupScheduleResponse'
'400':
$ref: '#/components/responses/400'
'401':
$ref: '#/components/responses/401'
'403':
description: Forbidden.
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
'/databases/{uuid}/storages/{storage_uuid}/backups':
put:
tags:
- Databases
summary: 'Set database storage backup schedule'
description: 'Create or replace the backup schedule for a database persistent volume or directory storage.'
operationId: set-database-storage-backup-schedule
parameters:
-
name: uuid
in: path
description: 'UUID of the database.'
required: true
schema:
type: string
-
name: storage_uuid
in: path
description: 'UUID of the persistent volume or directory storage.'
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/VolumeBackupScheduleRequest'
responses:
'200':
description: 'Backup schedule replaced.'
content:
application/json:
schema:
$ref: '#/components/schemas/VolumeBackupScheduleResponse'
'201':
description: 'Backup schedule created.'
content:
application/json:
schema:
$ref: '#/components/schemas/VolumeBackupScheduleResponse'
'400':
$ref: '#/components/responses/400'
'401':
$ref: '#/components/responses/401'
'403':
description: Forbidden.
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
'/services/{uuid}/storages/{storage_uuid}/backups':
put:
tags:
- Services
summary: 'Set service storage backup schedule'
description: 'Create or replace the backup schedule for a service persistent volume or directory storage.'
operationId: set-service-storage-backup-schedule
parameters:
-
name: uuid
in: path
description: 'UUID of the service.'
required: true
schema:
type: string
-
name: storage_uuid
in: path
description: 'UUID of the persistent volume or directory storage.'
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/VolumeBackupScheduleRequest'
responses:
'200':
description: 'Backup schedule replaced.'
content:
application/json:
schema:
$ref: '#/components/schemas/VolumeBackupScheduleResponse'
'201':
description: 'Backup schedule created.'
content:
application/json:
schema:
$ref: '#/components/schemas/VolumeBackupScheduleResponse'
'400':
$ref: '#/components/responses/400'
'401':
$ref: '#/components/responses/401'
'403':
description: Forbidden.
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
/vultr/regions:
get:
tags:
@ -10136,6 +10298,130 @@ paths:
bearerAuth: []
components:
schemas:
VolumeBackupScheduleRequest:
required:
- frequency
properties:
frequency:
type: string
maxLength: 255
example: '0 2 * * *'
enabled:
type: boolean
default: true
save_s3:
type: boolean
default: false
disable_local_backup:
type: boolean
default: false
stop_during_backup:
type: boolean
default: false
s3_storage_uuid:
type:
- string
- 'null'
retention_amount_locally:
type: integer
default: 7
maximum: 10000
minimum: 0
retention_days_locally:
type: integer
default: 0
maximum: 2147483647
minimum: 0
retention_max_storage_locally:
type: number
format: float
default: 0
maximum: 9999999999
minimum: 0
retention_amount_s3:
type: integer
default: 7
maximum: 10000
minimum: 0
retention_days_s3:
type: integer
default: 0
maximum: 2147483647
minimum: 0
retention_max_storage_s3:
type: number
format: float
default: 0
maximum: 9999999999
minimum: 0
timeout:
type: integer
default: 3600
maximum: 36000
minimum: 60
type: object
additionalProperties: false
VolumeBackupScheduleResponse:
required:
- uuid
- message
- storage_uuid
- storage_type
- frequency
- enabled
- save_s3
- disable_local_backup
- stop_during_backup
- retention_amount_locally
- retention_days_locally
- retention_max_storage_locally
- retention_amount_s3
- retention_days_s3
- retention_max_storage_s3
- timeout
properties:
uuid:
type: string
message:
type: string
storage_uuid:
type: string
storage_type:
type: string
enum:
- persistent
- directory
frequency:
type: string
enabled:
type: boolean
save_s3:
type: boolean
disable_local_backup:
type: boolean
stop_during_backup:
type: boolean
s3_storage_uuid:
type:
- string
- 'null'
retention_amount_locally:
type: integer
retention_days_locally:
type: integer
retention_max_storage_locally:
type: number
format: float
retention_amount_s3:
type: integer
retention_days_s3:
type: integer
retention_max_storage_s3:
type: number
format: float
timeout:
type: integer
type: object
Application:
description: 'Application model'
properties:

View file

@ -1,11 +1,11 @@
<form class="flex flex-col w-full gap-4 rounded-sm" wire:submit="submit">
@if ($volumes->isEmpty())
<div class="text-warning">Add a persistent volume before configuring a backup.</div>
@if ($targets->isEmpty())
<div class="text-warning">Add a persistent volume or directory mount before configuring a backup.</div>
@else
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<x-forms.select id="volumeId" label="Volume" required :disabled="$volumeLocked">
@foreach ($volumes as $volume)
<option value="{{ $volume->id }}">{{ $volume->name }}</option>
<x-forms.select id="targetKey" label="Backup Target" required :disabled="$targetLocked">
@foreach ($targets as $target)
<option value="{{ $target['key'] }}">{{ $target['type'] }}: {{ $target['name'] }}</option>
@endforeach
</x-forms.select>
<x-forms.input id="frequency" placeholder="0 0 * * * or daily"
@ -13,24 +13,6 @@
label="Frequency" required />
</div>
<div>
<h2 class="pb-2">S3</h2>
@if ($definedS3s->isEmpty())
<div class="text-sm text-neutral-600 dark:text-neutral-400">No validated S3 storages found.</div>
@else
<div class="flex flex-col gap-3">
<x-forms.checkbox live id="saveToS3" label="Save to S3" />
@if ($saveToS3)
<x-forms.select id="s3StorageId" label="S3 Storage">
@foreach ($definedS3s as $s3)
<option value="{{ $s3->id }}">{{ $s3->name }}</option>
@endforeach
</x-forms.select>
@endif
</div>
@endif
</div>
<x-forms.button type="submit">Save</x-forms.button>
@endif
</form>

View file

@ -1,13 +1,14 @@
<div x-data="{
search: '',
search: @js($search),
backups: @js($backups->map(fn ($backup) => [
'name' => strtolower($backup->volume->name),
'name' => strtolower($backup->targetName()),
'type' => strtolower($backup->targetType()),
'frequency' => strtolower($backup->frequency),
])->values()),
hasMatches() {
const query = this.search.toLowerCase();
return this.backups.some((backup) => backup.name.includes(query) || backup.frequency.includes(query));
return this.backups.some((backup) => backup.name.includes(query) || backup.type.includes(query) || backup.frequency.includes(query));
},
}">
<x-slot:title>
@ -28,7 +29,7 @@
</div>
<div class="max-w-md pb-4">
<x-forms.input id="null" type="search" x-model="search" placeholder="Search by volume name or frequency..." />
<x-forms.input id="null" type="search" x-model="search" placeholder="Search by target name, type, or frequency..." />
</div>
<div class="flex flex-col gap-2">
@ -37,7 +38,7 @@
</div>
@forelse ($backups as $backup)
@php($latestExecution = $backup->latestExecution)
<a x-show="search === '' || @js(strtolower($backup->volume->name)).includes(search.toLowerCase()) || @js(strtolower($backup->frequency)).includes(search.toLowerCase())" @class([
<a x-show="search === '' || @js(strtolower($backup->targetName())).includes(search.toLowerCase()) || @js(strtolower($backup->targetType())).includes(search.toLowerCase()) || @js(strtolower($backup->frequency)).includes(search.toLowerCase())" @class([
'flex flex-col border-l-2 transition-colors p-4 cursor-pointer bg-white hover:bg-gray-100 dark:bg-coolgray-100 dark:hover:bg-coolgray-200 text-black dark:text-white',
'border-blue-500/50 border-dashed' => $latestExecution?->status === 'running',
'border-error' => $latestExecution?->status === 'failed',
@ -66,7 +67,7 @@
@endif
</div>
<div class="text-sm text-gray-600 dark:text-gray-400">
Volume: {{ $backup->volume->name }}
{{ $backup->targetType() }}: {{ $backup->targetName() }}
@if ($latestExecution?->finished_at)
Last run {{ $latestExecution->finished_at->diffForHumans() }}
@else

View file

@ -1,11 +1,37 @@
<div>
<x-slot:title>
{{ data_get_str($application, 'name')->limit(10) }} > Volume Backups | Coolify
{{ data_get_str($application, 'name')->limit(10) }} > Storage Backups | Coolify
</x-slot>
<h1>Volume Backups</h1>
<h1>Storage Backups</h1>
<livewire:project.shared.configuration-checker :resource="$application" />
<livewire:project.application.heading :application="$application" />
<livewire:project.shared.storages.volume-backups :storage="$backup->volume" :resource="$application"
wire:key="volume-backup-{{ $backup->uuid }}" />
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
<div class="sub-menu-wrapper">
<a @class(['sub-menu-item', 'menu-item-active' => $section === 'general']) {{ wireNavigate() }}
href="{{ route('project.application.backup.show', $parameters) }}">
<span class="menu-item-label">General</span>
</a>
<a @class(['sub-menu-item', 'menu-item-active' => $section === 's3']) {{ wireNavigate() }}
href="{{ route('project.application.backup.s3', $parameters) }}">
<span class="menu-item-label">S3</span>
</a>
<a @class(['sub-menu-item', 'menu-item-active' => $section === 'retention']) {{ wireNavigate() }}
href="{{ route('project.application.backup.retention', $parameters) }}">
<span class="menu-item-label">Retention</span>
</a>
<a @class(['sub-menu-item', 'menu-item-active' => $section === 'executions']) {{ wireNavigate() }}
href="{{ route('project.application.backup.executions', $parameters) }}">
<span class="menu-item-label">Executions</span>
</a>
<a @class(['sub-menu-item', 'menu-item-active' => $section === 'danger']) {{ wireNavigate() }}
href="{{ route('project.application.backup.danger', $parameters) }}">
<span class="menu-item-label">Danger Zone</span>
</a>
</div>
<div class="w-full md:flex-grow">
<livewire:project.shared.storages.volume-backups :storage="$backup->backupable" :resource="$application"
:section="$section" wire:key="volume-backup-{{ $backup->uuid }}-{{ $section }}" />
</div>
</div>
</div>

View file

@ -14,7 +14,7 @@
[
'label' => 'Backups',
'route' => 'project.application.backup.index',
'active' => request()->routeIs('project.application.backup.index', 'project.application.backup.show'),
'active' => request()->routeIs('project.application.backup.*'),
],
[
'label' => 'Logs',
@ -452,7 +452,7 @@ class="scrollbar hidden min-h-10 w-full flex-nowrap items-center gap-6 overflow-
href="{{ route('project.application.deployment.index', $parameters) }}">
Deployments
</a>
<a class="hidden md:block shrink-0 {{ request()->routeIs('project.application.backup.index', 'project.application.backup.show') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
<a class="hidden md:block shrink-0 {{ request()->routeIs('project.application.backup.*') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
href="{{ route('project.application.backup.index', $parameters) }}">
Backups
</a>

View file

@ -1,160 +1,11 @@
<form wire:submit="submit">
<div class="flex flex-col gap-3 pb-4 sm:flex-row sm:items-center">
<h2>Scheduled Backup</h2>
<div class="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<x-forms.button type="submit" class="w-full sm:w-auto">
Save
</x-forms.button>
@if (!$backupEnabled)
<x-forms.button type="button" wire:click="toggleEnabled" wire:loading.attr="disabled"
wire:target="toggleEnabled" isHighlighted>Enable Backup</x-forms.button>
@else
<x-forms.button type="button" wire:click="toggleEnabled" wire:loading.attr="disabled"
wire:target="toggleEnabled">Disable Backup</x-forms.button>
@endif
@if (str($status)->startsWith('running'))
<x-forms.button wire:click='backupNow' class="w-full sm:w-auto">Backup Now</x-forms.button>
@endif
@if ($backup->database_id !== 0)
<div class="w-full sm:w-auto">
<x-modal-confirmation title="Confirm Backup Schedule Deletion?" isErrorButton submitAction="delete"
:checkboxes="$checkboxes" :actions="[
'The selected backup schedule will be deleted.',
'Scheduled backups for this database will be stopped (if this is the only backup schedule for this database).',
]"
confirmationText="{{ $backup->database->name }}"
confirmationLabel="Please confirm the execution of the actions by entering the Database Name of the scheduled backups below"
shortConfirmationLabel="Database Name">
<x-slot:trigger>
<x-forms.button isError class="w-full sm:w-auto">Delete Backups and Schedule</x-forms.button>
</x-slot:trigger>
</x-modal-confirmation>
</div>
@endif
</div>
</div>
<div class="w-full max-w-md pb-2">
@if ($availableS3Storages->count() > 0)
<x-forms.checkbox instantSave label="S3 Enabled" id="saveS3" />
@elseif ($saveS3)
<x-forms.checkbox instantSave label="S3 Enabled" id="saveS3"
helper="The configured S3 storage is no longer available. Disable S3 backups or configure a usable S3 storage." />
@else
<x-forms.checkbox instantSave helper="No validated S3 storage available." label="S3 Enabled" id="saveS3"
disabled />
@endif
@if ($saveS3)
<x-forms.checkbox instantSave label="Disable Local Backup" id="disableLocalBackup"
helper="When enabled, backup files will be deleted from local storage immediately after uploading to S3. This requires S3 backup to be enabled." />
@else
<x-forms.checkbox disabled label="Disable Local Backup" id="disableLocalBackup"
helper="When enabled, backup files will be deleted from local storage immediately after uploading to S3. This requires S3 backup to be enabled." />
@endif
</div>
<div class="w-full max-w-md pb-6">
<div class="flex gap-1 items-center mb-1 text-sm font-medium">
<span>S3 Storage</span>
@if (!$saveS3)
<span class="text-xs font-normal text-warning">(currently disabled)</span>
@endif
@if ($saveS3)
<x-highlighted text="*" />
@endif
</div>
<x-forms.select id="s3StorageId" wire:model.live="s3StorageId" :required="$saveS3"
:disabled="$availableS3Storages->isEmpty()">
@if ($availableS3Storages->isEmpty())
<option value="">No S3 storage available</option>
@else
@foreach ($availableS3Storages as $s3)
<option value="{{ $s3->id }}">{{ $s3->name }}</option>
@endforeach
@endif
</x-forms.select>
</div>
<div class="flex flex-col gap-2">
<h3>Settings</h3>
<div class="flex gap-2 flex-col ">
@if ($backup->database_type === 'App\Models\StandalonePostgresql' && $backup->database_id !== 0)
<div class="w-48">
<x-forms.checkbox label="Backup All Databases" id="dumpAll" instantSave />
</div>
@if (!$backup->dump_all)
<x-forms.input label="Databases To Backup"
helper="Comma separated list of databases to backup. Empty will include the default one."
id="databasesToBackup" />
@endif
@elseif($backup->database_type === 'App\Models\StandaloneMongodb')
<x-forms.input label="Databases To Include"
helper="A list of databases to backup. You can specify which collection(s) per database to exclude from the backup. Empty will include all databases and collections.<br><br>Example:<br><br>database1:collection1,collection2|database2:collection3,collection4<br><br> database1 will include all collections except collection1 and collection2. <br>database2 will include all collections except collection3 and collection4.<br><br>Another Example:<br><br>database1:collection1|database2<br><br> database1 will include all collections except collection1.<br>database2 will include ALL collections."
id="databasesToBackup" />
@elseif($backup->database_type === 'App\Models\StandaloneMysql')
<div class="w-48">
<x-forms.checkbox label="Backup All Databases" id="dumpAll" instantSave />
</div>
@if (!$backup->dump_all)
<x-forms.input label="Databases To Backup"
helper="Comma separated list of databases to backup. Empty will include the default one."
id="databasesToBackup" />
@endif
@elseif($backup->database_type === 'App\Models\StandaloneMariadb')
<div class="w-48">
<x-forms.checkbox label="Backup All Databases" id="dumpAll" instantSave />
</div>
@if (!$backup->dump_all)
<x-forms.input label="Databases To Backup"
helper="Comma separated list of databases to backup. Empty will include the default one."
id="databasesToBackup" />
@endif
@endif
</div>
<div class="grid grid-cols-1 gap-2 md:grid-cols-3">
<x-forms.input label="Frequency" id="frequency" required />
<x-forms.input label="Timezone" id="timezone" disabled
helper="The timezone of the server where the backup is scheduled to run (if not set, the instance timezone will be used)" required />
<x-forms.input label="Timeout" id="timeout" type="number" min="60" helper="The timeout of the backup job in seconds." required />
</div>
<h3 class="mt-6 mb-2 text-lg font-medium">Backup Retention Settings</h3>
<div class="mb-4">
<ul class="list-disc pl-6 space-y-2">
<li>Setting a value to 0 means unlimited retention.</li>
<li>The retention rules work independently - whichever limit is reached first will trigger cleanup.</li>
</ul>
</div>
<div class="flex gap-6 flex-col">
<div>
<h4 class="mb-3 font-medium">Local Backup Retention</h4>
<div class="grid grid-cols-1 gap-2 md:grid-cols-3">
<x-forms.input label="Number of backups to keep" id="databaseBackupRetentionAmountLocally"
type="number" min="0"
helper="Keeps only the specified number of most recent backups on the server. Set to 0 for unlimited backups." required />
<x-forms.input label="Days to keep backups" id="databaseBackupRetentionDaysLocally" type="number"
min="0"
helper="Automatically removes backups older than the specified number of days. Set to 0 for no time limit." required />
<x-forms.input label="Maximum storage (GB)" id="databaseBackupRetentionMaxStorageLocally"
type="number" min="0" step="any"
helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.001 for 1MB). Set to 0 for unlimited storage." required />
</div>
</div>
@if ($saveS3)
<div>
<h4 class="mb-3 font-medium">S3 Storage Retention</h4>
<div class="grid grid-cols-1 gap-2 md:grid-cols-3">
<x-forms.input label="Number of backups to keep" id="databaseBackupRetentionAmountS3"
type="number" min="0"
helper="Keeps only the specified number of most recent backups on S3 storage. Set to 0 for unlimited backups." required />
<x-forms.input label="Days to keep backups" id="databaseBackupRetentionDaysS3" type="number"
min="0"
helper="Automatically removes S3 backups older than the specified number of days. Set to 0 for no time limit." required />
<x-forms.input label="Maximum storage (GB)" id="databaseBackupRetentionMaxStorageS3"
type="number" min="0" step="any"
helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.5 for 500MB). Set to 0 for unlimited storage." required />
</div>
</div>
@endif
</div>
</div>
</form>
<div>
@if ($section === 'retention')
@include('livewire.project.database.backup-edit.retention')
@elseif ($section === 's3')
@include('livewire.project.database.backup-edit.s3')
@elseif ($section === 'danger')
@include('livewire.project.database.backup-edit.danger')
@else
@include('livewire.project.database.backup-edit.general')
@endif
</div>

View file

@ -0,0 +1,27 @@
<div class="flex flex-col gap-6">
<div>
<h2>Danger Zone</h2>
<div class="text-neutral-500">Woah. I hope you know what you are doing.</div>
</div>
<div>
<h3>Delete Scheduled Backup</h3>
<div class="pb-4 text-neutral-500">
This permanently deletes the schedule. You can also delete its associated backup archives. There is no coming back.
</div>
@if ($backup->database_id !== 0)
<x-modal-confirmation title="Confirm Backup Schedule Deletion?" isErrorButton submitAction="delete"
:checkboxes="$checkboxes" :actions="[
'The selected backup schedule will be deleted.',
'Scheduled backups for this database will be stopped (if this is the only backup schedule for this database).',
]"
confirmationText="{{ $backup->database->name }}"
confirmationLabel="Please confirm the execution of the actions by entering the Database Name of the scheduled backups below"
shortConfirmationLabel="Database Name">
<x-slot:trigger>
<x-forms.button isError>Delete Backups and Schedule</x-forms.button>
</x-slot:trigger>
</x-modal-confirmation>
@endif
</div>
</div>

View file

@ -0,0 +1,62 @@
<form wire:submit="submit" class="flex flex-col gap-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<h2>General</h2>
<div class="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<x-forms.button type="submit" class="w-full sm:w-auto">Save</x-forms.button>
@if (!$backupEnabled)
<x-forms.button type="button" wire:click="toggleEnabled" wire:loading.attr="disabled"
wire:target="toggleEnabled" isHighlighted>Enable Backup</x-forms.button>
@else
<x-forms.button type="button" wire:click="toggleEnabled" wire:loading.attr="disabled"
wire:target="toggleEnabled">Disable Backup</x-forms.button>
@endif
@if (str($status)->startsWith('running'))
<x-forms.button wire:click="backupNow" class="w-full sm:w-auto">Backup Now</x-forms.button>
@endif
</div>
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2">
@if ($backup->database_type === 'App\Models\StandalonePostgresql' && $backup->database_id !== 0)
<div class="w-48">
<x-forms.checkbox label="Backup All Databases" id="dumpAll" instantSave />
</div>
@if (!$backup->dump_all)
<x-forms.input label="Databases To Backup"
helper="Comma separated list of databases to backup. Empty will include the default one."
id="databasesToBackup" />
@endif
@elseif ($backup->database_type === 'App\Models\StandaloneMongodb')
<x-forms.input label="Databases To Include"
helper="A list of databases to backup. You can specify which collection(s) per database to exclude from the backup. Empty will include all databases and collections.<br><br>Example:<br><br>database1:collection1,collection2|database2:collection3,collection4<br><br> database1 will include all collections except collection1 and collection2. <br>database2 will include all collections except collection3 and collection4.<br><br>Another Example:<br><br>database1:collection1|database2<br><br> database1 will include all collections except collection1.<br>database2 will include ALL collections."
id="databasesToBackup" />
@elseif ($backup->database_type === 'App\Models\StandaloneMysql')
<div class="w-48">
<x-forms.checkbox label="Backup All Databases" id="dumpAll" instantSave />
</div>
@if (!$backup->dump_all)
<x-forms.input label="Databases To Backup"
helper="Comma separated list of databases to backup. Empty will include the default one."
id="databasesToBackup" />
@endif
@elseif ($backup->database_type === 'App\Models\StandaloneMariadb')
<div class="w-48">
<x-forms.checkbox label="Backup All Databases" id="dumpAll" instantSave />
</div>
@if (!$backup->dump_all)
<x-forms.input label="Databases To Backup"
helper="Comma separated list of databases to backup. Empty will include the default one."
id="databasesToBackup" />
@endif
@endif
</div>
<div class="grid grid-cols-1 gap-2 md:grid-cols-3">
<x-forms.input label="Frequency" id="frequency" required />
<x-forms.input label="Timezone" id="timezone" disabled
helper="The timezone of the server where the backup is scheduled to run (if not set, the instance timezone will be used)" required />
<x-forms.input label="Timeout" id="timeout" type="number" min="60"
helper="The timeout of the backup job in seconds." required />
</div>
</div>
</form>

View file

@ -0,0 +1,43 @@
<form wire:submit="submit" class="flex flex-col gap-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<h2>Retention</h2>
<x-forms.button type="submit" class="w-full sm:w-auto">Save</x-forms.button>
</div>
<ul class="pl-6 space-y-2 list-disc">
<li>Setting a value to 0 means unlimited retention.</li>
<li>The retention rules work independently - whichever limit is reached first will trigger cleanup.</li>
</ul>
<div class="flex flex-col gap-6">
<div>
<h3 class="mb-3">Local Backup Retention</h3>
<div class="grid grid-cols-1 gap-2 md:grid-cols-3">
<x-forms.input label="Number of backups to keep" id="databaseBackupRetentionAmountLocally"
type="number" min="0"
helper="Keeps only the specified number of most recent backups on the server. Set to 0 for unlimited backups." required />
<x-forms.input label="Days to keep backups" id="databaseBackupRetentionDaysLocally" type="number"
min="0"
helper="Automatically removes backups older than the specified number of days. Set to 0 for no time limit." required />
<x-forms.input label="Maximum storage (GB)" id="databaseBackupRetentionMaxStorageLocally"
type="number" min="0" step="any"
helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.001 for 1MB). Set to 0 for unlimited storage." required />
</div>
</div>
<div>
<h3 class="mb-3">S3 Storage Retention</h3>
<div class="grid grid-cols-1 gap-2 md:grid-cols-3">
<x-forms.input label="Number of backups to keep" id="databaseBackupRetentionAmountS3" type="number"
min="0"
helper="Keeps only the specified number of most recent backups on S3 storage. Set to 0 for unlimited backups." required />
<x-forms.input label="Days to keep backups" id="databaseBackupRetentionDaysS3" type="number"
min="0"
helper="Automatically removes S3 backups older than the specified number of days. Set to 0 for no time limit." required />
<x-forms.input label="Maximum storage (GB)" id="databaseBackupRetentionMaxStorageS3"
type="number" min="0" step="any"
helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.5 for 500MB). Set to 0 for unlimited storage." required />
</div>
</div>
</div>
</form>

View file

@ -0,0 +1,45 @@
<form wire:submit="submit" class="flex flex-col gap-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<h2>S3</h2>
<x-forms.button type="submit" class="w-full sm:w-auto">Save</x-forms.button>
@if (!$saveS3)
<x-forms.button type="button" wire:click="toggleS3" wire:loading.attr="disabled"
wire:target="toggleS3" isHighlighted :disabled="$availableS3Storages->isEmpty()">Enable S3</x-forms.button>
@else
<x-forms.button type="button" wire:click="toggleS3" wire:loading.attr="disabled"
wire:target="toggleS3">Disable S3</x-forms.button>
@endif
</div>
<div class="w-full max-w-md pb-2">
<div class="flex items-center gap-1 mb-1 text-sm font-medium">
<span>S3 Storage</span>
@if (!$saveS3)
<span class="text-xs font-normal text-warning">(currently disabled)</span>
@else
<x-highlighted text="*" />
@endif
</div>
<x-forms.select id="s3StorageId" wire:model.live="s3StorageId" :required="$saveS3"
:disabled="$availableS3Storages->isEmpty()">
@if ($availableS3Storages->isEmpty())
<option value="">No S3 storage available</option>
@else
@foreach ($availableS3Storages as $s3)
<option value="{{ $s3->id }}">{{ $s3->name }}</option>
@endforeach
@endif
</x-forms.select>
</div>
<div class="w-full max-w-md">
@if ($saveS3)
<x-forms.checkbox instantSave label="Disable Local Backup" id="disableLocalBackup"
helper="When enabled, backup files will be deleted from local storage immediately after uploading to S3. This requires S3 backup to be enabled." />
@else
<x-forms.checkbox disabled label="Disable Local Backup" id="disableLocalBackup"
helper="When enabled, backup files will be deleted from local storage immediately after uploading to S3. This requires S3 backup to be enabled." />
@endif
</div>
</form>

View file

@ -1,7 +1,7 @@
<div wire:init='refreshBackupExecutions'>
@isset($backup)
<div class="flex flex-col gap-3 py-4 sm:flex-row sm:flex-wrap sm:items-center">
<h3 class="py-0">Executions <span class="text-xs">({{ $executions_count }})</span></h3>
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<h2 class="py-0">Executions</h2>
@if ($executions_count > 0)
<div class="flex items-center gap-2">
<x-forms.button disabled="{{ !$showPrev }}" wire:click="previousPage('{{ $defaultTake }}')">
@ -36,7 +36,7 @@
</div>
</div>
<div @if (!$skip) wire:poll.5000ms="refreshBackupExecutions" @endif
class="flex flex-col gap-4">
class="flex flex-col gap-4 pt-2">
@forelse($executions as $execution)
<div wire:key="{{ data_get($execution, 'id') }}" @class([
'flex flex-col border-l-2 transition-colors p-4 bg-white dark:bg-coolgray-100 text-black dark:text-white',

View file

@ -5,8 +5,38 @@
<h1>Backups</h1>
<livewire:project.shared.configuration-checker :resource="$database" />
<livewire:project.database.heading :database="$database" />
<div>
<livewire:project.database.backup-edit :backup="$backup" :available-s3-storages="$s3s" :status="data_get($database, 'status')" />
<livewire:project.database.backup-executions :backup="$backup" />
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
<div class="sub-menu-wrapper">
<a @class(['sub-menu-item', 'menu-item-active' => $section === 'general']) {{ wireNavigate() }}
href="{{ route('project.database.backup.execution', $parameters) }}">
<span class="menu-item-label">General</span>
</a>
<a @class(['sub-menu-item', 'menu-item-active' => $section === 's3']) {{ wireNavigate() }}
href="{{ route('project.database.backup.s3', $parameters) }}">
<span class="menu-item-label">S3</span>
</a>
<a @class(['sub-menu-item', 'menu-item-active' => $section === 'retention']) {{ wireNavigate() }}
href="{{ route('project.database.backup.retention', $parameters) }}">
<span class="menu-item-label">Retention</span>
</a>
<a @class(['sub-menu-item', 'menu-item-active' => $section === 'executions']) {{ wireNavigate() }}
href="{{ route('project.database.backup.executions', $parameters) }}">
<span class="menu-item-label">Executions</span>
</a>
<a @class(['sub-menu-item', 'menu-item-active' => $section === 'danger']) {{ wireNavigate() }}
href="{{ route('project.database.backup.danger', $parameters) }}">
<span class="menu-item-label">Danger Zone</span>
</a>
</div>
<div class="w-full md:flex-grow">
@if ($section === 'executions')
<livewire:project.database.backup-executions :backup="$backup" />
@else
<livewire:project.database.backup-edit :backup="$backup" :available-s3-storages="$s3s"
:status="data_get($database, 'status')" :section="$section"
wire:key="database-backup-{{ $backup->uuid }}-{{ $section }}" />
@endif
</div>
</div>
</div>

View file

@ -2,19 +2,6 @@
<x-forms.input placeholder="0 0 * * * or daily" id="frequency"
helper="You can use every_minute, hourly, daily, weekly, monthly, yearly or a cron expression." label="Frequency"
required />
<h2>S3</h2>
@if ($definedS3s->count() === 0)
<div class="text-red-500">No validated S3 Storages found.</div>
@else
<x-forms.checkbox wire:model.live="saveToS3" label="Save to S3" />
@if ($saveToS3)
<x-forms.select id="s3StorageId" label="Select a S3 Storage">
@foreach ($definedS3s as $s3)
<option value="{{ $s3->id }}">{{ $s3->name }}</option>
@endforeach
</x-forms.select>
@endif
@endif
<x-forms.button type="submit" @click="modalOpen=false">
Save
</x-forms.button>

View file

@ -5,7 +5,7 @@
[
'label' => 'Backups',
'route' => 'project.database.backup.index',
'active' => request()->routeIs('project.database.backup.index', 'project.database.backup.execution'),
'active' => request()->routeIs('project.database.backup.*'),
'visible' => in_array($database->getMorphClass(), [
'App\Models\StandalonePostgresql',
'App\Models\StandaloneMongodb',
@ -195,7 +195,7 @@ class="scrollbar hidden min-h-10 w-full flex-nowrap items-center gap-6 overflow-
$database->getMorphClass() === 'App\Models\StandaloneMongodb' ||
$database->getMorphClass() === 'App\Models\StandaloneMysql' ||
$database->getMorphClass() === 'App\Models\StandaloneMariadb')
<a class="shrink-0 {{ request()->routeIs('project.database.backup.index') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
<a class="shrink-0 {{ request()->routeIs('project.database.backup.*') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
href="{{ route('project.database.backup.index', $parameters) }}">
Backups
</a>

View file

@ -123,10 +123,8 @@ class="px-3 py-1 rounded-md text-xs font-medium tracking-wide shadow-xs bg-gray-
</div>
</a>
@else
<div x-show="search === '' || @js(strtolower($database->name)).includes(search.toLowerCase()) || @js(strtolower($backup->frequency)).includes(search.toLowerCase())" @class([
<a x-show="search === '' || @js(strtolower($database->name)).includes(search.toLowerCase()) || @js(strtolower($backup->frequency)).includes(search.toLowerCase())" @class([
'flex flex-col border-l-2 transition-colors p-4 cursor-pointer bg-white hover:bg-gray-100 dark:bg-coolgray-100 dark:hover:bg-coolgray-200 text-black dark:text-white',
'bg-gray-200 dark:bg-coolgray-200' =>
data_get($backup, 'id') === data_get($selectedBackup, 'id'),
'border-blue-500/50 border-dashed' =>
$backup->latest_log &&
data_get($backup->latest_log, 'status') === 'running',
@ -137,9 +135,8 @@ class="px-3 py-1 rounded-md text-xs font-medium tracking-wide shadow-xs bg-gray-
$backup->latest_log &&
data_get($backup->latest_log, 'status') === 'success',
'border-gray-200 dark:border-coolgray-300' => !$backup->latest_log,
'border-coollabs' =>
data_get($backup, 'id') === data_get($selectedBackup, 'id'),
]) wire:click="setSelectedBackup('{{ data_get($backup, 'id') }}')">
]) {{ wireNavigate() }}
href="{{ route('project.service.database.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]) }}">
@if ($backup->latest_log && data_get($backup->latest_log, 'status') === 'running')
<div class="absolute top-2 right-2">
<x-loading />
@ -224,19 +221,11 @@ class="px-3 py-1 rounded-md text-xs font-medium tracking-wide shadow-xs bg-gray-
@endif
@endif
</div>
</div>
</a>
@endif
@empty
<div>No scheduled backups configured.</div>
@endforelse
@endif
</div>
@if ($type === 'service-database' && $selectedBackup)
<div class="pt-10">
<livewire:project.database.backup-edit wire:key="{{ $selectedBackup->id }}" :backup="$selectedBackup"
:available-s3-storages="$s3s" :status="data_get($database, 'status')" />
<livewire:project.database.backup-executions wire:key="{{ $selectedBackup->uuid }}" :backup="$selectedBackup"
:database="$database" />
</div>
@endif
</div>

View file

@ -1,23 +1,65 @@
<div>
<livewire:project.service.heading :service="$service" :parameters="$parameters" :query="$query" />
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
<x-service-database.sidebar :parameters="$parameters" :serviceDatabase="$serviceDatabase" :isImportSupported="$isImportSupported" />
@if ($backup)
<div class="sub-menu-wrapper">
<a class="sub-menu-item" {{ wireNavigate() }}
href="{{ route('project.service.database.backups', $parameters) }}">
<span class="menu-item-label">Back</span>
</a>
<a @class(['sub-menu-item', 'menu-item-active' => $section === 'general']) {{ wireNavigate() }}
href="{{ route('project.service.database.backup.show', $backupParameters) }}">
<span class="menu-item-label">General</span>
</a>
<a @class(['sub-menu-item', 'menu-item-active' => $section === 's3']) {{ wireNavigate() }}
href="{{ route('project.service.database.backup.s3', $backupParameters) }}">
<span class="menu-item-label">S3</span>
</a>
<a @class(['sub-menu-item', 'menu-item-active' => $section === 'retention']) {{ wireNavigate() }}
href="{{ route('project.service.database.backup.retention', $backupParameters) }}">
<span class="menu-item-label">Retention</span>
</a>
<a @class(['sub-menu-item', 'menu-item-active' => $section === 'executions']) {{ wireNavigate() }}
href="{{ route('project.service.database.backup.executions', $backupParameters) }}">
<span class="menu-item-label">Executions</span>
</a>
<a @class(['sub-menu-item', 'menu-item-active' => $section === 'danger']) {{ wireNavigate() }}
href="{{ route('project.service.database.backup.danger', $backupParameters) }}">
<span class="menu-item-label">Danger Zone</span>
</a>
</div>
@else
<x-service-database.sidebar :parameters="$parameters" :serviceDatabase="$serviceDatabase"
:isImportSupported="$isImportSupported" />
@endif
<div class="w-full">
<x-slot:title>
{{ data_get_str($service, 'name')->limit(10) }} >
{{ data_get_str($serviceDatabase, 'name')->limit(10) }} > Backups | Coolify
</x-slot>
<div class="flex gap-2">
<h2 class="pb-4">Scheduled Backups</h2>
@if (filled($serviceDatabase->custom_type) || !$serviceDatabase->is_migrated)
@can('update', $serviceDatabase)
<x-modal-input buttonTitle="+ Add" title="New Scheduled Backup">
<livewire:project.database.create-scheduled-backup :database="$serviceDatabase" />
</x-modal-input>
@endcan
@if ($backup)
@if ($section === 'executions')
<livewire:project.database.backup-executions :backup="$backup" :database="$serviceDatabase" />
@else
<livewire:project.database.backup-edit :backup="$backup" :available-s3-storages="$s3s"
:status="data_get($serviceDatabase, 'status')" :section="$section"
wire:key="service-database-backup-{{ $backup->uuid }}-{{ $section }}" />
@endif
</div>
<livewire:project.database.scheduled-backups :database="$serviceDatabase" />
@else
<div class="flex gap-2">
<h2 class="pb-4">Scheduled Backups</h2>
@if (filled($serviceDatabase->custom_type) || !$serviceDatabase->is_migrated)
@can('update', $serviceDatabase)
<x-modal-input buttonTitle="+ Add" title="New Scheduled Backup">
<livewire:project.database.create-scheduled-backup :database="$serviceDatabase" />
</x-modal-input>
@endcan
@endif
</div>
<livewire:project.database.scheduled-backups :database="$serviceDatabase" />
@endif
</div>
</div>
</div>

View file

@ -19,7 +19,15 @@
@endif
<div class="flex flex-col justify-center text-sm select-text">
<div class="flex gap-2 md:flex-row flex-col">
<x-forms.input label="Source Path" :value="$fileStorage->fs_path" readonly />
<x-forms.input label="Source Path" :value="$fileStorage->fs_path" readonly>
<x-slot:labelSuffix>
@if ($hasEnabledBackup)
<x-status-badge :as="$backupUrl ? 'a' : 'span'" :href="$backupUrl"
status="Backup enabled" type="success"
:class="$backupUrl ? 'cursor-pointer underline' : null" />
@endif
</x-slot:labelSuffix>
</x-forms.input>
<x-forms.input label="Destination Path" :value="$fileStorage->mount_path" readonly />
</div>
</div>
@ -51,6 +59,14 @@
confirmationText="{{ $fs_path }}"
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
shortConfirmationLabel="Filepath" :confirmWithPassword="false" step2ButtonText="Convert to file" />
@if ($resource instanceof \App\Models\Application)
<x-modal-input buttonTitle="Configure Backup" title="Configure Directory Backup"
:wireIgnore="false">
<livewire:project.application.backup.create :application="$resource"
:selected-target-key="'directory:' . $fileStorage->id"
wire:key="configure-directory-backup-{{ $fileStorage->id }}" />
</x-modal-input>
@endif
<x-modal-confirmation :ignoreWire="false" title="Confirm Directory Deletion?" buttonTitle="Delete"
isErrorButton submitAction="delete" :checkboxes="$directoryDeletionCheckboxes" :actions="[
'The selected directory and all its contents will be permanently deleted from the container.',
@ -130,5 +146,16 @@
@endif
@endif
</form>
@if ($isReadOnly && $fileStorage->is_directory && $resource instanceof \App\Models\Application)
@can('update', $resource)
<div>
<x-modal-input buttonTitle="Configure Backup" title="Configure Directory Backup" :wireIgnore="false">
<livewire:project.application.backup.create :application="$resource"
:selected-target-key="'directory:' . $fileStorage->id"
wire:key="configure-readonly-directory-backup-{{ $fileStorage->id }}" />
</x-modal-input>
</div>
@endcan
@endif
</div>
</div>

View file

@ -23,7 +23,7 @@
['label' => 'Back', 'route' => 'project.service.configuration', 'active' => false, 'parameters' => [...$parameters, 'stack_service_uuid' => null]],
['label' => 'General', 'route' => 'project.service.index', 'active' => request()->routeIs('project.service.index')],
['label' => 'Advanced', 'route' => 'project.service.index.advanced', 'active' => request()->routeIs('project.service.index.advanced')],
['label' => 'Backups', 'route' => 'project.service.database.backups', 'active' => request()->routeIs('project.service.database.backups')],
['label' => 'Backups', 'route' => 'project.service.database.backups', 'active' => request()->routeIs('project.service.database.backup*')],
['label' => 'Import Backup', 'route' => 'project.service.database.import', 'active' => request()->routeIs('project.service.database.import')],
];
}

View file

@ -55,6 +55,7 @@ class="relative flex max-h-[85vh] w-full flex-col rounded-sm border border-neutr
<h3 class="text-2xl font-bold text-black dark:text-white">Configuration changes</h3>
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
These changes are not applied to the latest deployment yet.
(If you think it is incorrect, just ignore)
</p>
</div>
<button type="button" @click="configurationDiffModalOpen = false"

View file

@ -15,7 +15,9 @@
helper="Warning: Changing the volume name after the initial start could cause problems. Only use it when you know what are you doing.">
<x-slot:labelSuffix>
@if ($hasEnabledBackup)
<x-status-badge status="Backup enabled" type="success" />
<x-status-badge :as="$backupUrl ? 'a' : 'span'" :href="$backupUrl"
status="Backup enabled" type="success"
:class="$backupUrl ? 'cursor-pointer underline' : null" />
@endif
</x-slot:labelSuffix>
</x-forms.input>
@ -24,7 +26,9 @@
helper="Warning: Changing the volume name after the initial start could cause problems. Only use it when you know what are you doing.">
<x-slot:labelSuffix>
@if ($hasEnabledBackup)
<x-status-badge status="Backup enabled" type="success" />
<x-status-badge :as="$backupUrl ? 'a' : 'span'" :href="$backupUrl"
status="Backup enabled" type="success"
:class="$backupUrl ? 'cursor-pointer underline' : null" />
@endif
</x-slot:labelSuffix>
</x-forms.input>
@ -48,7 +52,9 @@
<x-forms.input id="name" :label="$hasEnabledBackup ? 'Volume Name' : null" required readonly>
<x-slot:labelSuffix>
@if ($hasEnabledBackup)
<x-status-badge status="Backup enabled" type="success" />
<x-status-badge :as="$backupUrl ? 'a' : 'span'" :href="$backupUrl"
status="Backup enabled" type="success"
:class="$backupUrl ? 'cursor-pointer underline' : null" />
@endif
</x-slot:labelSuffix>
</x-forms.input>
@ -69,7 +75,7 @@
@can('update', $resource)
<x-modal-input buttonTitle="Configure Backup" title="Configure Volume Backup" :wireIgnore="false">
<livewire:project.application.backup.create :application="$resource"
:selected-volume-id="$storage->id"
:selected-target-key="'volume:' . $storage->id"
wire:key="configure-readonly-volume-backup-{{ $storage->id }}" />
</x-modal-input>
@endcan
@ -81,7 +87,9 @@
<x-forms.input id="name" label="Volume Name" required>
<x-slot:labelSuffix>
@if ($hasEnabledBackup)
<x-status-badge status="Backup enabled" type="success" />
<x-status-badge :as="$backupUrl ? 'a' : 'span'" :href="$backupUrl"
status="Backup enabled" type="success"
:class="$backupUrl ? 'cursor-pointer underline' : null" />
@endif
</x-slot:labelSuffix>
</x-forms.input>
@ -94,7 +102,9 @@
<x-forms.input id="name" :label="$hasEnabledBackup ? 'Volume Name' : null" required>
<x-slot:labelSuffix>
@if ($hasEnabledBackup)
<x-status-badge status="Backup enabled" type="success" />
<x-status-badge :as="$backupUrl ? 'a' : 'span'" :href="$backupUrl"
status="Backup enabled" type="success"
:class="$backupUrl ? 'cursor-pointer underline' : null" />
@endif
</x-slot:labelSuffix>
</x-forms.input>
@ -116,7 +126,7 @@
@if ($resource instanceof \App\Models\Application)
<x-modal-input buttonTitle="Configure Backup" title="Configure Volume Backup" :wireIgnore="false">
<livewire:project.application.backup.create :application="$resource"
:selected-volume-id="$storage->id"
:selected-target-key="'volume:' . $storage->id"
wire:key="configure-volume-backup-{{ $storage->id }}" />
</x-modal-input>
@endif
@ -134,7 +144,9 @@
<x-forms.input id="name" label="Volume Name" required disabled>
<x-slot:labelSuffix>
@if ($hasEnabledBackup)
<x-status-badge status="Backup enabled" type="success" />
<x-status-badge :as="$backupUrl ? 'a' : 'span'" :href="$backupUrl"
status="Backup enabled" type="success"
:class="$backupUrl ? 'cursor-pointer underline' : null" />
@endif
</x-slot:labelSuffix>
</x-forms.input>
@ -148,7 +160,9 @@
<x-forms.input id="name" :label="$hasEnabledBackup ? 'Volume Name' : null" required disabled>
<x-slot:labelSuffix>
@if ($hasEnabledBackup)
<x-status-badge status="Backup enabled" type="success" />
<x-status-badge :as="$backupUrl ? 'a' : 'span'" :href="$backupUrl"
status="Backup enabled" type="success"
:class="$backupUrl ? 'cursor-pointer underline' : null" />
@endif
</x-slot:labelSuffix>
</x-forms.input>

View file

@ -1,331 +1,14 @@
<div class="flex flex-col gap-4">
<form wire:submit="save" class="flex flex-col gap-4">
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<div>
<div class="flex gap-3">
<h2>Scheduled Backup</h2>
<div class="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<x-forms.button type="submit" class="w-full sm:w-auto">Save</x-forms.button>
@if (!$enabled)
<x-forms.button type="button" wire:click="toggleEnabled" wire:loading.attr="disabled"
wire:target="toggleEnabled" isHighlighted>Enable Backup</x-forms.button>
@else
<x-forms.button type="button" wire:click="toggleEnabled" wire:loading.attr="disabled"
wire:target="toggleEnabled">Disable Backup</x-forms.button>
@endif
<x-forms.button type="button" wire:click="backupNow" class="w-full sm:w-auto">Backup Now</x-forms.button>
@if ($backup)
<x-modal-confirmation title="Confirm Backup Schedule Deletion?" isErrorButton submitAction="delete"
:actions="['The selected backup schedule will be deleted.', 'All local and S3 archives created by this schedule will be deleted.']"
confirmationText="{{ $storage->name }}"
confirmationLabel="Please confirm the execution of the actions by entering the Volume Name below"
shortConfirmationLabel="Volume Name">
<x-slot:trigger>
<x-forms.button isError class="w-full sm:w-auto">Delete Backups and Schedule</x-forms.button>
</x-slot:trigger>
</x-modal-confirmation>
@endif
</div>
</div>
<p class="pt-1 text-sm text-neutral-600 dark:text-neutral-400">
Persistent volume:
<span class="font-medium text-neutral-800 dark:text-neutral-200">{{ $storage->name }}</span>
</p>
</div>
<div class="p-3 text-sm rounded bg-warning/10 text-warning">
Backups made while the application is writing to this volume may be inconsistent or corrupted. You can
gracefully stop containers during the archive step for a safer file-level backup, but this briefly
interrupts the application.
</div>
</div>
<div class="w-full max-w-md">
<x-forms.checkbox instantSave id="stopDuringBackup" label="Stop containers while creating the archive"
helper="Off by default. Containers using this volume are gracefully stopped and restarted immediately after the archive is created." />
@if ($availableS3Storages->isNotEmpty())
<x-forms.checkbox instantSave id="saveToS3" label="S3 Enabled" />
@elseif ($saveToS3)
<x-forms.checkbox instantSave id="saveToS3" label="S3 Enabled"
helper="The configured S3 storage is no longer available. Disable S3 backups or configure a usable S3 storage." />
@else
<x-forms.checkbox instantSave id="saveToS3" label="S3 Enabled"
helper="No validated S3 storage available." disabled />
@endif
@if ($saveToS3)
<x-forms.checkbox instantSave id="disableLocalBackup" label="Disable Local Backup"
helper="When enabled, backup files are deleted locally after a successful S3 upload." />
@else
<x-forms.checkbox id="disableLocalBackup" label="Disable Local Backup"
helper="When enabled, backup files are deleted locally after a successful S3 upload." disabled />
@endif
</div>
<div class="w-full max-w-md pb-2">
<div class="flex items-center gap-1 mb-1 text-sm font-medium">
<span>S3 Storage</span>
@if (!$saveToS3)
<span class="text-xs font-normal text-warning">(currently disabled)</span>
@else
<x-highlighted text="*" />
@endif
</div>
<x-forms.select id="s3StorageId" wire:model.live="s3StorageId" :required="$saveToS3"
:disabled="$availableS3Storages->isEmpty()">
@if ($availableS3Storages->isEmpty())
<option value="">No S3 storage available</option>
@else
@foreach ($availableS3Storages as $s3Storage)
<option value="{{ $s3Storage->id }}">{{ $s3Storage->name }}</option>
@endforeach
@endif
</x-forms.select>
</div>
<div class="flex flex-col gap-4">
<h3>Settings</h3>
<div class="grid grid-cols-1 gap-2 md:grid-cols-3">
<x-forms.input id="frequency" label="Frequency" required
helper="Use every_minute, hourly, daily, weekly, monthly, yearly, or a cron expression." />
<x-forms.input id="timezone" label="Timezone" disabled
helper="The timezone of the server where the backup is scheduled to run (if not set, the instance timezone will be used)" required />
<x-forms.input id="timeout" type="number" min="60" max="36000" label="Timeout"
helper="The timeout of the backup job in seconds." required />
</div>
<h3 class="mt-6 mb-2 text-lg font-medium">Backup Retention Settings</h3>
<div class="mb-4">
<ul class="pl-6 space-y-2 list-disc">
<li>Setting a value to 0 means unlimited retention.</li>
<li>The retention rules work independently - whichever limit is reached first will trigger cleanup.</li>
</ul>
</div>
<div class="flex flex-col gap-6">
<div>
<h4 class="mb-3 font-medium">Local Backup Retention</h4>
<div class="grid grid-cols-1 gap-2 md:grid-cols-3">
<x-forms.input label="Number of backups to keep" id="retentionAmountLocally" type="number"
min="0"
helper="Keeps only the specified number of most recent backups on the server. Set to 0 for unlimited backups."
required />
<x-forms.input label="Days to keep backups" id="retentionDaysLocally" type="number"
min="0"
helper="Automatically removes backups older than the specified number of days. Set to 0 for no time limit."
required />
<x-forms.input label="Maximum storage (GB)" id="retentionMaxStorageLocally" type="number"
min="0" step="any"
helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.001 for 1MB). Set to 0 for unlimited storage."
required />
</div>
</div>
@if ($saveToS3)
<div>
<h4 class="mb-3 font-medium">S3 Storage Retention</h4>
<div class="grid grid-cols-1 gap-2 md:grid-cols-3">
<x-forms.input label="Number of backups to keep" id="retentionAmountS3" type="number"
min="0"
helper="Keeps only the specified number of most recent backups on S3 storage. Set to 0 for unlimited backups."
required />
<x-forms.input label="Days to keep backups" id="retentionDaysS3" type="number"
min="0"
helper="Automatically removes S3 backups older than the specified number of days. Set to 0 for no time limit."
required />
<x-forms.input label="Maximum storage (GB)" id="retentionMaxStorageS3" type="number"
min="0" step="any"
helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.5 for 500MB). Set to 0 for unlimited storage."
required />
</div>
</div>
@endif
</div>
</div>
</form>
@if ($backup)
@php
$executionCount = method_exists($executions, 'total') ? $executions->total() : $executions->count();
$currentPage = method_exists($executions, 'currentPage') ? $executions->currentPage() : 1;
$lastPage = method_exists($executions, 'lastPage') ? $executions->lastPage() : 1;
@endphp
<div wire:poll.5000ms="$refresh">
<div class="flex flex-col gap-3 py-4 sm:flex-row sm:flex-wrap sm:items-center">
<h3 class="py-0">Executions <span class="text-xs">({{ $executionCount }})</span></h3>
@if ($executionCount > 0)
<div class="flex items-center gap-2">
<x-forms.button :disabled="$currentPage === 1" wire:click="previousPage">
<svg class="w-4 h-4" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round"
stroke-linejoin="round" stroke-width="2" d="m14 6l-6 6l6 6z" />
</svg>
</x-forms.button>
<span class="px-2 text-sm text-gray-600 dark:text-gray-400">
Page {{ $currentPage }} of {{ $lastPage }}
</span>
<x-forms.button :disabled="$currentPage === $lastPage" wire:click="nextPage">
<svg class="w-4 h-4" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round"
stroke-linejoin="round" stroke-width="2" d="m10 18l6-6l-6-6z" />
</svg>
</x-forms.button>
</div>
@endif
<div class="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<x-forms.button wire:click="cleanupFailed" class="w-full sm:w-auto">
Cleanup Failed Backups
</x-forms.button>
<x-modal-confirmation title="Cleanup Deleted Backup Entries?" isErrorButton
submitAction="cleanupDeleted()"
:actions="['This permanently deletes backup execution entries whose local and S3 files have already been deleted.', 'This only removes database entries, not backup files.']"
confirmationText="cleanup deleted backups"
confirmationLabel="Please confirm by typing 'cleanup deleted backups' below"
shortConfirmationLabel="Confirmation">
<x-slot:trigger>
<x-forms.button isError class="w-full sm:w-auto">Cleanup Deleted</x-forms.button>
</x-slot:trigger>
</x-modal-confirmation>
</div>
</div>
<div class="flex flex-col gap-4">
@forelse ($executions as $execution)
<div wire:key="volume-backup-execution-{{ $execution->id }}" @class([
'relative flex flex-col border-l-2 transition-colors p-4 bg-white dark:bg-coolgray-100 text-black dark:text-white',
'border-blue-500/50 border-dashed' => $execution->status === 'running',
'border-error' => $execution->status === 'failed',
'border-success' => $execution->status === 'success',
])>
@if ($execution->status === 'running')
<div class="absolute top-2 right-2"><x-loading /></div>
@endif
<div class="flex items-center gap-2 mb-2">
<span @class([
'px-3 py-1 rounded-md text-xs font-medium tracking-wide shadow-xs',
'bg-blue-100/80 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300' => $execution->status === 'running',
'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200' => $execution->status === 'failed',
'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-200' => $execution->status === 'success' && $execution->s3_uploaded === false,
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200' => $execution->status === 'success' && $execution->s3_uploaded !== false,
])>
@if ($execution->status === 'running')
In Progress
@elseif ($execution->status === 'success' && $execution->s3_uploaded === false)
Success (S3 Warning)
@else
{{ ucfirst($execution->status) }}
@endif
</span>
</div>
<div class="text-sm text-gray-600 dark:text-gray-400">
@if ($execution->status === 'running')
<span title="Started: {{ $execution->created_at->toDateTimeString() }}">
Running for {{ calculateDuration($execution->created_at, now()) }}
</span>
@else
@php
$finishedAt = $execution->finished_at ?? $execution->updated_at;
@endphp
<span title="Started: {{ $execution->created_at->toDateTimeString() }}&#10;Ended: {{ $finishedAt->toDateTimeString() }}">
{{ $finishedAt->diffForHumans() }}
({{ calculateDuration($execution->created_at, $finishedAt) }})
{{ $finishedAt->format('M j, H:i') }}
</span>
@endif
Volume: {{ $storage->name }}
@if ($execution->size > 0)
Size: {{ formatBytes($execution->size) }}
@endif
</div>
<div class="text-sm text-gray-600 dark:text-gray-400">
Location: {{ $execution->filename ?? 'N/A' }}
</div>
<div class="flex flex-col gap-2 mt-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
<div class="text-sm text-gray-600 dark:text-gray-400">Backup Availability:</div>
<span @class([
'px-2 py-1 rounded-sm text-xs font-medium',
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200' => !$execution->local_storage_deleted,
'bg-gray-100 text-gray-600 dark:bg-gray-800/50 dark:text-gray-400' => $execution->local_storage_deleted,
])>
<span class="flex items-center gap-1">
@if (!$execution->local_storage_deleted)
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
@else
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
@endif
Local Storage
</span>
</span>
@if ($execution->s3_uploaded !== null)
<span @class([
'px-2 py-1 rounded-sm text-xs font-medium',
'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200' => $execution->s3_uploaded === false && !$execution->s3_storage_deleted,
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200' => $execution->s3_uploaded === true && !$execution->s3_storage_deleted,
'bg-gray-100 text-gray-600 dark:bg-gray-800/50 dark:text-gray-400' => $execution->s3_storage_deleted,
])>
<span class="flex items-center gap-1">
@if ($execution->s3_uploaded === true && !$execution->s3_storage_deleted)
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
@else
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
@endif
S3 Storage
</span>
</span>
@endif
</div>
@if ($execution->message)
<div class="p-2 mt-2 bg-gray-100 rounded-sm dark:bg-coolgray-200">
<pre class="text-sm whitespace-pre-wrap">{{ $execution->message }}</pre>
</div>
@endif
@if ($execution->status !== 'running')
<div class="grid grid-cols-2 gap-2 mt-4 sm:flex sm:flex-wrap">
@if ($execution->status === 'success' && !$execution->local_storage_deleted)
<x-forms.button class="w-full dark:hover:bg-coolgray-400 sm:w-auto"
x-on:click="download_volume_backup_file('{{ $execution->id }}')">
Download
</x-forms.button>
@endif
@php
$executionCheckboxes = [];
$deleteActions = [];
if (!$execution->local_storage_deleted) {
$deleteActions[] = 'This backup will be permanently deleted from local storage.';
}
if ($execution->s3_uploaded === true && !$execution->s3_storage_deleted) {
$executionCheckboxes[] = ['id' => 'delete_backup_s3', 'label' => 'Delete the selected backup permanently from S3 Storage'];
}
if (empty($deleteActions)) {
$deleteActions[] = 'This backup execution record will be deleted.';
}
@endphp
<x-modal-confirmation title="Confirm Backup Deletion?" isErrorButton
submitAction="deleteBackup({{ $execution->id }})"
:checkboxes="$executionCheckboxes" :actions="$deleteActions"
confirmationText="{{ $execution->filename }}"
confirmationLabel="Please confirm the execution of the actions by entering the Backup Filename below"
shortConfirmationLabel="Backup Filename">
<x-slot:trigger>
<x-forms.button isError class="w-full sm:w-auto">Delete</x-forms.button>
</x-slot:trigger>
</x-modal-confirmation>
</div>
@endif
</div>
@empty
<div class="p-4 bg-gray-100 rounded-sm dark:bg-coolgray-100">No executions found.</div>
@endforelse
</div>
</div>
<div>
@if ($section === 'retention')
@include('livewire.project.shared.storages.volume-backups.retention')
@elseif ($section === 's3')
@include('livewire.project.shared.storages.volume-backups.s3')
@elseif ($section === 'executions')
@include('livewire.project.shared.storages.volume-backups.executions')
@elseif ($section === 'danger')
@include('livewire.project.shared.storages.volume-backups.danger')
@else
@include('livewire.project.shared.storages.volume-backups.general')
@endif
</div>

View file

@ -0,0 +1,24 @@
<div class="flex flex-col gap-6">
<div>
<h2>Danger Zone</h2>
<div class="text-neutral-500">Woah. I hope you know what you are doing.</div>
</div>
<div>
<h3>Delete Scheduled Backup</h3>
<div class="pb-4 text-neutral-500">
This permanently deletes the schedule and all local and S3 archives created by it. There is no coming back.
</div>
@if ($backup)
<x-modal-confirmation title="Confirm Backup Schedule Deletion?" isErrorButton submitAction="delete"
:actions="['The selected backup schedule will be deleted.', 'All local and S3 archives created by this schedule will be deleted.']"
confirmationText="{{ $backup->targetName() }}"
confirmationLabel="Please confirm by entering the {{ $backup->targetType() }} identifier below"
shortConfirmationLabel="{{ $backup->targetType() }} identifier">
<x-slot:trigger>
<x-forms.button isError>Delete Backups and Schedule</x-forms.button>
</x-slot:trigger>
</x-modal-confirmation>
@endif
</div>
</div>

View file

@ -0,0 +1,183 @@
@if ($backup)
@php
$executionCount = method_exists($executions, 'total') ? $executions->total() : $executions->count();
$currentPage = method_exists($executions, 'currentPage') ? $executions->currentPage() : 1;
$lastPage = method_exists($executions, 'lastPage') ? $executions->lastPage() : 1;
@endphp
<div wire:poll.5000ms="$refresh">
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<h2>Executions</h2>
@if ($executionCount > 0)
<div class="flex items-center gap-2">
<x-forms.button :disabled="$currentPage === 1" wire:click="previousPage">
<svg class="w-4 h-4" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round"
stroke-linejoin="round" stroke-width="2" d="m14 6l-6 6l6 6z" />
</svg>
</x-forms.button>
<span class="px-2 text-sm text-gray-600 dark:text-gray-400">
Page {{ $currentPage }} of {{ $lastPage }}
</span>
<x-forms.button :disabled="$currentPage === $lastPage" wire:click="nextPage">
<svg class="w-4 h-4" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round"
stroke-linejoin="round" stroke-width="2" d="m10 18l6-6l-6-6z" />
</svg>
</x-forms.button>
</div>
@endif
<div class="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<x-forms.button wire:click="cleanupFailed" class="w-full sm:w-auto">
Cleanup Failed Backups
</x-forms.button>
<x-modal-confirmation title="Cleanup Deleted Backup Entries?" isErrorButton
submitAction="cleanupDeleted()"
:actions="['This permanently deletes backup execution entries whose local and S3 files have already been deleted.', 'This only removes database entries, not backup files.']"
confirmationText="cleanup deleted backups"
confirmationLabel="Please confirm by typing 'cleanup deleted backups' below"
shortConfirmationLabel="Confirmation">
<x-slot:trigger>
<x-forms.button isError class="w-full sm:w-auto">Cleanup Deleted</x-forms.button>
</x-slot:trigger>
</x-modal-confirmation>
</div>
</div>
<div class="flex flex-col gap-4 pt-2">
@forelse ($executions as $execution)
<div wire:key="volume-backup-execution-{{ $execution->id }}" @class([
'relative flex flex-col border-l-2 transition-colors p-4 bg-white dark:bg-coolgray-100 text-black dark:text-white',
'border-blue-500/50 border-dashed' => $execution->status === 'running',
'border-error' => $execution->status === 'failed',
'border-success' => $execution->status === 'success',
])>
@if ($execution->status === 'running')
<div class="absolute top-2 right-2"><x-loading /></div>
@endif
<div class="flex items-center gap-2 mb-2">
<span @class([
'px-3 py-1 rounded-md text-xs font-medium tracking-wide shadow-xs',
'bg-blue-100/80 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300' => $execution->status === 'running',
'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200' => $execution->status === 'failed',
'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-200' => $execution->status === 'success' && $execution->s3_uploaded === false,
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200' => $execution->status === 'success' && $execution->s3_uploaded !== false,
])>
@if ($execution->status === 'running')
In Progress
@elseif ($execution->status === 'success' && $execution->s3_uploaded === false)
Success (S3 Warning)
@else
{{ ucfirst($execution->status) }}
@endif
</span>
</div>
<div class="text-sm text-gray-600 dark:text-gray-400">
@if ($execution->status === 'running')
<span title="Started: {{ $execution->created_at->toDateTimeString() }}">
Running for {{ calculateDuration($execution->created_at, now()) }}
</span>
@else
@php
$finishedAt = $execution->finished_at ?? $execution->updated_at;
@endphp
<span title="Started: {{ $execution->created_at->toDateTimeString() }}&#10;Ended: {{ $finishedAt->toDateTimeString() }}">
{{ $finishedAt->diffForHumans() }}
({{ calculateDuration($execution->created_at, $finishedAt) }})
{{ $finishedAt->format('M j, H:i') }}
</span>
@endif
{{ $backup->targetType() }}: {{ $backup->targetName() }}
@if ($execution->size > 0)
Size: {{ formatBytes($execution->size) }}
@endif
</div>
<div class="text-sm text-gray-600 dark:text-gray-400">
Location: {{ $execution->filename ?? 'N/A' }}
</div>
<div class="flex flex-col gap-2 mt-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
<div class="text-sm text-gray-600 dark:text-gray-400">Backup Availability:</div>
<span @class([
'px-2 py-1 rounded-sm text-xs font-medium',
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200' => !$execution->local_storage_deleted,
'bg-gray-100 text-gray-600 dark:bg-gray-800/50 dark:text-gray-400' => $execution->local_storage_deleted,
])>
<span class="flex items-center gap-1">
@if (!$execution->local_storage_deleted)
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
@else
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
@endif
Local Storage
</span>
</span>
@if ($execution->s3_uploaded !== null)
<span @class([
'px-2 py-1 rounded-sm text-xs font-medium',
'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200' => $execution->s3_uploaded === false && !$execution->s3_storage_deleted,
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200' => $execution->s3_uploaded === true && !$execution->s3_storage_deleted,
'bg-gray-100 text-gray-600 dark:bg-gray-800/50 dark:text-gray-400' => $execution->s3_storage_deleted,
])>
<span class="flex items-center gap-1">
@if ($execution->s3_uploaded === true && !$execution->s3_storage_deleted)
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
@else
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
@endif
S3 Storage
</span>
</span>
@endif
</div>
@if ($execution->message)
<div class="p-2 mt-2 bg-gray-100 rounded-sm dark:bg-coolgray-200">
<pre class="text-sm whitespace-pre-wrap">{{ $execution->message }}</pre>
</div>
@endif
@if ($execution->status !== 'running')
<div class="grid grid-cols-2 gap-2 mt-4 sm:flex sm:flex-wrap">
@if ($execution->status === 'success' && !$execution->local_storage_deleted)
<x-forms.button class="w-full dark:hover:bg-coolgray-400 sm:w-auto"
x-on:click="download_volume_backup_file('{{ $execution->id }}')">
Download
</x-forms.button>
@endif
@php
$executionCheckboxes = [];
$deleteActions = [];
if (!$execution->local_storage_deleted) {
$deleteActions[] = 'This backup will be permanently deleted from local storage.';
}
if ($execution->s3_uploaded === true && !$execution->s3_storage_deleted) {
$executionCheckboxes[] = ['id' => 'delete_backup_s3', 'label' => 'Delete the selected backup permanently from S3 Storage'];
}
if (empty($deleteActions)) {
$deleteActions[] = 'This backup execution record will be deleted.';
}
@endphp
<x-modal-confirmation title="Confirm Backup Deletion?" isErrorButton
submitAction="deleteBackup({{ $execution->id }})"
:checkboxes="$executionCheckboxes" :actions="$deleteActions"
confirmationText="{{ $execution->filename }}"
confirmationLabel="Please confirm the execution of the actions by entering the Backup Filename below"
shortConfirmationLabel="Backup Filename">
<x-slot:trigger>
<x-forms.button isError class="w-full sm:w-auto">Delete</x-forms.button>
</x-slot:trigger>
</x-modal-confirmation>
</div>
@endif
</div>
@empty
<div class="p-4 bg-gray-100 rounded-sm dark:bg-coolgray-100">No executions found.</div>
@endforelse
</div>
</div>
@endif

View file

@ -0,0 +1,47 @@
<form wire:submit="save" class="flex flex-col gap-4">
<div>
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<h2>General</h2>
<div class="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<x-forms.button type="submit" class="w-full sm:w-auto">Save</x-forms.button>
@if (!$enabled)
<x-forms.button type="button" wire:click="toggleEnabled" wire:loading.attr="disabled"
wire:target="toggleEnabled" isHighlighted>Enable Backup</x-forms.button>
@else
<x-forms.button type="button" wire:click="toggleEnabled" wire:loading.attr="disabled"
wire:target="toggleEnabled">Disable Backup</x-forms.button>
@endif
<x-forms.button type="button" wire:click="backupNow" class="w-full sm:w-auto">Backup Now</x-forms.button>
</div>
</div>
<p class="pt-1 text-sm text-neutral-600 dark:text-neutral-400">
{{ $backup?->targetType() ?? ($storage instanceof \App\Models\LocalFileVolume ? 'Directory' : 'Volume') }}:
<span class="font-medium text-neutral-800 dark:text-neutral-200">
{{ $backup?->targetName() ?? ($storage instanceof \App\Models\LocalFileVolume ? $storage->fs_path : $storage->name) }}
</span>
</p>
</div>
<div class="p-3 text-sm rounded bg-warning/10 text-warning">
Backups made while the application is writing to this storage may be inconsistent or corrupted. You can
gracefully stop containers during the archive step for a safer file-level backup, but this briefly
interrupts the application.
</div>
<div class="w-full max-w-md">
<x-forms.checkbox instantSave id="stopDuringBackup" label="Stop containers while creating the archive"
helper="Off by default. Containers using this storage are gracefully stopped and restarted immediately after the archive is created." />
</div>
<div class="flex flex-col gap-4">
<div class="grid grid-cols-1 gap-2 md:grid-cols-3">
<x-forms.input id="frequency" label="Frequency" required
helper="Use every_minute, hourly, daily, weekly, monthly, yearly, or a cron expression." />
<x-forms.input id="timezone" label="Timezone" disabled
helper="The timezone of the server where the backup is scheduled to run (if not set, the instance timezone will be used)" required />
<x-forms.input id="timeout" type="number" min="60" max="36000" label="Timeout"
helper="The timeout of the backup job in seconds." required />
</div>
</div>
</form>

View file

@ -0,0 +1,33 @@
<form wire:submit="save" class="flex flex-col gap-4">
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<h2>Retention</h2>
<x-forms.button type="submit" class="w-full sm:w-auto">Save</x-forms.button>
</div>
<div>
<ul class="pl-6 space-y-2 list-disc">
<li>Setting a value to 0 means unlimited retention.</li>
<li>The retention rules work independently - whichever limit is reached first will trigger cleanup.</li>
</ul>
</div>
<div class="flex flex-col gap-6">
<div>
<h3 class="mb-3">Local Backup Retention</h3>
<div class="grid grid-cols-1 gap-2 md:grid-cols-3">
<x-forms.input label="Number of backups to keep" id="retentionAmountLocally" type="number"
min="0"
helper="Keeps only the specified number of most recent backups on the server. Set to 0 for unlimited backups."
required />
<x-forms.input label="Days to keep backups" id="retentionDaysLocally" type="number"
min="0"
helper="Automatically removes backups older than the specified number of days. Set to 0 for no time limit."
required />
<x-forms.input label="Maximum storage (GB)" id="retentionMaxStorageLocally" type="number"
min="0" step="any"
helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.001 for 1MB). Set to 0 for unlimited storage."
required />
</div>
</div>
</div>
</form>

View file

@ -0,0 +1,60 @@
<form wire:submit="save" class="flex flex-col gap-4">
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<h2>S3</h2>
<x-forms.button type="submit" class="w-full sm:w-auto">Save</x-forms.button>
@if (!$saveToS3)
<x-forms.button type="button" wire:click="toggleS3" wire:loading.attr="disabled"
wire:target="toggleS3" isHighlighted :disabled="$availableS3Storages->isEmpty()">Enable S3</x-forms.button>
@else
<x-forms.button type="button" wire:click="toggleS3" wire:loading.attr="disabled"
wire:target="toggleS3">Disable S3</x-forms.button>
@endif
</div>
<div class="w-full max-w-md pb-2">
<div class="flex items-center gap-1 mb-1 text-sm font-medium">
<span>S3 Storage</span>
@if (!$saveToS3)
<span class="text-xs font-normal text-warning">(currently disabled)</span>
@else
<x-highlighted text="*" />
@endif
</div>
<x-forms.select id="s3StorageId" wire:model.live="s3StorageId" :required="$saveToS3"
:disabled="$availableS3Storages->isEmpty()">
@if ($availableS3Storages->isEmpty())
<option value="">No S3 storage available</option>
@else
@foreach ($availableS3Storages as $s3Storage)
<option value="{{ $s3Storage->id }}">{{ $s3Storage->name }}</option>
@endforeach
@endif
</x-forms.select>
</div>
<div class="w-full max-w-md">
@if ($saveToS3)
<x-forms.checkbox instantSave id="disableLocalBackup" label="Disable Local Backup"
helper="When enabled, backup files are deleted locally after a successful S3 upload." />
@else
<x-forms.checkbox id="disableLocalBackup" label="Disable Local Backup"
helper="When enabled, backup files are deleted locally after a successful S3 upload." disabled />
@endif
</div>
<div>
<h3 class="mb-3">S3 Storage Retention</h3>
<div class="grid grid-cols-1 gap-2 md:grid-cols-3">
<x-forms.input label="Number of backups to keep" id="retentionAmountS3" type="number" min="0"
helper="Keeps only the specified number of most recent backups on S3 storage. Set to 0 for unlimited backups."
required />
<x-forms.input label="Days to keep backups" id="retentionDaysS3" type="number" min="0"
helper="Automatically removes S3 backups older than the specified number of days. Set to 0 for no time limit."
required />
<x-forms.input label="Maximum storage (GB)" id="retentionMaxStorageS3" type="number" min="0"
step="any"
helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.5 for 500MB). Set to 0 for unlimited storage."
required />
</div>
</div>
</form>

View file

@ -20,6 +20,7 @@
use App\Http\Controllers\Api\ServicesController;
use App\Http\Controllers\Api\TagsController;
use App\Http\Controllers\Api\TeamController;
use App\Http\Controllers\Api\VolumeBackupsController;
use App\Http\Controllers\Api\VultrController;
use App\Http\Middleware\ApiAllowed;
use Illuminate\Support\Facades\Route;
@ -150,6 +151,9 @@
Route::post('/applications/{uuid}/storages', [ApplicationsController::class, 'create_storage'])->middleware(['api.ability:write']);
Route::patch('/applications/{uuid}/storages', [ApplicationsController::class, 'update_storage'])->middleware(['api.ability:write']);
Route::delete('/applications/{uuid}/storages/{storage_uuid}', [ApplicationsController::class, 'delete_storage'])->middleware(['api.ability:write']);
Route::put('/applications/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'upsert'])
->defaults('resource_type', 'application')
->middleware(['api.ability:write']);
Route::get('/applications/{uuid}/tags', [ApplicationsController::class, 'tags'])->middleware(['api.ability:read']);
Route::post('/applications/{uuid}/tags', [ApplicationsController::class, 'create_tag'])->middleware(['api.ability:write']);
@ -194,6 +198,9 @@
Route::post('/databases/{uuid}/storages', [DatabasesController::class, 'create_storage'])->middleware(['api.ability:write']);
Route::patch('/databases/{uuid}/storages', [DatabasesController::class, 'update_storage'])->middleware(['api.ability:write']);
Route::delete('/databases/{uuid}/storages/{storage_uuid}', [DatabasesController::class, 'delete_storage'])->middleware(['api.ability:write']);
Route::put('/databases/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'upsert'])
->defaults('resource_type', 'database')
->middleware(['api.ability:write']);
Route::get('/databases/{uuid}/envs', [DatabasesController::class, 'envs'])->middleware(['api.ability:read']);
Route::post('/databases/{uuid}/envs', [DatabasesController::class, 'create_env'])->middleware(['api.ability:write']);
@ -221,6 +228,9 @@
Route::post('/services/{uuid}/storages', [ServicesController::class, 'create_storage'])->middleware(['api.ability:write']);
Route::patch('/services/{uuid}/storages', [ServicesController::class, 'update_storage'])->middleware(['api.ability:write']);
Route::delete('/services/{uuid}/storages/{storage_uuid}', [ServicesController::class, 'delete_storage'])->middleware(['api.ability:write']);
Route::put('/services/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'upsert'])
->defaults('resource_type', 'service')
->middleware(['api.ability:write']);
Route::get('/services/{uuid}/envs', [ServicesController::class, 'envs'])->middleware(['api.ability:read']);
Route::post('/services/{uuid}/envs', [ServicesController::class, 'create_env'])->middleware(['api.ability:write']);

View file

@ -229,6 +229,10 @@
Route::get('/persistent-storage', ApplicationConfiguration::class)->name('project.application.persistent-storage');
Route::get('/backups', ApplicationBackupIndex::class)->name('project.application.backup.index');
Route::get('/backups/{backup_uuid}', ApplicationBackupShow::class)->name('project.application.backup.show');
Route::get('/backups/{backup_uuid}/s3', ApplicationBackupShow::class)->name('project.application.backup.s3');
Route::get('/backups/{backup_uuid}/retention', ApplicationBackupShow::class)->name('project.application.backup.retention');
Route::get('/backups/{backup_uuid}/executions', ApplicationBackupShow::class)->name('project.application.backup.executions');
Route::get('/backups/{backup_uuid}/danger', ApplicationBackupShow::class)->name('project.application.backup.danger');
Route::get('/source', ApplicationConfiguration::class)->name('project.application.source');
Route::get('/servers', ApplicationConfiguration::class)->name('project.application.servers');
Route::get('/scheduled-tasks', ApplicationConfiguration::class)->name('project.application.scheduled-tasks.show');
@ -266,6 +270,10 @@
Route::get('/terminal', ExecuteContainerCommand::class)->name('project.database.command')->middleware('can.access.terminal');
Route::get('/backups', DatabaseBackupIndex::class)->name('project.database.backup.index');
Route::get('/backups/{backup_uuid}', DatabaseBackupExecution::class)->name('project.database.backup.execution');
Route::get('/backups/{backup_uuid}/s3', DatabaseBackupExecution::class)->name('project.database.backup.s3');
Route::get('/backups/{backup_uuid}/retention', DatabaseBackupExecution::class)->name('project.database.backup.retention');
Route::get('/backups/{backup_uuid}/executions', DatabaseBackupExecution::class)->name('project.database.backup.executions');
Route::get('/backups/{backup_uuid}/danger', DatabaseBackupExecution::class)->name('project.database.backup.danger');
});
Route::prefix('project/{project_uuid}/environment/{environment_uuid}/service/{service_uuid}')->group(function () {
Route::get('/', ServiceConfiguration::class)->name('project.service.configuration');
@ -279,6 +287,11 @@
Route::get('/danger', ServiceConfiguration::class)->name('project.service.danger');
Route::get('/terminal', ExecuteContainerCommand::class)->name('project.service.command')->middleware('can.access.terminal');
Route::get('/{stack_service_uuid}/backups', ServiceDatabaseBackups::class)->name('project.service.database.backups');
Route::get('/{stack_service_uuid}/backups/{backup_uuid}', ServiceDatabaseBackups::class)->name('project.service.database.backup.show');
Route::get('/{stack_service_uuid}/backups/{backup_uuid}/s3', ServiceDatabaseBackups::class)->name('project.service.database.backup.s3');
Route::get('/{stack_service_uuid}/backups/{backup_uuid}/retention', ServiceDatabaseBackups::class)->name('project.service.database.backup.retention');
Route::get('/{stack_service_uuid}/backups/{backup_uuid}/executions', ServiceDatabaseBackups::class)->name('project.service.database.backup.executions');
Route::get('/{stack_service_uuid}/backups/{backup_uuid}/danger', ServiceDatabaseBackups::class)->name('project.service.database.backup.danger');
Route::get('/{stack_service_uuid}/import', ServiceIndex::class)->name('project.service.database.import')->middleware('can.update.resource');
Route::get('/{stack_service_uuid}/advanced', ServiceIndex::class)->name('project.service.index.advanced');
Route::get('/{stack_service_uuid}', ServiceIndex::class)->name('project.service.index');
@ -428,7 +441,7 @@
}
$execution = ScheduledVolumeBackupExecution::query()
->with('scheduledVolumeBackup.volume.resource')
->with('scheduledVolumeBackup.backupable.resource')
->findOrFail(request()->route('executionId'));
if ($team->id !== 0 && $team->id !== $execution->scheduledVolumeBackup->team_id) {
return response()->json(['message' => 'Permission denied.'], 403);

View file

@ -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);
});

View file

@ -73,12 +73,137 @@ function createS3StorageForBackupEditValidationTest(Team|int $team, string $name
});
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 @@ function createS3StorageForBackupEditValidationTest(Team|int $team, string $name
'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 @@ function createS3StorageForBackupEditValidationTest(Team|int $team, string $name
'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 @@ function createS3StorageForBackupEditValidationTest(Team|int $team, string $name
$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 @@ function createS3StorageForBackupEditValidationTest(Team|int $team, string $name
'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)');
});

View file

@ -5,7 +5,6 @@
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 @@
uses(RefreshDatabase::class);
beforeEach(function () {
config(['app.maintenance.driver' => 'file']);
InstanceSettings::forceCreate(['id' => 0]);
$this->team = Team::factory()->create();
@ -30,13 +30,11 @@
$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 @@
->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.');

View file

@ -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 @@
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();
});

View file

@ -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.');
});

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 @@
->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 @@
->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 @@
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();
});

View file

@ -5,6 +5,7 @@
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 @@ function markConfigurationCheckerApplicationDeployed(Application $application):
->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);

View file

@ -5,6 +5,7 @@
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 @@
])
->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

View file

@ -4,6 +4,8 @@
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 @@ function markSnapshotTestApplicationDeployed(Application $application): Applicat
->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',