Ctrl K
ComunidadGuías…

app/Support/DatabaseImport/DatabaseImportCleanup.php

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<?php

namespace App\Support\DatabaseImport;

use App\Enums\ProcessStatus;
use App\Events\DatabaseImportFinished;
use App\Support\ResourceStartActivity;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Spatie\Activitylog\Models\Activity;
use Throwable;

/**
 * Cleanup of a database import: helper containers and temporary files on the server
 * and in the database container.
 *
 * The normal path sends the cleanup data through the encrypted CoolifyTask payload.
 * The import also stores the same data on its activity, so Coolify can stop and clean up
 * an import that it marks as failed after a restart or because it is stale.
 * The data has only ids, container names and paths. It must never have credentials.
 */
class DatabaseImportCleanup
{
    public const PROPERTY = 'cleanup';

    /**
     * Set on the activity when Coolify stopped the import. A retried CoolifyTask does not run it again.
     */
    public const STOP_REQUESTED_PROPERTY = 'stop_requested_at';

    /**
     * Why a failed import task was stopped. The activity gets this error when the stop has run.
     */
    public const STOP_REASON_PROPERTY = 'stop_reason';

    /**
     * Exit codes of the local SSH command that do not come from the restore: ssh exits with 255
     * when the connection fails and the local `timeout` wrapper exits with 124. The restore
     * command in the database container reports its own 124 and 255 as 1.
     *
     * @var array<int, string>
     */
    public const TRANSPORT_FAILURE_REASONS = [
        124 => 'The import timed out.',
        255 => 'The connection to the server was lost.',
    ];

    public const CLAIM_TTL_SECONDS = 7 * 24 * 3600;

    /**
     * The only keys that are stored on the activity and sent to the cleanup listener.
     */
    private const KEYS = [
        'container',
        'containerTmpPath',
        'scriptPath',
        'serverId',
        'operationUuid',
        'containerName',
        'serverTmpPath',
        'credentialTmpPath',
    ];

    /**
     * @param  array<string, mixed>  $data
     * @return array<string, int|string>
     */
    public static function storable(array $data): array
    {
        return array_filter(
            Arr::only($data, self::KEYS),
            fn ($value): bool => is_string($value) || is_int($value),
        );
    }

    /**
     * The cleanup data stored on an import activity, or null if the activity has no usable data.
     *
     * @return array<string, int|string>|null
     */
    public static function stored(Activity $activity): ?array
    {
        $data = data_get($activity, 'properties.'.self::PROPERTY);
        if (! is_array($data)) {
            return null;
        }

        $data = self::storable($data);
        if (! is_int($data['serverId'] ?? null) || ! Str::isUuid($data['operationUuid'] ?? null)) {
            return null;
        }

        return $data;
    }

    public static function stopRequested(Activity $activity): bool
    {
        return filled(data_get($activity, 'properties.'.self::STOP_REQUESTED_PROPERTY));
    }

    /**
     * Queue the stop of the remote restore and the cleanup. It does not run SSH commands
     * inline, so it is safe during boot. Errors are logged and do not stop the caller.
     *
     * @param  array<string, int|string>  $data
     */
    public static function queueStop(array $data): void
    {
        try {
            event(new DatabaseImportFinished([...$data, 'stopRestore' => true]));
        } catch (Throwable $exception) {
            Log::warning('Could not queue the stop of an interrupted database import', [
                'operationUuid' => $data['operationUuid'] ?? null,
                'serverId' => $data['serverId'] ?? null,
                'error' => $exception->getMessage(),
            ]);
        }
    }

    /**
     * Stop a database import whose CoolifyTask failed (for example it timed out) before the import
     * reported its end, because the restore can still run in the database container.
     *
     * The activity stays in progress, so the import still blocks a start, restart, import or backup
     * of the database. The cleanup listener marks it as failed after the stop has run.
     *
     * @return bool True when the import is stopped this way; the caller must not mark the activity as failed.
     */
    public static function stopAfterTaskFailure(Activity $activity, string $reason): bool
    {
        if (data_get($activity, 'properties.operation') !== ResourceStartActivity::DATABASE_IMPORT_OPERATION) {
            return false;
        }

        $current = Activity::query()->find($activity->getKey());
        $status = data_get($current, 'properties.status');
        if (! $current || ! in_array($status, [ProcessStatus::QUEUED->value, ProcessStatus::IN_PROGRESS->value], true)) {
            return false;
        }

        if (self::stopRequested($current)) {
            return true;
        }

        $cleanup = self::stored($current);
        if ($cleanup === null) {
            return false;
        }

        // Save the stop flag before the stop is queued, so the listener and a retried CoolifyTask see it.
        $current->properties = $current->properties->merge([
            self::STOP_REQUESTED_PROPERTY => now()->toIso8601String(),
            self::STOP_REASON_PROPERTY => $reason,
        ]);
        $current->save();
        $activity->properties = $current->properties;

        self::queueStop($cleanup);

        return true;
    }

    /**
     * Stop a database import whose SSH command ended without the result of the restore (the
     * connection was lost or the local timeout ended it). The restore runs with `docker exec`
     * and does not stop with the SSH session, so it is handled like a failed task.
     *
     * @return bool True when the import is stopped this way; the caller must keep it in progress.
     */
    public static function stopAfterTransportFailure(Activity $activity, ?int $exitCode): bool
    {
        $reason = self::TRANSPORT_FAILURE_REASONS[$exitCode] ?? null;

        return $reason !== null && self::stopAfterTaskFailure($activity, $reason);
    }

    /**
     * Mark the imports of an operation as failed that wait for the stop of their failed task.
     * Called by the cleanup listener after the stop has run.
     */
    public static function finishStop(string $operationUuid): void
    {
        Activity::query()
            ->where('properties->operation_uuid', $operationUuid)
            ->where('properties->operation', ResourceStartActivity::DATABASE_IMPORT_OPERATION)
            ->whereIn('properties->status', [ProcessStatus::QUEUED->value, ProcessStatus::IN_PROGRESS->value])
            ->get()
            ->filter(fn (Activity $activity): bool => filled(data_get($activity, 'properties.'.self::STOP_REASON_PROPERTY)))
            ->each(fn (Activity $activity) => ResourceStartActivity::markFailed(
                $activity,
                data_get($activity, 'properties.'.self::STOP_REASON_PROPERTY).' '.ResourceStartActivity::IMPORT_STOPPED_MESSAGE,
            ));
    }

    public static function claimKey(string $operationUuid): string
    {
        return "database-import-cleanup:{$operationUuid}";
    }

    /**
     * Only the first caller for an operation gets true, so the cleanup runs at most once per import.
     */
    public static function claim(string $operationUuid): bool
    {
        return Cache::add(self::claimKey($operationUuid), true, self::CLAIM_TTL_SECONDS);
    }

    /**
     * Release the claim after a failed attempt, so a retry of the cleanup can run.
     */
    public static function release(string $operationUuid): void
    {
        Cache::forget(self::claimKey($operationUuid));
    }
}