2026-07-15 13:47:57 +00:00
|
|
|
<?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
|
|
|
|
|
{
|
2026-07-15 15:34:22 +00:00
|
|
|
$execution->loadMissing('scheduledVolumeBackup.backupable.resource');
|
2026-07-15 13:47:57 +00:00
|
|
|
|
2026-07-15 13:59:35 +00:00
|
|
|
if ($execution->stop_recovery_pending) {
|
2026-07-15 13:47:57 +00:00
|
|
|
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();
|
2026-07-15 13:59:35 +00:00
|
|
|
$execution->update(['stop_container_ids' => $containers]);
|
2026-07-15 13:47:57 +00:00
|
|
|
|
|
|
|
|
$remainingFile = $stateFile.'.remaining';
|
|
|
|
|
$script = 'status=0; : > '.escapeshellarg($remainingFile).'; '
|
|
|
|
|
.'if [ -f '.escapeshellarg($stateFile).' ]; then while IFS= read -r container; do '
|
2026-07-15 13:59:35 +00:00
|
|
|
.'[ -z "$container" ] && continue; running=$(docker inspect --format \'{{.State.Running}}\' "$container" 2>/dev/null) '
|
2026-07-15 13:47:57 +00:00
|
|
|
.'|| { echo "$container" >> '.escapeshellarg($remainingFile).'; status=1; continue; }; '
|
2026-07-15 13:59:35 +00:00
|
|
|
.'if [ "$running" != true ] && ! docker start "$container" >/dev/null; then echo "$container" >> '
|
2026-07-15 13:47:57 +00:00
|
|
|
.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([
|
2026-07-15 13:59:35 +00:00
|
|
|
'stop_container_ids' => null,
|
|
|
|
|
'stop_recovery_pending' => false,
|
2026-07-15 13:47:57 +00:00
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static function cleanupS3Upload(ScheduledVolumeBackupExecution $execution): void
|
|
|
|
|
{
|
2026-07-16 12:30:58 +00:00
|
|
|
$execution->loadMissing('s3');
|
|
|
|
|
$s3 = $execution->s3;
|
2026-07-15 13:47:57 +00:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
{
|
2026-07-15 13:59:35 +00:00
|
|
|
return '/tmp/coolify-volume-backup-'.$execution->uuid.'.stopped';
|
2026-07-15 13:47:57 +00:00
|
|
|
}
|
|
|
|
|
}
|