Beide Module wurden aus einem anderen Kundenprojekt kopiert und bereinigt: Knowledgecenter: - Bild-Cache (1.985 Dateien) geleert, Ordnerstruktur mit .gitkeep behalten - Files-Gallery-Lizenzschlüssel entfernt (config.php) - Kundenspezifische Kategorien gelöscht: Teil I/II/III QM-Struktur, nummerierte QM-Kapitel, Gremien (Präsidium/Finanzausschuss/Landesausschuss), Sitzungsservice-Serie, Protokoll-Abteilungen, Testeinträge - 56 generische Kategorien bleiben (Downloads, Onboarding, Personalthemen …) Tasks: - Alle Betriebsdaten gelöscht (114 Tasks, 914 Submissions, 579 Assignments) - Task-Definitionen und Verlinkungen geleert - Kategoriestruktur bleibt (Allgemein, IT, Personal, Buchhaltung …) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
260 lines
8.2 KiB
PHP
260 lines
8.2 KiB
PHP
<?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;
|
|
}
|
|
}
|