false, 'dry_run' => false, 'json' => false, 'no_lock' => false, 'task_id' => null, 'help' => false, ]; $count = count($argv); for ($i = 1; $i < $count; $i++) { $arg = (string) $argv[$i]; if ($arg === '--apply') { $options['apply'] = true; continue; } if ($arg === '--dry-run') { $options['dry_run'] = true; continue; } if ($arg === '--json') { $options['json'] = true; continue; } if ($arg === '--no-lock') { $options['no_lock'] = true; continue; } if ($arg === '--help') { $options['help'] = true; continue; } if (strpos($arg, '--task_id=') === 0) { if (!$allowTaskId) { throw new InvalidArgumentException('--task_id wird für diesen Job nicht unterstützt.'); } $raw = trim(substr($arg, strlen('--task_id='))); if ($raw === '' || !ctype_digit($raw)) { throw new InvalidArgumentException('Ungültiger Wert für --task_id.'); } $options['task_id'] = (int) $raw; continue; } if ($arg === '--task_id') { if (!$allowTaskId) { throw new InvalidArgumentException('--task_id wird für diesen Job nicht unterstützt.'); } $next = $argv[$i + 1] ?? null; if (!is_string($next) || trim($next) === '' || !ctype_digit(trim($next))) { throw new InvalidArgumentException('Ungültiger Wert für --task_id.'); } $options['task_id'] = (int) trim($next); $i++; continue; } throw new InvalidArgumentException('Unbekannte Option: ' . $arg); } if ($options['apply'] && $options['dry_run']) { throw new InvalidArgumentException('--apply und --dry-run können nicht gleichzeitig gesetzt werden.'); } if ($options['task_id'] !== null && (int) $options['task_id'] <= 0) { throw new InvalidArgumentException('--task_id muss > 0 sein.'); } return $options; } public static function runWithHandling(string $jobName, array $options, callable $runner): int { $startedAt = microtime(true); $runId = self::buildRunId($jobName); $mode = $options['apply'] ? 'apply' : 'dry_run'; $lockHandle = null; $basePayload = [ 'timestamp' => date('c'), 'job' => $jobName, 'job_run_id' => $runId, 'mode' => $mode, 'task_id' => $options['task_id'], ]; try { if (!$options['no_lock']) { $lockResult = self::acquireLock($jobName); $lockHandle = $lockResult['handle'] ?? null; $acquired = (bool) ($lockResult['acquired'] ?? false); if (!$acquired) { $payload = $basePayload + [ 'success' => false, 'skipped' => true, 'skip_reason' => 'lock_active', 'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000), ]; self::writeCronLog($payload); if ((bool) ($options['json'] ?? false)) { self::emitJson($payload); } else { fwrite(STDOUT, '[SKIP] ' . $jobName . ': Lock aktiv, Job wird übersprungen.' . PHP_EOL); } return 3; } } $context = [ 'job_run_id' => $runId, 'mode' => $mode, 'task_id' => $options['task_id'], ]; $summary = $runner($context); if (!is_array($summary)) { $summary = []; } $payload = $basePayload + [ 'success' => true, 'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000), 'summary' => $summary, ]; self::writeCronLog($payload); if ((bool) ($options['json'] ?? false)) { self::emitJson($payload); } else { fwrite(STDOUT, '[OK] ' . $jobName . ' (' . $mode . ') abgeschlossen in ' . $payload['duration_ms'] . 'ms.' . PHP_EOL); } return 0; } catch (Throwable $throwable) { $payload = $basePayload + [ 'success' => false, 'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000), 'error' => $throwable->getMessage(), ]; self::writeCronLog($payload); if ((bool) ($options['json'] ?? false)) { self::emitJson($payload); } else { fwrite(STDERR, '[ERROR] ' . $jobName . ' (' . $mode . '): ' . $throwable->getMessage() . PHP_EOL); } return 1; } finally { self::releaseLock($lockHandle); } } public static function printUsage(string $usage): void { fwrite(STDOUT, trim($usage) . PHP_EOL); } public static function printOptionError(string $message, string $usage): void { fwrite(STDERR, $message . PHP_EOL . PHP_EOL . trim($usage) . PHP_EOL); } private static function acquireLock(string $jobName): array { $lockDir = self::logsDir() . '/locks'; if (!is_dir($lockDir)) { @mkdir($lockDir, 0775, true); } $lockPath = $lockDir . '/task_cert_' . $jobName . '.lock'; $handle = @fopen($lockPath, 'c+'); if ($handle === false) { throw new RuntimeException('Lock-Datei konnte nicht geöffnet werden: ' . $lockPath); } $acquired = flock($handle, LOCK_EX | LOCK_NB); if (!$acquired) { return [ 'acquired' => false, 'handle' => $handle, ]; } $meta = [ 'pid' => getmypid(), 'started_at' => date('c'), ]; ftruncate($handle, 0); rewind($handle); fwrite($handle, json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL); fflush($handle); return [ 'acquired' => true, 'handle' => $handle, ]; } private static function releaseLock($lockHandle): void { if (!is_resource($lockHandle)) { return; } @flock($lockHandle, LOCK_UN); @fclose($lockHandle); } private static function writeCronLog(array $payload): void { $logDir = self::logsDir(); if (!is_dir($logDir)) { @mkdir($logDir, 0775, true); } $file = $logDir . '/task_cert_cron.log'; $line = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if (!is_string($line) || $line === '') { $line = json_encode([ 'timestamp' => date('c'), 'success' => false, 'error' => 'cron_log_encode_failed', ]); } @file_put_contents($file, $line . PHP_EOL, FILE_APPEND | LOCK_EX); } private static function emitJson(array $payload): void { echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; } private static function logsDir(): string { return dirname(__DIR__) . '/logs'; } private static function buildRunId(string $jobName): string { try { $entropy = bin2hex(random_bytes(6)); } catch (Throwable $throwable) { $entropy = substr(sha1((string) microtime(true)), 0, 12); } return $jobName . '-' . date('YmdHis') . '-' . $entropy; } }