Ctrl K
ComunidadGuías…

app/Jobs/PrepareImageScannerJob.php

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

Archivo original ↗Preguntar a KiBot
<?php

namespace App\Jobs;

use App\Actions\ImageScan\ScanImage;
use App\Events\ImageScanUpdated;
use App\Models\Server;
use App\Services\ImageScan\ScannerCommands;
use Carbon\CarbonImmutable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;

/**
 * Pulls the pinned scanner images on a server and updates the Grype database.
 *
 * The progress is kept in the cache (`image-scan-prepare:{server_id}`: pulling, updating_database, ready,
 * failed) and broadcast as ImageScanUpdated with the scan UUID `prepare`. All steps run under the scan lock of the
 * server, so a deletion of the scanner data cannot run at the same time; when the lock is busy, the job runs again
 * later instead of waiting for it.
 */
class PrepareImageScannerJob implements ShouldBeEncrypted, ShouldBeUnique, ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public const SCAN_UUID = 'prepare';

    public const STATE_PULLING = 'pulling';

    public const STATE_UPDATING_DATABASE = 'updating_database';

    public const STATE_READY = 'ready';

    public const STATE_FAILED = 'failed';

    public const FAILED_PULLING = 'pulling';

    public const FAILED_SETUP = 'setup';

    public const FAILED_CONNECTION = 'connection';

    public $timeout = 3600;

    public $tries = ScanResourceImagesJob::MAX_ATTEMPTS;

    /** An unexpected error fails the job; only a busy scan lock retries it. */
    public $maxExceptions = 1;

    public $uniqueFor = 3600;

    private const TIMEOUT_PREPARE = 1900;

    private const TIMEOUT_DB_UPDATE = 400;

    private const TIMEOUT_DB_STATUS = 120;

    public function __construct(public Server $server)
    {
        // Same queue as the other scan jobs: self-hosted Horizon works through 'high' first.
        $this->onQueue(isCloud() ? maintenance_queue() : 'default');
    }

    public function uniqueId(): string
    {
        return (string) $this->server->id;
    }

    public static function stateCacheKey(Server $server): string
    {
        return "image-scan-prepare:{$server->id}";
    }

    public static function builtAtCacheKey(Server $server): string
    {
        return self::stateCacheKey($server).':built-at';
    }

    public static function diskUsedCacheKey(Server $server): string
    {
        return self::stateCacheKey($server).':disk-used';
    }

    /**
     * The Redis store returns numbers as strings, so the cached value is not always an int.
     */
    public static function diskUsedBytes(Server $server): ?int
    {
        $bytes = Cache::get(self::diskUsedCacheKey($server));

        return is_numeric($bytes) ? (int) $bytes : null;
    }

    public static function failedStepCacheKey(Server $server): string
    {
        return self::stateCacheKey($server).':failed-step';
    }

    public static function forget(Server $server): void
    {
        Cache::forget(self::stateCacheKey($server));
        Cache::forget(self::failedStepCacheKey($server));
        Cache::forget(self::builtAtCacheKey($server));
        Cache::forget(self::diskUsedCacheKey($server));
    }

    public function handle(): void
    {
        $server = $this->server->fresh(['settings']);
        if (! $server instanceof Server || $server->isTransferredAway()) {
            return;
        }

        if (! $server->settings?->is_image_scan_enabled) {
            return;
        }

        try {
            $this->prepare($server);
        } catch (\Throwable $e) {
            report($e);
            $this->setState($server, self::STATE_FAILED, self::FAILED_CONNECTION);
        }
    }

    public function failed(\Throwable $exception): void
    {
        $this->setState($this->server, self::STATE_FAILED);
    }

    private function prepare(Server $server): void
    {
        $commands = new ScannerCommands($server, Str::lower(Str::random(24)));

        $this->setState($server, self::STATE_PULLING);
        $lock = ScanImage::serverLock($server);
        if (! ScanImage::acquireServerLock($lock, $server, 0)) {
            if ($this->attempts() >= $this->tries) {
                $this->setState($server, self::STATE_FAILED);

                return;
            }
            $this->release(ScanResourceImagesJob::lockRetryDelay($this->attempts()));

            return;
        }

        try {
            $this->prepareLocked($server, $commands);
        } finally {
            $lock->release();
        }
    }

    /**
     * Runs under the server lock, so a scan or a deletion of the scanner data cannot run at the same time.
     */
    private function prepareLocked(Server $server, ScannerCommands $commands): void
    {
        // The scanner data can be deleted and scanning turned off before this job got the lock.
        if (! $this->isStillEnabled($server)) {
            return;
        }

        $pulled = self::run($commands->prepare(), $server, self::TIMEOUT_PREPARE);
        if ($pulled['exit_codes'] === []) {
            $this->setState($server, self::STATE_FAILED, self::FAILED_CONNECTION);

            return;
        }
        foreach (['pull-syft', 'pull-grype', 'pull-regctl', 'pull-init'] as $step) {
            if (($pulled['exit_codes'][$step] ?? null) !== 0) {
                $this->setState($server, self::STATE_FAILED, self::FAILED_PULLING);

                return;
            }
        }
        if (($pulled['exit_codes']['db-volume'] ?? null) !== 0) {
            $this->setState($server, self::STATE_FAILED, self::FAILED_SETUP);

            return;
        }

        if (! $this->isStillEnabled($server)) {
            return;
        }

        $this->setState($server, self::STATE_UPDATING_DATABASE);
        $updated = self::run($commands->dbUpdate(), $server, self::TIMEOUT_DB_UPDATE);
        if (($updated['exit_codes']['db-update'] ?? null) !== 0) {
            $this->setState($server, self::STATE_FAILED);

            return;
        }

        if (! $this->isStillEnabled($server)) {
            return;
        }

        self::storeDetails($server, $commands);
        $this->setState($server, self::STATE_READY);
    }

    /**
     * @param  list<string>  $commands
     * @return array{exit_codes: array<string, int>, sizes: array<string, int>, payload: string}
     */
    private static function run(array $commands, Server $server, int $timeout): array
    {
        return ScannerCommands::parseOutput(instant_remote_process($commands, $server, throwError: false, timeout: $timeout));
    }

    /**
     * Caches the database build date and the disk use of the scanner data that the server page shows. A valid
     * database and all scanner images on the server prove that the scanner is ready, so this also marks it as
     * ready, unless a preparation is running and sets the state itself.
     */
    public static function storeDetails(Server $server, ScannerCommands $commands): void
    {
        Cache::forget(self::builtAtCacheKey($server));
        Cache::forget(self::diskUsedCacheKey($server));

        try {
            $status = self::run($commands->dbStatus(), $server, self::TIMEOUT_DB_STATUS);
            $report = ($status['exit_codes']['db-status'] ?? null) === 0 ? json_decode($status['payload'], true) : null;
            $built = is_array($report) && ($report['valid'] ?? false) === true ? ($report['built'] ?? null) : null;
            if (is_string($built)) {
                Cache::put(self::builtAtCacheKey($server), CarbonImmutable::parse($built)->toIso8601ZuluString(), now()->addDays(7));
                if (! in_array(Cache::get(self::stateCacheKey($server)), [self::STATE_PULLING, self::STATE_UPDATING_DATABASE], true)
                    && (self::run($commands->scannerImages(), $server, self::TIMEOUT_DB_STATUS)['exit_codes']['scanner-images'] ?? null) === 0) {
                    Cache::forget(self::failedStepCacheKey($server));
                    Cache::put(self::stateCacheKey($server), self::STATE_READY, now()->addDays(7));
                    ImageScanUpdated::dispatch($server->team_id, $server->uuid, self::SCAN_UUID);
                }
            }
        } catch (\Throwable $e) {
            report($e);
        }

        try {
            $usage = self::run($commands->diskUsage(), $server, self::TIMEOUT_DB_STATUS);
            if (isset($usage['sizes']['disk-db']) || isset($usage['sizes']['disk-sboms'])) {
                Cache::put(self::diskUsedCacheKey($server), ($usage['sizes']['disk-db'] ?? 0) + ($usage['sizes']['disk-sboms'] ?? 0), now()->addDays(7));
            }
        } catch (\Throwable $e) {
            report($e);
        }
    }

    private function isStillEnabled(Server $server): bool
    {
        $enabled = (bool) $server->settings()->value('is_image_scan_enabled');
        if (! $enabled) {
            self::forget($server);
        }

        return $enabled;
    }

    /**
     * @param  string|null  $failedStep  one of the FAILED_* steps; without it a failure is put on the running step
     */
    private function setState(Server $server, string $state, ?string $failedStep = null): void
    {
        $running = in_array($state, [self::STATE_PULLING, self::STATE_UPDATING_DATABASE], true);
        $previous = Cache::get(self::stateCacheKey($server));
        $failedStep ??= in_array($previous, [self::STATE_PULLING, self::STATE_UPDATING_DATABASE], true) ? $previous : null;
        if ($state === self::STATE_FAILED && $failedStep !== null) {
            Cache::put(self::failedStepCacheKey($server), $failedStep, now()->addDays(7));
        } elseif ($running) {
            Cache::forget(self::failedStepCacheKey($server));
        }
        Cache::put(self::stateCacheKey($server), $state, $running ? now()->addHour() : now()->addDays(7));
        ImageScanUpdated::dispatch($server->team_id, $server->uuid, self::SCAN_UUID);
    }
}