Files
fotospiel-app/app/Console/Commands/MonitorStorageCommand.php
Codex Agent eeffe4c6f1
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Add checksum validation for archived media
2026-01-30 11:29:40 +01:00

282 lines
9.4 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Console\Concerns\InteractsWithCacheLocks;
use App\Models\EventMediaAsset;
use App\Models\MediaStorageTarget;
use App\Services\Storage\StorageHealthService;
use Illuminate\Console\Command;
use Illuminate\Contracts\Cache\Lock;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class MonitorStorageCommand extends Command
{
use InteractsWithCacheLocks;
protected $signature = 'storage:monitor {--force : Execute even if another run is still in progress}';
protected $description = 'Collect storage capacity statistics and cache them for dashboards & alerts.';
public function __construct(private readonly StorageHealthService $storageHealth)
{
parent::__construct();
}
public function handle(): int
{
$lockSeconds = (int) config('storage-monitor.monitor.lock_seconds', 300);
$lock = $this->acquireCommandLock('storage:monitor', $lockSeconds, (bool) $this->option('force'));
if ($lock === false) {
$this->warn('storage:monitor is already running on another worker.');
return self::SUCCESS;
}
try {
$targets = MediaStorageTarget::active()->get();
if ($targets->isEmpty()) {
$this->info('No media storage targets available.');
return self::SUCCESS;
}
$assetStats = $this->buildAssetStatistics();
$thresholds = $this->capacityThresholds();
$checksumConfig = $this->checksumAlertConfig();
$checksumWindowMinutes = $checksumConfig['window_minutes'];
$checksumThresholds = $checksumConfig['thresholds'];
$checksumMismatches = $checksumConfig['enabled'] && $checksumWindowMinutes > 0
? $this->checksumMismatchCounts($checksumWindowMinutes)
: [];
$alerts = [];
$snapshotTargets = [];
foreach ($targets as $target) {
$capacity = $this->storageHealth->getCapacity($target);
$assets = $assetStats[$target->id] ?? [
'total' => 0,
'bytes' => 0,
'by_status' => [],
];
$severity = $this->determineCapacitySeverity($capacity, $thresholds);
if ($severity !== 'ok') {
$alerts[] = [
'target' => $target->key,
'type' => 'capacity',
'severity' => $severity,
'percentage' => $capacity['percentage'] ?? null,
'status' => $capacity['status'] ?? null,
];
}
$failedCount = $assets['by_status']['failed']['count'] ?? 0;
if ($failedCount > 0) {
$alerts[] = [
'target' => $target->key,
'type' => 'failed_assets',
'severity' => 'warning',
'failed' => $failedCount,
];
}
$targetChecksumMismatches = $checksumMismatches[$target->id] ?? 0;
$snapshotTargets[] = [
'id' => $target->id,
'key' => $target->key,
'name' => $target->name,
'is_hot' => (bool) $target->is_hot,
'capacity' => $capacity,
'assets' => $assets,
'checksum_mismatches' => [
'count' => $targetChecksumMismatches,
'window_minutes' => $checksumWindowMinutes,
],
];
}
if ($checksumConfig['enabled'] && $checksumWindowMinutes > 0) {
$totalMismatches = array_sum($checksumMismatches);
$checksumSeverity = $this->determineChecksumSeverity($totalMismatches, $checksumThresholds);
if ($checksumSeverity !== 'ok') {
$alerts[] = [
'type' => 'checksum_mismatch',
'severity' => $checksumSeverity,
'count' => $totalMismatches,
'window_minutes' => $checksumWindowMinutes,
];
}
}
$snapshot = [
'generated_at' => now()->toIso8601String(),
'targets' => $snapshotTargets,
'alerts' => $alerts,
'checksum' => [
'window_minutes' => $checksumWindowMinutes,
'mismatch_total' => array_sum($checksumMismatches),
],
];
$ttlMinutes = max(1, (int) config('storage-monitor.monitor.cache_minutes', 15));
Cache::put('storage:monitor:last', $snapshot, now()->addMinutes($ttlMinutes));
Log::channel('storage-jobs')->info('Storage monitor snapshot generated', [
'targets' => count($snapshotTargets),
'alerts' => count($alerts),
]);
$this->info(sprintf(
'Storage monitor finished: %d targets, %d alerts.',
count($snapshotTargets),
count($alerts)
));
return self::SUCCESS;
} finally {
if ($lock instanceof Lock) {
$lock->release();
}
}
}
/**
* @return array<int, array>
*/
private function buildAssetStatistics(): array
{
return EventMediaAsset::query()
->selectRaw('media_storage_target_id, status, COUNT(*) as total_count, COALESCE(SUM(size_bytes), 0) as total_bytes')
->groupBy('media_storage_target_id', 'status')
->get()
->groupBy('media_storage_target_id')
->map(function ($rows) {
$byStatus = [];
$totalCount = 0;
$totalBytes = 0;
foreach ($rows as $row) {
$count = (int) ($row->total_count ?? 0);
$bytes = (int) ($row->total_bytes ?? 0);
$totalCount += $count;
$totalBytes += $bytes;
$byStatus[$row->status] = [
'count' => $count,
'bytes' => $bytes,
];
}
ksort($byStatus);
return [
'total' => $totalCount,
'bytes' => $totalBytes,
'by_status' => $byStatus,
];
})
->all();
}
private function capacityThresholds(): array
{
$warning = (int) config('storage-monitor.capacity_thresholds.warning', 75);
$critical = (int) config('storage-monitor.capacity_thresholds.critical', 90);
if ($warning > $critical) {
[$warning, $critical] = [$critical, $warning];
}
return [
'warning' => $warning,
'critical' => $critical,
];
}
private function determineCapacitySeverity(array $capacity, array $thresholds): string
{
$status = $capacity['status'] ?? 'ok';
if ($status !== 'ok') {
return $status;
}
$percentage = $capacity['percentage'] ?? null;
if ($percentage === null) {
return 'unknown';
}
if ($percentage >= ($thresholds['critical'] ?? 95)) {
return 'critical';
}
if ($percentage >= ($thresholds['warning'] ?? 75)) {
return 'warning';
}
return 'ok';
}
private function checksumAlertConfig(): array
{
$enabled = (bool) config('storage-monitor.checksum_validation.enabled', true);
$windowMinutes = max(0, (int) config('storage-monitor.checksum_validation.alert_window_minutes', 60));
$warning = (int) config('storage-monitor.checksum_validation.thresholds.warning', 1);
$critical = (int) config('storage-monitor.checksum_validation.thresholds.critical', 5);
if ($warning > $critical && $critical > 0) {
[$warning, $critical] = [$critical, $warning];
}
return [
'enabled' => $enabled,
'window_minutes' => $windowMinutes,
'thresholds' => [
'warning' => $warning,
'critical' => $critical,
],
];
}
private function checksumMismatchCounts(int $windowMinutes): array
{
$query = EventMediaAsset::query()
->selectRaw('media_storage_target_id, COUNT(*) as total_count')
->where('status', 'failed')
->where('meta->checksum_status', 'mismatch');
if ($windowMinutes > 0) {
$query->where('updated_at', '>=', now()->subMinutes($windowMinutes));
}
return $query->groupBy('media_storage_target_id')
->get()
->mapWithKeys(fn ($row) => [(int) $row->media_storage_target_id => (int) $row->total_count])
->all();
}
private function determineChecksumSeverity(int $count, array $thresholds): string
{
$warning = (int) ($thresholds['warning'] ?? 1);
$critical = (int) ($thresholds['critical'] ?? 5);
if ($count <= 0) {
return 'ok';
}
if ($critical > 0 && $count >= $critical) {
return 'critical';
}
if ($warning > 0 && $count >= $warning) {
return 'warning';
}
return 'ok';
}
}