feat(backups): add scheduled storage volume backups (#10946)
This commit is contained in:
commit
9b284973e1
84 changed files with 8228 additions and 474 deletions
37
RELEASE.md
37
RELEASE.md
|
|
@ -15,20 +15,35 @@ ## Table of Contents
|
|||
|
||||
## Release Process
|
||||
|
||||
1. **Development on `next` or Feature Branches**
|
||||
- Improvements, fixes, and new features are developed on the `next` branch or separate feature branches.
|
||||
1. **Prepare the Release**
|
||||
- Develop changes on `next` or feature branches.
|
||||
- Set the release version in `config/constants.php` and `versions.json`. Both values must match the planned Git tag without the `v` prefix (for example, `4.2.0` for tag `v4.2.0`).
|
||||
- Verify the changelog and required tests before merging.
|
||||
|
||||
2. **Merging to `main`**
|
||||
- Once ready, changes are merged from the `next` branch into the `main` branch (via a pull request).
|
||||
2. **Build the Release Commit**
|
||||
- Merge the release commit into `v4.x` through a pull request.
|
||||
- The `Production Build (v4)` workflow builds AMD64 and ARM64 images and publishes them to Docker Hub and GHCR using immutable architecture tags.
|
||||
- After both builds complete, the workflow creates the multi-architecture `sha-<commit-sha>` manifest in both registries.
|
||||
- This workflow does not update a semantic version tag or `latest`.
|
||||
|
||||
3. **Building the Release**
|
||||
- After merging to `main`, GitHub Actions automatically builds release images for all architectures and pushes them to the GitHub Container Registry and Docker Hub with the specific version tag and the `latest` tag.
|
||||
3. **Wait for the SHA Image**
|
||||
- Confirm the complete `Production Build (v4)` workflow, including its `merge-manifest` job, succeeded.
|
||||
- Do not publish the release before the multi-architecture SHA image exists in both registries.
|
||||
|
||||
4. **Creating a GitHub Release**
|
||||
- A new GitHub release is manually created with details of the changes made in the version.
|
||||
4. **Create and Publish the GitHub Release**
|
||||
- Create a GitHub release with a semantic version tag such as `v4.2.0`, targeting the exact commit that produced the SHA image.
|
||||
- Mark beta or other test releases as prereleases. Publish production versions as stable releases.
|
||||
- Publishing the release starts the `Release Coolify` workflow. It verifies that the Git tag matches `config/constants.php`, then promotes the existing SHA image without rebuilding it.
|
||||
- The workflow assigns the semantic version tag in Docker Hub and GHCR. Stable releases also update `latest`; prereleases do not.
|
||||
|
||||
5. **Updating the CDN**
|
||||
- To make a new version publicly available, the version information on the CDN needs to be updated manually. After that the new version number will be available at [https://cdn.coollabs.io/coolify/versions.json](https://cdn.coollabs.io/coolify/versions.json).
|
||||
5. **Verify the Promotion**
|
||||
- Confirm the `Release Coolify` workflow succeeded.
|
||||
- Verify the semantic version image has the same manifest digest as `sha-<commit-sha>` in Docker Hub and GHCR.
|
||||
- For stable releases, also verify `latest` points to the promoted release manifest.
|
||||
|
||||
6. **Update the CDN**
|
||||
- To make a new version available to self-hosted instances, update the version information on the CDN manually.
|
||||
- Confirm the new version is available at [https://cdn.coollabs.io/coolify/versions.json](https://cdn.coollabs.io/coolify/versions.json).
|
||||
|
||||
> [!NOTE]
|
||||
> The CDN update may not occur immediately after the GitHub release. It can take hours or even days due to additional testing, stability checks, or potential hotfixes. **The update becomes available only after the CDN is updated. After the CDN is updated, a discord announcement will be made in the Production Release channel.**
|
||||
|
|
@ -36,7 +51,7 @@ ## Release Process
|
|||
## Version Types
|
||||
|
||||
<details>
|
||||
<summary><strong>Stable (coming soon)</strong></summary>
|
||||
<summary><strong>Stable</strong></summary>
|
||||
|
||||
- **Stable**
|
||||
- The production version suitable for stable, production environments (recommended).
|
||||
|
|
|
|||
71
app/Actions/Shared/DeleteScheduledVolumeBackup.php
Normal file
71
app/Actions/Shared/DeleteScheduledVolumeBackup.php
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Shared;
|
||||
|
||||
use App\Jobs\VolumeBackupJob;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class DeleteScheduledVolumeBackup
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(ScheduledVolumeBackup $backup, ?Server $server = null): void
|
||||
{
|
||||
$lock = Cache::lock(VolumeBackupJob::lockKey($backup->id), $backup->timeout + 300);
|
||||
|
||||
if (! $lock->get()) {
|
||||
throw new \RuntimeException('Wait for the queued or running storage backup to finish before deleting this schedule.');
|
||||
}
|
||||
|
||||
try {
|
||||
if ($backup->executions()
|
||||
->where(fn ($query) => $query
|
||||
->where('status', 'running')
|
||||
->orWhere('stop_recovery_pending', true)
|
||||
->orWhere('s3_cleanup_pending', true))
|
||||
->exists()) {
|
||||
throw new \RuntimeException('Wait for the running storage backup and recovery operations to finish before deleting this schedule.');
|
||||
}
|
||||
|
||||
$localFilenames = $backup->executions()
|
||||
->where('local_storage_deleted', false)
|
||||
->pluck('filename')
|
||||
->filter()
|
||||
->all();
|
||||
|
||||
if ($localFilenames !== []) {
|
||||
$server ??= $backup->server();
|
||||
if (! $server) {
|
||||
throw new \RuntimeException('The server is unavailable, so local backup archives cannot be deleted.');
|
||||
}
|
||||
|
||||
deleteBackupsLocally($localFilenames, $server, throwError: true);
|
||||
}
|
||||
|
||||
$s3Executions = $backup->executions()
|
||||
->with('s3')
|
||||
->where('s3_uploaded', true)
|
||||
->where('s3_storage_deleted', false)
|
||||
->get();
|
||||
|
||||
foreach ($s3Executions->groupBy('s3_storage_id') as $executions) {
|
||||
$s3 = $executions->first()->s3;
|
||||
if (! $s3) {
|
||||
throw new \RuntimeException('The S3 storage used by an existing backup is unavailable.');
|
||||
}
|
||||
|
||||
$filenames = $executions->pluck('filename')->filter()->all();
|
||||
if ($filenames !== []) {
|
||||
deleteBackupsS3($filenames, $s3);
|
||||
}
|
||||
}
|
||||
|
||||
$backup->delete();
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4910,6 +4910,8 @@ public function delete_storage(Request $request): JsonResponse
|
|||
], 422);
|
||||
}
|
||||
|
||||
$storage->abortIfScheduledBackupsExist();
|
||||
|
||||
if ($storage instanceof LocalFileVolume) {
|
||||
$storage->deleteStorageOnServer();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4484,6 +4484,8 @@ public function delete_storage(Request $request): JsonResponse
|
|||
], 422);
|
||||
}
|
||||
|
||||
$storage->abortIfScheduledBackupsExist();
|
||||
|
||||
if ($storage instanceof LocalFileVolume) {
|
||||
$storage->deleteStorageOnServer();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2911,6 +2911,8 @@ public function delete_storage(Request $request): JsonResponse
|
|||
], 422);
|
||||
}
|
||||
|
||||
$storage->abortIfScheduledBackupsExist();
|
||||
|
||||
if ($storage instanceof LocalFileVolume) {
|
||||
$storage->deleteStorageOnServer();
|
||||
}
|
||||
|
|
|
|||
445
app/Http/Controllers/Api/VolumeBackupsController.php
Normal file
445
app/Http/Controllers/Api/VolumeBackupsController.php
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Shared\DeleteScheduledVolumeBackup;
|
||||
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 Illuminate\Support\MessageBag;
|
||||
use OpenApi\Attributes as OA;
|
||||
use RuntimeException;
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
['errors' => $errors, 's3Storage' => $s3Storage, 'saveToS3' => $saveToS3] = $this->validateUpsertRequest($request, $storage, $teamId);
|
||||
|
||||
if ($errors->isNotEmpty()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
return $this->persistSchedule($request, $storage, $teamId, $s3Storage, $saveToS3, $resourceType, $resource);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{errors: MessageBag, s3Storage: S3Storage|null, saveToS3: bool}
|
||||
*/
|
||||
private function validateUpsertRequest(
|
||||
Request $request,
|
||||
LocalPersistentVolume|LocalFileVolume $storage,
|
||||
int|string $teamId,
|
||||
): array {
|
||||
$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.');
|
||||
}
|
||||
|
||||
return [
|
||||
'errors' => $errors,
|
||||
's3Storage' => $s3Storage,
|
||||
'saveToS3' => $saveToS3,
|
||||
];
|
||||
}
|
||||
|
||||
private function persistSchedule(
|
||||
Request $request,
|
||||
LocalPersistentVolume|LocalFileVolume $storage,
|
||||
int|string $teamId,
|
||||
?S3Storage $s3Storage,
|
||||
bool $saveToS3,
|
||||
string $resourceType,
|
||||
Model $resource,
|
||||
): JsonResponse {
|
||||
$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);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete application storage backup schedule',
|
||||
description: 'Delete the backup schedule and its local and S3 archives for an application storage.',
|
||||
path: '/applications/{uuid}/storages/{storage_uuid}/backups',
|
||||
operationId: 'delete-application-storage-backup-schedule',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'storage_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Backup schedule and archives deleted.'),
|
||||
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: 409, description: 'Backup or recovery operation is still running.'),
|
||||
],
|
||||
)]
|
||||
#[OA\Delete(
|
||||
summary: 'Delete database storage backup schedule',
|
||||
description: 'Delete the backup schedule and its local and S3 archives for a database storage.',
|
||||
path: '/databases/{uuid}/storages/{storage_uuid}/backups',
|
||||
operationId: 'delete-database-storage-backup-schedule',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'storage_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Backup schedule and archives deleted.'),
|
||||
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: 409, description: 'Backup or recovery operation is still running.'),
|
||||
],
|
||||
)]
|
||||
#[OA\Delete(
|
||||
summary: 'Delete service storage backup schedule',
|
||||
description: 'Delete the backup schedule and its local and S3 archives for a service storage.',
|
||||
path: '/services/{uuid}/storages/{storage_uuid}/backups',
|
||||
operationId: 'delete-service-storage-backup-schedule',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Services'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'storage_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Backup schedule and archives deleted.'),
|
||||
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: 409, description: 'Backup or recovery operation is still running.'),
|
||||
],
|
||||
)]
|
||||
public function destroy(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$resourceType = $request->route('resource_type');
|
||||
$resource = $this->findResource($resourceType, $request->route('uuid'), $teamId);
|
||||
if (! $resource) {
|
||||
return response()->json(['message' => '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);
|
||||
}
|
||||
|
||||
$backup = $storage->scheduledBackups()->first();
|
||||
if (! $backup) {
|
||||
return response()->json(['message' => 'Storage backup schedule not found.'], 404);
|
||||
}
|
||||
|
||||
try {
|
||||
DeleteScheduledVolumeBackup::run($backup);
|
||||
} catch (RuntimeException $exception) {
|
||||
return response()->json(['message' => $exception->getMessage()], 409);
|
||||
}
|
||||
|
||||
auditLog('api.volume_backup.schedule_deleted', [
|
||||
'team_id' => $teamId,
|
||||
'resource_type' => $resourceType,
|
||||
'resource_uuid' => $resource->uuid,
|
||||
'storage_uuid' => $storage->uuid,
|
||||
'backup_uuid' => $backup->uuid,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Storage backup schedule and archives deleted.']);
|
||||
}
|
||||
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -497,7 +497,7 @@ public function handle(): void
|
|||
}
|
||||
}
|
||||
if ($this->backup_log && $this->backup_log->status === 'success') {
|
||||
removeOldBackups($this->backup);
|
||||
$this->removeExpiredBackups();
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
throw $e;
|
||||
|
|
@ -513,6 +513,19 @@ public function handle(): void
|
|||
}
|
||||
}
|
||||
|
||||
private function removeExpiredBackups(): void
|
||||
{
|
||||
try {
|
||||
removeOldBackups($this->backup);
|
||||
} catch (Throwable $exception) {
|
||||
Log::channel('scheduled-errors')->warning('Database backup retention cleanup failed', [
|
||||
'backup_id' => $this->backup->id,
|
||||
'execution_id' => $this->backup_log?->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function backup_standalone_mongodb(string $databaseWithCollections): void
|
||||
{
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@
|
|||
use App\Actions\Server\CleanupDocker;
|
||||
use App\Actions\Service\DeleteService;
|
||||
use App\Actions\Service\StopService;
|
||||
use App\Actions\Shared\DeleteScheduledVolumeBackup;
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
|
|
@ -42,6 +45,10 @@ public function __construct(
|
|||
|
||||
public function handle()
|
||||
{
|
||||
if (! $this->resource instanceof ApplicationPreview) {
|
||||
$this->deleteScheduledVolumeBackups();
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle ApplicationPreview instances separately
|
||||
if ($this->resource instanceof ApplicationPreview) {
|
||||
|
|
@ -113,6 +120,24 @@ public function handle()
|
|||
}
|
||||
}
|
||||
|
||||
private function deleteScheduledVolumeBackups(): void
|
||||
{
|
||||
$server = data_get($this->resource, 'server') ?? data_get($this->resource, 'destination.server');
|
||||
$resources = $this->resource instanceof Service
|
||||
? $this->resource->applications()->get()->concat($this->resource->databases()->get())
|
||||
: collect([$this->resource]);
|
||||
|
||||
foreach ($resources as $resource) {
|
||||
$storages = $resource->persistentStorages()->get()->concat($resource->fileStorages()->get());
|
||||
|
||||
foreach ($storages as $storage) {
|
||||
foreach ($storage->scheduledBackups()->get() as $backup) {
|
||||
DeleteScheduledVolumeBackup::run($backup, $server);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteApplicationPreview()
|
||||
{
|
||||
$application = $this->resource->application;
|
||||
|
|
@ -125,11 +150,11 @@ private function deleteApplicationPreview()
|
|||
}
|
||||
|
||||
// Cancel any active deployments for this PR (same logic as API cancel_deployment)
|
||||
$activeDeployments = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id)
|
||||
$activeDeployments = ApplicationDeploymentQueue::where('application_id', $application->id)
|
||||
->where('pull_request_id', $pull_request_id)
|
||||
->whereIn('status', [
|
||||
\App\Enums\ApplicationDeploymentStatus::QUEUED->value,
|
||||
\App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
ApplicationDeploymentStatus::QUEUED->value,
|
||||
ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
])
|
||||
->get();
|
||||
|
||||
|
|
@ -137,7 +162,7 @@ private function deleteApplicationPreview()
|
|||
try {
|
||||
// Mark deployment as cancelled
|
||||
$activeDeployment->update([
|
||||
'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
|
||||
'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
|
||||
]);
|
||||
|
||||
// Add cancellation log entry
|
||||
|
|
|
|||
|
|
@ -2,8 +2,11 @@
|
|||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\Shared\DeleteScheduledVolumeBackup;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use App\Models\ScheduledVolumeBackupExecution;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Cron\CronExpression;
|
||||
|
|
@ -109,6 +112,16 @@ public function handle(): void
|
|||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->recoverStoppedVolumeBackupContainers();
|
||||
$this->processScheduledVolumeBackups();
|
||||
} catch (\Exception $e) {
|
||||
Log::channel('scheduled-errors')->error('Failed to process scheduled volume backups', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Process Docker cleanups - don't let failures stop the job manager
|
||||
try {
|
||||
$this->processDockerCleanups();
|
||||
|
|
@ -341,6 +354,112 @@ private function processScheduledTask(ScheduledTask $task, ?Server $precheckedSe
|
|||
}
|
||||
}
|
||||
|
||||
private function processScheduledVolumeBackups(): void
|
||||
{
|
||||
ScheduledVolumeBackup::query()
|
||||
->with(['backupable', 'team.subscription'])
|
||||
->where('enabled', true)
|
||||
->chunkById(self::CHUNK_SIZE, function ($backups): void {
|
||||
foreach ($backups as $backup) {
|
||||
$this->processScheduledVolumeBackup($backup);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function recoverStoppedVolumeBackupContainers(): void
|
||||
{
|
||||
ScheduledVolumeBackupExecution::query()
|
||||
->where(fn (Builder $query) => $query
|
||||
->where('stop_recovery_pending', true)
|
||||
->orWhere('s3_cleanup_pending', true))
|
||||
->chunkById(self::CHUNK_SIZE, function ($executions): void {
|
||||
foreach ($executions as $execution) {
|
||||
VolumeBackupRecoveryJob::dispatch($execution);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function processScheduledVolumeBackup(ScheduledVolumeBackup $backup): void
|
||||
{
|
||||
try {
|
||||
$server = $backup->server();
|
||||
|
||||
if ($backup->executions()
|
||||
->where(fn (Builder $query) => $query
|
||||
->where('stop_recovery_pending', true)
|
||||
->orWhere('s3_cleanup_pending', true))
|
||||
->exists()) {
|
||||
$this->skippedCount++;
|
||||
$this->logSkip('volume_backup', 'container_recovery_pending', [
|
||||
'backup_id' => $backup->id,
|
||||
'team_id' => $backup->team_id,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $backup->backupable) {
|
||||
DeleteScheduledVolumeBackup::run($backup, $server);
|
||||
$this->skippedCount++;
|
||||
$this->logSkip('volume_backup', 'resource_deleted', [
|
||||
'backup_id' => $backup->id,
|
||||
'team_id' => $backup->team_id,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $server) {
|
||||
$this->skippedCount++;
|
||||
$this->logSkip('volume_backup', 'server_missing', [
|
||||
'backup_id' => $backup->id,
|
||||
'team_id' => $backup->team_id,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $server->isFunctional()) {
|
||||
$this->skippedCount++;
|
||||
$this->logSkip('volume_backup', 'server_not_functional', [
|
||||
'backup_id' => $backup->id,
|
||||
'team_id' => $backup->team_id,
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCloud() && $backup->team_id !== 0 && ! data_get($backup, 'team.subscription.stripe_invoice_paid', false)) {
|
||||
$this->skippedCount++;
|
||||
$this->logSkip('volume_backup', 'subscription_unpaid', [
|
||||
'backup_id' => $backup->id,
|
||||
'team_id' => $backup->team_id,
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->shouldDispatch($backup->frequency, $server, "scheduled-volume-backup:{$backup->id}")) {
|
||||
VolumeBackupJob::dispatch($backup);
|
||||
$this->dispatchedCount++;
|
||||
Log::channel('scheduled')->info('Volume backup dispatched', [
|
||||
'backup_id' => $backup->id,
|
||||
'backupable_type' => $backup->backupable_type,
|
||||
'backupable_id' => $backup->backupable_id,
|
||||
'team_id' => $backup->team_id,
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::channel('scheduled-errors')->error('Error processing volume backup', [
|
||||
'backup_id' => $backup->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function getBackupSkipReason(ScheduledDatabaseBackup $backup, ?Server $server): ?string
|
||||
{
|
||||
if (blank(data_get($backup, 'database'))) {
|
||||
|
|
|
|||
443
app/Jobs/VolumeBackupJob.php
Normal file
443
app/Jobs/VolumeBackupJob.php
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Events\BackupCreated;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use App\Models\ScheduledVolumeBackupExecution;
|
||||
use App\Models\Server;
|
||||
use App\Rules\SafeWebhookUrl;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $maxExceptions = 1;
|
||||
|
||||
public int $timeout = 3600;
|
||||
|
||||
private ?ScheduledVolumeBackupExecution $execution = null;
|
||||
|
||||
public function __construct(public ScheduledVolumeBackup $backup)
|
||||
{
|
||||
$this->onQueue(crons_queue());
|
||||
$this->timeout = $backup->timeout ?? 3600;
|
||||
}
|
||||
|
||||
public function middleware(): array
|
||||
{
|
||||
return [
|
||||
(new WithoutOverlapping('volume-backup-'.$this->backup->id))
|
||||
->shared()
|
||||
->expireAfter($this->timeout + 60)
|
||||
->dontRelease(),
|
||||
];
|
||||
}
|
||||
|
||||
public static function lockKey(int $backupId): string
|
||||
{
|
||||
return 'laravel-queue-overlap:volume-backup-'.$backupId;
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$this->backup->loadMissing(['backupable.resource', 'team', 's3']);
|
||||
$server = $this->backup->server();
|
||||
$target = $this->backup->backupable;
|
||||
$team = $this->backup->team;
|
||||
|
||||
if (! $server || ! $target || ! $team) {
|
||||
throw new \RuntimeException('The storage backup resource, team, or server no longer exists.');
|
||||
}
|
||||
|
||||
$this->execution = $this->backup->executions()->create([
|
||||
's3_storage_id' => $this->backup->save_s3 ? $this->backup->s3_storage_id : null,
|
||||
]);
|
||||
BackupCreated::dispatch($team->id);
|
||||
|
||||
$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 = $this->backup->sourcePath();
|
||||
$containerName = 'volume-backup-'.$this->execution->uuid;
|
||||
$image = coolifyHelperImage().':'.getHelperVersion();
|
||||
$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')
|
||||
.' '.escapeshellarg($image)
|
||||
.' tar -czf - -C /volume . > '.escapeshellarg($backupLocation);
|
||||
|
||||
if ($this->backup->stop_during_backup) {
|
||||
$containers = $this->containersUsingVolume($source, $server);
|
||||
if ($containers !== []) {
|
||||
$this->execution->update(['stop_recovery_pending' => true]);
|
||||
$archiveCommand = $this->archiveWithStoppedContainers(
|
||||
$archiveCommand,
|
||||
$containers,
|
||||
VolumeBackupRecoveryJob::stateFile($this->execution),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
instant_remote_process([
|
||||
$verifySourceCommand,
|
||||
'mkdir -p '.escapeshellarg($backupDirectory),
|
||||
$archiveCommand,
|
||||
], $server, timeout: $this->timeout, disableMultiplexing: true);
|
||||
$this->execution->update([
|
||||
'stop_container_ids' => null,
|
||||
'stop_recovery_pending' => false,
|
||||
]);
|
||||
|
||||
$size = (int) instant_remote_process(
|
||||
['du -b '.escapeshellarg($backupLocation).' | cut -f1'],
|
||||
$server,
|
||||
disableMultiplexing: true,
|
||||
);
|
||||
|
||||
if ($size <= 0) {
|
||||
throw new \RuntimeException('The storage backup archive is empty or was not created.');
|
||||
}
|
||||
|
||||
$warning = null;
|
||||
$s3Uploaded = null;
|
||||
$s3CleanupPending = false;
|
||||
$localStorageDeleted = false;
|
||||
|
||||
if ($this->backup->save_s3) {
|
||||
$s3CleanupPending = true;
|
||||
$this->execution->update(['s3_cleanup_pending' => true]);
|
||||
|
||||
try {
|
||||
$this->uploadToS3($backupLocation, $backupDirectory, $server);
|
||||
$s3Uploaded = true;
|
||||
$s3CleanupPending = false;
|
||||
} catch (Throwable $exception) {
|
||||
$s3Uploaded = false;
|
||||
$warning = 'S3 upload failed: '.$exception->getMessage();
|
||||
|
||||
try {
|
||||
VolumeBackupRecoveryJob::cleanupS3Upload($this->execution);
|
||||
$s3CleanupPending = false;
|
||||
} catch (Throwable $cleanupException) {
|
||||
VolumeBackupRecoveryJob::dispatch($this->execution);
|
||||
$warning .= ' Partial S3 upload cleanup failed: '.$cleanupException->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
if ($s3Uploaded && $this->backup->disable_local_backup) {
|
||||
try {
|
||||
deleteBackupsLocally($backupLocation, $server, throwError: true);
|
||||
$localStorageDeleted = true;
|
||||
} catch (Throwable $exception) {
|
||||
$warning = 'S3 upload succeeded, but the local archive could not be deleted: '.$exception->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->execution->update([
|
||||
'status' => 'success',
|
||||
'message' => $warning,
|
||||
'size' => $size,
|
||||
'filename' => $backupLocation,
|
||||
's3_uploaded' => $s3Uploaded,
|
||||
's3_cleanup_pending' => $s3CleanupPending,
|
||||
'local_storage_deleted' => $localStorageDeleted,
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->removeExpiredBackups($server);
|
||||
} catch (Throwable $exception) {
|
||||
Log::channel('scheduled-errors')->warning('Volume backup retention cleanup failed', [
|
||||
'backup_id' => $this->backup->id,
|
||||
'execution_id' => $this->execution->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
$recoveryError = $this->recoverIncompleteBackup($this->execution);
|
||||
$archiveDeleted = false;
|
||||
|
||||
try {
|
||||
deleteBackupsLocally($backupLocation, $server, throwError: true);
|
||||
$archiveDeleted = true;
|
||||
} catch (Throwable $cleanupException) {
|
||||
$recoveryError .= ' Archive cleanup failed: '.$cleanupException->getMessage();
|
||||
}
|
||||
|
||||
$s3CleanupPending = $this->execution->fresh()->s3_cleanup_pending;
|
||||
|
||||
$this->execution->update([
|
||||
'status' => 'failed',
|
||||
'message' => $exception->getMessage().$recoveryError,
|
||||
'filename' => $archiveDeleted && ! $s3CleanupPending ? null : $backupLocation,
|
||||
'local_storage_deleted' => $archiveDeleted,
|
||||
]);
|
||||
|
||||
throw $exception;
|
||||
} finally {
|
||||
$this->execution->update(['finished_at' => now()]);
|
||||
BackupCreated::dispatch($team->id);
|
||||
}
|
||||
}
|
||||
|
||||
public function failed(?Throwable $exception): void
|
||||
{
|
||||
$execution = $this->execution ?? $this->backup->executions()
|
||||
->where('status', 'running')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if ($execution) {
|
||||
$recoveryError = $this->recoverIncompleteBackup($execution);
|
||||
$server = $this->backup->server();
|
||||
$filename = $execution->filename;
|
||||
$localStorageDeleted = $execution->local_storage_deleted;
|
||||
|
||||
if ($server && filled($filename)) {
|
||||
try {
|
||||
deleteBackupsLocally($filename, $server, throwError: true);
|
||||
$localStorageDeleted = true;
|
||||
if (! $execution->fresh()->s3_cleanup_pending) {
|
||||
$filename = null;
|
||||
}
|
||||
} catch (Throwable $cleanupException) {
|
||||
$recoveryError .= ' Archive cleanup failed: '.$cleanupException->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$execution->update([
|
||||
'status' => 'failed',
|
||||
'message' => ($exception?->getMessage() ?? 'Volume backup timed out or was terminated.').$recoveryError,
|
||||
'finished_at' => now(),
|
||||
'filename' => $filename,
|
||||
'local_storage_deleted' => $localStorageDeleted,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function containersUsingVolume(string $source, Server $server): array
|
||||
{
|
||||
$output = instant_remote_process(
|
||||
["containers=\$(docker ps -q) || exit 1; for container in \$containers; do paused=\$(docker inspect --format '{{.State.Paused}}' \"\$container\") || exit 1; [ \"\$paused\" = true ] && continue; mounts=\$(docker inspect --format '{{range .Mounts}}{{println .Source}}{{println .Name}}{{end}}' \"\$container\") || exit 1; if printf '%s\\n' \"\$mounts\" | grep -Fqx -- ".escapeshellarg($source).'; then echo "$container"; fi; done'],
|
||||
$server,
|
||||
disableMultiplexing: true,
|
||||
);
|
||||
$containers = collect(preg_split('/\s+/', trim((string) $output)))
|
||||
->filter(fn (string $container): bool => preg_match('/^[a-f0-9]{6,64}$/i', $container) === 1)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return $containers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $containers
|
||||
*/
|
||||
private function archiveWithStoppedContainers(string $archiveCommand, array $containers, string $stateFile): string
|
||||
{
|
||||
if ($containers === []) {
|
||||
return $archiveCommand;
|
||||
}
|
||||
|
||||
$containerList = implode(' ', $containers);
|
||||
$script = "set -eu\n"
|
||||
.'state_file='.escapeshellarg($stateFile)."\n"
|
||||
.": > \"\$state_file\"\n"
|
||||
."cleanup() { status=\$?; trap - EXIT HUP INT TERM; while IFS= read -r container; do docker start \"\$container\" >/dev/null || status=1; done < \"\$state_file\"; [ \$status -eq 0 ] && rm -f \"\$state_file\"; exit \$status; }\n"
|
||||
."trap cleanup EXIT\n"
|
||||
."trap 'exit 129' HUP\n"
|
||||
."trap 'exit 130' INT\n"
|
||||
."trap 'exit 143' TERM\n"
|
||||
."for container in {$containerList}; do echo \"\$container\" >> \"\$state_file\"; docker stop \"\$container\" >/dev/null; done\n"
|
||||
.$archiveCommand;
|
||||
|
||||
return 'sh -c '.escapeshellarg($script);
|
||||
}
|
||||
|
||||
private function recoverIncompleteBackup(ScheduledVolumeBackupExecution $execution): string
|
||||
{
|
||||
if (! $execution->stop_recovery_pending && ! $execution->s3_cleanup_pending) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
VolumeBackupRecoveryJob::recover($execution);
|
||||
|
||||
return '';
|
||||
} catch (Throwable $exception) {
|
||||
VolumeBackupRecoveryJob::dispatch($execution);
|
||||
|
||||
return ' Container recovery failed: '.$exception->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private function uploadToS3(string $backupLocation, string $backupDirectory, Server $server): void
|
||||
{
|
||||
$s3 = $this->backup->s3;
|
||||
|
||||
if (! $s3) {
|
||||
$this->backup->update(['save_s3' => false, 's3_storage_id' => null]);
|
||||
|
||||
throw new \RuntimeException('The selected S3 storage no longer exists. S3 backup has been disabled.');
|
||||
}
|
||||
|
||||
$s3->testConnection(shouldSave: true);
|
||||
$containerName = 'volume-upload-'.$this->execution->uuid;
|
||||
$image = coolifyHelperImage().':'.getHelperVersion();
|
||||
$resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($s3->endpoint))
|
||||
->map(fn (string $option): string => '--resolve '.escapeshellarg($option))
|
||||
->implode(' ');
|
||||
$resolveOptions = $resolveOptions === '' ? '' : ' '.$resolveOptions;
|
||||
|
||||
try {
|
||||
instant_remote_process([
|
||||
'docker rm -f '.escapeshellarg($containerName).' >/dev/null 2>&1 || true',
|
||||
'docker run -d --name '.escapeshellarg($containerName).' --rm -v '
|
||||
.escapeshellarg($backupLocation.':'.$backupLocation.':ro').' '.escapeshellarg($image),
|
||||
'docker exec '.escapeshellarg($containerName).' mc alias set'.$resolveOptions.' temporary '
|
||||
.escapeshellarg($s3->endpoint).' '.escapeshellarg($s3->key).' '.escapeshellarg($s3->secret),
|
||||
'docker exec '.escapeshellarg($containerName).' mc cp '.escapeshellarg($backupLocation).' '
|
||||
.escapeshellarg('temporary/'.$s3->bucket.$backupDirectory.'/'),
|
||||
], $server, timeout: $this->timeout, disableMultiplexing: true);
|
||||
} finally {
|
||||
instant_remote_process(
|
||||
['docker rm -f '.escapeshellarg($containerName)],
|
||||
$server,
|
||||
throwError: false,
|
||||
disableMultiplexing: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function removeExpiredBackups(Server $server): void
|
||||
{
|
||||
if ($this->hasRetentionLimits(
|
||||
$this->backup->retention_amount_locally,
|
||||
$this->backup->retention_days_locally,
|
||||
$this->backup->retention_max_storage_locally,
|
||||
)) {
|
||||
$localExecutions = $this->backup->executions()
|
||||
->where('status', 'success')
|
||||
->where('local_storage_deleted', false)
|
||||
->get();
|
||||
$localExecutions = $this->executionsOutsideRetention(
|
||||
$localExecutions,
|
||||
$this->backup->retention_amount_locally,
|
||||
$this->backup->retention_days_locally,
|
||||
$this->backup->retention_max_storage_locally,
|
||||
);
|
||||
|
||||
$filenames = $localExecutions->pluck('filename')->filter()->all();
|
||||
if ($filenames !== []) {
|
||||
deleteBackupsLocally($filenames, $server, throwError: true);
|
||||
$this->backup->executions()->whereKey($localExecutions->pluck('id')->all())
|
||||
->update(['local_storage_deleted' => true]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->backup->save_s3 && $this->backup->s3 && $this->hasRetentionLimits(
|
||||
$this->backup->retention_amount_s3,
|
||||
$this->backup->retention_days_s3,
|
||||
$this->backup->retention_max_storage_s3,
|
||||
)) {
|
||||
$s3Executions = $this->backup->executions()
|
||||
->with('s3')
|
||||
->where('status', 'success')
|
||||
->where('s3_uploaded', true)
|
||||
->where('s3_storage_deleted', false)
|
||||
->get();
|
||||
$s3Executions = $this->executionsOutsideRetention(
|
||||
$s3Executions,
|
||||
$this->backup->retention_amount_s3,
|
||||
$this->backup->retention_days_s3,
|
||||
$this->backup->retention_max_storage_s3,
|
||||
);
|
||||
|
||||
foreach ($s3Executions->groupBy('s3_storage_id') as $executions) {
|
||||
$s3 = $executions->first()->s3;
|
||||
if (! $s3) {
|
||||
throw new \RuntimeException('The S3 storage used by an existing backup is unavailable.');
|
||||
}
|
||||
|
||||
$filenames = $executions->pluck('filename')->filter()->all();
|
||||
if ($filenames !== []) {
|
||||
deleteBackupsS3($filenames, $s3);
|
||||
$this->backup->executions()->whereKey($executions->pluck('id')->all())
|
||||
->update(['s3_storage_deleted' => true]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->backup->executions()
|
||||
->where('local_storage_deleted', true)
|
||||
->where(function (Builder $query): void {
|
||||
$query->where('s3_storage_deleted', true)->orWhereNull('s3_uploaded');
|
||||
})
|
||||
->delete();
|
||||
}
|
||||
|
||||
private function hasRetentionLimits(int $amount, int $days, float $maxStorageGb): bool
|
||||
{
|
||||
return $amount > 0 || $days > 0 || $maxStorageGb > 0;
|
||||
}
|
||||
|
||||
private function executionsOutsideRetention(Collection $executions, int $amount, int $days, float $maxStorageGb): Collection
|
||||
{
|
||||
if ($amount === 0 && $days === 0 && $maxStorageGb == 0) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$executionsToDelete = collect();
|
||||
|
||||
if ($amount > 0) {
|
||||
$executionsToDelete = $executionsToDelete->merge($executions->skip($amount));
|
||||
}
|
||||
|
||||
if ($days > 0) {
|
||||
$oldestAllowedDate = now()->subDays($days);
|
||||
$executionsToDelete = $executionsToDelete->merge(
|
||||
$executions->filter(fn (ScheduledVolumeBackupExecution $execution): bool => $execution->created_at->isBefore($oldestAllowedDate)),
|
||||
);
|
||||
}
|
||||
|
||||
if ($maxStorageGb > 0) {
|
||||
$maxStorageBytes = $maxStorageGb * 1024 ** 3;
|
||||
$totalSize = 0;
|
||||
|
||||
foreach ($executions as $index => $execution) {
|
||||
$totalSize += (int) $execution->size;
|
||||
|
||||
if ($index > 0 && $totalSize > $maxStorageBytes) {
|
||||
$executionsToDelete = $executionsToDelete->merge($executions->slice($index));
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $executionsToDelete->unique('id')->values();
|
||||
}
|
||||
}
|
||||
117
app/Jobs/VolumeBackupRecoveryJob.php
Normal file
117
app/Jobs/VolumeBackupRecoveryJob.php
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\ScheduledVolumeBackupExecution;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class VolumeBackupRecoveryJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 20;
|
||||
|
||||
public int $backoff = 60;
|
||||
|
||||
public int $timeout = 120;
|
||||
|
||||
public function __construct(public ScheduledVolumeBackupExecution $execution)
|
||||
{
|
||||
$this->onQueue(crons_queue());
|
||||
}
|
||||
|
||||
public function middleware(): array
|
||||
{
|
||||
return [
|
||||
(new WithoutOverlapping('volume-backup-'.$this->execution->scheduled_volume_backup_id))
|
||||
->shared()
|
||||
->expireAfter(300)
|
||||
->dontRelease(),
|
||||
(new WithoutOverlapping('volume-backup-recovery-'.$this->execution->id))
|
||||
->expireAfter(300)
|
||||
->dontRelease(),
|
||||
];
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
self::recover($this->execution);
|
||||
}
|
||||
|
||||
public static function recover(ScheduledVolumeBackupExecution $execution): void
|
||||
{
|
||||
$execution->loadMissing('scheduledVolumeBackup.backupable.resource');
|
||||
|
||||
if ($execution->stop_recovery_pending) {
|
||||
self::recoverContainers($execution);
|
||||
}
|
||||
|
||||
if ($execution->s3_cleanup_pending) {
|
||||
self::cleanupS3Upload($execution);
|
||||
}
|
||||
}
|
||||
|
||||
private static function recoverContainers(ScheduledVolumeBackupExecution $execution): void
|
||||
{
|
||||
$server = $execution->scheduledVolumeBackup?->server();
|
||||
|
||||
if (! $server) {
|
||||
throw new \RuntimeException('The server is unavailable for container recovery.');
|
||||
}
|
||||
|
||||
$stateFile = self::stateFile($execution);
|
||||
$output = instant_remote_process(
|
||||
['cat '.escapeshellarg($stateFile).' 2>/dev/null || true'],
|
||||
$server,
|
||||
disableMultiplexing: true,
|
||||
);
|
||||
$containers = collect(preg_split('/\s+/', trim((string) $output)))
|
||||
->filter(fn (string $container): bool => preg_match('/^[a-f0-9]{6,64}$/i', $container) === 1)
|
||||
->values()
|
||||
->all();
|
||||
$execution->update(['stop_container_ids' => $containers]);
|
||||
|
||||
$remainingFile = $stateFile.'.remaining';
|
||||
$script = 'status=0; : > '.escapeshellarg($remainingFile).'; '
|
||||
.'if [ -f '.escapeshellarg($stateFile).' ]; then while IFS= read -r container; do '
|
||||
.'[ -z "$container" ] && continue; running=$(docker inspect --format \'{{.State.Running}}\' "$container" 2>/dev/null) '
|
||||
.'|| { echo "$container" >> '.escapeshellarg($remainingFile).'; status=1; continue; }; '
|
||||
.'if [ "$running" != true ] && ! docker start "$container" >/dev/null; then echo "$container" >> '
|
||||
.escapeshellarg($remainingFile).'; status=1; fi; done < '.escapeshellarg($stateFile).'; fi; '
|
||||
.'if [ -s '.escapeshellarg($remainingFile).' ]; then mv '.escapeshellarg($remainingFile).' '.escapeshellarg($stateFile)
|
||||
.'; else rm -f '.escapeshellarg($stateFile).' '.escapeshellarg($remainingFile).'; fi; exit $status';
|
||||
|
||||
instant_remote_process(['sh -c '.escapeshellarg($script)], $server, disableMultiplexing: true);
|
||||
$execution->update([
|
||||
'stop_container_ids' => null,
|
||||
'stop_recovery_pending' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public static function cleanupS3Upload(ScheduledVolumeBackupExecution $execution): void
|
||||
{
|
||||
$execution->loadMissing('s3');
|
||||
$s3 = $execution->s3;
|
||||
|
||||
if (! $s3 || blank($execution->filename)) {
|
||||
throw new \RuntimeException('The S3 storage or backup filename is unavailable for upload cleanup.');
|
||||
}
|
||||
|
||||
deleteBackupsS3($execution->filename, $s3);
|
||||
$execution->update([
|
||||
's3_cleanup_pending' => false,
|
||||
's3_storage_deleted' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public static function stateFile(ScheduledVolumeBackupExecution $execution): string
|
||||
{
|
||||
return '/tmp/coolify-volume-backup-'.$execution->uuid.'.stopped';
|
||||
}
|
||||
}
|
||||
145
app/Livewire/Project/Application/Backup/Create.php
Normal file
145
app/Livewire/Project/Application/Backup/Create.php
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Application\Backup;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
class Create extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
#[Locked]
|
||||
public Application $application;
|
||||
|
||||
public ?string $selectedTargetKey = null;
|
||||
|
||||
public ?string $targetKey = null;
|
||||
|
||||
public bool $targetLocked = false;
|
||||
|
||||
public string $frequency = 'daily';
|
||||
|
||||
public Collection $targets;
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'targetKey' => ['required', 'string', 'regex:/^(volume|directory):[1-9][0-9]*$/'],
|
||||
'frequency' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorize('view', $this->application);
|
||||
$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 updatedTargetKey(): void
|
||||
{
|
||||
$this->loadSelectedBackup();
|
||||
}
|
||||
|
||||
public function submit(): void
|
||||
{
|
||||
$this->authorize('update', $this->application);
|
||||
$this->validate();
|
||||
|
||||
$target = $this->selectedTarget();
|
||||
if (! $target) {
|
||||
$this->addError('targetKey', 'Select a volume or directory owned by this application.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! validate_cron_expression($this->frequency)) {
|
||||
$this->addError('frequency', 'The frequency must be a valid cron or human expression.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$backup = $target->scheduledBackups()->updateOrCreate(
|
||||
[],
|
||||
[
|
||||
'team_id' => currentTeam()->id,
|
||||
'frequency' => $this->frequency,
|
||||
'enabled' => true,
|
||||
],
|
||||
);
|
||||
|
||||
$this->dispatch('success', $backup->wasRecentlyCreated ? 'Scheduled storage backup created.' : 'Scheduled storage backup updated.');
|
||||
redirectRoute($this, '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,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.application.backup.create');
|
||||
}
|
||||
|
||||
private function loadSelectedBackup(): void
|
||||
{
|
||||
$target = $this->selectedTarget();
|
||||
if (! $target) {
|
||||
return;
|
||||
}
|
||||
|
||||
$backup = $target->scheduledBackups()->first();
|
||||
if ($backup) {
|
||||
$this->frequency = $backup->frequency;
|
||||
}
|
||||
}
|
||||
|
||||
private function selectedTarget(): LocalPersistentVolume|LocalFileVolume|null
|
||||
{
|
||||
[$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,
|
||||
};
|
||||
}
|
||||
}
|
||||
57
app/Livewire/Project/Application/Backup/Index.php
Normal file
57
app/Livewire/Project/Application/Backup/Index.php
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Application\Backup;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class Index extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Application $application;
|
||||
|
||||
public array $parameters;
|
||||
|
||||
public string $search = '';
|
||||
|
||||
protected $listeners = ['refreshVolumeBackups' => '$refresh'];
|
||||
|
||||
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
|
||||
{
|
||||
$backups = ScheduledVolumeBackup::query()
|
||||
->with(['backupable', 'latestExecution'])
|
||||
->withCount('executions')
|
||||
->forApplication($this->application)
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
return view('livewire.project.application.backup.index', ['backups' => $backups]);
|
||||
}
|
||||
|
||||
private function findApplication(): Application
|
||||
{
|
||||
$project = currentTeam()->projects()
|
||||
->where('uuid', request()->route('project_uuid'))
|
||||
->firstOrFail();
|
||||
$environment = $project->environments()
|
||||
->where('uuid', request()->route('environment_uuid'))
|
||||
->firstOrFail();
|
||||
|
||||
return $environment->applications()
|
||||
->with('destination.server')
|
||||
->where('uuid', request()->route('application_uuid'))
|
||||
->firstOrFail();
|
||||
}
|
||||
}
|
||||
55
app/Livewire/Project/Application/Backup/Show.php
Normal file
55
app/Livewire/Project/Application/Backup/Show.php
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Application\Backup;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class Show extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Application $application;
|
||||
|
||||
public ScheduledVolumeBackup $backup;
|
||||
|
||||
public array $parameters;
|
||||
|
||||
public string $section = 'general';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$project = currentTeam()->projects()
|
||||
->where('uuid', request()->route('project_uuid'))
|
||||
->firstOrFail();
|
||||
$environment = $project->environments()
|
||||
->where('uuid', request()->route('environment_uuid'))
|
||||
->firstOrFail();
|
||||
$this->application = $environment->applications()
|
||||
->with('destination.server')
|
||||
->where('uuid', request()->route('application_uuid'))
|
||||
->firstOrFail();
|
||||
$this->authorize('view', $this->application);
|
||||
|
||||
$this->backup = ScheduledVolumeBackup::query()
|
||||
->with('backupable')
|
||||
->where('uuid', request()->route('backup_uuid'))
|
||||
->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()
|
||||
{
|
||||
return view('livewire.project.application.backup.show');
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
use Exception;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
|
@ -19,6 +20,8 @@ class BackupEdit extends Component
|
|||
|
||||
public ScheduledDatabaseBackup $backup;
|
||||
|
||||
public string $section = 'general';
|
||||
|
||||
#[Locked]
|
||||
public $availableS3Storages;
|
||||
|
||||
|
|
@ -82,17 +85,49 @@ class BackupEdit extends Component
|
|||
#[Validate(['required', 'int', 'min:60', 'max:36000'])]
|
||||
public int|string $timeout = 3600;
|
||||
|
||||
public function getListeners(): array
|
||||
{
|
||||
// Keep "Backup Now" in sync when the database starts/stops without a full page refresh.
|
||||
$listeners = ['databaseUpdated' => 'refreshStatus'];
|
||||
|
||||
$user = Auth::user();
|
||||
if (! $user) {
|
||||
return $listeners;
|
||||
}
|
||||
|
||||
$listeners["echo-private:user.{$user->id},DatabaseStatusChanged"] = 'refreshStatus';
|
||||
|
||||
$team = $user->currentTeam();
|
||||
if ($team) {
|
||||
$listeners["echo-private:team.{$team->id},ServiceChecked"] = 'refreshStatus';
|
||||
}
|
||||
|
||||
return $listeners;
|
||||
}
|
||||
|
||||
public function mount()
|
||||
{
|
||||
try {
|
||||
$this->authorize('view', $this->backup->database);
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->syncData();
|
||||
$this->refreshStatus();
|
||||
} catch (Exception $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function refreshStatus(): void
|
||||
{
|
||||
$database = $this->backup->database;
|
||||
if (! $database) {
|
||||
return;
|
||||
}
|
||||
|
||||
$database->refresh();
|
||||
$this->status = $database->status;
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
{
|
||||
if ($toModel) {
|
||||
|
|
@ -184,7 +219,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());
|
||||
|
|
@ -200,6 +239,25 @@ public function backupNow()
|
|||
|
||||
DatabaseBackupJob::dispatch($this->backup);
|
||||
$this->dispatch('success', 'Backup queued. It will be available in a few minutes.');
|
||||
|
||||
$database = $this->backup->database;
|
||||
|
||||
if ($database instanceof ServiceDatabase) {
|
||||
return redirect()->route('project.service.database.backup.executions', [
|
||||
'project_uuid' => $database->service->project()->uuid,
|
||||
'environment_uuid' => $database->service->environment->uuid,
|
||||
'service_uuid' => $database->service->uuid,
|
||||
'stack_service_uuid' => $database->uuid,
|
||||
'backup_uuid' => $this->backup->uuid,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('project.database.backup.executions', [
|
||||
'project_uuid' => $database->project()->uuid,
|
||||
'environment_uuid' => $database->environment->uuid,
|
||||
'database_uuid' => $database->uuid,
|
||||
'backup_uuid' => $this->backup->uuid,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
@ -217,11 +275,37 @@ public function instantSave()
|
|||
}
|
||||
}
|
||||
|
||||
public function toggleEnabled(): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('manageBackups', $this->backup->database);
|
||||
|
||||
$this->backupEnabled = ! $this->backupEnabled;
|
||||
$this->backup->update(['enabled' => $this->backupEnabled]);
|
||||
$this->dispatch('success', $this->backupEnabled ? 'Backup enabled.' : 'Backup disabled.');
|
||||
} catch (\Throwable $e) {
|
||||
$this->dispatch('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
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)) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -56,20 +34,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.');
|
||||
|
|
@ -80,8 +44,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,
|
||||
|
|
@ -99,11 +63,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 {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
namespace App\Livewire\Project\Database;
|
||||
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ServiceDatabase;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
|
|
@ -17,42 +17,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 +62,13 @@ 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');
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
$this->database->loadMissing('scheduledBackups.s3');
|
||||
|
||||
return view('livewire.project.database.scheduled-backups');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,53 @@ 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) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->resource instanceof ServiceDatabase) {
|
||||
$this->backupUrl = route('project.service.database.backups', [
|
||||
'project_uuid' => $this->resource->service->project()->uuid,
|
||||
'environment_uuid' => $this->resource->service->environment->uuid,
|
||||
'service_uuid' => $this->resource->service->uuid,
|
||||
'stack_service_uuid' => $this->resource->uuid,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->resource instanceof Application) {
|
||||
$this->hasEnabledBackup = false;
|
||||
|
||||
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 +188,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 +219,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 +237,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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -2,9 +2,12 @@
|
|||
|
||||
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;
|
||||
use Livewire\Component;
|
||||
|
||||
class Show extends Component
|
||||
|
|
@ -32,6 +35,10 @@ class Show extends Component
|
|||
|
||||
public bool $isPreviewSuffixEnabled = true;
|
||||
|
||||
public bool $hasEnabledBackup = false;
|
||||
|
||||
public ?string $backupUrl = null;
|
||||
|
||||
protected $validationAttributes = [
|
||||
'name' => 'name',
|
||||
'mountPath' => 'mount',
|
||||
|
|
@ -81,10 +88,38 @@ private function syncData(bool $toModel = false): void
|
|||
}
|
||||
}
|
||||
|
||||
public function mount()
|
||||
public function mount(): void
|
||||
{
|
||||
$this->syncData(false);
|
||||
$this->isReadOnly = $this->storage->shouldBeReadOnlyInUI();
|
||||
$this->refreshBackupStatus();
|
||||
}
|
||||
|
||||
#[On('refreshVolumeBackups')]
|
||||
public function refreshBackupStatus(): void
|
||||
{
|
||||
$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
|
||||
|
|
@ -115,8 +150,15 @@ public function delete($password, $selectedActions = [])
|
|||
return 'The provided password is incorrect.';
|
||||
}
|
||||
|
||||
if ($this->storage->scheduledBackups()->exists()) {
|
||||
$this->dispatch('error', 'Delete this volume backup schedule and its archives before deleting the volume.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->storage->delete();
|
||||
$this->dispatch('refreshStorages');
|
||||
$this->dispatch('configurationChanged');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
388
app/Livewire/Project/Shared/Storages/VolumeBackups.php
Normal file
388
app/Livewire/Project/Shared/Storages/VolumeBackups.php
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Shared\Storages;
|
||||
|
||||
use App\Actions\Shared\DeleteScheduledVolumeBackup;
|
||||
use App\Jobs\VolumeBackupJob;
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Routing\Redirector;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use Throwable;
|
||||
|
||||
class VolumeBackups extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
use WithPagination;
|
||||
|
||||
public LocalPersistentVolume|LocalFileVolume $storage;
|
||||
|
||||
public $resource;
|
||||
|
||||
public ?ScheduledVolumeBackup $backup = null;
|
||||
|
||||
public string $section = 'general';
|
||||
|
||||
public string $frequency = 'daily';
|
||||
|
||||
public bool $enabled = false;
|
||||
|
||||
public bool $saveToS3 = false;
|
||||
|
||||
public bool $disableLocalBackup = false;
|
||||
|
||||
public bool $stopDuringBackup = false;
|
||||
|
||||
public ?int $s3StorageId = null;
|
||||
|
||||
public int $retentionAmountLocally = 7;
|
||||
|
||||
public int $retentionDaysLocally = 0;
|
||||
|
||||
public float $retentionMaxStorageLocally = 0;
|
||||
|
||||
public int $retentionAmountS3 = 7;
|
||||
|
||||
public int $retentionDaysS3 = 0;
|
||||
|
||||
public float $retentionMaxStorageS3 = 0;
|
||||
|
||||
public string $timezone = '';
|
||||
|
||||
public int $timeout = 3600;
|
||||
|
||||
public bool $delete_backup_s3 = false;
|
||||
|
||||
public Collection $availableS3Storages;
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'frequency' => ['required', 'string'],
|
||||
'enabled' => ['required', 'boolean'],
|
||||
'saveToS3' => ['required', 'boolean'],
|
||||
'disableLocalBackup' => ['required', 'boolean'],
|
||||
'stopDuringBackup' => ['required', 'boolean'],
|
||||
's3StorageId' => ['nullable', 'integer'],
|
||||
'retentionAmountLocally' => ['required', 'integer', 'min:0', 'max:10000'],
|
||||
'retentionDaysLocally' => ['required', 'integer', 'min:0'],
|
||||
'retentionMaxStorageLocally' => ['required', 'numeric', 'min:0'],
|
||||
'retentionAmountS3' => ['required', 'integer', 'min:0', 'max:10000'],
|
||||
'retentionDaysS3' => ['required', 'integer', 'min:0'],
|
||||
'retentionMaxStorageS3' => ['required', 'numeric', 'min:0'],
|
||||
'timeout' => ['required', 'integer', 'min:60', 'max:36000'],
|
||||
];
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorize('view', $this->resource);
|
||||
$this->availableS3Storages = S3Storage::ownedByCurrentTeam()
|
||||
->where('is_usable', true)
|
||||
->get();
|
||||
$this->backup = $this->storage->scheduledBackups()->first();
|
||||
$server = $this->backup?->server() ?? data_get($this->resource, 'destination.server');
|
||||
$this->timezone = data_get($server, 'settings.server_timezone', 'Instance timezone');
|
||||
|
||||
if ($this->backup) {
|
||||
$this->frequency = $this->backup->frequency;
|
||||
$this->enabled = $this->backup->enabled;
|
||||
$this->saveToS3 = $this->backup->save_s3;
|
||||
$this->disableLocalBackup = $this->backup->disable_local_backup;
|
||||
$this->stopDuringBackup = $this->backup->stop_during_backup;
|
||||
$this->s3StorageId = $this->backup->s3_storage_id ?? $this->availableS3Storages->first()?->id;
|
||||
$this->retentionAmountLocally = $this->backup->retention_amount_locally;
|
||||
$this->retentionDaysLocally = $this->backup->retention_days_locally;
|
||||
$this->retentionMaxStorageLocally = $this->backup->retention_max_storage_locally;
|
||||
$this->retentionAmountS3 = $this->backup->retention_amount_s3;
|
||||
$this->retentionDaysS3 = $this->backup->retention_days_s3;
|
||||
$this->retentionMaxStorageS3 = $this->backup->retention_max_storage_s3;
|
||||
$this->timeout = $this->backup->timeout;
|
||||
} else {
|
||||
$this->s3StorageId = $this->availableS3Storages->first()?->id;
|
||||
}
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if (! $this->validateSettings()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->backup = $this->persistBackup($this->enabled);
|
||||
$this->dispatch('success', 'Storage backup schedule saved.');
|
||||
}
|
||||
|
||||
public function instantSave(): void
|
||||
{
|
||||
$this->save();
|
||||
}
|
||||
|
||||
public function updatedS3StorageId(): void
|
||||
{
|
||||
$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
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if (! $this->backup) {
|
||||
if (! $this->validateSettings()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->enabled = true;
|
||||
$this->backup = $this->persistBackup(true);
|
||||
} else {
|
||||
$this->enabled = ! $this->enabled;
|
||||
$this->backup->update(['enabled' => $this->enabled]);
|
||||
}
|
||||
|
||||
$this->dispatch('success', $this->enabled ? 'Storage backups enabled.' : 'Storage backups disabled.');
|
||||
}
|
||||
|
||||
public function backupNow(): Redirector|RedirectResponse|null
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if (! $this->backup) {
|
||||
if (! $this->validateSettings()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->enabled = false;
|
||||
$this->backup = $this->persistBackup(false);
|
||||
}
|
||||
|
||||
VolumeBackupJob::dispatch($this->backup);
|
||||
$this->dispatch('success', 'Storage backup queued.');
|
||||
|
||||
return redirect()->route('project.application.backup.executions', [
|
||||
'project_uuid' => $this->resource->project()->uuid,
|
||||
'environment_uuid' => $this->resource->environment->uuid,
|
||||
'application_uuid' => $this->resource->uuid,
|
||||
'backup_uuid' => $this->backup->uuid,
|
||||
]);
|
||||
}
|
||||
|
||||
public function delete(?string $password = null, array $selectedActions = []): bool|string
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if (! verifyPasswordConfirmation($password, $this)) {
|
||||
return 'The provided password is incorrect.';
|
||||
}
|
||||
|
||||
if (! $this->backup) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
DeleteScheduledVolumeBackup::run($this->backup);
|
||||
$this->backup = null;
|
||||
$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) {
|
||||
$this->dispatch('error', 'Could not delete the backup archives: '.$exception->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function cleanupFailed(): void
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$deletedCount = $this->backup?->executions()
|
||||
->where('status', 'failed')
|
||||
->where('stop_recovery_pending', false)
|
||||
->where('s3_cleanup_pending', false)
|
||||
->where(fn ($query) => $query
|
||||
->whereNull('filename')
|
||||
->orWhere('local_storage_deleted', true))
|
||||
->delete() ?? 0;
|
||||
|
||||
$this->dispatch(
|
||||
$deletedCount > 0 ? 'success' : 'info',
|
||||
$deletedCount > 0 ? 'Failed backup entries cleaned up.' : 'No safely removable failed backup entries found.',
|
||||
);
|
||||
}
|
||||
|
||||
public function cleanupDeleted(): void
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$deletedCount = $this->backup?->executions()
|
||||
->where('local_storage_deleted', true)
|
||||
->where(fn ($query) => $query
|
||||
->where('s3_storage_deleted', true)
|
||||
->orWhereNull('s3_uploaded')
|
||||
->orWhere('s3_uploaded', false))
|
||||
->delete() ?? 0;
|
||||
|
||||
$this->dispatch(
|
||||
$deletedCount > 0 ? 'success' : 'info',
|
||||
$deletedCount > 0 ? "Cleaned up {$deletedCount} deleted backup entries." : 'No deleted backup entries found.',
|
||||
);
|
||||
}
|
||||
|
||||
public function deleteBackup(int $executionId, string $password, array $selectedActions = []): bool|string
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if (! verifyPasswordConfirmation($password, $this)) {
|
||||
return 'The provided password is incorrect.';
|
||||
}
|
||||
|
||||
$execution = $this->backup?->executions()->whereKey($executionId)->first();
|
||||
if (! $execution) {
|
||||
$this->dispatch('error', 'Backup execution not found.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($execution->status === 'running' || $execution->stop_recovery_pending || $execution->s3_cleanup_pending) {
|
||||
$this->dispatch('error', 'Wait for the backup and recovery operations to finish before deleting it.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$server = $this->backup->server();
|
||||
if (! $execution->local_storage_deleted && filled($execution->filename)) {
|
||||
if (! $server) {
|
||||
throw new \RuntimeException('The server is unavailable.');
|
||||
}
|
||||
|
||||
deleteBackupsLocally($execution->filename, $server, throwError: true);
|
||||
}
|
||||
|
||||
if ($this->delete_backup_s3 && $execution->s3_uploaded && ! $execution->s3_storage_deleted) {
|
||||
if (! $execution->s3) {
|
||||
throw new \RuntimeException('The S3 storage is unavailable.');
|
||||
}
|
||||
|
||||
deleteBackupsS3($execution->filename, $execution->s3);
|
||||
}
|
||||
|
||||
$execution->delete();
|
||||
$this->delete_backup_s3 = false;
|
||||
$this->dispatch('success', 'Backup deleted.');
|
||||
|
||||
return true;
|
||||
} catch (Throwable $exception) {
|
||||
$this->dispatch('error', 'Failed to delete backup: '.$exception->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$executions = $this->backup?->executions()->paginate(10);
|
||||
|
||||
return view('livewire.project.shared.storages.volume-backups', [
|
||||
'executions' => $executions ?? collect(),
|
||||
'latestExecution' => $this->backup?->executions()->first(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function validateSettings(): bool
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
if (! validate_cron_expression($this->frequency)) {
|
||||
$this->addError('frequency', 'The frequency must be a valid cron or human expression.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->saveToS3 && ! $this->hasValidS3Storage()) {
|
||||
$this->addError('s3StorageId', 'Select a usable S3 storage owned by your team.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->disableLocalBackup = $this->saveToS3 && $this->disableLocalBackup;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function persistBackup(bool $enabled): ScheduledVolumeBackup
|
||||
{
|
||||
return $this->storage->scheduledBackups()->updateOrCreate(
|
||||
[],
|
||||
[
|
||||
'team_id' => currentTeam()->id,
|
||||
'frequency' => $this->frequency,
|
||||
'enabled' => $enabled,
|
||||
'save_s3' => $this->saveToS3,
|
||||
'disable_local_backup' => $this->disableLocalBackup,
|
||||
'stop_during_backup' => $this->stopDuringBackup,
|
||||
'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,
|
||||
'retention_amount_s3' => $this->retentionAmountS3,
|
||||
'retention_days_s3' => $this->retentionDaysS3,
|
||||
'retention_max_storage_s3' => $this->retentionMaxStorageS3,
|
||||
'timeout' => $this->timeout,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function hasValidS3Storage(): bool
|
||||
{
|
||||
return $this->s3StorageId !== null
|
||||
&& S3Storage::query()
|
||||
->whereKey($this->s3StorageId)
|
||||
->where('team_id', currentTeam()->id)
|
||||
->where('is_usable', true)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
|
@ -30,7 +31,8 @@ public function mount()
|
|||
return $this->redirectRoute('storage.index', navigate: true);
|
||||
}
|
||||
$this->currentRoute = request()->route()->getName();
|
||||
$this->backupCount = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id)->count();
|
||||
$this->backupCount = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id)->count()
|
||||
+ ScheduledVolumeBackup::where('s3_storage_id', $this->storage->id)->count();
|
||||
}
|
||||
|
||||
public function delete()
|
||||
|
|
|
|||
|
|
@ -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,28 @@ 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 abortIfScheduledBackupsExist(): void
|
||||
{
|
||||
if ($this->scheduledBackups()->exists()) {
|
||||
abort(422, 'Delete this directory backup schedule and its archives before deleting the directory.');
|
||||
}
|
||||
}
|
||||
|
||||
public function loadStorageOnServer()
|
||||
{
|
||||
if ($this->is_host_file) {
|
||||
|
|
|
|||
|
|
@ -3,10 +3,20 @@
|
|||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
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',
|
||||
|
|
@ -41,6 +51,18 @@ public function database()
|
|||
return $this->morphTo('resource');
|
||||
}
|
||||
|
||||
public function scheduledBackups(): MorphMany
|
||||
{
|
||||
return $this->morphMany(ScheduledVolumeBackup::class, 'backupable');
|
||||
}
|
||||
|
||||
public function abortIfScheduledBackupsExist(): void
|
||||
{
|
||||
if ($this->scheduledBackups()->exists()) {
|
||||
abort(422, 'Delete this volume backup schedule and its archives before deleting the volume.');
|
||||
}
|
||||
}
|
||||
|
||||
protected function customizeName($value)
|
||||
{
|
||||
return str($value)->trim()->value;
|
||||
|
|
|
|||
|
|
@ -69,6 +69,15 @@ protected static function boot(): void
|
|||
'save_s3' => false,
|
||||
's3_storage_id' => null,
|
||||
]);
|
||||
ScheduledVolumeBackupExecution::where('s3_storage_id', $storage->id)
|
||||
->update([
|
||||
's3_storage_deleted' => true,
|
||||
's3_cleanup_pending' => false,
|
||||
]);
|
||||
ScheduledVolumeBackup::where('s3_storage_id', $storage->id)->update([
|
||||
'save_s3' => false,
|
||||
's3_storage_id' => null,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -101,6 +110,11 @@ public function scheduledBackups()
|
|||
return $this->hasMany(ScheduledDatabaseBackup::class, 's3_storage_id');
|
||||
}
|
||||
|
||||
public function scheduledVolumeBackups()
|
||||
{
|
||||
return $this->hasMany(ScheduledVolumeBackup::class, 's3_storage_id');
|
||||
}
|
||||
|
||||
public function awsUrl()
|
||||
{
|
||||
return "{$this->endpoint}/{$this->bucket}";
|
||||
|
|
|
|||
149
app/Models/ScheduledVolumeBackup.php
Normal file
149
app/Models/ScheduledVolumeBackup.php
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
<?php
|
||||
|
||||
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',
|
||||
'backupable_type',
|
||||
'backupable_id',
|
||||
'team_id',
|
||||
's3_storage_id',
|
||||
'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',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => 'boolean',
|
||||
'save_s3' => 'boolean',
|
||||
'disable_local_backup' => 'boolean',
|
||||
'stop_during_backup' => 'boolean',
|
||||
'retention_amount_locally' => 'integer',
|
||||
'retention_days_locally' => 'integer',
|
||||
'retention_max_storage_locally' => 'float',
|
||||
'retention_amount_s3' => 'integer',
|
||||
'retention_days_s3' => 'integer',
|
||||
'retention_max_storage_s3' => 'float',
|
||||
'timeout' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function scopeForApplication(Builder $query, Application $application): Builder
|
||||
{
|
||||
$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
|
||||
{
|
||||
return $this->belongsTo(Team::class);
|
||||
}
|
||||
|
||||
public function s3(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(S3Storage::class, 's3_storage_id');
|
||||
}
|
||||
|
||||
public function executions(): HasMany
|
||||
{
|
||||
return $this->hasMany(ScheduledVolumeBackupExecution::class)->latest();
|
||||
}
|
||||
|
||||
public function latestExecution(): HasOne
|
||||
{
|
||||
return $this->hasOne(ScheduledVolumeBackupExecution::class)->latestOfMany();
|
||||
}
|
||||
|
||||
public function server(): ?Server
|
||||
{
|
||||
$resource = $this->targetResource();
|
||||
|
||||
if ($resource instanceof ServiceApplication || $resource instanceof ServiceDatabase) {
|
||||
return $resource->service?->server?->fresh();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
49
app/Models/ScheduledVolumeBackupExecution.php
Normal file
49
app/Models/ScheduledVolumeBackupExecution.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ScheduledVolumeBackupExecution extends BaseModel
|
||||
{
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'scheduled_volume_backup_id',
|
||||
's3_storage_id',
|
||||
'status',
|
||||
'message',
|
||||
'size',
|
||||
'filename',
|
||||
'stop_container_ids',
|
||||
'stop_recovery_pending',
|
||||
's3_cleanup_pending',
|
||||
'finished_at',
|
||||
'local_storage_deleted',
|
||||
's3_storage_deleted',
|
||||
's3_uploaded',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'size' => 'integer',
|
||||
'finished_at' => 'datetime',
|
||||
'stop_container_ids' => 'array',
|
||||
'stop_recovery_pending' => 'boolean',
|
||||
's3_cleanup_pending' => 'boolean',
|
||||
'local_storage_deleted' => 'boolean',
|
||||
's3_storage_deleted' => 'boolean',
|
||||
's3_uploaded' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function scheduledVolumeBackup(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ScheduledVolumeBackup::class);
|
||||
}
|
||||
|
||||
public function s3(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(S3Storage::class, 's3_storage_id');
|
||||
}
|
||||
}
|
||||
|
|
@ -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>>
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ public function __construct(
|
|||
public ?string $helper = null,
|
||||
public string|bool|null $checked = false,
|
||||
public string|bool $instantSave = false,
|
||||
public bool $live = false,
|
||||
public bool $disabled = false,
|
||||
public string $defaultClass = 'dark:border-neutral-700 text-coolgray-400 dark:bg-coolgray-100 rounded-sm cursor-pointer dark:disabled:bg-base dark:disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base',
|
||||
public ?string $canGate = null,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@
|
|||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Throwable;
|
||||
|
||||
function create_standalone_postgresql($environmentId, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null, string $databaseImage = 'postgres:16-alpine'): StandalonePostgresql
|
||||
{
|
||||
|
|
@ -179,7 +183,7 @@ function create_standalone_clickhouse($environment_id, StandaloneDocker|SwarmDoc
|
|||
return $database;
|
||||
}
|
||||
|
||||
function deleteBackupsLocally(string|array|null $filenames, Server $server): void
|
||||
function deleteBackupsLocally(string|array|null $filenames, Server $server, bool $throwError = false): void
|
||||
{
|
||||
if (empty($filenames)) {
|
||||
return;
|
||||
|
|
@ -187,13 +191,48 @@ function deleteBackupsLocally(string|array|null $filenames, Server $server): voi
|
|||
if (is_string($filenames)) {
|
||||
$filenames = [$filenames];
|
||||
}
|
||||
$quotedFiles = array_map(fn ($file) => "\"$file\"", $filenames);
|
||||
instant_remote_process(['rm -f '.implode(' ', $quotedFiles)], $server, throwError: false);
|
||||
$quotedFiles = array_map(fn ($file) => escapeshellarg($file), $filenames);
|
||||
instant_remote_process(['rm -f '.implode(' ', $quotedFiles)], $server, throwError: $throwError);
|
||||
|
||||
$foldersToCheck = collect($filenames)->map(fn ($file) => dirname($file))->unique();
|
||||
$foldersToCheck->each(fn ($folder) => deleteEmptyBackupFolder($folder, $server));
|
||||
}
|
||||
|
||||
function streamBackupFromServer(Server $server, string $filename, string $contentType): StreamedResponse
|
||||
{
|
||||
$disk = Storage::build([
|
||||
'driver' => 'sftp',
|
||||
'host' => $server->ip,
|
||||
'port' => (int) $server->port,
|
||||
'username' => $server->user,
|
||||
'privateKey' => $server->privateKey->getKeyLocation(),
|
||||
'root' => '/',
|
||||
]);
|
||||
|
||||
if (! $disk->exists($filename)) {
|
||||
throw new FileNotFoundException($filename);
|
||||
}
|
||||
|
||||
return new StreamedResponse(function () use ($disk, $filename) {
|
||||
if (ob_get_level()) {
|
||||
ob_end_clean();
|
||||
}
|
||||
$stream = $disk->readStream($filename);
|
||||
if ($stream === false || is_null($stream)) {
|
||||
abort(500, 'Failed to open stream for the requested file.');
|
||||
}
|
||||
while (! feof($stream)) {
|
||||
echo fread($stream, 2048);
|
||||
flush();
|
||||
}
|
||||
|
||||
fclose($stream);
|
||||
}, 200, [
|
||||
'Content-Type' => $contentType,
|
||||
'Content-Disposition' => 'attachment; filename="'.basename($filename).'"',
|
||||
]);
|
||||
}
|
||||
|
||||
function deleteBackupsS3(string|array|null $filenames, S3Storage $s3): void
|
||||
{
|
||||
if (empty($filenames) || ! $s3) {
|
||||
|
|
@ -214,7 +253,9 @@ function deleteBackupsS3(string|array|null $filenames, S3Storage $s3): void
|
|||
'aws_url' => $s3->awsUrl(),
|
||||
]);
|
||||
|
||||
$disk->delete($filenames);
|
||||
if (! $disk->delete($filenames)) {
|
||||
throw new RuntimeException('One or more S3 backup files could not be deleted.');
|
||||
}
|
||||
}
|
||||
|
||||
function deleteEmptyBackupFolder($folderPath, Server $server): void
|
||||
|
|
@ -428,8 +469,12 @@ function deleteOldBackupsFromS3($backup): Collection
|
|||
->all();
|
||||
|
||||
if (! empty($filesToDelete)) {
|
||||
deleteBackupsS3($filesToDelete, $backup->s3);
|
||||
$processedBackups = $backupsToDelete;
|
||||
try {
|
||||
deleteBackupsS3($filesToDelete, $backup->s3);
|
||||
$processedBackups = $backupsToDelete;
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
return $processedBackups;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('scheduled_volume_backups', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$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');
|
||||
$table->boolean('enabled')->default(true);
|
||||
$table->boolean('save_s3')->default(false);
|
||||
$table->boolean('disable_local_backup')->default(false);
|
||||
$table->boolean('stop_during_backup')->default(false);
|
||||
$table->unsignedInteger('retention_amount_locally')->default(7);
|
||||
$table->unsignedInteger('retention_days_locally')->default(0);
|
||||
$table->decimal('retention_max_storage_locally', 17, 7)->default(0);
|
||||
$table->unsignedInteger('retention_amount_s3')->default(7);
|
||||
$table->unsignedInteger('retention_days_s3')->default(0);
|
||||
$table->decimal('retention_max_storage_s3', 17, 7)->default(0);
|
||||
$table->unsignedInteger('timeout')->default(3600);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['backupable_type', 'backupable_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('scheduled_volume_backups');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('scheduled_volume_backup_executions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->foreignId('scheduled_volume_backup_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('s3_storage_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->enum('status', ['success', 'failed', 'running'])->default('running');
|
||||
$table->longText('message')->nullable();
|
||||
$table->unsignedBigInteger('size')->default(0);
|
||||
$table->text('filename')->nullable();
|
||||
$table->json('stop_container_ids')->nullable();
|
||||
$table->boolean('stop_recovery_pending')->default(false);
|
||||
$table->boolean('s3_cleanup_pending')->default(false);
|
||||
$table->timestamp('finished_at')->nullable();
|
||||
$table->boolean('local_storage_deleted')->default(false);
|
||||
$table->boolean('s3_storage_deleted')->default(false);
|
||||
$table->boolean('s3_uploaded')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['scheduled_volume_backup_id', 'created_at'], 'scheduled_volume_executions_backup_created_index');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('scheduled_volume_backup_executions');
|
||||
}
|
||||
};
|
||||
553
openapi.json
553
openapi.json
|
|
@ -15536,6 +15536,396 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"\/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": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Applications"
|
||||
],
|
||||
"summary": "Delete application storage backup schedule",
|
||||
"description": "Delete the backup schedule and its local and S3 archives for an application storage.",
|
||||
"operationId": "delete-application-storage-backup-schedule",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "uuid",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "storage_uuid",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Backup schedule and archives deleted."
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden."
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
"409": {
|
||||
"description": "Backup or recovery operation is still running."
|
||||
}
|
||||
},
|
||||
"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": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Databases"
|
||||
],
|
||||
"summary": "Delete database storage backup schedule",
|
||||
"description": "Delete the backup schedule and its local and S3 archives for a database storage.",
|
||||
"operationId": "delete-database-storage-backup-schedule",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "uuid",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "storage_uuid",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Backup schedule and archives deleted."
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden."
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
"409": {
|
||||
"description": "Backup or recovery operation is still running."
|
||||
}
|
||||
},
|
||||
"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": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Services"
|
||||
],
|
||||
"summary": "Delete service storage backup schedule",
|
||||
"description": "Delete the backup schedule and its local and S3 archives for a service storage.",
|
||||
"operationId": "delete-service-storage-backup-schedule",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "uuid",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "storage_uuid",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Backup schedule and archives deleted."
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden."
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
"409": {
|
||||
"description": "Backup or recovery operation is still running."
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/vultr\/regions": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
|
@ -15672,6 +16062,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": {
|
||||
|
|
|
|||
385
openapi.yaml
385
openapi.yaml
|
|
@ -9925,6 +9925,267 @@ 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: []
|
||||
delete:
|
||||
tags:
|
||||
- Applications
|
||||
summary: 'Delete application storage backup schedule'
|
||||
description: 'Delete the backup schedule and its local and S3 archives for an application storage.'
|
||||
operationId: delete-application-storage-backup-schedule
|
||||
parameters:
|
||||
-
|
||||
name: uuid
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
-
|
||||
name: storage_uuid
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: 'Backup schedule and archives deleted.'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'403':
|
||||
description: Forbidden.
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'409':
|
||||
description: 'Backup or recovery operation is still running.'
|
||||
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: []
|
||||
delete:
|
||||
tags:
|
||||
- Databases
|
||||
summary: 'Delete database storage backup schedule'
|
||||
description: 'Delete the backup schedule and its local and S3 archives for a database storage.'
|
||||
operationId: delete-database-storage-backup-schedule
|
||||
parameters:
|
||||
-
|
||||
name: uuid
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
-
|
||||
name: storage_uuid
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: 'Backup schedule and archives deleted.'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'403':
|
||||
description: Forbidden.
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'409':
|
||||
description: 'Backup or recovery operation is still running.'
|
||||
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: []
|
||||
delete:
|
||||
tags:
|
||||
- Services
|
||||
summary: 'Delete service storage backup schedule'
|
||||
description: 'Delete the backup schedule and its local and S3 archives for a service storage.'
|
||||
operationId: delete-service-storage-backup-schedule
|
||||
parameters:
|
||||
-
|
||||
name: uuid
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
-
|
||||
name: storage_uuid
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: 'Backup schedule and archives deleted.'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'403':
|
||||
description: Forbidden.
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'409':
|
||||
description: 'Backup or recovery operation is still running.'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
/vultr/regions:
|
||||
get:
|
||||
tags:
|
||||
|
|
@ -10014,6 +10275,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:
|
||||
|
|
|
|||
|
|
@ -39,7 +39,8 @@
|
|||
value={{ $domValue }} id="{{ $htmlId }}" @if ($checked) checked @endif />
|
||||
@else
|
||||
<input type="checkbox" @disabled($disabled) {{ $attributes->class([$defaultClass, 'shrink-0']) }}
|
||||
wire:model={{ $value ?? $modelBinding }} id="{{ $htmlId }}" @if ($checked) checked @endif />
|
||||
@if ($live) wire:model.live={{ $value ?? $modelBinding }} @else wire:model={{ $value ?? $modelBinding }} @endif
|
||||
id="{{ $htmlId }}" @if ($checked) checked @endif />
|
||||
@endif
|
||||
@endif
|
||||
</label>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@
|
|||
@if ($helper)
|
||||
<x-helper :helper="$helper" />
|
||||
@endif
|
||||
@isset($labelSuffix)
|
||||
{{ $labelSuffix }}
|
||||
@endisset
|
||||
</label>
|
||||
@endif
|
||||
@if ($type === 'password')
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
$showUnknownHelper = ! str($status)->startsWith('Proxy') && (str($status)->contains('unknown') || str($healthStatus)->contains('unknown'));
|
||||
$showUnhealthyHelper = ! str($status)->startsWith('Proxy') && (str($status)->contains('unhealthy') || str($healthStatus)->contains('unhealthy'));
|
||||
@endphp
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex items-center gap-1 leading-none">
|
||||
@if ($lastDeploymentLink)
|
||||
<x-status-badge as="a" href="{{ $lastDeploymentLink }}" target="_blank" status="{{ $badgeStatus }}" type="success"
|
||||
title="{{ $title }}" class="cursor-pointer underline" />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
<form class="flex flex-col w-full gap-4 rounded-sm" wire:submit="submit">
|
||||
@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="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"
|
||||
helper="Use every_minute, hourly, daily, weekly, monthly, yearly, or a cron expression."
|
||||
label="Frequency" required />
|
||||
</div>
|
||||
|
||||
<x-forms.button type="submit">Save</x-forms.button>
|
||||
@endif
|
||||
</form>
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
<div x-data="{
|
||||
search: @js($search),
|
||||
backups: @js($backups->map(fn ($backup) => [
|
||||
'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.type.includes(query) || backup.frequency.includes(query));
|
||||
},
|
||||
}">
|
||||
<x-slot:title>
|
||||
{{ data_get_str($application, 'name')->limit(10) }} > Backups | Coolify
|
||||
</x-slot>
|
||||
<h1>Backups</h1>
|
||||
<livewire:project.shared.configuration-checker :resource="$application" />
|
||||
<livewire:project.application.heading :application="$application" />
|
||||
|
||||
<div class="flex items-center gap-2 pb-4">
|
||||
<h2>Scheduled Backups</h2>
|
||||
@can('update', $application)
|
||||
<x-modal-input buttonTitle="+ Add" title="New Scheduled Backup" :wireIgnore="false">
|
||||
<livewire:project.application.backup.create :application="$application"
|
||||
wire:key="create-volume-backup-{{ $application->id }}" />
|
||||
</x-modal-input>
|
||||
@endcan
|
||||
</div>
|
||||
|
||||
<div class="max-w-md pb-4">
|
||||
<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">
|
||||
<div x-cloak x-show="search !== '' && backups.length > 0 && !hasMatches()">
|
||||
No scheduled backups match your search.
|
||||
</div>
|
||||
@forelse ($backups as $backup)
|
||||
@php($latestExecution = $backup->latestExecution)
|
||||
<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',
|
||||
'border-success' => $latestExecution?->status === 'success',
|
||||
'border-gray-200 dark:border-coolgray-300' => !$latestExecution,
|
||||
]) {{ wireNavigate() }}
|
||||
href="{{ route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]) }}">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-2">
|
||||
@if ($latestExecution)
|
||||
<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' => $latestExecution->status === 'running',
|
||||
'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200' => $latestExecution->status === 'failed',
|
||||
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200' => $latestExecution->status === 'success',
|
||||
])>
|
||||
{{ $latestExecution->status === 'running' ? 'In Progress' : ucfirst($latestExecution->status) }}
|
||||
</span>
|
||||
@else
|
||||
<span class="px-3 py-1 text-xs font-medium tracking-wide text-gray-800 bg-gray-100 rounded-md shadow-xs dark:bg-neutral-800 dark:text-gray-200">
|
||||
No executions yet
|
||||
</span>
|
||||
@endif
|
||||
<h3 class="font-semibold">{{ $backup->frequency }}</h3>
|
||||
@if (!$backup->enabled)
|
||||
<span class="text-xs text-neutral-500">Disabled</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ $backup->targetType() }}: {{ $backup->targetName() }}
|
||||
@if ($latestExecution?->finished_at)
|
||||
• Last run {{ $latestExecution->finished_at->diffForHumans() }}
|
||||
@else
|
||||
• Last run: Never
|
||||
@endif
|
||||
• Total executions: {{ $backup->executions_count }}
|
||||
@if ($backup->save_s3)
|
||||
• S3: Enabled
|
||||
@endif
|
||||
@if (($latestExecution?->size ?? 0) > 0)
|
||||
• Size: {{ formatBytes($latestExecution->size) }}
|
||||
@endif
|
||||
</div>
|
||||
</a>
|
||||
@empty
|
||||
<div>No scheduled backups configured.</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<div>
|
||||
<x-slot:title>
|
||||
{{ data_get_str($application, 'name')->limit(10) }} > Backups | Coolify
|
||||
</x-slot>
|
||||
<h1>Backups</h1>
|
||||
<livewire:project.shared.configuration-checker :resource="$application" />
|
||||
<livewire:project.application.heading :application="$application" />
|
||||
|
||||
<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>
|
||||
|
|
@ -11,6 +11,11 @@
|
|||
'route' => 'project.application.deployment.index',
|
||||
'active' => request()->routeIs('project.application.deployment.index', 'project.application.deployment.show'),
|
||||
],
|
||||
[
|
||||
'label' => 'Backups',
|
||||
'route' => 'project.application.backup.index',
|
||||
'active' => request()->routeIs('project.application.backup.*'),
|
||||
],
|
||||
[
|
||||
'label' => 'Logs',
|
||||
'route' => 'project.application.logs',
|
||||
|
|
@ -447,6 +452,10 @@ 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.*') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('project.application.backup.index', $parameters) }}">
|
||||
Backups
|
||||
</a>
|
||||
<a class="hidden md:block shrink-0 {{ request()->routeIs('project.application.logs') ? 'dark:text-white' : '' }}"
|
||||
href="{{ route('project.application.logs', $parameters) }}">
|
||||
<div class="flex items-center gap-1">
|
||||
|
|
|
|||
|
|
@ -1,155 +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 (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">
|
||||
<x-forms.checkbox instantSave label="Backup Enabled" id="backupEnabled" />
|
||||
@if ($availableS3Storages->count() > 0)
|
||||
<x-forms.checkbox instantSave label="S3 Enabled" id="saveS3" />
|
||||
@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
|
||||
@elseif($backup->database_type === 'App\Models\StandaloneClickhouse')
|
||||
<x-forms.input label="Databases To Backup"
|
||||
helper="Comma separated list of databases to backup. Empty will include the default one."
|
||||
id="databasesToBackup" />
|
||||
@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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
<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
|
||||
@elseif ($backup->database_type === 'App\Models\StandaloneClickhouse')
|
||||
<x-forms.input label="Databases To Backup"
|
||||
helper="Comma separated list of databases to backup. Empty will include the default one."
|
||||
id="databasesToBackup" />
|
||||
@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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
@if ($availableS3Storages->isEmpty())
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2>S3</h2>
|
||||
<div>
|
||||
No validated S3 available. Configure one <a class="underline dark:text-white" {{ wireNavigate() }}
|
||||
href="{{ route('storage.index') }}">here</a>.
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<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>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">
|
||||
@foreach ($availableS3Storages as $s3)
|
||||
<option value="{{ $s3->id }}">{{ $s3->name }}</option>
|
||||
@endforeach
|
||||
</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>
|
||||
@endif
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@
|
|||
@php
|
||||
$databasePageItems = [
|
||||
['label' => 'Configuration', 'route' => 'project.database.configuration', 'active' => request()->routeIs('project.database.configuration')],
|
||||
['label' => 'Logs', 'route' => 'project.database.logs', 'active' => request()->routeIs('project.database.logs')],
|
||||
['label' => 'Terminal', 'route' => 'project.database.command', 'active' => request()->routeIs('project.database.command'), 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')],
|
||||
[
|
||||
'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' => $database->isBackupSolutionAvailable(),
|
||||
],
|
||||
['label' => 'Logs', 'route' => 'project.database.logs', 'active' => request()->routeIs('project.database.logs')],
|
||||
['label' => 'Terminal', 'route' => 'project.database.command', 'active' => request()->routeIs('project.database.command'), 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')],
|
||||
];
|
||||
|
||||
$databaseConfigurationItems = [
|
||||
|
|
@ -185,6 +185,12 @@ class="scrollbar hidden min-h-10 w-full flex-nowrap items-center gap-6 overflow-
|
|||
Configuration
|
||||
</a>
|
||||
|
||||
@if ($database->isBackupSolutionAvailable())
|
||||
<a class="shrink-0 {{ request()->routeIs('project.database.backup.*') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('project.database.backup.index', $parameters) }}">
|
||||
Backups
|
||||
</a>
|
||||
@endif
|
||||
<a class="shrink-0 {{ request()->routeIs('project.database.logs') ? 'dark:text-white' : '' }}"
|
||||
href="{{ route('project.database.logs', $parameters) }}">
|
||||
Logs
|
||||
|
|
@ -195,12 +201,7 @@ class="scrollbar hidden min-h-10 w-full flex-nowrap items-center gap-6 overflow-
|
|||
Terminal
|
||||
</a>
|
||||
@endcan
|
||||
@if ($database->isBackupSolutionAvailable())
|
||||
<a class="shrink-0 {{ request()->routeIs('project.database.backup.index') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('project.database.backup.index', $parameters) }}">
|
||||
Backups
|
||||
</a>
|
||||
@endif
|
||||
|
||||
</nav>
|
||||
|
||||
@if ($database->destination->server->isFunctional())
|
||||
|
|
|
|||
|
|
@ -1,4 +1,16 @@
|
|||
<div>
|
||||
<div x-data="{
|
||||
search: '',
|
||||
backups: @js($database->scheduledBackups->map(fn ($backup) => [
|
||||
'name' => strtolower($database->name),
|
||||
'frequency' => strtolower($backup->frequency),
|
||||
's3_storage' => strtolower($backup->s3?->name ?? ''),
|
||||
])->values()),
|
||||
hasMatches() {
|
||||
const query = this.search.toLowerCase();
|
||||
|
||||
return this.backups.some((backup) => backup.name.includes(query) || backup.frequency.includes(query) || backup.s3_storage.includes(query));
|
||||
},
|
||||
}">
|
||||
<div class="flex flex-col gap-2">
|
||||
@if ($database->is_migrated && blank($database->custom_type))
|
||||
<div>
|
||||
|
|
@ -18,9 +30,16 @@
|
|||
</form>
|
||||
</div>
|
||||
@else
|
||||
<div class="max-w-md pb-4">
|
||||
<x-forms.input id="null" type="search" x-model="search"
|
||||
placeholder="Search by database name, frequency, or S3 storage..." />
|
||||
</div>
|
||||
<div x-cloak x-show="search !== '' && backups.length > 0 && !hasMatches()">
|
||||
No scheduled backups match your search.
|
||||
</div>
|
||||
@forelse($database->scheduledBackups as $backup)
|
||||
@if ($type == 'database')
|
||||
<a @class([
|
||||
<a x-show="search === '' || @js(strtolower($database->name)).includes(search.toLowerCase()) || @js(strtolower($backup->frequency)).includes(search.toLowerCase()) || @js(strtolower($backup->s3?->name ?? '')).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' =>
|
||||
$backup->latest_log &&
|
||||
|
|
@ -94,21 +113,19 @@ class="px-3 py-1 rounded-md text-xs font-medium tracking-wide shadow-xs bg-gray-
|
|||
@endif
|
||||
@endif
|
||||
@if ($backup->save_s3)
|
||||
• S3: Enabled
|
||||
• S3: {{ $backup->s3?->name ?? 'Storage unavailable' }}
|
||||
@endif
|
||||
@else
|
||||
Last Run: Never • Total Executions: 0
|
||||
@if ($backup->save_s3)
|
||||
• S3: Enabled
|
||||
• S3: {{ $backup->s3?->name ?? 'Storage unavailable' }}
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</a>
|
||||
@else
|
||||
<div @class([
|
||||
<a x-show="search === '' || @js(strtolower($database->name)).includes(search.toLowerCase()) || @js(strtolower($backup->frequency)).includes(search.toLowerCase()) || @js(strtolower($backup->s3?->name ?? '')).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',
|
||||
|
|
@ -119,9 +136,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 />
|
||||
|
|
@ -182,7 +198,7 @@ class="px-3 py-1 rounded-md text-xs font-medium tracking-wide shadow-xs bg-gray-
|
|||
@endif
|
||||
@endif
|
||||
@if ($backup->save_s3)
|
||||
• S3: Enabled
|
||||
• S3: {{ $backup->s3?->name ?? 'Storage unavailable' }}
|
||||
@endif
|
||||
<br>Total Executions: {{ $backup->executions()->count() }}
|
||||
@php
|
||||
|
|
@ -202,23 +218,15 @@ class="px-3 py-1 rounded-md text-xs font-medium tracking-wide shadow-xs bg-gray-
|
|||
@else
|
||||
Last Run: Never • Total Executions: 0
|
||||
@if ($backup->save_s3)
|
||||
• S3: Enabled
|
||||
• S3: {{ $backup->s3?->name ?? 'Storage unavailable' }}
|
||||
@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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -12,10 +12,26 @@
|
|||
$storage->resource_type === 'App\Models\ServiceApplication' ||
|
||||
$storage->resource_type === 'App\Models\ServiceDatabase')
|
||||
<x-forms.input id="name" label="Volume Name" required readonly
|
||||
helper="Warning: Changing the volume name after the initial start could cause problems. Only use it when you know what are you doing." />
|
||||
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 :as="$backupUrl ? 'a' : 'span'" :href="$backupUrl"
|
||||
status="Backup enabled" type="success"
|
||||
:class="$backupUrl ? 'cursor-pointer underline' : null" />
|
||||
@endif
|
||||
</x-slot:labelSuffix>
|
||||
</x-forms.input>
|
||||
@else
|
||||
<x-forms.input id="name" label="Volume Name" required readonly
|
||||
helper="Warning: Changing the volume name after the initial start could cause problems. Only use it when you know what are you doing." />
|
||||
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 :as="$backupUrl ? 'a' : 'span'" :href="$backupUrl"
|
||||
status="Backup enabled" type="success"
|
||||
:class="$backupUrl ? 'cursor-pointer underline' : null" />
|
||||
@endif
|
||||
</x-slot:labelSuffix>
|
||||
</x-forms.input>
|
||||
@endif
|
||||
@if ($isService || $startedAt)
|
||||
<x-forms.input id="hostPath" readonly helper="Directory on the host system."
|
||||
|
|
@ -33,7 +49,15 @@
|
|||
</div>
|
||||
@else
|
||||
<div class="flex gap-2 items-end w-full">
|
||||
<x-forms.input id="name" required readonly />
|
||||
<x-forms.input id="name" :label="$hasEnabledBackup ? 'Volume Name' : null" required 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 id="hostPath" readonly />
|
||||
<x-forms.input id="mountPath" required readonly />
|
||||
</div>
|
||||
|
|
@ -47,18 +71,43 @@
|
|||
</div>
|
||||
@endcan
|
||||
@endif
|
||||
@if ($resource instanceof \App\Models\Application)
|
||||
@can('update', $resource)
|
||||
<x-modal-input buttonTitle="Configure Backup" title="Configure Volume Backup" :wireIgnore="false">
|
||||
<livewire:project.application.backup.create :application="$resource"
|
||||
:selected-target-key="'volume:' . $storage->id"
|
||||
wire:key="configure-readonly-volume-backup-{{ $storage->id }}" />
|
||||
</x-modal-input>
|
||||
@endcan
|
||||
@endif
|
||||
@else
|
||||
@can('update', $resource)
|
||||
@if ($isFirst)
|
||||
<div class="flex gap-2 items-end w-full">
|
||||
<x-forms.input id="name" label="Volume Name" required />
|
||||
<x-forms.input id="name" label="Volume Name" required>
|
||||
<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 id="hostPath" helper="Directory on the host system." label="Source Path" />
|
||||
<x-forms.input id="mountPath" label="Destination Path"
|
||||
helper="Directory inside the container." required />
|
||||
</div>
|
||||
@else
|
||||
<div class="flex gap-2 items-end w-full">
|
||||
<x-forms.input id="name" required />
|
||||
<x-forms.input id="name" :label="$hasEnabledBackup ? 'Volume Name' : null" required>
|
||||
<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 id="hostPath" />
|
||||
<x-forms.input id="mountPath" required />
|
||||
</div>
|
||||
|
|
@ -74,6 +123,13 @@
|
|||
<x-forms.button type="submit">
|
||||
Update
|
||||
</x-forms.button>
|
||||
@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-target-key="'volume:' . $storage->id"
|
||||
wire:key="configure-volume-backup-{{ $storage->id }}" />
|
||||
</x-modal-input>
|
||||
@endif
|
||||
<x-modal-confirmation title="Confirm persistent storage deletion?" isErrorButton buttonTitle="Delete"
|
||||
submitAction="delete" :actions="[
|
||||
'The selected persistent storage/volume will be permanently deleted.',
|
||||
|
|
@ -85,7 +141,15 @@
|
|||
@else
|
||||
@if ($isFirst)
|
||||
<div class="flex gap-2 items-end w-full">
|
||||
<x-forms.input id="name" label="Volume Name" required disabled />
|
||||
<x-forms.input id="name" label="Volume Name" required disabled>
|
||||
<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 id="hostPath" helper="Directory on the host system." label="Source Path"
|
||||
disabled />
|
||||
<x-forms.input id="mountPath" label="Destination Path"
|
||||
|
|
@ -93,7 +157,15 @@
|
|||
</div>
|
||||
@else
|
||||
<div class="flex gap-2 items-end w-full">
|
||||
<x-forms.input id="name" required disabled />
|
||||
<x-forms.input id="name" :label="$hasEnabledBackup ? 'Volume Name' : null" required disabled>
|
||||
<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 id="hostPath" disabled />
|
||||
<x-forms.input id="mountPath" required disabled />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
<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
|
||||
|
||||
@script
|
||||
<script>
|
||||
window.download_volume_backup_file = function(executionId) {
|
||||
window.open('/download/volume-backup/' + executionId, '_blank');
|
||||
}
|
||||
</script>
|
||||
@endscript
|
||||
</div>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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() }} 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
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
<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" canGate="update"
|
||||
:canResource="$backup">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" canGate="update" :canResource="$backup"
|
||||
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" canGate="update" :canResource="$backup"
|
||||
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" canGate="update" :canResource="$backup"
|
||||
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="retentionAmountS3" type="number" min="0"
|
||||
canGate="update" :canResource="$backup"
|
||||
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"
|
||||
canGate="update" :canResource="$backup"
|
||||
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" canGate="update" :canResource="$backup"
|
||||
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>
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
@if ($availableS3Storages->isEmpty())
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2>S3</h2>
|
||||
<div>
|
||||
No validated S3 available. Configure one <a class="underline dark:text-white" {{ wireNavigate() }}
|
||||
href="{{ route('storage.index') }}">here</a>.
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<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" canGate="update"
|
||||
:canResource="$backup">Save</x-forms.button>
|
||||
@if (!$saveToS3)
|
||||
<x-forms.button type="button" wire:click="toggleS3" wire:loading.attr="disabled"
|
||||
wire:target="toggleS3" isHighlighted canGate="update" :canResource="$backup">Enable S3</x-forms.button>
|
||||
@else
|
||||
<x-forms.button type="button" wire:click="toggleS3" wire:loading.attr="disabled"
|
||||
wire:target="toggleS3" canGate="update" :canResource="$backup">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" canGate="update"
|
||||
:canResource="$backup">
|
||||
@foreach ($availableS3Storages as $s3Storage)
|
||||
<option value="{{ $s3Storage->id }}">{{ $s3Storage->name }}</option>
|
||||
@endforeach
|
||||
</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." canGate="update"
|
||||
:canResource="$backup" />
|
||||
@else
|
||||
<x-forms.checkbox id="disableLocalBackup" label="Disable Local Backup"
|
||||
helper="When enabled, backup files are deleted locally after a successful S3 upload." disabled
|
||||
canGate="update" :canResource="$backup" />
|
||||
@endif
|
||||
</div>
|
||||
|
||||
</form>
|
||||
@endif
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
submitAction="delete({{ $storage->id }})" :actions="array_filter([
|
||||
'The selected storage location will be permanently deleted from Coolify.',
|
||||
$backupCount > 0
|
||||
? $backupCount . ' backup schedule(s) will be updated to no longer save to S3 and will only store backups locally on the server.'
|
||||
? $backupCount . ' backup schedule(s) will stop saving to S3. Existing objects in this storage will not be deleted.'
|
||||
: null,
|
||||
])" confirmationText="{{ $storage->name }}"
|
||||
confirmationLabel="Please confirm the execution of the actions by entering the Storage Name below"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -154,6 +155,12 @@
|
|||
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::delete('/applications/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'destroy'])
|
||||
->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']);
|
||||
|
|
@ -201,6 +208,12 @@
|
|||
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::delete('/databases/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'destroy'])
|
||||
->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']);
|
||||
|
|
@ -231,6 +244,12 @@
|
|||
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::delete('/services/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'destroy'])
|
||||
->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']);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@
|
|||
use App\Livewire\Notifications\Webhook as NotificationWebhook;
|
||||
use App\Livewire\Profile\Appearance as ProfileAppearance;
|
||||
use App\Livewire\Profile\Index as ProfileIndex;
|
||||
use App\Livewire\Project\Application\Backup\Index as ApplicationBackupIndex;
|
||||
use App\Livewire\Project\Application\Backup\Show as ApplicationBackupShow;
|
||||
use App\Livewire\Project\Application\Configuration as ApplicationConfiguration;
|
||||
use App\Livewire\Project\Application\Deployment\Index as DeploymentIndex;
|
||||
use App\Livewire\Project\Application\Deployment\Show as DeploymentShow;
|
||||
|
|
@ -91,12 +93,12 @@
|
|||
use App\Livewire\Team\Member\Index as TeamMemberIndex;
|
||||
use App\Livewire\Terminal\Index as TerminalIndex;
|
||||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\ScheduledVolumeBackupExecution;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Providers\RouteServiceProvider;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException;
|
||||
|
||||
Route::post('/forgot-password', [Controller::class, 'forgot_password'])->name('password.forgot')->middleware('throttle:forgot-password');
|
||||
Route::get('/realtime', [Controller::class, 'realtime_test'])->middleware('auth');
|
||||
|
|
@ -224,6 +226,12 @@
|
|||
Route::get('/advanced', ApplicationConfiguration::class)->name('project.application.advanced');
|
||||
Route::get('/environment-variables', ApplicationConfiguration::class)->name('project.application.environment-variables');
|
||||
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');
|
||||
|
|
@ -261,6 +269,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');
|
||||
|
|
@ -274,6 +286,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');
|
||||
|
|
@ -371,46 +388,52 @@
|
|||
$server = $execution->scheduledDatabaseBackup->database->destination->server;
|
||||
}
|
||||
|
||||
$privateKeyLocation = $server->privateKey->getKeyLocation();
|
||||
$disk = Storage::build([
|
||||
'driver' => 'sftp',
|
||||
'host' => $server->ip,
|
||||
'port' => (int) $server->port,
|
||||
'username' => $server->user,
|
||||
'privateKey' => $privateKeyLocation,
|
||||
'root' => '/',
|
||||
]);
|
||||
if (! $disk->exists($filename)) {
|
||||
if ($execution->scheduledDatabaseBackup->disable_local_backup === true && $execution->scheduledDatabaseBackup->save_s3 === true) {
|
||||
return response()->json(['message' => 'Backup not available locally, but available on S3.'], 404);
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Backup not found locally on the server.'], 404);
|
||||
return streamBackupFromServer($server, $filename, 'application/octet-stream');
|
||||
} catch (FileNotFoundException) {
|
||||
if (isset($execution) && $execution->scheduledDatabaseBackup->disable_local_backup === true && $execution->scheduledDatabaseBackup->save_s3 === true) {
|
||||
return response()->json(['message' => 'Backup not available locally, but available on S3.'], 404);
|
||||
}
|
||||
|
||||
return new StreamedResponse(function () use ($disk, $filename) {
|
||||
if (ob_get_level()) {
|
||||
ob_end_clean();
|
||||
}
|
||||
$stream = $disk->readStream($filename);
|
||||
if ($stream === false || is_null($stream)) {
|
||||
abort(500, 'Failed to open stream for the requested file.');
|
||||
}
|
||||
while (! feof($stream)) {
|
||||
echo fread($stream, 2048);
|
||||
flush();
|
||||
}
|
||||
|
||||
fclose($stream);
|
||||
}, 200, [
|
||||
'Content-Type' => 'application/octet-stream',
|
||||
'Content-Disposition' => 'attachment; filename="'.basename($filename).'"',
|
||||
]);
|
||||
return response()->json(['message' => 'Backup not found locally on the server.'], 404);
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to download backup.'], 500);
|
||||
}
|
||||
})->name('download.backup');
|
||||
|
||||
Route::get('/download/volume-backup/{executionId}', function () {
|
||||
try {
|
||||
$user = auth()->user();
|
||||
$team = $user->currentTeam();
|
||||
if (is_null($team)) {
|
||||
return response()->json(['message' => 'Team not found.'], 404);
|
||||
}
|
||||
if ($user->isAdminFromSession() === false) {
|
||||
return response()->json(['message' => 'Only team admins/owners can download backups.'], 403);
|
||||
}
|
||||
|
||||
$execution = ScheduledVolumeBackupExecution::query()
|
||||
->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);
|
||||
}
|
||||
if ($execution->local_storage_deleted || blank($execution->filename)) {
|
||||
return response()->json(['message' => 'Backup not found locally on the server.'], 404);
|
||||
}
|
||||
|
||||
$server = $execution->scheduledVolumeBackup->server();
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
return streamBackupFromServer($server, $execution->filename, 'application/gzip');
|
||||
} catch (FileNotFoundException) {
|
||||
return response()->json(['message' => 'Backup not found locally on the server.'], 404);
|
||||
} catch (Throwable) {
|
||||
return response()->json(['message' => 'Failed to download backup.'], 500);
|
||||
}
|
||||
})->name('download.volume-backup');
|
||||
|
||||
});
|
||||
|
||||
Route::any('/{any}', function () {
|
||||
|
|
|
|||
406
tests/Feature/Api/VolumeBackupScheduleApiTest.php
Normal file
406
tests/Feature/Api/VolumeBackupScheduleApiTest.php
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
<?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\Facades\Config;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Config::set('cache.default', 'array');
|
||||
Config::set('app.maintenance.store', 'array');
|
||||
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('refuses to delete an application directory before its backup schedule is removed', function () {
|
||||
Process::fake();
|
||||
$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(),
|
||||
])));
|
||||
$directory->scheduledBackups()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'frequency' => 'daily',
|
||||
]);
|
||||
|
||||
$this->withHeaders($this->headers)
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/storages/{$directory->uuid}")
|
||||
->assertUnprocessable()
|
||||
->assertJsonPath('message', 'Delete this directory backup schedule and its archives before deleting the directory.');
|
||||
|
||||
expect($directory->fresh())->not->toBeNull();
|
||||
Process::assertNothingRan();
|
||||
});
|
||||
|
||||
it('deletes an application volume backup schedule through the API', function () {
|
||||
$backup = $this->volume->scheduledBackups()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'frequency' => 'daily',
|
||||
]);
|
||||
|
||||
$this->withHeaders($this->headers)
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/storages/{$this->volume->uuid}/backups")
|
||||
->assertOk()
|
||||
->assertJsonPath('message', 'Storage backup schedule and archives deleted.');
|
||||
|
||||
expect($backup->fresh())->toBeNull();
|
||||
});
|
||||
|
||||
it('sets and deletes 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');
|
||||
|
||||
$this->withHeaders($this->headers)
|
||||
->deleteJson("/api/v1/databases/{$database->uuid}/storages/{$databaseVolume->uuid}/backups")
|
||||
->assertOk();
|
||||
$this->withHeaders($this->headers)
|
||||
->deleteJson("/api/v1/services/{$service->uuid}/storages/{$serviceVolume->uuid}/backups")
|
||||
->assertOk();
|
||||
|
||||
expect($databaseVolume->scheduledBackups()->count())->toBe(0)
|
||||
->and($serviceVolume->scheduledBackups()->count())->toBe(0);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\DatabaseBackupJob;
|
||||
use App\Livewire\Project\Database\BackupEdit;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
|
|
@ -12,6 +13,7 @@
|
|||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
|
@ -72,6 +74,196 @@ function createS3StorageForBackupEditValidationTest(Team|int $team, string $name
|
|||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
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/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"')
|
||||
->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()
|
||||
->assertSeeText('No validated S3 available. Configure one here.')
|
||||
->assertDontSee('Disable Local Backup')
|
||||
->assertDontSee('Enable S3')
|
||||
->assertDontSee('Disable S3')
|
||||
->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 () {
|
||||
$backup = createBackupForEditValidationTest($this->team, [
|
||||
'enabled' => false,
|
||||
'save_s3' => false,
|
||||
]);
|
||||
|
||||
$component = Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
|
||||
->assertSet('backupEnabled', false)
|
||||
->assertSee('Enable Backup')
|
||||
->call('toggleEnabled')
|
||||
->assertSet('backupEnabled', true)
|
||||
->assertSee('Disable Backup');
|
||||
|
||||
expect($backup->refresh()->enabled)->toBeTruthy();
|
||||
|
||||
$component->call('toggleEnabled')->assertSet('backupEnabled', false);
|
||||
|
||||
expect($backup->refresh()->enabled)->toBeFalsy();
|
||||
});
|
||||
|
||||
it('redirects to executions after queuing a database backup with unusable S3 storage', function () {
|
||||
Queue::fake();
|
||||
$s3Storage = createS3StorageForBackupEditValidationTest($this->team, 'Unavailable storage');
|
||||
$s3Storage->update(['is_usable' => false]);
|
||||
$backup = createBackupForEditValidationTest($this->team, [
|
||||
'enabled' => true,
|
||||
's3_storage_id' => $s3Storage->id,
|
||||
'database_backup_retention_amount_locally' => 7,
|
||||
'database_backup_retention_days_locally' => 0,
|
||||
'database_backup_retention_max_storage_locally' => 0,
|
||||
'database_backup_retention_amount_s3' => 7,
|
||||
'database_backup_retention_days_s3' => 0,
|
||||
'database_backup_retention_max_storage_s3' => 0,
|
||||
'dump_all' => false,
|
||||
'timeout' => 3600,
|
||||
]);
|
||||
$database = $backup->database;
|
||||
$parameters = [
|
||||
'project_uuid' => $database->project()->uuid,
|
||||
'environment_uuid' => $database->environment->uuid,
|
||||
'database_uuid' => $database->uuid,
|
||||
'backup_uuid' => $backup->uuid,
|
||||
];
|
||||
|
||||
Livewire::test(BackupEdit::class, [
|
||||
'backup' => $backup,
|
||||
'availableS3Storages' => $this->team->s3s,
|
||||
])
|
||||
->call('backupNow')
|
||||
->assertRedirectToRoute('project.database.backup.executions', $parameters);
|
||||
|
||||
Queue::assertPushed(DatabaseBackupJob::class);
|
||||
});
|
||||
|
||||
it('disables S3 backup when saved without a selected S3 storage', function () {
|
||||
$backup = createBackupForEditValidationTest($this->team);
|
||||
|
||||
|
|
@ -162,19 +354,52 @@ 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');
|
||||
});
|
||||
|
||||
it('shows disabled S3 storage dropdown when no storages are available', function () {
|
||||
it('shows only an empty S3 state when no storages are available', function () {
|
||||
$backup = createBackupForEditValidationTest($this->team, [
|
||||
'save_s3' => false,
|
||||
's3_storage_id' => null,
|
||||
]);
|
||||
|
||||
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
|
||||
->assertSee('No S3 storage available');
|
||||
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, 'section' => 's3'])
|
||||
->assertSeeHtml('<h2>S3</h2>')
|
||||
->assertSeeText('No validated S3 available. Configure one here.')
|
||||
->assertSeeHtml('href="'.route('storage.index').'"')
|
||||
->assertSeeHtml('>here</a>')
|
||||
->assertDontSee('Save')
|
||||
->assertDontSee('Enable S3')
|
||||
->assertDontSee('Disable S3')
|
||||
->assertDontSee('S3 Storage')
|
||||
->assertDontSee('Disable Local Backup')
|
||||
->assertDontSee('No S3 storage available');
|
||||
});
|
||||
|
||||
it('allows S3 backups to be disabled when no usable storage remains', function () {
|
||||
$backup = createBackupForEditValidationTest($this->team, [
|
||||
'save_s3' => true,
|
||||
's3_storage_id' => null,
|
||||
]);
|
||||
|
||||
$component = Livewire::test(BackupEdit::class, [
|
||||
'backup' => $backup->fresh(),
|
||||
'availableS3Storages' => collect(),
|
||||
'section' => 's3',
|
||||
])
|
||||
->assertSet('saveS3', true)
|
||||
->assertSeeText('No validated S3 available. Configure one here.')
|
||||
->assertDontSee('Disable S3')
|
||||
->call('toggleS3')
|
||||
->assertDispatched('success')
|
||||
->assertSet('saveS3', false)
|
||||
->assertDontSee('Enable S3');
|
||||
|
||||
expect($backup->refresh()->save_s3)->toBeFalsy()
|
||||
->and($backup->s3_storage_id)->toBeNull();
|
||||
|
||||
});
|
||||
|
||||
it('shows when S3 backups are currently disabled', function () {
|
||||
|
|
@ -184,7 +409,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)');
|
||||
});
|
||||
|
|
@ -205,3 +430,59 @@ function createS3StorageForBackupEditValidationTest(Team|int $team, string $name
|
|||
expect($backup->save_s3)->toBeFalsy();
|
||||
expect($backup->s3_storage_id)->toBe($secondS3->id);
|
||||
});
|
||||
|
||||
it('subscribes to database status broadcasts so Backup Now can refresh without a full page reload', function () {
|
||||
$component = app(BackupEdit::class);
|
||||
$method = new ReflectionMethod($component, 'getListeners');
|
||||
$method->setAccessible(true);
|
||||
$listeners = (array) $method->invoke($component);
|
||||
|
||||
expect($listeners)
|
||||
->toHaveKey("echo-private:user.{$this->user->id},DatabaseStatusChanged")
|
||||
->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked")
|
||||
->toHaveKey('databaseUpdated');
|
||||
});
|
||||
|
||||
it('shows Backup Now after refresh when the database becomes running', function () {
|
||||
$backup = createBackupForEditValidationTest($this->team, [
|
||||
'enabled' => true,
|
||||
]);
|
||||
$database = $backup->database;
|
||||
$database->update(['status' => 'exited:unhealthy']);
|
||||
|
||||
$component = Livewire::test(BackupEdit::class, [
|
||||
'backup' => $backup->fresh(),
|
||||
'availableS3Storages' => $this->team->s3s,
|
||||
'status' => 'exited:unhealthy',
|
||||
])
|
||||
->assertDontSee('Backup Now')
|
||||
->assertSet('status', 'exited:unhealthy');
|
||||
|
||||
$database->update(['status' => 'running:healthy']);
|
||||
|
||||
$component->call('refreshStatus')
|
||||
->assertSet('status', 'running:healthy')
|
||||
->assertSee('Backup Now');
|
||||
});
|
||||
|
||||
it('hides Backup Now after refresh when the database stops', function () {
|
||||
$backup = createBackupForEditValidationTest($this->team, [
|
||||
'enabled' => true,
|
||||
]);
|
||||
$database = $backup->database;
|
||||
$database->update(['status' => 'running:healthy']);
|
||||
|
||||
$component = Livewire::test(BackupEdit::class, [
|
||||
'backup' => $backup->fresh(),
|
||||
'availableS3Storages' => $this->team->s3s,
|
||||
'status' => 'running:healthy',
|
||||
])
|
||||
->assertSee('Backup Now')
|
||||
->assertSet('status', 'running:healthy');
|
||||
|
||||
$database->update(['status' => 'exited:unhealthy']);
|
||||
|
||||
$component->call('refreshStatus')
|
||||
->assertSet('status', 'exited:unhealthy')
|
||||
->assertDontSee('Backup Now');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@
|
|||
|
||||
use App\Jobs\CleanupInstanceStuffsJob;
|
||||
use App\Jobs\DatabaseBackupJob;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
|
|
@ -235,6 +237,55 @@
|
|||
expect($successfulBackups->first()->uuid)->toBe('exec-uuid-1');
|
||||
});
|
||||
|
||||
test('database S3 retention remains best effort when deletion fails', function () {
|
||||
$team = Team::factory()->create();
|
||||
$s3 = S3Storage::create([
|
||||
'name' => 'Test S3',
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'test-key',
|
||||
'secret' => 'test-secret',
|
||||
'bucket' => 'test-bucket',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => true,
|
||||
'disable_local_backup' => true,
|
||||
's3_storage_id' => $s3->id,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
'team_id' => $team->id,
|
||||
'database_backup_retention_amount_s3' => 1,
|
||||
]);
|
||||
$newestExecution = ScheduledDatabaseBackupExecution::create([
|
||||
'uuid' => 'newest-s3-backup',
|
||||
'database_name' => 'test_db',
|
||||
'filename' => '/backup/newest.dmp',
|
||||
'scheduled_database_backup_id' => $backup->id,
|
||||
'status' => 'success',
|
||||
's3_uploaded' => true,
|
||||
]);
|
||||
$oldExecution = ScheduledDatabaseBackupExecution::create([
|
||||
'uuid' => 'old-s3-backup',
|
||||
'database_name' => 'test_db',
|
||||
'filename' => '/backup/old.dmp',
|
||||
'scheduled_database_backup_id' => $backup->id,
|
||||
'status' => 'success',
|
||||
's3_uploaded' => true,
|
||||
]);
|
||||
ScheduledDatabaseBackupExecution::whereKey($newestExecution->id)->update(['created_at' => now()]);
|
||||
ScheduledDatabaseBackupExecution::whereKey($oldExecution->id)->update(['created_at' => now()->subDay()]);
|
||||
$disk = Mockery::mock();
|
||||
$disk->shouldReceive('delete')->once()->with(['/backup/old.dmp'])->andReturnFalse();
|
||||
Storage::shouldReceive('build')->once()->andReturn($disk);
|
||||
|
||||
removeOldBackups($backup);
|
||||
|
||||
expect($oldExecution->fresh()->s3_storage_deleted)->toBeFalse()
|
||||
->and($backup->executions()->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('cleanup instance stuffs job throttles retention enforcement via cache', function () {
|
||||
Cache::forget('backup-retention-enforcement');
|
||||
|
||||
|
|
|
|||
178
tests/Feature/BackupSearchTest.php
Normal file
178
tests/Feature/BackupSearchTest.php
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Models\Project;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config(['app.maintenance.driver' => 'file']);
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
|
||||
$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]);
|
||||
});
|
||||
|
||||
it('renders frontend-only application backup search data for volume names and frequencies', function () {
|
||||
$application = createBackupSearchApplication($this->team);
|
||||
$dailyVolume = createBackupSearchVolume($application, 'app-data');
|
||||
$weeklyVolume = createBackupSearchVolume($application, 'Cache-Data');
|
||||
|
||||
$dailyVolume->scheduledBackups()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'frequency' => 'daily',
|
||||
]);
|
||||
$weeklyVolume->scheduledBackups()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'frequency' => '0 4 * * 0',
|
||||
]);
|
||||
|
||||
$parameters = [
|
||||
'project_uuid' => $application->project()->uuid,
|
||||
'environment_uuid' => $application->environment->uuid,
|
||||
'application_uuid' => $application->uuid,
|
||||
];
|
||||
|
||||
$this->get(route('project.application.backup.index', [...$parameters, 'search' => 'cache']))
|
||||
->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.');
|
||||
});
|
||||
|
||||
it('renders frontend-only database backup search data for database names and frequencies', function () {
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'Orders-Primary',
|
||||
'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(),
|
||||
]);
|
||||
$storage = S3Storage::create([
|
||||
'name' => 'Archive-Bucket',
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'test-key',
|
||||
'secret' => 'test-secret',
|
||||
'bucket' => 'test-bucket',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'is_usable' => true,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
$database->scheduledBackups()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'frequency' => 'daily',
|
||||
'save_s3' => true,
|
||||
's3_storage_id' => $storage->id,
|
||||
]);
|
||||
$database->scheduledBackups()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'frequency' => '0 3 * * 1',
|
||||
]);
|
||||
|
||||
$parameters = [
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
'database_uuid' => $database->uuid,
|
||||
];
|
||||
|
||||
$this->get(route('project.database.backup.index', [...$parameters, 'search' => '0 3']))
|
||||
->assertOk()
|
||||
->assertSee('<h3 class="font-semibold">0 3 * * 1</h3>', false)
|
||||
->assertSee('<h3 class="font-semibold">daily</h3>', false)
|
||||
->assertSee('S3: Archive-Bucket')
|
||||
->assertSee('archive-bucket', false)
|
||||
->assertSee('backup.s3_storage.includes(query)', false)
|
||||
->assertSee('Search by database name, frequency, or S3 storage...')
|
||||
->assertSee('x-model="search"', false)
|
||||
->assertSee('x-show=', false)
|
||||
->assertSee('No scheduled backups match your search.');
|
||||
});
|
||||
|
||||
it('renders unavailable S3 storage only for S3-enabled database backups', function () {
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'Orders-Primary',
|
||||
'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(),
|
||||
]);
|
||||
$database->scheduledBackups()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'frequency' => 'daily',
|
||||
'save_s3' => true,
|
||||
's3_storage_id' => null,
|
||||
]);
|
||||
$database->scheduledBackups()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'frequency' => 'weekly',
|
||||
'save_s3' => false,
|
||||
's3_storage_id' => null,
|
||||
]);
|
||||
|
||||
$parameters = [
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
'database_uuid' => $database->uuid,
|
||||
];
|
||||
|
||||
$response = $this->get(route('project.database.backup.index', $parameters))
|
||||
->assertOk()
|
||||
->assertSee('S3: Storage unavailable');
|
||||
|
||||
expect(substr_count($response->getContent(), 'S3:'))->toBe(1);
|
||||
});
|
||||
|
||||
function createBackupSearchApplication(Team $team): Application
|
||||
{
|
||||
$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]);
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
return $application;
|
||||
}
|
||||
|
||||
function createBackupSearchVolume(Application $application, string $name): LocalPersistentVolume
|
||||
{
|
||||
return LocalPersistentVolume::create([
|
||||
'name' => $name,
|
||||
'mount_path' => '/'.str($name)->lower(),
|
||||
'resource_id' => $application->id,
|
||||
'resource_type' => $application->getMorphClass(),
|
||||
]);
|
||||
}
|
||||
|
|
@ -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\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
|
|
@ -17,126 +18,80 @@
|
|||
|
||||
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();
|
||||
});
|
||||
|
||||
it('creates a clickhouse backup for its configured database', function () {
|
||||
|
|
@ -154,13 +109,19 @@ function createS3StorageForTeam(Team $team, string $name = 'Test S3'): S3Storage
|
|||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
Livewire::test(CreateScheduledBackup::class, ['database' => $database])
|
||||
$component = Livewire::test(CreateScheduledBackup::class, ['database' => $database])
|
||||
->set('frequency', 'daily')
|
||||
->call('submit')
|
||||
->assertDispatched('refreshScheduledBackups');
|
||||
->call('submit');
|
||||
|
||||
$backup = ScheduledDatabaseBackup::firstOrFail();
|
||||
|
||||
$component->assertRedirectToRoute('project.database.backup.execution', [
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
'database_uuid' => $database->uuid,
|
||||
'backup_uuid' => $backup->uuid,
|
||||
]);
|
||||
|
||||
expect($backup->database_type)->toBe(StandaloneClickhouse::class)
|
||||
->and($backup->databases_to_backup)->toBe('analytics');
|
||||
});
|
||||
|
|
|
|||
26
tests/Feature/ExecutionViewStructureTest.php
Normal file
26
tests/Feature/ExecutionViewStructureTest.php
Normal 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.');
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
|
|
@ -238,6 +239,57 @@
|
|||
expect($log->message)->toContain('Some real failure');
|
||||
});
|
||||
|
||||
test('retention cleanup failure does not fail a successful database backup', function () {
|
||||
$team = Team::factory()->create();
|
||||
$s3 = S3Storage::create([
|
||||
'name' => 'Test S3',
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'test-key',
|
||||
'secret' => 'test-secret',
|
||||
'bucket' => 'test-bucket',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => true,
|
||||
'disable_local_backup' => true,
|
||||
's3_storage_id' => $s3->id,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
'team_id' => $team->id,
|
||||
'database_backup_retention_amount_s3' => 1,
|
||||
]);
|
||||
$oldExecution = ScheduledDatabaseBackupExecution::create([
|
||||
'uuid' => 'old-backup',
|
||||
'database_name' => 'database',
|
||||
'filename' => '/backup/old.dmp',
|
||||
'scheduled_database_backup_id' => $backup->id,
|
||||
'status' => 'success',
|
||||
's3_uploaded' => true,
|
||||
'local_storage_deleted' => true,
|
||||
'created_at' => now()->subDay(),
|
||||
]);
|
||||
ScheduledDatabaseBackupExecution::create([
|
||||
'uuid' => 'new-backup',
|
||||
'database_name' => 'database',
|
||||
'filename' => '/backup/new.dmp',
|
||||
'scheduled_database_backup_id' => $backup->id,
|
||||
'status' => 'success',
|
||||
's3_uploaded' => true,
|
||||
'local_storage_deleted' => true,
|
||||
]);
|
||||
$disk = Mockery::mock();
|
||||
$disk->shouldReceive('delete')->once()->with(['/backup/old.dmp'])->andReturnFalse();
|
||||
Storage::shouldReceive('build')->once()->andReturn($disk);
|
||||
$job = new DatabaseBackupJob($backup);
|
||||
|
||||
expect(fn () => (new ReflectionClass($job))->getMethod('removeExpiredBackups')->invoke($job))
|
||||
->not->toThrow(Throwable::class);
|
||||
|
||||
expect($oldExecution->fresh()->s3_storage_deleted)->toBeFalse();
|
||||
});
|
||||
|
||||
test('s3 storage has scheduled backups relationship', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -131,6 +131,19 @@ function mobileActionsAreBeforeSelect(string $heading, string $actionsId, string
|
|||
return $actionsPosition !== false && $selectPosition !== false && $actionsPosition < $selectPosition;
|
||||
}
|
||||
|
||||
it('places database backups immediately after configuration in navigation menus', function () {
|
||||
$databaseHeading = file_get_contents(resource_path('views/livewire/project/database/heading.blade.php'));
|
||||
$mobileMenuItems = str($databaseHeading)->between('$databasePageItems = [', '$databaseConfigurationItems = [')->toString();
|
||||
$desktopMenuItems = str($databaseHeading)->between('class="scrollbar hidden', '</nav>')->toString();
|
||||
|
||||
foreach ([$mobileMenuItems, $desktopMenuItems] as $menuItems) {
|
||||
expect(strpos($menuItems, 'Configuration'))
|
||||
->toBeLessThan(strpos($menuItems, 'Backups'))
|
||||
->and(strpos($menuItems, 'Backups'))->toBeLessThan(strpos($menuItems, 'Logs'))
|
||||
->and(strpos($menuItems, 'Logs'))->toBeLessThan(strpos($menuItems, 'Terminal'));
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps configuration sidebars hidden until desktop breakpoint', function () {
|
||||
expect(file_get_contents(resource_path('views/livewire/project/database/configuration.blade.php')))
|
||||
->toContain('sub-menu-wrapper hidden md:flex');
|
||||
|
|
|
|||
|
|
@ -28,3 +28,35 @@
|
|||
['AuthorizesRequests', "authorize('view'", "authorize('canAccessTerminal'"],
|
||||
],
|
||||
]);
|
||||
|
||||
it('authorizes every volume backup form control', function (string $path, array $controlPatterns) {
|
||||
$source = file_get_contents(base_path($path));
|
||||
|
||||
foreach ($controlPatterns as $controlPattern) {
|
||||
expect($source)->toMatch($controlPattern);
|
||||
}
|
||||
})->with([
|
||||
'retention controls' => [
|
||||
'resources/views/livewire/project/shared/storages/volume-backups/retention.blade.php',
|
||||
[
|
||||
'/<x-forms\.button(?=[^>]*type="submit")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*>Save<\/x-forms\.button>/s',
|
||||
'/<x-forms\.input(?=[^>]*id="retentionAmountLocally")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s',
|
||||
'/<x-forms\.input(?=[^>]*id="retentionDaysLocally")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s',
|
||||
'/<x-forms\.input(?=[^>]*id="retentionMaxStorageLocally")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s',
|
||||
'/<x-forms\.input(?=[^>]*id="retentionAmountS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s',
|
||||
'/<x-forms\.input(?=[^>]*id="retentionDaysS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s',
|
||||
'/<x-forms\.input(?=[^>]*id="retentionMaxStorageS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s',
|
||||
],
|
||||
],
|
||||
'S3 controls' => [
|
||||
'resources/views/livewire/project/shared/storages/volume-backups/s3.blade.php',
|
||||
[
|
||||
'/<x-forms\.button(?=[^>]*type="submit")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*>Save<\/x-forms\.button>/s',
|
||||
'/<x-forms\.button(?=[^>]*wire:click="toggleS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*>Enable S3<\/x-forms\.button>/s',
|
||||
'/<x-forms\.button(?=[^>]*wire:click="toggleS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*>Disable S3<\/x-forms\.button>/s',
|
||||
'/<x-forms\.select(?=[^>]*id="s3StorageId")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*>/s',
|
||||
'/<x-forms\.checkbox(?=[^>]*id="disableLocalBackup")(?=[^>]*instantSave)(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s',
|
||||
'/<x-forms\.checkbox(?=[^>]*id="disableLocalBackup")(?=[^>]*disabled)(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -59,23 +59,27 @@ public function __construct(public string $status) {}
|
|||
->not->toContain('<svg');
|
||||
});
|
||||
|
||||
it('renders health warning helpers as badges instead of warning icons', function () {
|
||||
it('renders health warning helpers without increasing the badge row height', function (string $status, string $expectedText) {
|
||||
$html = view('components.status.running', [
|
||||
'status' => 'running:unknown',
|
||||
'status' => $status,
|
||||
])->render();
|
||||
$runningStatus = file_get_contents(resource_path('views/components/status/running.blade.php'));
|
||||
|
||||
expect($html)
|
||||
->toContain('No health check')
|
||||
->toContain($expectedText)
|
||||
->toContain('class="flex items-center gap-1 leading-none"')
|
||||
->toContain('inline-flex h-5 max-w-full items-center gap-1 rounded-sm border')
|
||||
->not->toContain('<svg');
|
||||
|
||||
expect($runningStatus)
|
||||
->toContain('<x-status-badge')
|
||||
->toContain('class="flex items-center gap-1"')
|
||||
->toContain('class="flex items-center gap-1 leading-none"')
|
||||
->not->toContain('class="px-2"')
|
||||
->not->toContain('viewBox="0 0 256 256"');
|
||||
});
|
||||
})->with([
|
||||
'unknown health' => ['running:unknown', 'No health check'],
|
||||
'unhealthy' => ['running:unhealthy', 'Unhealthy'],
|
||||
]);
|
||||
|
||||
it('renders restart counts as warning badges', function () {
|
||||
$resource = new class
|
||||
|
|
|
|||
2210
tests/Feature/VolumeBackupTest.php
Normal file
2210
tests/Feature/VolumeBackupTest.php
Normal file
File diff suppressed because it is too large
Load diff
72
tests/Unit/BackupDownloadHelperTest.php
Normal file
72
tests/Unit/BackupDownloadHelperTest.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
function backupDownloadServer(): Server
|
||||
{
|
||||
$server = new Server;
|
||||
$server->forceFill([
|
||||
'ip' => '192.0.2.10',
|
||||
'port' => 2222,
|
||||
'user' => 'root',
|
||||
]);
|
||||
|
||||
$privateKey = Mockery::mock(PrivateKey::class);
|
||||
$privateKey->shouldReceive('getKeyLocation')->andReturn('/tmp/private-key');
|
||||
$server->setRelation('privateKey', $privateKey);
|
||||
|
||||
return $server;
|
||||
}
|
||||
|
||||
it('throws when the backup file does not exist on the server', function () {
|
||||
$disk = Mockery::mock();
|
||||
$disk->shouldReceive('exists')
|
||||
->once()
|
||||
->with('/backups/archive.tar.gz')
|
||||
->andReturnFalse();
|
||||
|
||||
Storage::shouldReceive('build')
|
||||
->once()
|
||||
->with([
|
||||
'driver' => 'sftp',
|
||||
'host' => '192.0.2.10',
|
||||
'port' => 2222,
|
||||
'username' => 'root',
|
||||
'privateKey' => '/tmp/private-key',
|
||||
'root' => '/',
|
||||
])
|
||||
->andReturn($disk);
|
||||
|
||||
streamBackupFromServer(backupDownloadServer(), '/backups/archive.tar.gz', 'application/gzip');
|
||||
})->throws(FileNotFoundException::class);
|
||||
|
||||
it('streams a backup file with the requested content type', function () {
|
||||
$stream = fopen('php://memory', 'r+');
|
||||
fwrite($stream, 'backup contents');
|
||||
rewind($stream);
|
||||
|
||||
$disk = Mockery::mock();
|
||||
$disk->shouldReceive('exists')->once()->andReturnTrue();
|
||||
$disk->shouldReceive('readStream')
|
||||
->once()
|
||||
->with('/backups/archive.tar.gz')
|
||||
->andReturn($stream);
|
||||
Storage::shouldReceive('build')->once()->andReturn($disk);
|
||||
|
||||
$response = streamBackupFromServer(backupDownloadServer(), '/backups/archive.tar.gz', 'application/gzip');
|
||||
|
||||
expect($response)->toBeInstanceOf(StreamedResponse::class)
|
||||
->and($response->headers->get('content-type'))->toBe('application/gzip')
|
||||
->and($response->headers->get('content-disposition'))->toBe('attachment; filename="archive.tar.gz"');
|
||||
|
||||
ob_start();
|
||||
ob_start();
|
||||
$response->sendContent();
|
||||
$contents = ob_get_clean();
|
||||
|
||||
expect($contents)->toBe('backup contents');
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -32,3 +32,15 @@
|
|||
->toContain('if: ${{ ! github.event.release.prerelease }}')
|
||||
->toContain('--tag "${IMAGE}:latest"');
|
||||
});
|
||||
|
||||
it('documents the sha image release process', function () {
|
||||
$releaseGuide = file_get_contents(dirname(__DIR__, 2).'/RELEASE.md');
|
||||
|
||||
expect($releaseGuide)
|
||||
->toContain('Merge the release commit into `v4.x`')
|
||||
->toContain('`sha-<commit-sha>`')
|
||||
->toContain('targeting the exact commit that produced the SHA image')
|
||||
->toContain('promotes the existing SHA image without rebuilding it')
|
||||
->toContain('Update the CDN')
|
||||
->not->toContain('Merging to `main`');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue