fix(image-scan): ship the scanner templates and show the scan error log

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-11-09 14:44:55 +01:00
co-authored by Claude Opus 5.5
parent ab4964ea85
commit 957c01fcc8
10 changed files with 118 additions and 33 deletions
@@ -0,0 +1,4 @@
---
type: fixed
---
Failed image scans on self-hosted instances, because the scanner templates were missing from the Coolify image
+6
View File
@@ -0,0 +1,6 @@
---
type: added
---
The reason of a failed image scan in a `Show error log` section on the `Security` page and in the Laravel log
- The error log shows the failed step, its exit code, and the end of the command output. Only users who can change the resource or server see it
+5 -4
View File
@@ -55,14 +55,14 @@ class ScanHostPackages
$packages = ScanImage::runStep($server, $commands->hostPackagesSize(), 'packages-size', self::TIMEOUT_DEFAULT);
if ($packages['code'] !== 0 || $packages['size'] === null) {
throw new ScanFailed(ImageScanErrorCode::Unknown);
throw new ScanFailed(ImageScanErrorCode::Unknown, detail: "step packages-size exited with code {$packages['code']}");
}
ScanImage::checkDiskSpace($commands, $packages['size'] * 1024);
$sbom = ScanImage::runStep($server, $commands->sbomFromHost(), 'sbom', self::TIMEOUT_SBOM);
$copy = $sbom['exit_codes']['copy'] ?? 2;
if ($copy !== 0) {
throw new ScanFailed($copy === 1 ? ImageScanErrorCode::NoPackageDatabase : ImageScanErrorCode::Unknown);
throw new ScanFailed($copy === 1 ? ImageScanErrorCode::NoPackageDatabase : ImageScanErrorCode::Unknown, detail: "step copy exited with code {$copy}");
}
ScanImage::assertSucceeded($sbom, 'sbom');
@@ -72,10 +72,11 @@ class ScanHostPackages
$runningKernel = trim((string) instant_remote_process(['uname -r'], $server, throwError: false, timeout: self::TIMEOUT_DEFAULT));
$this->complete($server, $grype, $databaseBuiltAt, $runningKernel);
} catch (ScanFailed $e) {
self::recordFailure($server, $e->errorCode, $e->context);
$e->log($server, 'the host packages');
self::recordFailure($server, $e->errorCode, $e->errorContext());
} catch (Throwable $e) {
report($e);
self::recordFailure($server, ImageScanErrorCode::Unknown);
self::recordFailure($server, ImageScanErrorCode::Unknown, (new ScanFailed(ImageScanErrorCode::Unknown, detail: $e::class.': '.$e->getMessage()))->errorContext());
} finally {
instant_remote_process($commands->cleanup(), $server, throwError: false, timeout: self::TIMEOUT_DEFAULT);
$lock->release();
+33 -19
View File
@@ -122,8 +122,11 @@ class ScanImage
try {
ImageRef::parse($imageRef);
} catch (InvalidArgumentException) {
return $this->failBeforeRow($server, $resource, $imageRef, null, ImageScanErrorCode::Unknown, $context);
} catch (InvalidArgumentException $e) {
$failure = new ScanFailed(ImageScanErrorCode::Unknown, detail: "invalid image reference: {$e->getMessage()}");
$failure->log($server, $imageRef);
return $this->failBeforeRow($server, $resource, $imageRef, null, ImageScanErrorCode::Unknown, $failure->errorContext($context));
}
$lock = self::serverLock($server);
@@ -202,7 +205,10 @@ class ScanImage
}
if (preg_match('/\Asha256:[a-f0-9]{64}\z/', $imageId) !== 1) {
return array_map(fn (array $target) => $this->failBeforeRow($server, $target[0], $target[1], null, ImageScanErrorCode::Unknown, $context), $targets);
$failure = new ScanFailed(ImageScanErrorCode::Unknown, detail: 'invalid image ID');
$failure->log($server, $imageId);
return array_map(fn (array $target) => $this->failBeforeRow($server, $target[0], $target[1], null, ImageScanErrorCode::Unknown, $failure->errorContext($context)), $targets);
}
$lock = self::serverLock($server);
@@ -326,13 +332,16 @@ class ScanImage
return $this->complete($server, $resource, $imageRef, $deployment, $policy, $sbom, $this->report($grype), $image, $databaseBuiltAt, $isBaseline, $baseImageRef);
} catch (ScanFailed $e) {
$errorContext = [...$context, ...$e->context];
$e->log($server, $imageRef);
$errorContext = $e->errorContext($context);
return $image === null
? $this->failBeforeRow($server, $resource, $imageRef, null, $e->errorCode, $errorContext)
: $this->failAfterInspect($server, $resource, $imageRef, $deployment, $image, $e->errorCode, $errorContext);
} catch (Throwable $e) {
$this->rememberError($resource, ImageScanErrorCode::Unknown, $context);
$failure = new ScanFailed(ImageScanErrorCode::Unknown, detail: $e::class.': '.$e->getMessage());
$failure->log($server, $imageRef);
$this->rememberError($resource, ImageScanErrorCode::Unknown, $failure->errorContext($context));
throw $e;
} finally {
@@ -374,14 +383,17 @@ class ScanImage
array_keys($targets),
);
} catch (ScanFailed $e) {
$errorContext = [...$context, ...$e->context];
$e->log($server, $imageId);
$errorContext = $e->errorContext($context);
return array_map(fn (array $target) => $image === null
? $this->failBeforeRow($server, $target[0], $target[1], null, $e->errorCode, $errorContext)
: $this->failAfterInspect($server, $target[0], $target[1], null, $image, $e->errorCode, $errorContext), $targets);
} catch (Throwable $e) {
$failure = new ScanFailed(ImageScanErrorCode::Unknown, detail: $e::class.': '.$e->getMessage());
$failure->log($server, $imageId);
foreach ($resources as $resource) {
$this->rememberError($resource, ImageScanErrorCode::Unknown, $context);
$this->rememberError($resource, ImageScanErrorCode::Unknown, $failure->errorContext($context));
}
throw $e;
@@ -432,7 +444,7 @@ class ScanImage
if ($builtAt === null) {
self::updateDatabase($commands);
$builtAt = self::databaseStatus($commands) ?? throw new ScanFailed(ImageScanErrorCode::DbMissing);
$builtAt = self::databaseStatus($commands) ?? throw new ScanFailed(ImageScanErrorCode::DbMissing, detail: 'no valid vulnerability database after the update');
}
if ($builtAt->lt(now()->subDays(self::DB_MAX_AGE_DAYS))) {
@@ -457,7 +469,7 @@ class ScanImage
{
$result = self::runStep($commands->server, $commands->dbStatus(), 'db-status', self::TIMEOUT_DEFAULT);
if (in_array($result['code'], self::EXIT_TIMEOUT, true)) {
throw new ScanFailed(ImageScanErrorCode::Timeout);
throw new ScanFailed(ImageScanErrorCode::Timeout, detail: "step db-status exited with code {$result['code']}");
}
$status = $result['code'] === 0 ? json_decode($result['payload'], true) : null;
@@ -489,7 +501,7 @@ class ScanImage
$this->progress($resource, 'pull');
$result = self::runStep($commands->server, $commands->pull($imageRef), 'pull', self::TIMEOUT_PULL);
if ($result['code'] !== 0) {
throw new ScanFailed(in_array($result['code'], self::EXIT_TIMEOUT, true) ? ImageScanErrorCode::Timeout : ImageScanErrorCode::PullFailed);
throw new ScanFailed(in_array($result['code'], self::EXIT_TIMEOUT, true) ? ImageScanErrorCode::Timeout : ImageScanErrorCode::PullFailed, detail: "step pull exited with code {$result['code']}");
}
$this->progress($resource, 'inspect');
@@ -503,7 +515,7 @@ class ScanImage
{
$result = self::runStep($commands->server, $commands->inspect($imageRef), 'inspect', self::TIMEOUT_DEFAULT);
if ($result['code'] !== 0) {
throw new ScanFailed(ImageScanErrorCode::ImageMissing);
throw new ScanFailed(ImageScanErrorCode::ImageMissing, detail: "step inspect exited with code {$result['code']}");
}
$parts = explode('|', trim($result['payload']));
@@ -511,7 +523,7 @@ class ScanImage
|| preg_match('/\Asha256:([a-f0-9]{64})\z/', $parts[0], $id) !== 1
|| preg_match('#\A[a-z0-9]+/[a-z0-9_]+\z#', $parts[2]) !== 1
|| preg_match('/\A\d{1,18}\z/', $parts[3]) !== 1) {
throw new ScanFailed(ImageScanErrorCode::Unknown);
throw new ScanFailed(ImageScanErrorCode::Unknown, detail: 'unexpected docker image inspect output: '.ScanFailed::outputTail($result['payload']));
}
$repoDigest = Str::afterLast($parts[1], '@');
@@ -536,12 +548,12 @@ class ScanImage
{
$output = instant_remote_process($commands->diskFree(), $commands->server, throwError: false, timeout: self::TIMEOUT_DEFAULT);
if (preg_match('/(\d+) +(\d+)\s*\z/', (string) $output, $free) !== 1) {
throw new ScanFailed(ImageScanErrorCode::Unknown);
throw new ScanFailed(ImageScanErrorCode::Unknown, detail: 'could not read the free disk space: '.ScanFailed::outputTail($output));
}
$needed = $imageSize * 2 + self::DISK_RESERVE_BYTES;
if ((int) $free[1] < $needed || (int) $free[2] < $needed) {
throw new ScanFailed(ImageScanErrorCode::DiskSpace, ['n' => (int) ceil($needed / self::DISK_RESERVE_BYTES)]);
throw new ScanFailed(ImageScanErrorCode::DiskSpace, ['n' => (int) ceil($needed / self::DISK_RESERVE_BYTES)], "needed {$needed} bytes, free {$free[1]} and {$free[2]} bytes");
}
}
@@ -712,7 +724,7 @@ class ScanImage
*/
private function upsert(Model $resource, string $imageId, array $values): ImageScan
{
$scan = $this->firstOrCreateScan($resource, $imageId, $values) ?? throw new ScanFailed(ImageScanErrorCode::Unknown);
$scan = $this->firstOrCreateScan($resource, $imageId, $values) ?? throw new ScanFailed(ImageScanErrorCode::Unknown, detail: "could not save the scan row of image {$imageId}");
if (! $scan->wasRecentlyCreated) {
$scan->forceFill($values)->save();
@@ -920,7 +932,7 @@ class ScanImage
$parsed = ScannerCommands::parseOutput($output);
if ($output === null || trim($output) === '' || ! array_key_exists($step, $parsed['exit_codes'])) {
throw new ScanFailed(ImageScanErrorCode::Unknown);
throw new ScanFailed(ImageScanErrorCode::Unknown, detail: "step {$step} did not report an exit code (the command did not run or the SSH connection failed), output: ".ScanFailed::outputTail($output));
}
return [
@@ -942,13 +954,15 @@ class ScanImage
{
$code = $result['exit_codes'][$step] ?? null;
if ($code !== 0 && $step !== 'save' && in_array($step, $result['oom'], true)) {
throw new ScanFailed(ImageScanErrorCode::OutOfMemory);
throw new ScanFailed(ImageScanErrorCode::OutOfMemory, detail: "step {$step} was killed because it ran out of memory");
}
if (in_array($code, self::EXIT_TIMEOUT, true)) {
throw new ScanFailed(ImageScanErrorCode::Timeout);
throw new ScanFailed(ImageScanErrorCode::Timeout, detail: "step {$step} exited with code {$code} (timeout)");
}
if ($code !== 0) {
throw new ScanFailed(ImageScanErrorCode::Unknown);
$codes = json_encode($result['exit_codes']);
throw new ScanFailed(ImageScanErrorCode::Unknown, detail: 'step '.$step.' exited with code '.($code ?? 'none').", all exit codes: {$codes}");
}
}
+8 -6
View File
@@ -722,23 +722,25 @@ class Vulnerabilities extends Component
}
/**
* The last scan errors of the owners, as their fixed messages.
* The last scan errors of the owners, as their fixed messages, with the error log for users who can update the resource.
*
* @param Collection<int, Model> $owners
* @return list<string>
* @return list<array{message: string, detail: ?string}>
*/
private function errorMessages(Collection $owners): array
private function errorMessages(Collection $owners, bool $showDetail): array
{
$messages = [];
foreach ($owners as $owner) {
$error = Cache::get(ScanImage::lastErrorCacheKey($owner));
$code = is_array($error) ? ImageScanErrorCode::tryFrom((string) ($error['code'] ?? '')) : null;
if ($code !== null) {
$messages[] = $code->message((array) ($error['context'] ?? []));
$context = (array) ($error['context'] ?? []);
$detail = $showDetail && is_string($context['detail'] ?? null) ? $context['detail'] : null;
$messages[$code->message($context)."\n".$detail] = ['message' => $code->message($context), 'detail' => $detail];
}
}
return array_values(array_unique($messages));
return array_values($messages);
}
/**
@@ -1003,7 +1005,7 @@ class Vulnerabilities extends Component
'scans' => $scans,
'progress' => $progress,
'checkpoints' => $this->checkpoints($progress),
'errorMessages' => $this->errorMessages($owners),
'errorMessages' => $this->errorMessages($owners, $canUpdate),
'card' => $security === null ? null : SecurityCard::for($resource, $security, $owners, $scans, $user, $imageStatuses, fn (Model $owner): RelevanceRules => $this->rulesFor($owner)),
'isApplication' => $resource instanceof Application,
'checkedAt' => $checkedAt === null ? null : Carbon::instance($checkedAt),
+42 -2
View File
@@ -3,18 +3,58 @@
namespace App\Services\ImageScan;
use App\Enums\ImageScanErrorCode;
use App\Models\Server;
use Illuminate\Support\Facades\Log;
use RuntimeException;
/**
* Stops a scan with an error code. ScanImage catches it and stores the code on the scan row.
* The detail is for the log only: users see the fixed message of the error code.
*/
final class ScanFailed extends RuntimeException
{
private const DETAIL_LIMIT = 1000;
/**
* @param array<string, int|string> $context
*/
public function __construct(public readonly ImageScanErrorCode $errorCode, public readonly array $context = [])
public function __construct(public readonly ImageScanErrorCode $errorCode, public readonly array $context = [], public readonly ?string $detail = null)
{
parent::__construct($errorCode->value);
parent::__construct($detail === null ? $errorCode->value : $errorCode->value.': '.mb_substr($detail, 0, self::DETAIL_LIMIT));
}
/**
* Writes the failure with its detail to the Laravel log, so an admin can find why a scan could not run.
*/
public function log(Server $server, string $target): void
{
Log::warning("Image scan of {$target} on server {$server->name} failed: {$this->getMessage()}", [
'server_uuid' => $server->uuid,
'error_code' => $this->errorCode->value,
'context' => $this->context,
]);
}
/**
* The values of the error message, plus the detail for the error log on the UI (`detail`; the message ignores it).
*
* @param array<string, int|string> $context
* @return array<string, int|string>
*/
public function errorContext(array $context = []): array
{
$context = [...$context, ...$this->context];
return $this->detail === null ? $context : [...$context, 'detail' => mb_substr($this->detail, 0, self::DETAIL_LIMIT)];
}
/**
* The last part of the raw command output, for the detail.
*/
public static function outputTail(?string $output): string
{
$output = trim((string) $output);
return $output === '' ? '(no output)' : mb_substr($output, -self::DETAIL_LIMIT / 2);
}
}
+1
View File
@@ -192,6 +192,7 @@ COPY --chown=www-data:www-data routes ./routes
COPY --chown=www-data:www-data storage ./storage
COPY --chown=www-data:www-data templates ./templates
COPY --chown=www-data:www-data resources/views ./resources/views
COPY --chown=www-data:www-data resources/image-scan ./resources/image-scan
COPY --chown=www-data:www-data artisan artisan
COPY --chown=www-data:www-data openapi.yaml ./openapi.yaml
COPY --chown=www-data:www-data changelogs/ ./changelogs/
@@ -0,0 +1,11 @@
@props(['detail' => null])
@if (filled($detail))
<details class="group/error-log mt-2">
<summary class="inline-flex cursor-pointer list-none items-center gap-1.5 rounded-md text-[12px] font-medium text-red-800 hover:text-red-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-coollabs dark:text-red-300 dark:hover:text-red-200 dark:focus-visible:outline-brand [&::-webkit-details-marker]:hidden">
<x-reicon name="chevron-down" class="size-3.5 shrink-0 -rotate-90 group-open/error-log:rotate-0" />
Show error log
</summary>
<pre class="mt-2 max-h-60 overflow-auto whitespace-pre-wrap wrap-anywhere rounded-lg bg-white p-3 font-mono text-xs leading-5 text-neutral-700 ring-1 ring-neutral-200 dark:bg-neutral-950 dark:text-neutral-300 dark:ring-white/[0.08]">{{ $detail }}</pre>
</details>
@endif
@@ -51,8 +51,11 @@
</x-slot:actions>
<div class="flex flex-col gap-4">
@foreach ($errorMessages as $message)
<x-callout type="danger" title="The last scan could not run">{{ $message }}</x-callout>
@foreach ($errorMessages as $error)
<x-callout type="danger" title="The last scan could not run">
{{ $error['message'] }}
<x-image-scan.error-log :detail="$error['detail']" />
</x-callout>
@endforeach
@if ($isScanning)
@@ -148,6 +148,9 @@
@if ($hostScanError)
<x-callout type="danger" title="The last scan failed">
{{ $hostScanError }}
@can('update', $server)
<x-image-scan.error-log :detail="data_get($hostScan->error_context, 'detail')" />
@endcan
</x-callout>
@endif
@if ($hostScan?->eol)