$imageRefs Empty: the images of the resource (imageRefsFor()). * @param bool $retryWhenImageMissing A job after a service or database start retries an image that is not pulled yet. * @param ?string $baseImageRef The base image of the Dockerfile build of the application. * @param bool $pullWhenMissing "Scan now": pull an image that is not on the server. */ public function __construct(public Model $resource, public ?int $deploymentQueueId = null, public array $imageRefs = [], public bool $retryWhenImageMissing = false, public ?string $baseImageRef = null, public bool $pullWhenMissing = false) { // Self-hosted Horizon works through 'high' first, so scans there would delay deployments. $this->onQueue(isCloud() ? maintenance_queue() : 'default'); $images = $imageRefs === [] ? $this->derivedImageCount() : count($imageRefs); $this->timeout = $images * ScanImage::LOCK_TTL; } /** * Queues the scan of a service or standalone database that was started. Never blocks or fails the start. */ public static function dispatchAfterStart(Model $resource): void { try { if (! ImageScanPolicyResolver::isScanOn($resource)) { return; } self::dispatch($resource, null, [], true)->delay(now()->addSeconds(self::START_DELAY_SECONDS)); } catch (Throwable $e) { report($e); } } /** * Shows a scan that "Scan now" queued as running at once: marks each scanned owner as queued and hides its last * error. The scan replaces the mark with its progress; the job removes marks that are left when it ends. * * @param list $imageRefs */ public static function markQueued(Model $resource, array $imageRefs = []): void { foreach (self::markedOwners($resource, $imageRefs) as $owner) { Cache::put(ScanImage::progressCacheKey($owner), [ 'run_id' => self::QUEUED_STAGE, 'stage' => self::QUEUED_STAGE, 'started_at' => now()->toIso8601String(), ], ScanImage::LOCK_TTL); Cache::forget(ScanImage::lastErrorCacheKey($owner)); } } public function handle(): void { $deployment = $this->deployment(); $server = $this->server($deployment); if (! $server instanceof Server) { $this->clearQueuedMarks(); return; } $done = $this->doneTargets(); $missing = []; $lastAttempt = $this->attempts() >= $this->tries; foreach ($this->scanTargets() as [$resource, $imageRef]) { $key = $this->targetKey($resource, $imageRef); if (in_array($key, $done, true)) { continue; } $baseImageRef = $resource === $this->resource && $resource instanceof Application ? $this->baseImageRef : null; // Never sleeps on the server lock: a deploy gate may need it. A busy lock retries the job later. $scan = ScanImage::run($server, $resource, $imageRef, $deployment, 0, $baseImageRef, retryWhenBusy: ! $lastAttempt, pullWhenMissing: $this->pullWhenMissing); if (! $lastAttempt && ScanImage::lockWasBusy($scan)) { $this->rememberDoneTargets($done); $this->release(self::lockRetryDelay($this->attempts())); return; } if ($this->shouldRetry($scan)) { // Only the last attempt leaves the error visible. Cache::forget(ScanImage::lastErrorCacheKey($resource)); $missing[] = $key; } else { $done[] = $key; } } if ($missing !== []) { $this->rememberDoneTargets($done); $this->release(self::START_RETRY_DELAY_SECONDS); return; } $this->clearQueuedMarks(); } public function failed(?Throwable $exception = null): void { $this->clearQueuedMarks(); } /** * The owners that markQueued() marks: the application, or the scanned applications and databases of a service. * * @param list $imageRefs * @return list */ private static function markedOwners(Model $resource, array $imageRefs): array { if ($resource instanceof Application) { return $imageRefs === [] ? [] : [$resource]; } return array_map(fn (array $target): Model => $target[0], ResourceScans::scanTargets($resource)); } /** * Removes the queued marks that no scan replaced (for example a scan that could not start), and tells open pages. */ private function clearQueuedMarks(): void { foreach (self::markedOwners($this->resource, $this->imageRefs) as $owner) { $key = ScanImage::progressCacheKey($owner); if (data_get(Cache::get($key), 'stage') !== self::QUEUED_STAGE) { continue; } Cache::forget($key); $teamId = method_exists($owner, 'team') ? $owner->team()?->id : null; ImageScanUpdated::dispatch($teamId, (string) $owner->getAttribute('uuid'), self::QUEUED_STAGE, true); } } /** * The wait before attempt $attempt + 1 after a busy lock: 60, 120, then 300 seconds. */ public static function lockRetryDelay(int $attempt): int { return self::LOCK_RETRY_DELAYS[min(max($attempt, 1), count(self::LOCK_RETRY_DELAYS)) - 1]; } private function shouldRetry(ImageScan $scan): bool { return $this->retryWhenImageMissing && $this->attempts() < min(self::START_MAX_ATTEMPTS, $this->tries) && $scan->status === ImageScanStatus::Failed && $scan->error_code === ImageScanErrorCode::ImageMissing; } private function targetKey(Model $resource, string $imageRef): string { return $resource->getMorphClass().':'.$resource->getKey().':'.$imageRef; } /** * A released job runs again from its stored payload, so the targets of earlier attempts are kept in the cache. * * @return list */ private function doneTargets(): array { $uuid = $this->job?->uuid(); return $uuid === null ? [] : (array) Cache::get("image-scan:start-done:{$uuid}", []); } /** * @param list $done */ private function rememberDoneTargets(array $done): void { $uuid = $this->job?->uuid(); if ($uuid !== null) { Cache::put("image-scan:start-done:{$uuid}", $done, now()->addHours(2)); } } /** * The images a resource runs. Applications: the images of their latest completed scans. Services: the images of * their applications and databases. Standalone databases: their image. * * @return list */ public function imageRefsFor(Model $resource): array { if ($resource instanceof Application) { return ImageScan::query() ->where('resource_type', $resource->getMorphClass()) ->where('resource_id', $resource->getKey()) ->where('status', ImageScanStatus::Completed) ->orderByDesc('scanned_at') ->orderByDesc('id') ->pluck('image_ref') ->unique() ->take(self::MAX_DERIVED_IMAGES) ->values() ->all(); } return array_map(fn (array $target): string => $target[1], $this->scanTargetsFor($resource)); } /** * Each image with the resource its result belongs to. A service keeps results per application and database. * * @return list */ private function scanTargets(): array { if ($this->imageRefs !== []) { $targets = array_map(fn ($imageRef): array => [$this->resource, $imageRef], $this->imageRefs); } elseif ($this->resource instanceof Application) { $targets = array_map(fn (string $imageRef): array => [$this->resource, $imageRef], $this->imageRefsFor($this->resource)); } else { $targets = $this->scanTargetsFor($this->resource); } $seen = []; return array_values(array_filter($targets, function (array $target) use (&$seen): bool { [$resource, $imageRef] = $target; if (! is_string($imageRef)) { return false; } $key = $this->targetKey($resource, $imageRef); if (isset($seen[$key])) { return false; } $seen[$key] = true; return true; })); } /** * @return list */ private function scanTargetsFor(Model $resource): array { return ResourceScans::scanTargets($resource); } private function derivedImageCount(): int { if ($this->resource instanceof Service) { return max(self::MAX_DERIVED_IMAGES, $this->resource->applications()->count() + $this->resource->databases()->count()); } return self::MAX_DERIVED_IMAGES; } private function deployment(): ?ApplicationDeploymentQueue { if ($this->deploymentQueueId === null || ! $this->resource instanceof Application) { return null; } return ApplicationDeploymentQueue::query() ->whereKey($this->deploymentQueueId) ->where('application_id', (string) $this->resource->getKey()) ->first(); } /** * The server of the deployment (an additional server runs its own copy of the image), else the server the * scan policy of the resource uses. */ private function server(?ApplicationDeploymentQueue $deployment): ?Server { $server = $deployment?->server ?? ImageScanPolicyResolver::mainServerOf($this->resource); return $server instanceof Server ? $server : null; } }