, warning: string} * @phpstan-type ActionData array{key: string, type: string, sentence: string, copy: ?string, button: ?ButtonData} * @phpstan-type WhyData array{title: string, display_id: string, link: ?string, score: string, cvss: ?string, cvssNote: ?string, severity: string, signal: ?string, exploited: bool, package: string, version: string} * @phpstan-type ImageRow array{key: string, name: ?string, ref: string, status: string, checking: bool, badge: ?string, badgeType: string, open: bool, sentence: string, attackNote: ?string, advice: ?string, lines: list, primary: ?ActionData, mergedWith: ?string, why: list, whyMore: int, provenance: ?string, others: list, moreActions: int, versionNotes: list, filter: ?string, override: ?array{service: string, from: string, to: string}} */ final class SecurityCard { public const OTHER_ACTIONS_LIMIT = 5; /** Image rows, worst first (the order of the team page). */ private const ROW_ORDER = ['update_now', 'update_available', 'no_fix', 'check_needed', 'ok', 'not_checked']; private const PROBLEM_STATUSES = ['update_now', 'update_available', 'no_fix', 'check_needed']; private const DATABASE_NAMES = [ 'postgresql' => 'PostgreSQL', 'redis' => 'Redis', 'mongodb' => 'MongoDB', 'mysql' => 'MySQL', 'mariadb' => 'MariaDB', 'keydb' => 'KeyDB', 'dragonfly' => 'Dragonfly', 'clickhouse' => 'ClickHouse', ]; /** * @param Collection $owners The applications and databases of a service, else the resource. * @param Collection $scans The current scans of the owners, with their `resource` set. * @param array $imageStatuses `SecurityStatus::forScan()` of the scans the caller computed, by scan UUID. * @param (Closure(Model): RelevanceRules)|null $rulesFor The relevance rules of an owner, when the caller keeps them. * @return array{status: string, checking: bool, badge: ?string, badgeType: string, header: ?string, total: int, single: bool, rows: list} */ public static function for(Model $resource, ResourceSecurity $security, Collection $owners, Collection $scans, User $user, array $imageStatuses = [], ?Closure $rulesFor = null): array { $status = $security->status; $rows = self::images($resource, $security, $owners, $scans, $user, $imageStatuses, $rulesFor); $total = count($rows); $needsUpdate = count(array_filter($rows, fn (array $row): bool => in_array($row['status'], ['update_now', 'update_available'], true))); $waiting = array_filter($rows, fn (array $row): bool => $row['status'] === SecurityStatusType::CheckNeeded->value); $checking = $status === SecurityStatusType::CheckNeeded && $waiting !== [] && ! in_array(false, array_column($waiting, 'checking'), true); return [ 'status' => $status->value, 'checking' => $checking, 'badge' => $checking ? SecuritySentences::CHECKING_BADGE : SecuritySentences::badge($status), 'badgeType' => self::badgeType($status), 'header' => $total > 1 ? SecuritySentences::imagesNeedingUpdate($needsUpdate, $total) : null, 'total' => $total, 'single' => $total === 1, 'rows' => $rows, ]; } /** * One row per image the status counts: each current scan (of a service, of the applications and databases Coolify * scans), the scanned owners of a service without a current scan, and the images of a Compose app without a * result in its current deployment (`ResourceScans::uncheckedComposeRefs()`). Worst first; the first row with a problem * is open, and a single image is always open. A service update that merges several images (spec 15.5) has its * button on the first row of the group; the other rows say which row updates them. * * @param Collection $owners * @param Collection $scans * @param array $imageStatuses * @param (Closure(Model): RelevanceRules)|null $rulesFor * @return list */ public static function images(Model $resource, ResourceSecurity $security, Collection $owners, Collection $scans, User $user, array $imageStatuses = [], ?Closure $rulesFor = null): array { $rulesFor ??= fn (Model $owner): RelevanceRules => RelevanceRules::forResource($owner); $isService = $resource instanceof Service; $statusOwners = $isService ? $owners->filter(fn (Model $owner): bool => ResourceScans::isScanTarget($owner))->values() : $owners; $isStatusOwner = fn (?Model $owner): bool => $owner instanceof Model && $statusOwners->contains(fn (Model $candidate): bool => $candidate->is($owner)); $digests = []; foreach ($scans as $scan) { $digests[self::ownerKey($scan->resource)] ??= SecuritySentences::shortDigest($scan->repo_digest ?: $scan->image_id); } $notChecked = new ResourceSecurity(SecurityStatusType::NotChecked, SecurityStatus::NOT_SCANNED); $composeServices = ResourceScans::composeServiceNames($resource); $entries = []; foreach ($scans as $scan) { if ($isService && ! $isStatusOwner($scan->resource)) { continue; } $entries[] = [ 'key' => 'scan-'.$scan->uuid, 'scan' => $scan, 'owner' => $scan->resource, 'ref' => (string) $scan->image_ref, 'name' => $isService ? self::ownerName($scan->resource) : ($composeServices[ScanImage::imageName((string) $scan->image_ref)] ?? null), 'security' => $imageStatuses[(string) $scan->uuid] ?? SecurityStatus::forScan($scan, $rulesFor($scan->resource)), ]; } foreach ($statusOwners as $owner) { if ($isService && ! $scans->contains(fn (ImageScan $scan): bool => $scan->resource instanceof Model && $scan->resource->is($owner))) { $entries[] = ['key' => 'owner-'.$owner->getAttribute('uuid'), 'scan' => null, 'owner' => $owner, 'ref' => trim((string) $owner->getAttribute('image')), 'name' => self::ownerName($owner), 'security' => $notChecked]; } } foreach (ResourceScans::uncheckedComposeRefs($resource, $scans) as $image) { $entries[] = ['key' => 'compose-'.md5($image['name'] ?? $image['ref']), 'scan' => null, 'owner' => $resource, 'ref' => $image['ref'], 'name' => $image['name'], 'security' => $notChecked]; } $rank = array_flip(self::ROW_ORDER); usort($entries, fn (array $a, array $b): int => [$rank[$a['security']->status->value], mb_strtolower((string) $a['name']), $a['ref']] <=> [$rank[$b['security']->status->value], mb_strtolower((string) $b['name']), $b['ref']]); $merged = array_values(array_filter($security->actions, fn (SecurityAction $action): bool => $action->parts !== [])); $mergedStats = []; foreach ($security->actions as $index => $action) { if ($action->parts !== []) { $mergedStats[self::key($action)] = (array) data_get($security->facts, "action_stats.{$index}", []); } } $groupFirst = []; $single = count($entries) === 1; $noun = match (true) { $resource instanceof Application => 'this app', $resource instanceof Service => 'this service', default => 'this database', }; $composeOverrides = $resource instanceof Application && $resource->build_pack === 'dockercompose' ? $resource->activeComposeImageOverrides() : []; $opened = false; $rows = []; foreach ($entries as $entry) { /** @var ResourceSecurity $imageSecurity */ $imageSecurity = $entry['security']; /** @var ?ImageScan $scan */ $scan = $entry['scan']; $owner = $entry['owner']; $status = $imageSecurity->status; $ref = $entry['ref']; $name = $entry['name']; $showsActions = in_array($status, [SecurityStatusType::UpdateNow, SecurityStatusType::UpdateAvailable], true); $actions = []; if ($showsActions) { foreach ($imageSecurity->actions as $index => $action) { $actions[] = self::action($resource, $action, (array) data_get($imageSecurity->facts, "action_stats.{$index}", []), $owners, $user, $digests); } } $primaryAction = $showsActions ? $imageSecurity->primary() : null; $primary = $actions[0] ?? null; $mergedWith = null; $group = $primaryAction === null ? null : self::mergedGroup($merged, $primaryAction, $owner); if ($group !== null) { $groupKey = self::key($group); if (isset($groupFirst[$groupKey])) { $mergedWith = $groupFirst[$groupKey]; $primary = null; } else { $groupFirst[$groupKey] = (string) ($name ?? $ref); $primary = self::action($resource, $group, $mergedStats[$groupKey] ?? [], $owners, $user, $digests); } } // A single image is always open; in a list, the first row with a problem (spec 15.11). $open = $single || (! $opened && in_array($status->value, self::PROBLEM_STATUSES, true)); $opened = $opened || $open; $others = array_slice($actions, 1); // The scan is done and only the queued version check is missing: say that it runs, not "Check needed". $checking = $status === SecurityStatusType::CheckNeeded && $scan !== null && $imageSecurity->checkReason === SecurityStatus::REASON_VERSIONS_UNCHECKED && RefreshCandidates::isQueued($scan); $rows[] = [ 'key' => $entry['key'], 'name' => $name, 'ref' => $ref, 'status' => $status->value, 'checking' => $checking, 'badge' => $checking ? SecuritySentences::CHECKING_BADGE : SecuritySentences::badge($status), 'badgeType' => self::badgeType($status), 'open' => $open, 'sentence' => $checking ? SecuritySentences::versionCheckRunning($ref, $scan) : SecuritySentences::imageSentence($imageSecurity, $ref, $scan), 'attackNote' => SecuritySentences::attackNote($imageSecurity), 'advice' => SecuritySentences::noFixAdvice($imageSecurity, $noun), 'lines' => [...SecuritySentences::lines($imageSecurity), ...self::eolLine($imageSecurity)], 'primary' => $primary, 'mergedWith' => $mergedWith, 'why' => in_array($status, [SecurityStatusType::UpdateNow, SecurityStatusType::UpdateAvailable, SecurityStatusType::NoFix], true) ? array_map(fn (array $why): array => self::whyRow($why), $imageSecurity->why) : [], 'whyMore' => $showsActions ? max(0, $imageSecurity->moreFixed) : 0, 'provenance' => $scan === null || ! in_array($status, [SecurityStatusType::UpdateNow, SecurityStatusType::UpdateAvailable, SecurityStatusType::NoFix], true) ? null : SecuritySentences::provenance($scan, $primaryAction, $imageSecurity, self::provenanceCounts($scan, $primaryAction)), 'others' => array_slice($others, 0, self::OTHER_ACTIONS_LIMIT), 'moreActions' => max(0, count($others) - self::OTHER_ACTIONS_LIMIT), 'versionNotes' => $scan === null ? [] : self::versionCheckNotes(collect([$scan]), $user), 'filter' => $single ? null : $ref, 'override' => $name !== null && isset($composeOverrides[$name]) ? ['service' => $name, ...$composeOverrides[$name]] : null, ]; } return $rows; } private static function badgeType(SecurityStatusType $status): string { return match ($status) { SecurityStatusType::UpdateNow => 'error', SecurityStatusType::UpdateAvailable => 'warning', default => 'neutral', }; } /** * The severity of a CVSS base score: 9.0 critical, 7.0 high, 4.0 medium, 0.1 low. */ public static function cvssSeverity(float $score): string { return match (true) { $score >= 9.0 => 'critical', $score >= 7.0 => 'high', $score >= 4.0 => 'medium', $score > 0.0 => 'low', default => 'unknown', }; } /** * A why row. The score shows next to the severity badge only when its CVSS range has the same severity; else it * is only in the tooltip ("CVSS 7.5"), so a badge never reads "Medium 7.5". * * @param array $why * @return WhyData */ private static function whyRow(array $why): array { $severity = strtolower((string) ($why['severity'] ?? 'unknown')); $cvss = $why['score'] === null ? null : number_format((float) $why['score'], 1); $matches = $cvss !== null && self::cvssSeverity((float) $why['score']) === $severity; return [ 'title' => ScannerText::tidyTitle((string) $why['title']), 'display_id' => (string) $why['display_id'], 'link' => $why['link'], 'score' => $why['score'] === null ? 'No score' : number_format((float) $why['score'], 1).' '.ucfirst((string) $why['severity']), 'cvss' => $matches ? $cvss : null, 'cvssNote' => $cvss === null ? null : 'CVSS '.$cvss, 'severity' => $severity, 'signal' => match ($why['signal']) { SecurityRules::EXPLOITED => 'Used by attackers', SecurityRules::LIKELY => 'Likely to be exploited', default => null, }, 'exploited' => $why['signal'] === SecurityRules::EXPLOITED, 'package' => (string) ($why['package'] ?? ''), 'version' => (string) ($why['version'] ?? ''), ]; } /** * The merged service update (from the status of the resource) that contains this image's update. * * @param list $merged */ private static function mergedGroup(array $merged, SecurityAction $action, Model $owner): ?SecurityAction { if ($action->type !== SecurityActionType::UpdateImage) { return null; } foreach ($merged as $group) { foreach ($group->parts as $part) { if ($part['owner_type'] === $owner->getMorphClass() && (string) $part['owner_id'] === (string) $owner->getKey() && $part['ref'] === $action->ref) { return $group; } } } return null; } /** * The first fixed version of a package upgrade, for the provenance line: the highest fixed version of the findings * of that package the action fixes. * * @return array{fixed_in?: ?string} */ private static function provenanceCounts(ImageScan $scan, ?SecurityAction $action): array { if ($action === null || ! in_array($action->type, [SecurityActionType::UpgradePackage, SecurityActionType::UpgradeInDockerfile], true)) { return []; } $ids = array_flip(array_map('mb_strtolower', $action->fixedIds)); $fixedIn = null; foreach (ResourceScans::findings($scan) as $finding) { if ($finding->package === $action->target && isset($ids[mb_strtolower($finding->id)]) && filled($finding->fixedIn) && ($fixedIn === null || version_compare(ltrim((string) $finding->fixedIn, 'vV'), ltrim($fixedIn, 'vV'), '>'))) { $fixedIn = (string) $finding->fixedIn; } } return ['fixed_in' => $fixedIn]; } private static function ownerKey(?Model $owner): string { return $owner === null ? '' : $owner->getMorphClass().':'.$owner->getKey(); } /** * Lines for version and base checks that only the user can unblock (`SecurityStatus::isBlockedCheck()`): they * count as checked, so the card says why no newer version is offered. A registry login links to the server's * registry logins when the user may update the server. * * @param Collection $scans The current scans of the page. * @return list */ public static function versionCheckNotes(Collection $scans, User $user): array { $notes = []; foreach ($scans as $scan) { $base = data_get($scan->candidates, 'base'); $checks = [false => [$scan->candidateStatus(), data_get($scan->candidates, 'error')]]; if (filled($scan->base_image_ref) && is_array($base) && ($base['image'] ?? null) === $scan->base_image_ref) { $checks[true] = [$base['status'] ?? null, $base['error'] ?? null]; } foreach ($checks as $isBase => [$status, $error]) { if (! SecurityStatus::isBlockedCheck($status, $error)) { continue; } $text = RefreshCandidates::message($scan, (bool) $isBase); if ($text === null || isset($notes[$text])) { continue; } $server = $scan->server; $notes[$text] = [ 'text' => $text, 'link' => $status === 'auth_required' && $server instanceof Server && $user->can('update', $server) ? route('server.registries', ['server_uuid' => $server->uuid]) : null, ]; } } return array_values($notes); } /** * A stable key of an action: its type, the image and candidate, and every part of a merged update. */ public static function key(SecurityAction $action): string { return substr(sha1(json_encode([ $action->type->value, $action->image, $action->ref, $action->target, array_map(fn (array $part): array => [(string) $part['owner_type'], (string) $part['owner_id'], (string) $part['ref']], $action->parts), ]) ?: ''), 0, 16); } /** * The deployment whose image runs now: the latest finished deployment that is not a preview. */ public static function runningDeployment(Application $application): ?ApplicationDeploymentQueue { return ApplicationDeploymentQueue::query() ->where('application_id', $application->id) ->where('pull_request_id', 0) ->where('status', ApplicationDeploymentStatus::FINISHED->value) ->orderByDesc('id') ->first(); } /** * The action the deploy gate popover shows for a blocked deployment: the action stored with the gate result, else * the primary action of the blocked image (of a Compose app, the image whose primary action fixes the most * findings with an exploit signal), computed once per version of its scans. Without a scan of the deployment, the * primary action of the main card. * * @return array{sentence: string, button: bool}|null */ public static function gateAction(Application $application, ApplicationDeploymentQueue $deployment): ?array { $stored = data_get($deployment->scan_gate_result, 'action'); if (is_array($stored) && filled($stored['sentence'] ?? null)) { return ['sentence' => (string) $stored['sentence'], 'button' => (bool) ($stored['button'] ?? false)]; } $scans = fn () => ImageScan::query() ->where('resource_type', $application->getMorphClass()) ->where('resource_id', $application->id) ->where('deployment_queue_id', $deployment->id) ->where('status', ImageScanStatus::Completed); // The version changes with the scan, its version and base checks, and its stored status (ignores). $versions = $scans()->orderBy('id')->get(['id', 'updated_at', 'candidates', 'summary']) ->map(fn (ImageScan $scan): array => [$scan->id, (string) $scan->updated_at, $scan->candidates, $scan->summary]); if ($versions->isEmpty()) { return self::actionSummary(SecurityStatus::stored($application)->primary()); } return Cache::remember( "image-scan:gate-action:{$deployment->id}:".md5((string) json_encode($versions)), now()->addDay(), function () use ($scans, $application): array { $best = null; $bestRank = null; foreach ($scans()->get() as $scan) { $action = self::rankedAction($scan->setRelation('resource', $application)); if ($action !== null && ($bestRank === null || $action['rank'] > $bestRank)) { [$best, $bestRank] = [$action['action'], $action['rank']]; } } return ['action' => self::actionSummary($best)]; }, )['action']; } /** * The primary action of a blocked image, stored with the gate result so the popover does not compute it again. * * @return array{sentence: string, button: bool}|null */ public static function blockedImageAction(ImageScan $scan): ?array { return self::actionSummary(self::rankedAction($scan)['action'] ?? null); } /** * @return array{action: SecurityAction, rank: array{0: int, 1: int, 2: int}}|null */ private static function rankedAction(ImageScan $scan): ?array { $security = SecurityStatus::forScan($scan); $primary = $security->primary(); if ($primary === null) { return null; } $stats = (array) data_get($security->facts, 'action_stats.0', []); return ['action' => $primary, 'rank' => [(int) ($stats['signal'] ?? 0), (int) ($stats['critical'] ?? 0) + (int) ($stats['high'] ?? 0), (int) ($stats['medium'] ?? 0)]]; } /** * @return array{sentence: string, button: bool}|null */ private static function actionSummary(?SecurityAction $action): ?array { return $action === null ? null : ['sentence' => $action->sentence, 'button' => $action->button]; } /** * A Git app can be rebuilt only from a known commit: "HEAD" or no commit would build whatever the branch has now. */ public static function knowsRunningCommit(Application $application, ApplicationDeploymentQueue $deployment): bool { $commit = trim((string) $deployment->commit); return ! $application->git_based() || ($commit !== '' && strtoupper($commit) !== 'HEAD'); } public static function shortCommit(?string $commit): string { $commit = trim((string) $commit); return $commit === '' ? 'HEAD' : mb_substr($commit, 0, 7); } /** * The end-of-life OS, when the sentence does not say it already. * * @return list */ private static function eolLine(ResourceSecurity $security): array { if ($security->eol === null || $security->eol['name'] === '') { return []; } $said = in_array($security->status, [SecurityStatusType::UpdateNow, SecurityStatusType::UpdateAvailable], true) && (int) data_get($security->facts, 'action_stats.0.eol', 0) > 0; if ($said) { return []; } return [trim('This image uses '.ucfirst($security->eol['name']).' '.$security->eol['version']).', which no longer gets security fixes.']; } /** * @param array $stats * @param Collection $owners * @param array $digests The short digest of the running image of each owner (morph type and key). * @return ActionData */ private static function action(Model $resource, SecurityAction $action, array $stats, Collection $owners, User $user, array $digests = []): array { return [ 'key' => self::key($action), 'type' => $action->type->value, 'sentence' => $action->sentence, 'copy' => $action->copy, 'button' => $action->button && (int) $resource->getKey() !== 0 ? self::button($resource, $action, $stats, $owners, $user, $digests) : null, ]; } /** * @param array $stats * @param Collection $owners * @param array $digests * @return ButtonData|null */ private static function button(Model $resource, SecurityAction $action, array $stats, Collection $owners, User $user, array $digests): ?array { if ($action->type === SecurityActionType::Rebuild) { if (! $resource instanceof Application || ! $user->can('deploy', $resource)) { return null; } $deployment = self::runningDeployment($resource); if ($deployment === null || ! self::knowsRunningCommit($resource, $deployment)) { return null; } $commit = self::shortCommit($deployment->commit); return [ 'label' => 'Rebuild', 'title' => 'Rebuild without cache?', 'submit' => 'rebuild', 'actions' => ["Rebuild commit {$commit} without cache.", 'Redeploy the app.'], 'warning' => "Coolify rebuilds commit {$commit} without cache and redeploys it.", ]; } if (! in_array($action->type, [SecurityActionType::UpdateImage, SecurityActionType::PullLatest], true) || ! $user->can('update', $resource)) { return null; } if ($action->parts !== []) { return self::mergedButton($resource, $action, $owners, $digests); } $image = (string) $action->image; $ref = (string) $action->ref; if ($image === '' || $ref === '') { return null; } $owner = $resource instanceof Service ? $owners->first(fn (Model $owner): bool => $owner->getMorphClass() === ($stats['owner_type'] ?? null) && (string) $owner->getKey() === (string) ($stats['owner_id'] ?? '')) : $resource; if (! $owner instanceof Model) { return null; } $ownerUuid = $resource instanceof Service ? (string) $owner->getAttribute('uuid') : ''; $submit = 'updateImage('.self::quote($ref).', '.self::quote($ownerUuid).')'; $restart = self::restartLine($resource, $owners); if ($action->type === SecurityActionType::PullLatest) { return [ 'label' => 'Pull latest and restart', 'title' => "Pull the newest build of {$image}?", 'submit' => $submit, 'actions' => ["Pull the build of {$image} that Coolify scanned.", $restart], 'warning' => "Coolify pulls exactly the build that it scanned, not a build pushed after the check. The settings keep {$image}, so later restarts pull its newest build as usual.", ]; } $tag = (string) $action->target; $changes = $resource instanceof Service ? UpdateResourceImage::subResourcesUsing($resource, $image) ->map(fn (Model $subResource): string => self::ownerName($subResource).': '.self::change($image, $digests[self::ownerKey($subResource)] ?? null, $ref)) ->all() : [self::change($image, $digests[self::ownerKey($resource)] ?? null, $ref)]; return [ 'label' => "Update to {$tag}", 'title' => "Update to {$tag}?", 'submit' => $submit, 'actions' => [...$changes, $restart], 'warning' => self::updateWarning($resource, $image, $ref), ]; } /** * One confirmation for every image of a merged service update. * * @param Collection $owners * @param array $digests * @return ButtonData|null */ private static function mergedButton(Model $resource, SecurityAction $action, Collection $owners, array $digests): ?array { if (! $resource instanceof Service) { return null; } $changes = []; foreach ($action->parts as $part) { $image = (string) $part['image']; $ref = (string) $part['ref']; if ($image === '' || $ref === '') { return null; } foreach (UpdateResourceImage::subResourcesUsing($resource, $image) as $subResource) { $changes[self::ownerKey($subResource)] = self::ownerName($subResource).': '.self::change($image, $digests[self::ownerKey($subResource)] ?? null, $ref); } } if ($changes === []) { return null; } $tag = (string) $action->target; return [ 'label' => "Update to {$tag}", 'title' => "Update to {$tag}?", 'submit' => 'updateImages('.self::quote(self::key($action)).')', 'actions' => [...array_values($changes), self::restartLine($resource, $owners)], 'warning' => self::updateWarning($resource, '', ''), ]; } private static function updateWarning(Model $resource, string $image, string $ref): string { if ($resource instanceof Service) { return 'One-click services pin versions that work together. Check the service template before you update.'; } if ($resource instanceof Application && $resource->build_pack === 'dockercompose') { return 'Coolify redeploys the app with the new image. Your compose file in Git does not change: Coolify keeps using the new image until the file names another one.'; } if ($resource instanceof Application) { return 'Coolify redeploys the app with the new image.'; } $fromTag = self::tag($image); $toTag = self::tag($ref); $name = self::DATABASE_NAMES[array_search($resource::class, STANDALONE_DATABASE_MODELS, true) ?: ''] ?? null; if ($name !== null && $fromTag !== null && $toTag !== null && self::major($fromTag) !== null && self::major($fromTag) === self::major($toTag)) { return SecuritySentences::databaseUpdateConfirmation($name, $fromTag, $toTag); } return 'Check the release notes. Some updates need a data migration.'; } /** * What restarts. A service restarts every container, so the line names its images. * * @param Collection $owners */ private static function restartLine(Model $resource, Collection $owners): string { if ($resource instanceof Service) { $names = $owners->map(fn (Model $owner): string => self::ownerName($owner))->filter()->values()->all(); return $names === [] ? 'Pull the images and restart the service.' : 'Pull the images and restart the service: '.implode(', ', $names).'.'; } return $resource instanceof Application ? 'Redeploy the app.' : 'Restart the database.'; } /** * "postgres:15.0 3f1a2b4c5d6e → postgres:15.13": the running image, its short digest, and the new image. */ private static function change(string $image, ?string $digest, string $ref): string { return trim($image.' '.($digest ?? '')).' → '.$ref; } private static function ownerName(Model $owner): string { return (string) ($owner->getAttribute('human_name') ?: $owner->getAttribute('name')); } private static function tag(string $ref): ?string { try { return ImageRef::parse($ref)->tag; } catch (InvalidArgumentException) { return null; } } private static function major(string $tag): ?string { return preg_match('/^v?(\d+)/i', $tag, $matches) === 1 ? $matches[1] : null; } /** * A single-quoted argument for the confirmation modal's submit action. */ private static function quote(string $value): string { return "'".str_replace(['\\', "'"], ['\\\\', "\\'"], $value)."'"; } }