digest !== null || ! ImageRef::isValidDigest($digest)) { throw new InvalidArgumentException('A pin needs an image tag and a digest.'); } $pins = self::peek($owner); $pins[self::normalize($ref)] = ['ref' => $ref, 'pull' => $image->qualifiedName().'@'.$digest]; Cache::put(self::key($owner), $pins, now()->addHours(self::TTL_HOURS)); } /** * The pins of the owner, which the start uses up. With a server, each server gets the same pins once until they * expire: an app that deploys to more servers runs one deploy per server, and each pulls the scanned build. * * @return array Keyed by the normalized image ref. */ public static function take(Model $owner, int|string|null $server = null): array { if ($server === null) { $pins = Cache::pull(self::key($owner)); return is_array($pins) ? $pins : []; } $pins = self::peek($owner); if ($pins === []) { return []; } // A new pin of the owner changes the set, so every server takes it again. $taken = self::key($owner).':taken:'.$server.':'.md5(serialize($pins)); return Cache::add($taken, true, now()->addHours(self::TTL_HOURS)) ? $pins : []; } public static function forget(Model $owner): void { Cache::forget(self::key($owner)); } /** * Only the pins of the given images (as a start names them), for a start that runs some of them. * * @param array $pins * @param iterable $images * @return array */ public static function for(array $pins, iterable $images): array { $names = []; foreach ($images as $image) { $names[self::normalize((string) $image)] = true; } return array_intersect_key($pins, $names); } /** * Pulls the exact build and gives it the configured tag. When the build cannot be pulled (a registry error, or the * publisher removed it), the start pulls the tag as usual and the scan after it checks the result. * `$prefix` is the Docker config prefix of the server. * * @param array $pins * @return list */ public static function commands(array $pins, string $prefix = ''): array { $commands = []; foreach ($pins as $pin) { $commands[] = 'echo '.escapeshellarg("Pulling the scanned build of {$pin['ref']}."); $commands[] = "{$prefix}docker pull ".escapeshellarg($pin['pull'])." && {$prefix}docker tag ".escapeshellarg($pin['pull']).' '.escapeshellarg($pin['ref'])." || {$prefix}docker pull ".escapeshellarg($pin['ref']); } return $commands; } /** * `mysql:8`, `docker.io/library/mysql:8`, and `index.docker.io/mysql:8` are the same image. */ private static function normalize(string $ref): string { try { $image = ImageRef::parse(trim($ref)); } catch (InvalidArgumentException) { return trim($ref); } return $image->qualifiedName().':'.($image->tag ?? 'latest'); } /** * @return array */ private static function peek(Model $owner): array { $pins = Cache::get(self::key($owner)); return is_array($pins) ? $pins : []; } private static function key(Model $owner): string { return 'image-scan:start-pin:'.$owner->getMorphClass().':'.$owner->getKey(); } }