Update generateTicketHash() to exclude hostname from hash for cluster-wide Ceph issues, enabling proper deduplication across all nodes in the cluster. Cluster-wide issues detected by: - [cluster-wide] tag in title - HEALTH_ERR or HEALTH_WARN in title - "cluster usage" in title This prevents all nodes from creating duplicate tickets for the same cluster-wide issue (e.g., Ceph HEALTH_WARN). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
261 lines
8.1 KiB
PHP
261 lines
8.1 KiB
PHP
<?php
|
|
header('Content-Type: application/json');
|
|
|
|
error_reporting(E_ALL);
|
|
ini_set('display_errors', 1);
|
|
file_put_contents('debug.log', print_r($_POST, true) . "\n" . file_get_contents('php://input') . "\n", FILE_APPEND);
|
|
|
|
// 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;
|
|
}
|
|
|
|
// Authenticate via API key
|
|
require_once __DIR__ . '/middleware/ApiKeyAuth.php';
|
|
require_once __DIR__ . '/models/AuditLogModel.php';
|
|
|
|
$apiKeyAuth = new ApiKeyAuth($conn);
|
|
|
|
try {
|
|
$systemUser = $apiKeyAuth->authenticate();
|
|
} catch (Exception $e) {
|
|
// Authentication failed - ApiKeyAuth already sent the response
|
|
exit;
|
|
}
|
|
|
|
$userId = $systemUser['user_id'];
|
|
file_put_contents('debug.log', "Authenticated as system user ID: $userId\n", FILE_APPEND);
|
|
|
|
// 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);
|
|
|
|
// Generate hash from stable components
|
|
function generateTicketHash($data) {
|
|
// Extract device name if present (matches /dev/sdX, /dev/nvmeXnY patterns)
|
|
preg_match('/\/dev\/(sd[a-z]|nvme\d+n\d+)/', $data['title'], $deviceMatches);
|
|
$isDriveTicket = !empty($deviceMatches);
|
|
|
|
// Extract hostname from title [hostname][tags]...
|
|
preg_match('/\[([\w\d-]+)\]/', $data['title'], $hostMatches);
|
|
$hostname = $hostMatches[1] ?? '';
|
|
|
|
// Detect issue category (not specific attribute values)
|
|
$issueCategory = '';
|
|
$isClusterWide = false; // Flag for cluster-wide issues (exclude hostname from hash)
|
|
|
|
if (stripos($data['title'], 'SMART issues') !== false) {
|
|
$issueCategory = 'smart';
|
|
} elseif (stripos($data['title'], 'LXC') !== false || stripos($data['title'], 'storage usage') !== false) {
|
|
$issueCategory = 'storage';
|
|
} elseif (stripos($data['title'], 'memory') !== false) {
|
|
$issueCategory = 'memory';
|
|
} elseif (stripos($data['title'], 'cpu') !== false) {
|
|
$issueCategory = 'cpu';
|
|
} elseif (stripos($data['title'], 'network') !== false) {
|
|
$issueCategory = 'network';
|
|
} elseif (stripos($data['title'], 'Ceph') !== false || stripos($data['title'], '[ceph]') !== false) {
|
|
$issueCategory = 'ceph';
|
|
// Ceph cluster-wide issues should deduplicate across all nodes
|
|
// Check if this is a cluster-wide issue (not node-specific like OSD down on this node)
|
|
if (stripos($data['title'], '[cluster-wide]') !== false ||
|
|
stripos($data['title'], 'HEALTH_ERR') !== false ||
|
|
stripos($data['title'], 'HEALTH_WARN') !== false ||
|
|
stripos($data['title'], 'cluster usage') !== false) {
|
|
$isClusterWide = true;
|
|
}
|
|
}
|
|
|
|
// Build stable components with only static data
|
|
$stableComponents = [
|
|
'issue_category' => $issueCategory, // Generic category, not specific errors
|
|
'environment_tags' => array_filter(
|
|
explode('][', $data['title']),
|
|
fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node', 'cluster-wide'])
|
|
)
|
|
];
|
|
|
|
// Only include hostname for non-cluster-wide issues
|
|
// This allows cluster-wide issues to deduplicate across all nodes
|
|
if (!$isClusterWide) {
|
|
$stableComponents['hostname'] = $hostname;
|
|
}
|
|
|
|
// Only include device info for drive-specific tickets
|
|
if ($isDriveTicket) {
|
|
$stableComponents['device'] = $deviceMatches[0];
|
|
}
|
|
|
|
// Sort arrays for consistent hashing
|
|
sort($stableComponents['environment_tags']);
|
|
|
|
return hash('sha256', json_encode($stableComponents, JSON_UNESCAPED_SLASHES));
|
|
}
|
|
|
|
// Check for duplicate tickets
|
|
$ticketHash = generateTicketHash($data);
|
|
$checkDuplicateSQL = "SELECT ticket_id FROM tickets WHERE hash = ? AND created_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)";
|
|
$checkStmt = $conn->prepare($checkDuplicateSQL);
|
|
$checkStmt->bind_param("s", $ticketHash);
|
|
$checkStmt->execute();
|
|
$result = $checkStmt->get_result();
|
|
|
|
if ($result->num_rows > 0) {
|
|
$existingTicket = $result->fetch_assoc();
|
|
echo json_encode([
|
|
'success' => false,
|
|
'error' => 'Duplicate ticket',
|
|
'existing_ticket_id' => $existingTicket['ticket_id']
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
// Force JSON content type for all incoming requests
|
|
header('Content-Type: application/json');
|
|
|
|
if (!$data) {
|
|
// Try parsing as URL-encoded data
|
|
parse_str($rawInput, $data);
|
|
}
|
|
|
|
// Generate ticket ID (9-digit format with leading zeros)
|
|
$ticket_id = sprintf('%09d', mt_rand(1, 999999999));
|
|
|
|
// Prepare insert query with created_by field
|
|
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
|
|
|
$stmt = $conn->prepare($sql);
|
|
// First, store all values in variables
|
|
$title = $data['title'];
|
|
$description = $data['description'];
|
|
$status = $data['status'] ?? 'Open';
|
|
$priority = $data['priority'] ?? '4';
|
|
$category = $data['category'] ?? 'General';
|
|
$type = $data['type'] ?? 'Issue';
|
|
|
|
// Then use the variables in bind_param
|
|
$stmt->bind_param(
|
|
"ssssssssi",
|
|
$ticket_id,
|
|
$title,
|
|
$description,
|
|
$status,
|
|
$priority,
|
|
$category,
|
|
$type,
|
|
$ticketHash,
|
|
$userId
|
|
);
|
|
|
|
if ($stmt->execute()) {
|
|
// Log ticket creation to audit log
|
|
$auditLog = new AuditLogModel($conn);
|
|
$auditLog->logTicketCreate($userId, $ticket_id, [
|
|
'title' => $title,
|
|
'priority' => $priority,
|
|
'category' => $category,
|
|
'type' => $type
|
|
]);
|
|
|
|
echo json_encode([
|
|
'success' => true,
|
|
'ticket_id' => $ticket_id,
|
|
'message' => 'Ticket created successfully'
|
|
]);
|
|
} else {
|
|
echo json_encode([
|
|
'success' => false,
|
|
'error' => $conn->error
|
|
]);
|
|
}
|
|
|
|
$stmt->close();
|
|
$conn->close();
|
|
|
|
// Discord webhook
|
|
$discord_webhook_url = $envVars['DISCORD_WEBHOOK_URL'];
|
|
|
|
// Map priorities to Discord colors (decimal format)
|
|
$priorityColors = [
|
|
"1" => 16736589, // --priority-1: #ff4d4d
|
|
"2" => 16753958, // --priority-2: #ffa726
|
|
"3" => 4363509, // --priority-3: #42a5f5
|
|
"4" => 6736490 // --priority-4: #66bb6a
|
|
];
|
|
|
|
$discord_data = [
|
|
"content" => "",
|
|
"embeds" => [[
|
|
"title" => "New Ticket Created: #" . $ticket_id,
|
|
"description" => $title,
|
|
"url" => "http://t.lotusguild.org/ticket/" . $ticket_id,
|
|
"color" => $priorityColors[$priority],
|
|
"fields" => [
|
|
["name" => "Priority", "value" => $priority, "inline" => true],
|
|
["name" => "Category", "value" => $category, "inline" => true],
|
|
["name" => "Type", "value" => $type, "inline" => true]
|
|
]
|
|
]]
|
|
];
|
|
|
|
$ch = curl_init($discord_webhook_url);
|
|
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
|
curl_setopt($ch, CURLOPT_POST, 1);
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($discord_data));
|
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
curl_exec($ch);
|
|
curl_close($ch);
|