Files
awo-hamburg-intranet/module/tasks/cli/CronRuntime.php

260 lines
8.2 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
final class TaskCertCronRuntime
{
public static function parseOptions(array $argv, bool $allowTaskId = true): array
{
$options = [
'apply' => 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;
}
}