feat(backups): add scheduled persistent volume backups
This commit is contained in:
parent
05dc2c65f6
commit
63961e0799
36 changed files with 3506 additions and 31 deletions
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
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 +111,16 @@ public function handle(): void
|
|||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->recoverPausedVolumeBackupContainers();
|
||||
$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 +353,101 @@ private function processScheduledTask(ScheduledTask $task, ?Server $precheckedSe
|
|||
}
|
||||
}
|
||||
|
||||
private function processScheduledVolumeBackups(): void
|
||||
{
|
||||
ScheduledVolumeBackup::query()
|
||||
->with(['volume', 'team.subscription'])
|
||||
->where('enabled', true)
|
||||
->chunkById(self::CHUNK_SIZE, function ($backups): void {
|
||||
foreach ($backups as $backup) {
|
||||
$this->processScheduledVolumeBackup($backup);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function recoverPausedVolumeBackupContainers(): void
|
||||
{
|
||||
ScheduledVolumeBackupExecution::query()
|
||||
->where(fn (Builder $query) => $query
|
||||
->where('pause_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('pause_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->volume || ! $server) {
|
||||
$backup->delete();
|
||||
$this->skippedCount++;
|
||||
$this->logSkip('volume_backup', 'resource_deleted', [
|
||||
'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,
|
||||
'volume_id' => $backup->local_persistent_volume_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'))) {
|
||||
|
|
|
|||
414
app/Jobs/VolumeBackupJob.php
Normal file
414
app/Jobs/VolumeBackupJob.php
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Events\BackupCreated;
|
||||
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(['volume.resource', 'team', 's3']);
|
||||
$server = $this->backup->server();
|
||||
$volume = $this->backup->volume;
|
||||
$team = $this->backup->team;
|
||||
|
||||
if (! $server || ! $volume || ! $team) {
|
||||
throw new \RuntimeException('The volume backup resource, team, or server no longer exists.');
|
||||
}
|
||||
|
||||
$this->execution = $this->backup->executions()->create();
|
||||
BackupCreated::dispatch($team->id);
|
||||
|
||||
$backupDirectory = backup_dir().'/volumes/'.str($team->name)->slug().'-'.$team->id.'/'.$volume->uuid;
|
||||
$filename = 'volume-'.str($volume->name)->slug().'-'.Carbon::now()->timestamp.'.tar.gz';
|
||||
$backupLocation = $backupDirectory.'/'.$filename;
|
||||
$this->execution->update(['filename' => $backupLocation]);
|
||||
|
||||
try {
|
||||
$source = filled($volume->host_path) ? $volume->host_path : $volume->name;
|
||||
$containerName = 'volume-backup-'.$this->execution->uuid;
|
||||
$image = coolifyHelperImage().':'.getHelperVersion();
|
||||
$verifySourceCommand = filled($volume->host_path)
|
||||
? 'test -d '.escapeshellarg($source)
|
||||
: 'docker volume inspect '.escapeshellarg($source).' >/dev/null';
|
||||
|
||||
$archiveCommand = 'docker run --rm --name '.escapeshellarg($containerName)
|
||||
.' -v '.escapeshellarg($source.':/volume:ro')
|
||||
.' '.escapeshellarg($image)
|
||||
.' tar -czf - -C /volume . > '.escapeshellarg($backupLocation);
|
||||
|
||||
if ($this->backup->pause_during_backup) {
|
||||
$containers = $this->containersUsingVolume($source, $server);
|
||||
if ($containers !== []) {
|
||||
$this->execution->update(['pause_recovery_pending' => true]);
|
||||
$archiveCommand = $this->archiveWithPausedContainers(
|
||||
$archiveCommand,
|
||||
$containers,
|
||||
VolumeBackupRecoveryJob::stateFile($this->execution),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
instant_remote_process([
|
||||
$verifySourceCommand,
|
||||
'mkdir -p '.escapeshellarg($backupDirectory),
|
||||
$archiveCommand,
|
||||
], $server, timeout: $this->timeout, disableMultiplexing: true);
|
||||
$this->execution->update([
|
||||
'pause_container_ids' => null,
|
||||
'pause_recovery_pending' => false,
|
||||
]);
|
||||
|
||||
$size = (int) instant_remote_process(
|
||||
['du -b '.escapeshellarg($backupLocation).' | cut -f1'],
|
||||
$server,
|
||||
disableMultiplexing: true,
|
||||
);
|
||||
|
||||
if ($size <= 0) {
|
||||
throw new \RuntimeException('The volume 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 archiveWithPausedContainers(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 unpause \"\$container\" >/dev/null || status=1; done < \"\$state_file\"; [ \$status -eq 0 ] && rm -f \"\$state_file\"; exit \$status; }\n"
|
||||
."trap cleanup EXIT HUP INT TERM\n"
|
||||
."for container in {$containerList}; do echo \"\$container\" >> \"\$state_file\"; docker pause \"\$container\" >/dev/null; done\n"
|
||||
.$archiveCommand;
|
||||
|
||||
return 'sh -c '.escapeshellarg($script);
|
||||
}
|
||||
|
||||
private function recoverIncompleteBackup(ScheduledVolumeBackupExecution $execution): string
|
||||
{
|
||||
if (! $execution->pause_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
|
||||
{
|
||||
$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) {
|
||||
$s3Executions = $this->backup->executions()
|
||||
->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,
|
||||
);
|
||||
|
||||
$filenames = $s3Executions->pluck('filename')->filter()->all();
|
||||
if ($filenames !== []) {
|
||||
deleteBackupsS3($filenames, $this->backup->s3);
|
||||
$this->backup->executions()->whereKey($s3Executions->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 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.volume.resource');
|
||||
|
||||
if ($execution->pause_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(['pause_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; paused=$(docker inspect --format \'{{.State.Paused}}\' "$container" 2>/dev/null) '
|
||||
.'|| { echo "$container" >> '.escapeshellarg($remainingFile).'; status=1; continue; }; '
|
||||
.'if [ "$paused" = true ] && ! docker unpause "$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([
|
||||
'pause_container_ids' => null,
|
||||
'pause_recovery_pending' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public static function cleanupS3Upload(ScheduledVolumeBackupExecution $execution): void
|
||||
{
|
||||
$execution->loadMissing('scheduledVolumeBackup.s3');
|
||||
$s3 = $execution->scheduledVolumeBackup?->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.'.paused';
|
||||
}
|
||||
}
|
||||
130
app/Livewire/Project/Application/Backup/Create.php
Normal file
130
app/Livewire/Project/Application/Backup/Create.php
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Application\Backup;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
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 ?int $selectedVolumeId = null;
|
||||
|
||||
public ?int $volumeId = null;
|
||||
|
||||
public bool $volumeLocked = false;
|
||||
|
||||
public string $frequency = 'daily';
|
||||
|
||||
public bool $saveToS3 = false;
|
||||
|
||||
public ?int $s3StorageId = null;
|
||||
|
||||
public Collection $volumes;
|
||||
|
||||
public Collection $definedS3s;
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'volumeId' => ['required', 'integer'],
|
||||
'frequency' => ['required', 'string'],
|
||||
'saveToS3' => ['required', 'boolean'],
|
||||
's3StorageId' => ['nullable', 'integer'],
|
||||
];
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorize('view', $this->application);
|
||||
$this->volumes = $this->application->persistentStorages()->orderBy('name')->get();
|
||||
$this->definedS3s = S3Storage::ownedByCurrentTeam()->where('is_usable', true)->get();
|
||||
$this->s3StorageId = $this->definedS3s->first()?->id;
|
||||
$this->volumeLocked = $this->selectedVolumeId !== null;
|
||||
$this->volumeId = $this->selectedVolumeId ?? $this->volumes->first()?->id;
|
||||
$this->loadSelectedBackup();
|
||||
}
|
||||
|
||||
public function updatedVolumeId(): void
|
||||
{
|
||||
$this->loadSelectedBackup();
|
||||
}
|
||||
|
||||
public function submit(): void
|
||||
{
|
||||
$this->authorize('update', $this->application);
|
||||
$this->validate();
|
||||
|
||||
$volume = $this->application->persistentStorages()->whereKey($this->volumeId)->first();
|
||||
if (! $volume) {
|
||||
$this->addError('volumeId', 'Select a volume owned by this application.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! validate_cron_expression($this->frequency)) {
|
||||
$this->addError('frequency', 'The frequency must be a valid cron or human expression.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->saveToS3 && ! $this->validS3StorageExists()) {
|
||||
$this->addError('s3StorageId', 'Select a usable S3 storage owned by your team.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$backup = ScheduledVolumeBackup::query()->updateOrCreate(
|
||||
['local_persistent_volume_id' => $volume->id],
|
||||
[
|
||||
'team_id' => currentTeam()->id,
|
||||
'frequency' => $this->frequency,
|
||||
'enabled' => true,
|
||||
'save_s3' => $this->saveToS3,
|
||||
's3_storage_id' => $this->saveToS3 ? $this->s3StorageId : null,
|
||||
],
|
||||
);
|
||||
|
||||
$this->dispatch('success', $backup->wasRecentlyCreated ? 'Scheduled volume backup created.' : 'Scheduled volume backup updated.');
|
||||
$this->dispatch('refreshVolumeBackups');
|
||||
$this->dispatch('close-modal');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.application.backup.create');
|
||||
}
|
||||
|
||||
private function loadSelectedBackup(): void
|
||||
{
|
||||
$volume = $this->volumes->firstWhere('id', $this->volumeId);
|
||||
if (! $volume) {
|
||||
return;
|
||||
}
|
||||
|
||||
$backup = $volume->scheduledBackups()->first();
|
||||
if ($backup) {
|
||||
$this->frequency = $backup->frequency;
|
||||
$this->saveToS3 = $backup->save_s3;
|
||||
$this->s3StorageId = $backup->s3_storage_id ?? $this->definedS3s->first()?->id;
|
||||
}
|
||||
}
|
||||
|
||||
private function validS3StorageExists(): bool
|
||||
{
|
||||
return $this->s3StorageId !== null
|
||||
&& S3Storage::ownedByCurrentTeam()
|
||||
->where('is_usable', true)
|
||||
->whereKey($this->s3StorageId)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
55
app/Livewire/Project/Application/Backup/Index.php
Normal file
55
app/Livewire/Project/Application/Backup/Index.php
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?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;
|
||||
|
||||
protected $listeners = ['refreshVolumeBackups' => '$refresh'];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->application = $this->findApplication();
|
||||
$this->authorize('view', $this->application);
|
||||
$this->parameters = get_route_parameters();
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
$volumeIds = $this->application->persistentStorages()->pluck('id');
|
||||
$backups = ScheduledVolumeBackup::query()
|
||||
->with(['volume', 'latestExecution'])
|
||||
->withCount('executions')
|
||||
->whereIn('local_persistent_volume_id', $volumeIds)
|
||||
->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();
|
||||
}
|
||||
}
|
||||
46
app/Livewire/Project/Application/Backup/Show.php
Normal file
46
app/Livewire/Project/Application/Backup/Show.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?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 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('volume')
|
||||
->where('uuid', request()->route('backup_uuid'))
|
||||
->whereIn('local_persistent_volume_id', $this->application->persistentStorages()->pluck('id'))
|
||||
->firstOrFail();
|
||||
$this->parameters = get_route_parameters();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.application.backup.show');
|
||||
}
|
||||
}
|
||||
|
|
@ -217,6 +217,19 @@ 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();
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
use App\Models\LocalPersistentVolume;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class Show extends Component
|
||||
|
|
@ -32,6 +33,8 @@ class Show extends Component
|
|||
|
||||
public bool $isPreviewSuffixEnabled = true;
|
||||
|
||||
public bool $hasEnabledBackup = false;
|
||||
|
||||
protected $validationAttributes = [
|
||||
'name' => 'name',
|
||||
'mountPath' => 'mount',
|
||||
|
|
@ -81,10 +84,19 @@ 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
|
||||
{
|
||||
$this->hasEnabledBackup = $this->storage->scheduledBackups()
|
||||
->where('enabled', true)
|
||||
->exists();
|
||||
}
|
||||
|
||||
public function instantSave(): void
|
||||
|
|
@ -115,6 +127,12 @@ 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');
|
||||
|
||||
|
|
|
|||
394
app/Livewire/Project/Shared/Storages/VolumeBackups.php
Normal file
394
app/Livewire/Project/Shared/Storages/VolumeBackups.php
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Shared\Storages;
|
||||
|
||||
use App\Jobs\VolumeBackupJob;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use Throwable;
|
||||
|
||||
class VolumeBackups extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
use WithPagination;
|
||||
|
||||
public LocalPersistentVolume $storage;
|
||||
|
||||
public $resource;
|
||||
|
||||
public ?ScheduledVolumeBackup $backup = null;
|
||||
|
||||
public string $frequency = 'daily';
|
||||
|
||||
public bool $enabled = false;
|
||||
|
||||
public bool $saveToS3 = false;
|
||||
|
||||
public bool $disableLocalBackup = false;
|
||||
|
||||
public bool $pauseDuringBackup = 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'],
|
||||
'pauseDuringBackup' => ['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->pauseDuringBackup = $this->backup->pause_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', 'Volume backup schedule saved.');
|
||||
}
|
||||
|
||||
public function instantSave(): void
|
||||
{
|
||||
$this->save();
|
||||
}
|
||||
|
||||
public function updatedS3StorageId(): void
|
||||
{
|
||||
if ($this->saveToS3) {
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
|
||||
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 ? 'Volume backups enabled.' : 'Volume backups disabled.');
|
||||
}
|
||||
|
||||
public function backupNow(): void
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if (! $this->validateSettings()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->backup) {
|
||||
$this->enabled = false;
|
||||
}
|
||||
|
||||
$this->backup = $this->persistBackup($this->enabled);
|
||||
VolumeBackupJob::dispatch($this->backup);
|
||||
$this->dispatch('success', 'Volume backup queued.');
|
||||
}
|
||||
|
||||
public function delete(?string $password = null, array $selectedActions = [])
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if (! $password || ! verifyPasswordConfirmation($password, $this)) {
|
||||
return 'The provided password is incorrect.';
|
||||
}
|
||||
|
||||
if (! $this->backup) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$lock = Cache::lock(VolumeBackupJob::lockKey($this->backup->id), $this->backup->timeout + 300);
|
||||
|
||||
if (! $lock->get()) {
|
||||
$this->dispatch('error', 'Wait for the queued or running volume backup to finish before deleting this schedule.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($this->backup->executions()
|
||||
->where(fn ($query) => $query
|
||||
->where('status', 'running')
|
||||
->orWhere('pause_recovery_pending', true)
|
||||
->orWhere('s3_cleanup_pending', true))
|
||||
->exists()) {
|
||||
$this->dispatch('error', 'Wait for the running volume backup and container recovery to finish before deleting this schedule.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$localFilenames = $this->backup->executions()
|
||||
->where('local_storage_deleted', false)
|
||||
->pluck('filename')
|
||||
->filter()
|
||||
->all();
|
||||
$server = $this->backup->server();
|
||||
|
||||
if ($localFilenames !== []) {
|
||||
if (! $server) {
|
||||
throw new \RuntimeException('The server is unavailable, so local backup archives cannot be deleted.');
|
||||
}
|
||||
|
||||
deleteBackupsLocally($localFilenames, $server, throwError: true);
|
||||
}
|
||||
|
||||
$s3Filenames = $this->backup->executions()
|
||||
->where('s3_uploaded', true)
|
||||
->where('s3_storage_deleted', false)
|
||||
->pluck('filename')
|
||||
->filter()
|
||||
->all();
|
||||
|
||||
if ($s3Filenames !== []) {
|
||||
if (! $this->backup->s3) {
|
||||
throw new \RuntimeException('The S3 storage is unavailable, so remote backup archives cannot be deleted.');
|
||||
}
|
||||
|
||||
deleteBackupsS3($s3Filenames, $this->backup->s3);
|
||||
}
|
||||
|
||||
$this->backup->delete();
|
||||
$this->backup = null;
|
||||
$this->dispatch('success', 'Volume backup schedule and archives deleted.');
|
||||
|
||||
return true;
|
||||
} catch (Throwable $exception) {
|
||||
$this->dispatch('error', 'Could not delete the backup archives: '.$exception->getMessage());
|
||||
|
||||
return false;
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
public function cleanupFailed(): void
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$deletedCount = $this->backup?->executions()
|
||||
->where('status', 'failed')
|
||||
->where('pause_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->pause_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 (! $this->backup->s3) {
|
||||
throw new \RuntimeException('The S3 storage is unavailable.');
|
||||
}
|
||||
|
||||
deleteBackupsS3($execution->filename, $this->backup->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 ScheduledVolumeBackup::query()->updateOrCreate(
|
||||
['local_persistent_volume_id' => $this->storage->id],
|
||||
[
|
||||
'team_id' => currentTeam()->id,
|
||||
'frequency' => $this->frequency,
|
||||
'enabled' => $enabled,
|
||||
'save_s3' => $this->saveToS3,
|
||||
'disable_local_backup' => $this->disableLocalBackup,
|
||||
'pause_during_backup' => $this->pauseDuringBackup,
|
||||
's3_storage_id' => $this->saveToS3 ? $this->s3StorageId : null,
|
||||
'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()
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class LocalPersistentVolume extends BaseModel
|
||||
|
|
@ -41,6 +42,11 @@ public function database()
|
|||
return $this->morphTo('resource');
|
||||
}
|
||||
|
||||
public function scheduledBackups(): HasMany
|
||||
{
|
||||
return $this->hasMany(ScheduledVolumeBackup::class, 'local_persistent_volume_id');
|
||||
}
|
||||
|
||||
protected function customizeName($value)
|
||||
{
|
||||
return str($value)->trim()->value;
|
||||
|
|
|
|||
|
|
@ -69,6 +69,16 @@ protected static function boot(): void
|
|||
'save_s3' => false,
|
||||
's3_storage_id' => null,
|
||||
]);
|
||||
$volumeBackupIds = ScheduledVolumeBackup::where('s3_storage_id', $storage->id)->pluck('id');
|
||||
ScheduledVolumeBackupExecution::whereIn('scheduled_volume_backup_id', $volumeBackupIds)
|
||||
->update([
|
||||
's3_storage_deleted' => true,
|
||||
's3_cleanup_pending' => false,
|
||||
]);
|
||||
ScheduledVolumeBackup::whereKey($volumeBackupIds)->update([
|
||||
'save_s3' => false,
|
||||
's3_storage_id' => null,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -101,6 +111,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}";
|
||||
|
|
|
|||
82
app/Models/ScheduledVolumeBackup.php
Normal file
82
app/Models/ScheduledVolumeBackup.php
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
class ScheduledVolumeBackup extends BaseModel
|
||||
{
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'local_persistent_volume_id',
|
||||
'team_id',
|
||||
's3_storage_id',
|
||||
'frequency',
|
||||
'enabled',
|
||||
'save_s3',
|
||||
'disable_local_backup',
|
||||
'pause_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',
|
||||
'pause_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 volume(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(LocalPersistentVolume::class, 'local_persistent_volume_id');
|
||||
}
|
||||
|
||||
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->volume?->resource;
|
||||
|
||||
if ($resource instanceof ServiceApplication || $resource instanceof ServiceDatabase) {
|
||||
return $resource->service?->server?->fresh();
|
||||
}
|
||||
|
||||
return $resource?->destination?->server?->fresh();
|
||||
}
|
||||
}
|
||||
43
app/Models/ScheduledVolumeBackupExecution.php
Normal file
43
app/Models/ScheduledVolumeBackupExecution.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ScheduledVolumeBackupExecution extends BaseModel
|
||||
{
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'scheduled_volume_backup_id',
|
||||
'status',
|
||||
'message',
|
||||
'size',
|
||||
'filename',
|
||||
'pause_container_ids',
|
||||
'pause_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',
|
||||
'pause_container_ids' => 'array',
|
||||
'pause_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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -179,7 +179,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,8 +187,8 @@ 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));
|
||||
|
|
@ -214,7 +214,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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
<?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->foreignId('local_persistent_volume_id')->unique()->constrained()->restrictOnDelete();
|
||||
$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('pause_during_backup')->default(false);
|
||||
$table->unsignedInteger('retention_amount_locally')->default(7);
|
||||
$table->unsignedInteger('retention_amount_s3')->default(7);
|
||||
$table->unsignedInteger('timeout')->default(3600);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('scheduled_volume_backups');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<?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->enum('status', ['success', 'failed', 'running'])->default('running');
|
||||
$table->longText('message')->nullable();
|
||||
$table->unsignedBigInteger('size')->default(0);
|
||||
$table->text('filename')->nullable();
|
||||
$table->json('pause_container_ids')->nullable();
|
||||
$table->boolean('pause_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');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('scheduled_volume_backups', function (Blueprint $table) {
|
||||
$table->unsignedInteger('retention_days_locally')->default(0);
|
||||
$table->decimal('retention_max_storage_locally', 17, 7)->default(0);
|
||||
$table->unsignedInteger('retention_days_s3')->default(0);
|
||||
$table->decimal('retention_max_storage_s3', 17, 7)->default(0);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('scheduled_volume_backups', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'retention_days_locally',
|
||||
'retention_max_storage_locally',
|
||||
'retention_days_s3',
|
||||
'retention_max_storage_s3',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
<form class="flex flex-col w-full gap-4 rounded-sm" wire:submit="submit">
|
||||
@if ($volumes->isEmpty())
|
||||
<div class="text-warning">Add a persistent volume before configuring a backup.</div>
|
||||
@else
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<x-forms.select id="volumeId" label="Volume" required :disabled="$volumeLocked">
|
||||
@foreach ($volumes as $volume)
|
||||
<option value="{{ $volume->id }}">{{ $volume->name }}</option>
|
||||
@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>
|
||||
|
||||
<div>
|
||||
<h2 class="pb-2">S3</h2>
|
||||
@if ($definedS3s->isEmpty())
|
||||
<div class="text-sm text-neutral-600 dark:text-neutral-400">No validated S3 storages found.</div>
|
||||
@else
|
||||
<div class="flex flex-col gap-3">
|
||||
<x-forms.checkbox live id="saveToS3" label="Save to S3" />
|
||||
@if ($saveToS3)
|
||||
<x-forms.select id="s3StorageId" label="S3 Storage">
|
||||
@foreach ($definedS3s as $s3)
|
||||
<option value="{{ $s3->id }}">{{ $s3->name }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<x-forms.button type="submit">Save</x-forms.button>
|
||||
@endif
|
||||
</form>
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
<div x-data="{
|
||||
search: '',
|
||||
backups: @js($backups->map(fn ($backup) => [
|
||||
'name' => strtolower($backup->volume->name),
|
||||
'frequency' => strtolower($backup->frequency),
|
||||
])->values()),
|
||||
hasMatches() {
|
||||
const query = this.search.toLowerCase();
|
||||
|
||||
return this.backups.some((backup) => backup.name.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 volume name 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->volume->name)).includes(search.toLowerCase()) || @js(strtolower($backup->frequency)).includes(search.toLowerCase())" @class([
|
||||
'flex flex-col border-l-2 transition-colors p-4 cursor-pointer bg-white hover:bg-gray-100 dark:bg-coolgray-100 dark:hover:bg-coolgray-200 text-black dark:text-white',
|
||||
'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">
|
||||
Volume: {{ $backup->volume->name }}
|
||||
@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,11 @@
|
|||
<div>
|
||||
<x-slot:title>
|
||||
{{ data_get_str($application, 'name')->limit(10) }} > Volume Backups | Coolify
|
||||
</x-slot>
|
||||
<h1>Volume Backups</h1>
|
||||
<livewire:project.shared.configuration-checker :resource="$application" />
|
||||
<livewire:project.application.heading :application="$application" />
|
||||
|
||||
<livewire:project.shared.storages.volume-backups :storage="$backup->volume" :resource="$application"
|
||||
wire:key="volume-backup-{{ $backup->uuid }}" />
|
||||
</div>
|
||||
|
|
@ -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.index', 'project.application.backup.show'),
|
||||
],
|
||||
[
|
||||
'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.index', 'project.application.backup.show') ? '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">
|
||||
|
|
|
|||
|
|
@ -5,6 +5,13 @@
|
|||
<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
|
||||
|
|
@ -27,9 +34,11 @@
|
|||
</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" />
|
||||
@elseif ($saveS3)
|
||||
<x-forms.checkbox instantSave label="S3 Enabled" id="saveS3"
|
||||
helper="The configured S3 storage is no longer available. Disable S3 backups or configure a usable S3 storage." />
|
||||
@else
|
||||
<x-forms.checkbox instantSave helper="No validated S3 storage available." label="S3 Enabled" id="saveS3"
|
||||
disabled />
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@
|
|||
@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',
|
||||
|
|
@ -15,6 +13,8 @@
|
|||
'App\Models\StandaloneMariadb',
|
||||
]),
|
||||
],
|
||||
['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 = [
|
||||
|
|
@ -190,16 +190,6 @@ class="scrollbar hidden min-h-10 w-full flex-nowrap items-center gap-6 overflow-
|
|||
Configuration
|
||||
</a>
|
||||
|
||||
<a class="shrink-0 {{ request()->routeIs('project.database.logs') ? 'dark:text-white' : '' }}"
|
||||
href="{{ route('project.database.logs', $parameters) }}">
|
||||
Logs
|
||||
</a>
|
||||
@can('canAccessTerminal')
|
||||
<a class="shrink-0 {{ request()->routeIs('project.database.command') ? 'dark:text-white' : '' }}"
|
||||
href="{{ route('project.database.command', $parameters) }}">
|
||||
Terminal
|
||||
</a>
|
||||
@endcan
|
||||
@if (
|
||||
$database->getMorphClass() === 'App\Models\StandalonePostgresql' ||
|
||||
$database->getMorphClass() === 'App\Models\StandaloneMongodb' ||
|
||||
|
|
@ -210,6 +200,16 @@ class="scrollbar hidden min-h-10 w-full flex-nowrap items-center gap-6 overflow-
|
|||
Backups
|
||||
</a>
|
||||
@endif
|
||||
<a class="shrink-0 {{ request()->routeIs('project.database.logs') ? 'dark:text-white' : '' }}"
|
||||
href="{{ route('project.database.logs', $parameters) }}">
|
||||
Logs
|
||||
</a>
|
||||
@can('canAccessTerminal')
|
||||
<a class="shrink-0 {{ request()->routeIs('project.database.command') ? 'dark:text-white' : '' }}"
|
||||
href="{{ route('project.database.command', $parameters) }}">
|
||||
Terminal
|
||||
</a>
|
||||
@endcan
|
||||
</nav>
|
||||
|
||||
@if ($database->destination->server->isFunctional())
|
||||
|
|
|
|||
|
|
@ -1,4 +1,15 @@
|
|||
<div>
|
||||
<div x-data="{
|
||||
search: '',
|
||||
backups: @js($database->scheduledBackups->map(fn ($backup) => [
|
||||
'name' => strtolower($database->name),
|
||||
'frequency' => strtolower($backup->frequency),
|
||||
])->values()),
|
||||
hasMatches() {
|
||||
const query = this.search.toLowerCase();
|
||||
|
||||
return this.backups.some((backup) => backup.name.includes(query) || backup.frequency.includes(query));
|
||||
},
|
||||
}">
|
||||
<div class="flex flex-col gap-2">
|
||||
@if ($database->is_migrated && blank($database->custom_type))
|
||||
<div>
|
||||
|
|
@ -18,9 +29,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 or frequency..." />
|
||||
</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())" @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 &&
|
||||
|
|
@ -105,7 +123,7 @@ class="px-3 py-1 rounded-md text-xs font-medium tracking-wide shadow-xs bg-gray-
|
|||
</div>
|
||||
</a>
|
||||
@else
|
||||
<div @class([
|
||||
<div x-show="search === '' || @js(strtolower($database->name)).includes(search.toLowerCase()) || @js(strtolower($backup->frequency)).includes(search.toLowerCase())" @class([
|
||||
'flex flex-col border-l-2 transition-colors p-4 cursor-pointer bg-white hover:bg-gray-100 dark:bg-coolgray-100 dark:hover:bg-coolgray-200 text-black dark:text-white',
|
||||
'bg-gray-200 dark:bg-coolgray-200' =>
|
||||
data_get($backup, 'id') === data_get($selectedBackup, 'id'),
|
||||
|
|
|
|||
|
|
@ -12,10 +12,22 @@
|
|||
$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 status="Backup enabled" type="success" />
|
||||
@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 status="Backup enabled" type="success" />
|
||||
@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 +45,13 @@
|
|||
</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 status="Backup enabled" type="success" />
|
||||
@endif
|
||||
</x-slot:labelSuffix>
|
||||
</x-forms.input>
|
||||
<x-forms.input id="hostPath" readonly />
|
||||
<x-forms.input id="mountPath" required readonly />
|
||||
</div>
|
||||
|
|
@ -51,14 +69,26 @@
|
|||
@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 status="Backup enabled" type="success" />
|
||||
@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 status="Backup enabled" type="success" />
|
||||
@endif
|
||||
</x-slot:labelSuffix>
|
||||
</x-forms.input>
|
||||
<x-forms.input id="hostPath" />
|
||||
<x-forms.input id="mountPath" required />
|
||||
</div>
|
||||
|
|
@ -74,6 +104,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-volume-id="$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 +122,13 @@
|
|||
@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 status="Backup enabled" type="success" />
|
||||
@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 +136,13 @@
|
|||
</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 status="Backup enabled" type="success" />
|
||||
@endif
|
||||
</x-slot:labelSuffix>
|
||||
</x-forms.input>
|
||||
<x-forms.input id="hostPath" disabled />
|
||||
<x-forms.input id="mountPath" required disabled />
|
||||
</div>
|
||||
|
|
@ -101,4 +150,15 @@
|
|||
@endcan
|
||||
@endif
|
||||
</form>
|
||||
@if ($isReadOnly && $resource instanceof \App\Models\Application)
|
||||
@can('update', $resource)
|
||||
<div class="pt-2">
|
||||
<x-modal-input buttonTitle="Configure Backup" title="Configure Volume Backup" :wireIgnore="false">
|
||||
<livewire:project.application.backup.create :application="$resource"
|
||||
:selected-volume-id="$storage->id"
|
||||
wire:key="configure-readonly-volume-backup-{{ $storage->id }}" />
|
||||
</x-modal-input>
|
||||
</div>
|
||||
@endcan
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,338 @@
|
|||
<div class="flex flex-col gap-4">
|
||||
<form wire:submit="save" class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<div>
|
||||
<div class="flex gap-3">
|
||||
<h2>Scheduled Backup</h2>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
|
||||
<x-forms.button type="submit" class="w-full sm:w-auto">Save</x-forms.button>
|
||||
@if (!$enabled)
|
||||
<x-forms.button type="button" wire:click="toggleEnabled" wire:loading.attr="disabled"
|
||||
wire:target="toggleEnabled" isHighlighted>Enable Backup</x-forms.button>
|
||||
@else
|
||||
<x-forms.button type="button" wire:click="toggleEnabled" wire:loading.attr="disabled"
|
||||
wire:target="toggleEnabled">Disable Backup</x-forms.button>
|
||||
@endif
|
||||
|
||||
<x-forms.button type="button" wire:click="backupNow" class="w-full sm:w-auto">Backup Now</x-forms.button>
|
||||
@if ($backup)
|
||||
<x-modal-confirmation title="Confirm Backup Schedule Deletion?" isErrorButton submitAction="delete"
|
||||
:actions="['The selected backup schedule will be deleted.', 'All local and S3 archives created by this schedule will be deleted.']"
|
||||
confirmationText="{{ $storage->name }}"
|
||||
confirmationLabel="Please confirm the execution of the actions by entering the Volume Name below"
|
||||
shortConfirmationLabel="Volume Name">
|
||||
<x-slot:trigger>
|
||||
<x-forms.button isError class="w-full sm:w-auto">Delete Backups and Schedule</x-forms.button>
|
||||
</x-slot:trigger>
|
||||
</x-modal-confirmation>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<p class="pt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Persistent volume:
|
||||
<span class="font-medium text-neutral-800 dark:text-neutral-200">{{ $storage->name }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="w-full max-w-md">
|
||||
<x-forms.checkbox id="pauseDuringBackup" label="Pause containers while creating the archive"
|
||||
helper="Off by default. Containers using this volume are resumed immediately after the archive is created." />
|
||||
@if ($availableS3Storages->isNotEmpty())
|
||||
<x-forms.checkbox instantSave id="saveToS3" label="S3 Enabled" />
|
||||
@elseif ($saveToS3)
|
||||
<x-forms.checkbox instantSave id="saveToS3" label="S3 Enabled"
|
||||
helper="The configured S3 storage is no longer available. Disable S3 backups or configure a usable S3 storage." />
|
||||
@else
|
||||
<x-forms.checkbox instantSave id="saveToS3" label="S3 Enabled"
|
||||
helper="No validated S3 storage available." disabled />
|
||||
@endif
|
||||
@if ($saveToS3)
|
||||
<x-forms.checkbox instantSave id="disableLocalBackup" label="Disable Local Backup"
|
||||
helper="When enabled, backup files are deleted locally after a successful S3 upload." />
|
||||
@else
|
||||
<x-forms.checkbox id="disableLocalBackup" label="Disable Local Backup"
|
||||
helper="When enabled, backup files are deleted locally after a successful S3 upload." disabled />
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="w-full max-w-md pb-2">
|
||||
<div class="flex items-center gap-1 mb-1 text-sm font-medium">
|
||||
<span>S3 Storage</span>
|
||||
@if (!$saveToS3)
|
||||
<span class="text-xs font-normal text-warning">(currently disabled)</span>
|
||||
@else
|
||||
<x-highlighted text="*" />
|
||||
@endif
|
||||
</div>
|
||||
<x-forms.select id="s3StorageId" wire:model.live="s3StorageId" :required="$saveToS3"
|
||||
:disabled="$availableS3Storages->isEmpty()">
|
||||
@if ($availableS3Storages->isEmpty())
|
||||
<option value="">No S3 storage available</option>
|
||||
@else
|
||||
@foreach ($availableS3Storages as $s3Storage)
|
||||
<option value="{{ $s3Storage->id }}">{{ $s3Storage->name }}</option>
|
||||
@endforeach
|
||||
@endif
|
||||
</x-forms.select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<h3>Settings</h3>
|
||||
<div class="p-3 text-sm rounded bg-warning/10 text-warning">
|
||||
Backups made while the application is writing to this volume may be inconsistent or corrupted. You can
|
||||
pause containers during the archive step for a more consistent backup, but this briefly interrupts the
|
||||
application.
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-3">
|
||||
<x-forms.input id="frequency" label="Frequency" required
|
||||
helper="Use every_minute, hourly, daily, weekly, monthly, yearly, or a cron expression." />
|
||||
<x-forms.input id="timezone" label="Timezone" disabled
|
||||
helper="The timezone of the server where the backup is scheduled to run (if not set, the instance timezone will be used)" required />
|
||||
<x-forms.input id="timeout" type="number" min="60" max="36000" label="Timeout"
|
||||
helper="The timeout of the backup job in seconds." required />
|
||||
</div>
|
||||
|
||||
|
||||
<h3 class="mt-6 mb-2 text-lg font-medium">Backup Retention Settings</h3>
|
||||
<div class="mb-4">
|
||||
<ul class="pl-6 space-y-2 list-disc">
|
||||
<li>Setting a value to 0 means unlimited retention.</li>
|
||||
<li>The retention rules work independently - whichever limit is reached first will trigger cleanup.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<div>
|
||||
<h4 class="mb-3 font-medium">Local Backup Retention</h4>
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-3">
|
||||
<x-forms.input label="Number of backups to keep" id="retentionAmountLocally" type="number"
|
||||
min="0"
|
||||
helper="Keeps only the specified number of most recent backups on the server. Set to 0 for unlimited backups."
|
||||
required />
|
||||
<x-forms.input label="Days to keep backups" id="retentionDaysLocally" type="number"
|
||||
min="0"
|
||||
helper="Automatically removes backups older than the specified number of days. Set to 0 for no time limit."
|
||||
required />
|
||||
<x-forms.input label="Maximum storage (GB)" id="retentionMaxStorageLocally" type="number"
|
||||
min="0" step="any"
|
||||
helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.001 for 1MB). Set to 0 for unlimited storage."
|
||||
required />
|
||||
</div>
|
||||
</div>
|
||||
@if ($saveToS3)
|
||||
<div>
|
||||
<h4 class="mb-3 font-medium">S3 Storage Retention</h4>
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-3">
|
||||
<x-forms.input label="Number of backups to keep" id="retentionAmountS3" type="number"
|
||||
min="0"
|
||||
helper="Keeps only the specified number of most recent backups on S3 storage. Set to 0 for unlimited backups."
|
||||
required />
|
||||
<x-forms.input label="Days to keep backups" id="retentionDaysS3" type="number"
|
||||
min="0"
|
||||
helper="Automatically removes S3 backups older than the specified number of days. Set to 0 for no time limit."
|
||||
required />
|
||||
<x-forms.input label="Maximum storage (GB)" id="retentionMaxStorageS3" type="number"
|
||||
min="0" step="any"
|
||||
helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.5 for 500MB). Set to 0 for unlimited storage."
|
||||
required />
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@if ($backup)
|
||||
@php
|
||||
$executionCount = method_exists($executions, 'total') ? $executions->total() : $executions->count();
|
||||
$currentPage = method_exists($executions, 'currentPage') ? $executions->currentPage() : 1;
|
||||
$lastPage = method_exists($executions, 'lastPage') ? $executions->lastPage() : 1;
|
||||
@endphp
|
||||
<div wire:poll.5000ms="$refresh">
|
||||
<div class="flex flex-col gap-3 py-4 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<h3 class="py-0">Executions <span class="text-xs">({{ $executionCount }})</span></h3>
|
||||
@if ($executionCount > 0)
|
||||
<div class="flex items-center gap-2">
|
||||
<x-forms.button :disabled="$currentPage === 1" wire:click="previousPage">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24">
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round"
|
||||
stroke-linejoin="round" stroke-width="2" d="m14 6l-6 6l6 6z" />
|
||||
</svg>
|
||||
</x-forms.button>
|
||||
<span class="px-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Page {{ $currentPage }} of {{ $lastPage }}
|
||||
</span>
|
||||
<x-forms.button :disabled="$currentPage === $lastPage" wire:click="nextPage">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24">
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round"
|
||||
stroke-linejoin="round" stroke-width="2" d="m10 18l6-6l-6-6z" />
|
||||
</svg>
|
||||
</x-forms.button>
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
|
||||
<x-forms.button wire:click="cleanupFailed" class="w-full sm:w-auto">
|
||||
Cleanup Failed Backups
|
||||
</x-forms.button>
|
||||
<x-modal-confirmation title="Cleanup Deleted Backup Entries?" isErrorButton
|
||||
submitAction="cleanupDeleted()"
|
||||
:actions="['This permanently deletes backup execution entries whose local and S3 files have already been deleted.', 'This only removes database entries, not backup files.']"
|
||||
confirmationText="cleanup deleted backups"
|
||||
confirmationLabel="Please confirm by typing 'cleanup deleted backups' below"
|
||||
shortConfirmationLabel="Confirmation">
|
||||
<x-slot:trigger>
|
||||
<x-forms.button isError class="w-full sm:w-auto">Cleanup Deleted</x-forms.button>
|
||||
</x-slot:trigger>
|
||||
</x-modal-confirmation>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
@forelse ($executions as $execution)
|
||||
<div wire:key="volume-backup-execution-{{ $execution->id }}" @class([
|
||||
'relative flex flex-col border-l-2 transition-colors p-4 bg-white dark:bg-coolgray-100 text-black dark:text-white',
|
||||
'border-blue-500/50 border-dashed' => $execution->status === 'running',
|
||||
'border-error' => $execution->status === 'failed',
|
||||
'border-success' => $execution->status === 'success',
|
||||
])>
|
||||
@if ($execution->status === 'running')
|
||||
<div class="absolute top-2 right-2"><x-loading /></div>
|
||||
@endif
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span @class([
|
||||
'px-3 py-1 rounded-md text-xs font-medium tracking-wide shadow-xs',
|
||||
'bg-blue-100/80 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300' => $execution->status === 'running',
|
||||
'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200' => $execution->status === 'failed',
|
||||
'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-200' => $execution->status === 'success' && $execution->s3_uploaded === false,
|
||||
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200' => $execution->status === 'success' && $execution->s3_uploaded !== false,
|
||||
])>
|
||||
@if ($execution->status === 'running')
|
||||
In Progress
|
||||
@elseif ($execution->status === 'success' && $execution->s3_uploaded === false)
|
||||
Success (S3 Warning)
|
||||
@else
|
||||
{{ ucfirst($execution->status) }}
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
@if ($execution->status === 'running')
|
||||
<span title="Started: {{ $execution->created_at->toDateTimeString() }}">
|
||||
Running for {{ calculateDuration($execution->created_at, now()) }}
|
||||
</span>
|
||||
@else
|
||||
@php
|
||||
$finishedAt = $execution->finished_at ?? $execution->updated_at;
|
||||
@endphp
|
||||
<span title="Started: {{ $execution->created_at->toDateTimeString() }} Ended: {{ $finishedAt->toDateTimeString() }}">
|
||||
{{ $finishedAt->diffForHumans() }}
|
||||
({{ calculateDuration($execution->created_at, $finishedAt) }})
|
||||
• {{ $finishedAt->format('M j, H:i') }}
|
||||
</span>
|
||||
@endif
|
||||
• Volume: {{ $storage->name }}
|
||||
@if ($execution->size > 0)
|
||||
• Size: {{ formatBytes($execution->size) }}
|
||||
@endif
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Location: {{ $execution->filename ?? 'N/A' }}
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 mt-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Backup Availability:</div>
|
||||
<span @class([
|
||||
'px-2 py-1 rounded-sm text-xs font-medium',
|
||||
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200' => !$execution->local_storage_deleted,
|
||||
'bg-gray-100 text-gray-600 dark:bg-gray-800/50 dark:text-gray-400' => $execution->local_storage_deleted,
|
||||
])>
|
||||
<span class="flex items-center gap-1">
|
||||
@if (!$execution->local_storage_deleted)
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
@else
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
@endif
|
||||
Local Storage
|
||||
</span>
|
||||
</span>
|
||||
@if ($execution->s3_uploaded !== null)
|
||||
<span @class([
|
||||
'px-2 py-1 rounded-sm text-xs font-medium',
|
||||
'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200' => $execution->s3_uploaded === false && !$execution->s3_storage_deleted,
|
||||
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200' => $execution->s3_uploaded === true && !$execution->s3_storage_deleted,
|
||||
'bg-gray-100 text-gray-600 dark:bg-gray-800/50 dark:text-gray-400' => $execution->s3_storage_deleted,
|
||||
])>
|
||||
<span class="flex items-center gap-1">
|
||||
@if ($execution->s3_uploaded === true && !$execution->s3_storage_deleted)
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
@else
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
@endif
|
||||
S3 Storage
|
||||
</span>
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
@if ($execution->message)
|
||||
<div class="p-2 mt-2 bg-gray-100 rounded-sm dark:bg-coolgray-200">
|
||||
<pre class="text-sm whitespace-pre-wrap">{{ $execution->message }}</pre>
|
||||
</div>
|
||||
@endif
|
||||
@if ($execution->status !== 'running')
|
||||
<div class="grid grid-cols-2 gap-2 mt-4 sm:flex sm:flex-wrap">
|
||||
@if ($execution->status === 'success' && !$execution->local_storage_deleted)
|
||||
<x-forms.button class="w-full dark:hover:bg-coolgray-400 sm:w-auto"
|
||||
x-on:click="download_volume_backup_file('{{ $execution->id }}')">
|
||||
Download
|
||||
</x-forms.button>
|
||||
@endif
|
||||
@php
|
||||
$executionCheckboxes = [];
|
||||
$deleteActions = [];
|
||||
if (!$execution->local_storage_deleted) {
|
||||
$deleteActions[] = 'This backup will be permanently deleted from local storage.';
|
||||
}
|
||||
if ($execution->s3_uploaded === true && !$execution->s3_storage_deleted) {
|
||||
$executionCheckboxes[] = ['id' => 'delete_backup_s3', 'label' => 'Delete the selected backup permanently from S3 Storage'];
|
||||
}
|
||||
if (empty($deleteActions)) {
|
||||
$deleteActions[] = 'This backup execution record will be deleted.';
|
||||
}
|
||||
@endphp
|
||||
<x-modal-confirmation title="Confirm Backup Deletion?" isErrorButton
|
||||
submitAction="deleteBackup({{ $execution->id }})"
|
||||
:checkboxes="$executionCheckboxes" :actions="$deleteActions"
|
||||
confirmationText="{{ $execution->filename }}"
|
||||
confirmationLabel="Please confirm the execution of the actions by entering the Backup Filename below"
|
||||
shortConfirmationLabel="Backup Filename">
|
||||
<x-slot:trigger>
|
||||
<x-forms.button isError class="w-full sm:w-auto">Delete</x-forms.button>
|
||||
</x-slot:trigger>
|
||||
</x-modal-confirmation>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@empty
|
||||
<div class="p-4 bg-gray-100 rounded-sm dark:bg-coolgray-100">No executions found.</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@script
|
||||
<script>
|
||||
window.download_volume_backup_file = function(executionId) {
|
||||
window.open('/download/volume-backup/' + executionId, '_blank');
|
||||
}
|
||||
</script>
|
||||
@endscript
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,6 +93,7 @@
|
|||
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;
|
||||
|
|
@ -224,6 +227,8 @@
|
|||
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('/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');
|
||||
|
|
@ -411,6 +416,68 @@
|
|||
}
|
||||
})->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.volume.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);
|
||||
}
|
||||
|
||||
$filename = $execution->filename;
|
||||
$disk = Storage::build([
|
||||
'driver' => 'sftp',
|
||||
'host' => $server->ip,
|
||||
'port' => (int) $server->port,
|
||||
'username' => $server->user,
|
||||
'privateKey' => $server->privateKey->getKeyLocation(),
|
||||
'root' => '/',
|
||||
]);
|
||||
if (! $disk->exists($filename)) {
|
||||
return response()->json(['message' => 'Backup not found locally on the server.'], 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/gzip',
|
||||
'Content-Disposition' => 'attachment; filename="'.basename($filename).'"',
|
||||
]);
|
||||
} catch (Throwable) {
|
||||
return response()->json(['message' => 'Failed to download backup.'], 500);
|
||||
}
|
||||
})->name('download.volume-backup');
|
||||
|
||||
});
|
||||
|
||||
Route::any('/{any}', function () {
|
||||
|
|
|
|||
|
|
@ -72,6 +72,35 @@ 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.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"');
|
||||
});
|
||||
|
||||
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('disables S3 backup when saved without a selected S3 storage', function () {
|
||||
$backup = createBackupForEditValidationTest($this->team);
|
||||
|
||||
|
|
@ -177,6 +206,30 @@ function createS3StorageForBackupEditValidationTest(Team|int $team, string $name
|
|||
->assertSee('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(),
|
||||
])->assertSet('saveS3', true);
|
||||
|
||||
preg_match('/<input\b(?=[^>]*wire:model=(?:"saveS3"|saveS3))[^>]*>/', $component->html(), $matches);
|
||||
expect($matches[0] ?? null)->not->toBeNull()
|
||||
->and(preg_match('/\sdisabled(?:\s|\/>)/', $matches[0]))->toBe(0);
|
||||
|
||||
$component->set('saveS3', false)->call('instantSave')->assertDispatched('success');
|
||||
|
||||
expect($backup->refresh()->save_s3)->toBeFalsy()
|
||||
->and($backup->s3_storage_id)->toBeNull();
|
||||
|
||||
preg_match('/<input\b(?=[^>]*wire:model=(?:"saveS3"|saveS3))[^>]*>/', $component->html(), $matches);
|
||||
expect(preg_match('/\sdisabled(?:\s|\/>)/', $matches[0]))->toBe(1);
|
||||
});
|
||||
|
||||
it('shows when S3 backups are currently disabled', function () {
|
||||
createS3StorageForBackupEditValidationTest($this->team);
|
||||
$backup = createBackupForEditValidationTest($this->team, [
|
||||
|
|
|
|||
121
tests/Feature/BackupSearchTest.php
Normal file
121
tests/Feature/BackupSearchTest.php
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Models\Project;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
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');
|
||||
|
||||
ScheduledVolumeBackup::create([
|
||||
'local_persistent_volume_id' => $dailyVolume->id,
|
||||
'team_id' => $this->team->id,
|
||||
'frequency' => 'daily',
|
||||
]);
|
||||
ScheduledVolumeBackup::create([
|
||||
'local_persistent_volume_id' => $weeklyVolume->id,
|
||||
'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('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(),
|
||||
]);
|
||||
$database->scheduledBackups()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'frequency' => 'daily',
|
||||
]);
|
||||
$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('x-model="search"', false)
|
||||
->assertSee('x-show=', false)
|
||||
->assertSee('No scheduled backups match your search.');
|
||||
});
|
||||
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
1092
tests/Feature/VolumeBackupTest.php
Normal file
1092
tests/Feature/VolumeBackupTest.php
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue