fe9c6b3ee0
Instead of crashing (PHP 8.2 TypeError) or silently failing on duplicate hash, the API now: - Checks for any existing ticket with the same hash (no 24h limit) - If open/pending/in-progress: returns Duplicate ticket with existing ID - If closed: reopens the ticket, posts a recurrence comment, returns action=reopened - If new: creates the ticket as before - Wraps INSERT in try/catch for mysqli_sql_exception to handle race conditions gracefully when multiple nodes POST simultaneously Also improves the hash function: - Ceph issues now include a subtype (bluestore_slow, clock_skew, osd_down, etc.) so different Ceph warnings get distinct tickets instead of colliding - LXC storage issues include the container ID so each container gets its own ticket - Fixed potential null-subject issue in preg_match for missing titles - Added early input validation (400 + JSON error) before any processing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
306 lines
10 KiB
PHP
306 lines
10 KiB
PHP
<?php
|
|
header('Content-Type: application/json');
|
|
|
|
error_reporting(E_ALL);
|
|
ini_set('display_errors', 0);
|
|
|
|
// Load environment variables with error check
|
|
$envFile = __DIR__ . '/.env';
|
|
if (!file_exists($envFile)) {
|
|
echo json_encode([
|
|
'success' => false,
|
|
'error' => 'Configuration file not found'
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
$envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED);
|
|
if (!$envVars) {
|
|
echo json_encode([
|
|
'success' => false,
|
|
'error' => 'Invalid configuration file'
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
// Strip quotes from values if present (parse_ini_file may include them)
|
|
foreach ($envVars as $key => $value) {
|
|
if (is_string($value)) {
|
|
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
|
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
|
|
$envVars[$key] = substr($value, 1, -1);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Database connection with detailed error handling
|
|
$conn = new mysqli(
|
|
$envVars['DB_HOST'],
|
|
$envVars['DB_USER'],
|
|
$envVars['DB_PASS'],
|
|
$envVars['DB_NAME']
|
|
);
|
|
|
|
if ($conn->connect_error) {
|
|
echo json_encode([
|
|
'success' => false,
|
|
'error' => 'Database connection failed: ' . $conn->connect_error
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
// Load application config so UrlHelper can resolve APP_DOMAIN
|
|
require_once __DIR__ . '/config/config.php';
|
|
|
|
// Authenticate via API key
|
|
require_once __DIR__ . '/middleware/ApiKeyAuth.php';
|
|
require_once __DIR__ . '/models/AuditLogModel.php';
|
|
require_once __DIR__ . '/helpers/UrlHelper.php';
|
|
|
|
$apiKeyAuth = new ApiKeyAuth($conn);
|
|
|
|
try {
|
|
$systemUser = $apiKeyAuth->authenticate();
|
|
} catch (Exception $e) {
|
|
// Authentication failed - ApiKeyAuth already sent the response
|
|
exit;
|
|
}
|
|
|
|
$userId = $systemUser['user_id'];
|
|
|
|
// Create tickets table with hash column if not exists
|
|
$createTableSQL = "CREATE TABLE IF NOT EXISTS tickets (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
ticket_id VARCHAR(9) NOT NULL,
|
|
title VARCHAR(255) NOT NULL,
|
|
hash VARCHAR(64) NOT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE KEY unique_hash (hash)
|
|
)";
|
|
|
|
$conn->query($createTableSQL);
|
|
|
|
// Parse input regardless of content-type header
|
|
$rawInput = file_get_contents('php://input');
|
|
$data = json_decode($rawInput, true);
|
|
|
|
// Validate required fields before any processing
|
|
if (!is_array($data) || empty($data['title'])) {
|
|
// Try URL-encoded fallback
|
|
if (empty($data['title'])) {
|
|
parse_str($rawInput, $urlData);
|
|
if (!empty($urlData['title'])) {
|
|
$data = $urlData;
|
|
}
|
|
}
|
|
if (!is_array($data) || empty($data['title'])) {
|
|
http_response_code(400);
|
|
echo json_encode(['success' => false, 'error' => 'title is required']);
|
|
exit;
|
|
}
|
|
}
|
|
|
|
// Generate hash from stable components
|
|
function generateTicketHash($data) {
|
|
$title = (string)($data['title'] ?? '');
|
|
|
|
// Extract device name if present (matches /dev/sdX, /dev/nvmeXnY patterns)
|
|
preg_match('/\/dev\/(sd[a-z]+|nvme\d+n\d+)/', $title, $deviceMatches);
|
|
$isDriveTicket = !empty($deviceMatches);
|
|
|
|
// Extract first bracketed tag as hostname/source
|
|
preg_match('/^\[([^\]]+)\]/', $title, $hostMatches);
|
|
$hostname = $hostMatches[1] ?? '';
|
|
|
|
// Detect issue category and optional sub-type
|
|
$issueCategory = '';
|
|
$issueSubtype = '';
|
|
$isClusterWide = false;
|
|
|
|
if (stripos($title, 'SMART issues') !== false) {
|
|
$issueCategory = 'smart';
|
|
} elseif (stripos($title, 'LXC') !== false || stripos($title, 'storage usage') !== false) {
|
|
$issueCategory = 'storage';
|
|
// Include the LXC container ID so each container gets its own ticket
|
|
if (preg_match('/LXC\s+(\d+)/i', $title, $lxcMatch)) {
|
|
$issueSubtype = 'lxc_' . $lxcMatch[1];
|
|
}
|
|
} elseif (stripos($title, 'memory') !== false) {
|
|
$issueCategory = 'memory';
|
|
} elseif (stripos($title, 'cpu') !== false) {
|
|
$issueCategory = 'cpu';
|
|
} elseif (stripos($title, 'network') !== false) {
|
|
$issueCategory = 'network';
|
|
} elseif (stripos($title, 'Ceph') !== false || stripos($title, '[ceph]') !== false) {
|
|
$issueCategory = 'ceph';
|
|
if (stripos($title, '[cluster-wide]') !== false ||
|
|
stripos($title, 'HEALTH_ERR') !== false ||
|
|
stripos($title, 'HEALTH_WARN') !== false ||
|
|
stripos($title, 'cluster usage') !== false) {
|
|
$isClusterWide = true;
|
|
}
|
|
// Normalize the specific Ceph warning type so different warnings get distinct tickets
|
|
if (stripos($title, 'slow') !== false && stripos($title, 'BlueStore') !== false) {
|
|
$issueSubtype = 'bluestore_slow';
|
|
} elseif (stripos($title, 'clock skew') !== false) {
|
|
$issueSubtype = 'clock_skew';
|
|
} elseif (stripos($title, 'cluster usage') !== false) {
|
|
$issueSubtype = 'usage';
|
|
} elseif (stripos($title, 'OSD down') !== false) {
|
|
$issueSubtype = 'osd_down';
|
|
} elseif (stripos($title, 'HEALTH_ERR') !== false) {
|
|
$issueSubtype = 'health_err';
|
|
}
|
|
}
|
|
|
|
// Build stable components
|
|
$stableComponents = [
|
|
'issue_category' => $issueCategory,
|
|
'issue_subtype' => $issueSubtype,
|
|
'environment_tags' => array_values(array_filter(
|
|
explode('][', $title),
|
|
fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node', 'cluster-wide'])
|
|
)),
|
|
];
|
|
|
|
// Include hostname for node-specific issues
|
|
if (!$isClusterWide) {
|
|
$stableComponents['hostname'] = $hostname;
|
|
}
|
|
|
|
// Include device path for drive-specific tickets
|
|
if ($isDriveTicket) {
|
|
$stableComponents['device'] = $deviceMatches[0];
|
|
}
|
|
|
|
sort($stableComponents['environment_tags']);
|
|
|
|
return hash('sha256', json_encode($stableComponents, JSON_UNESCAPED_SLASHES));
|
|
}
|
|
|
|
// Shared ticket data
|
|
$title = (string)($data['title'] ?? '');
|
|
$description = (string)($data['description'] ?? '');
|
|
$status = (string)($data['status'] ?? 'Open');
|
|
$priority = $data['priority'] ?? '4';
|
|
$category = (string)($data['category'] ?? 'General');
|
|
$type = (string)($data['type'] ?? 'Issue');
|
|
|
|
$ticketHash = generateTicketHash($data);
|
|
$auditLog = new AuditLogModel($conn);
|
|
|
|
// Look up any existing ticket with this hash (open OR closed)
|
|
$checkStmt = $conn->prepare("SELECT ticket_id, status FROM tickets WHERE hash = ? ORDER BY created_at DESC LIMIT 1");
|
|
$checkStmt->bind_param("s", $ticketHash);
|
|
$checkStmt->execute();
|
|
$existing = $checkStmt->get_result()->fetch_assoc();
|
|
$checkStmt->close();
|
|
|
|
if ($existing) {
|
|
$existingId = $existing['ticket_id'];
|
|
$existingStatus = $existing['status'];
|
|
|
|
if ($existingStatus !== 'Closed') {
|
|
// Ticket is still active — do not create a duplicate
|
|
echo json_encode([
|
|
'success' => false,
|
|
'error' => 'Duplicate ticket',
|
|
'existing_ticket_id' => $existingId,
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
// Ticket was closed — reopen it and add a recurrence comment
|
|
$reopenStmt = $conn->prepare(
|
|
"UPDATE tickets SET status = 'Open', closed_at = NULL, updated_at = NOW(), updated_by = ? WHERE ticket_id = ?"
|
|
);
|
|
$reopenStmt->bind_param("is", $userId, $existingId);
|
|
$reopenStmt->execute();
|
|
$reopenStmt->close();
|
|
|
|
$commentText = "**Issue recurred — ticket reopened automatically.**\n\n" .
|
|
"New report received from hwmonDaemon:\n\n" . $description;
|
|
$commentStmt = $conn->prepare(
|
|
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
|
|
);
|
|
$commentStmt->bind_param("sis", $existingId, $userId, $commentText);
|
|
$commentStmt->execute();
|
|
$commentStmt->close();
|
|
|
|
$auditLog->log($userId, 'update', 'ticket', $existingId, [
|
|
'status' => ['from' => 'Closed', 'to' => 'Open'],
|
|
'reason' => 'auto-reopened by hwmonDaemon (issue recurred)',
|
|
]);
|
|
|
|
$conn->close();
|
|
|
|
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
|
NotificationHelper::sendTicketNotification($existingId, [
|
|
'title' => $title,
|
|
'priority' => $priority,
|
|
'category' => $category,
|
|
'type' => $type,
|
|
'status' => 'Open',
|
|
], 'automated');
|
|
|
|
echo json_encode([
|
|
'success' => true,
|
|
'ticket_id' => $existingId,
|
|
'message' => 'Existing closed ticket reopened',
|
|
'action' => 'reopened',
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
// No existing ticket — create a new one
|
|
$ticket_id = sprintf('%09d', mt_rand(1, 999999999));
|
|
$insertStmt = $conn->prepare(
|
|
"INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
);
|
|
$insertStmt->bind_param("ssssssssi",
|
|
$ticket_id, $title, $description, $status, $priority, $category, $type, $ticketHash, $userId
|
|
);
|
|
|
|
try {
|
|
$inserted = $insertStmt->execute();
|
|
} catch (mysqli_sql_exception $e) {
|
|
$insertStmt->close();
|
|
if ($e->getCode() === 1062) {
|
|
// Race condition: another node inserted the same hash between our SELECT and INSERT
|
|
echo json_encode(['success' => false, 'error' => 'Duplicate ticket']);
|
|
} else {
|
|
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
|
}
|
|
exit;
|
|
}
|
|
$insertStmt->close();
|
|
|
|
if ($inserted) {
|
|
$auditLog->logTicketCreate($userId, $ticket_id, [
|
|
'title' => $title,
|
|
'priority' => $priority,
|
|
'category' => $category,
|
|
'type' => $type,
|
|
]);
|
|
|
|
$conn->close();
|
|
|
|
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
|
NotificationHelper::sendTicketNotification($ticket_id, [
|
|
'title' => $title,
|
|
'priority' => $priority,
|
|
'category' => $category,
|
|
'type' => $type,
|
|
'status' => $status,
|
|
], 'automated');
|
|
|
|
echo json_encode([
|
|
'success' => true,
|
|
'ticket_id' => $ticket_id,
|
|
'message' => 'Ticket created successfully',
|
|
]);
|
|
} else {
|
|
echo json_encode(['success' => false, 'error' => $conn->error]);
|
|
}
|