mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-13 10:15:40 +00:00
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:
1 parent
995ec5fbb6
commit
d7385ad0c4
60 files changed
+3570
-985
No files matched your search
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -356,7 +356,7 @@ class ScheduledJobManager implements ShouldQueue
|
||||
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 @@ class ScheduledJobManager implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $backup->volume || ! $server) {
|
||||
if (! $backup->backupable || ! $server) {
|
||||
$backup->delete();
|
||||
$this->skippedCount++;
|
||||
$this->logSkip('volume_backup', 'resource_deleted', [
|
||||
@@ -435,7 +435,8 @@ class ScheduledJobManager implements ShouldQueue
|
||||
$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,
|
||||
]);
|
||||
|
||||
@@ -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 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
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 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
@@ -46,7 +46,7 @@ class VolumeBackupRecoveryJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
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);
|
||||
|
||||
@@ -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 @@ class Create extends Component
|
||||
$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 @@ class Create extends Component
|
||||
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 @@ class Create extends Component
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 @@ class Index extends Component
|
||||
$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();
|
||||
|
||||
|
||||
@@ -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 @@ class Show extends Component
|
||||
$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()
|
||||
|
||||
@@ -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 @@ class Execution extends Component
|
||||
$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()
|
||||
|
||||
@@ -19,6 +19,8 @@ class BackupEdit extends Component
|
||||
|
||||
public ScheduledDatabaseBackup $backup;
|
||||
|
||||
public string $section = 'general';
|
||||
|
||||
#[Locked]
|
||||
public $availableS3Storages;
|
||||
|
||||
@@ -184,7 +186,11 @@ class BackupEdit extends Component
|
||||
'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 @@ class BackupEdit extends Component
|
||||
$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)) {
|
||||
|
||||
@@ -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 @@ class CreateScheduledBackup extends Component
|
||||
|
||||
$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 @@ class CreateScheduledBackup extends Component
|
||||
$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 @@ class CreateScheduledBackup extends Component
|
||||
|
||||
$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 {
|
||||
|
||||
@@ -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 @@ class ScheduledBackups extends Component
|
||||
public function refreshScheduledBackups(?int $id = null): void
|
||||
{
|
||||
$this->database->refresh();
|
||||
if ($id) {
|
||||
$this->setSelectedBackup($id);
|
||||
}
|
||||
$this->dispatch('refreshScheduledBackups');
|
||||
}
|
||||
}
|
||||
@@ -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 @@ class DatabaseBackups extends Component
|
||||
$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);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Livewire\Project\Service;
|
||||
|
||||
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\StandaloneMysql;
|
||||
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 @@ class FileStorage extends Component
|
||||
|
||||
$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 @@ class FileStorage extends Component
|
||||
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 @@ class FileStorage extends Component
|
||||
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 @@ class FileStorage extends Component
|
||||
$this->fileStorage->deleteStorageOnServer();
|
||||
}
|
||||
$this->fileStorage->delete();
|
||||
$this->dispatch('configurationChanged');
|
||||
$this->dispatch('success', $message);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
|
||||
@@ -131,10 +131,12 @@ class Storage extends Component
|
||||
'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 @@ class Storage extends Component
|
||||
'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 @@ class Storage extends Component
|
||||
'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 @@ class Storage extends Component
|
||||
'resource_type' => get_class($this->resource),
|
||||
]);
|
||||
|
||||
$this->dispatch('configurationChanged');
|
||||
$this->dispatch('success', 'Directory mount added successfully');
|
||||
$this->dispatch('closeStorageModal', 'directory');
|
||||
$this->clearForm();
|
||||
|
||||
@@ -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 @@ class Show extends Component
|
||||
#[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 @@ class Show extends Component
|
||||
|
||||
$this->storage->delete();
|
||||
$this->dispatch('refreshStorages');
|
||||
$this->dispatch('configurationChanged');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -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 @@ class VolumeBackups extends Component
|
||||
}
|
||||
|
||||
$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 @@ class VolumeBackups extends Component
|
||||
|
||||
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 @@ class VolumeBackups extends Component
|
||||
$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 @@ class VolumeBackups extends Component
|
||||
|
||||
$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 @@ class VolumeBackups extends Component
|
||||
$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 @@ class VolumeBackups extends Component
|
||||
->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 @@ class VolumeBackups extends Component
|
||||
|
||||
$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 @@ class VolumeBackups extends Component
|
||||
|
||||
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 @@ class VolumeBackups extends Component
|
||||
'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,
|
||||
|
||||
@@ -6,6 +6,8 @@ use App\Events\FileStorageChanged;
|
||||
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 @@ class LocalFileVolume extends BaseModel
|
||||
$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 @@ class LocalFileVolume extends BaseModel
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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 @@ class LocalPersistentVolume extends BaseModel
|
||||
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)
|
||||
|
||||
@@ -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 @@ class ScheduledVolumeBackup extends BaseModel
|
||||
];
|
||||
}
|
||||
|
||||
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 @@ class ScheduledVolumeBackup extends BaseModel
|
||||
|
||||
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 @@ class ScheduledVolumeBackup extends BaseModel
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ namespace App\Services\DeploymentConfiguration;
|
||||
|
||||
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 @@ class ApplicationConfigurationSnapshot
|
||||
'label' => 'Environment Variables',
|
||||
'items' => $this->environmentItems(),
|
||||
],
|
||||
'storage' => [
|
||||
'label' => 'Storage',
|
||||
'items' => $this->storageItems(),
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -214,6 +220,40 @@ class ApplicationConfigurationSnapshot
|
||||
->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>>
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user