Lint / JS (eslint) (push) Successful in 12s
Lint / PHP (phpcs PSR-12) (push) Successful in 27s
Lint / PHP requirements (version + extensions) (push) Successful in 33s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Successful in 9s
Lint / PHP (phpcs PSR-12) (pull_request) Successful in 1m12s
Security / PHP Security (semgrep) (push) Successful in 3m26s
Lint / PHP requirements (version + extensions) (pull_request) Successful in 35s
Lint / JS (eslint) (pull_request) Successful in 11s
Lint / Deploy (pull_request) Skipped
Lint / Notify on failure (pull_request) Skipped
Security / PHP Security (semgrep) (pull_request) Successful in 1m48s
Network tickets never set issue_subtype, so every automated network alert for a host hashed alike: Gandalf's "Interface enp3s0 link-down" for micro1 matched closed reboot ticket 993441773, got noted there as a recurrence, and never got its own ticket. Automated network tickets now key on the optional `check_code` payload field when sent (it must name the instance, e.g. iface_down:enp3s0), otherwise on the title text outside the [tags], normalised with digits kept. Manual tickets and every other category hash exactly as before. The hash logic moves to helpers/TicketHash.php so the new scripts/rehash_network_tickets.php can re-key open network tickets once after deploy (--dry-run first). Closed tickets keep their old hash so they stop catching unrelated alerts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
187 lines
9.2 KiB
PHP
187 lines
9.2 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Dedup hash for tickets created through create_ticket_api.php.
|
|
*
|
|
* Two reports with the same hash land on the same ticket (updated while open,
|
|
* reopened when closed), so the components below decide what counts as "the
|
|
* same problem". Changing them re-keys existing tickets: see
|
|
* scripts/rehash_network_tickets.php for how #116 handled that.
|
|
*/
|
|
class TicketHash
|
|
{
|
|
/**
|
|
* The stable components the hash is built from.
|
|
*
|
|
* @param array $data Request payload: title, plus optional serial / check_code.
|
|
*/
|
|
public static function components(array $data): array
|
|
{
|
|
$title = (string)($data['title'] ?? '');
|
|
|
|
// Prefer explicit serial from payload; fall back to extracting device path from title
|
|
// for backwards compatibility with older hwmonDaemon versions.
|
|
$serial = isset($data['serial']) && $data['serial'] !== null && $data['serial'] !== ''
|
|
? (string)$data['serial']
|
|
: null;
|
|
|
|
// 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) || $serial !== null;
|
|
|
|
// 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, 'ZFS pool') !== false) {
|
|
$issueCategory = 'zfs';
|
|
// Extract pool name so each pool gets its own ticket
|
|
if (preg_match("/ZFS pool '([^']+)'/i", $title, $poolMatch)) {
|
|
$poolName = strtolower(preg_replace('/[^a-z0-9_]/i', '_', $poolMatch[1]));
|
|
if (stripos($title, 'state:') !== false || preg_match('/DEGRADED|FAULTED|UNAVAIL|OFFLINE/i', $title)) {
|
|
$issueSubtype = 'pool_state_' . $poolName;
|
|
} elseif (stripos($title, 'usage') !== false) {
|
|
$issueSubtype = 'pool_usage_' . $poolName;
|
|
} elseif (stripos($title, 'errors') !== false) {
|
|
$issueSubtype = 'pool_errors_' . $poolName;
|
|
} else {
|
|
$issueSubtype = 'pool_' . $poolName;
|
|
}
|
|
}
|
|
} 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';
|
|
// One ticket per kind of network problem per host, not one per
|
|
// host. With an empty subtype every network alert for a host
|
|
// (reboot, NIC link-down, cable mismatch, ...) hashed alike, so an
|
|
// interface alert landed on an unrelated closed reboot ticket
|
|
// (#116). Prefer the reporter's check_code when sent; it must name
|
|
// the instance too (e.g. "iface_down:enp3s0"), not just the alert
|
|
// type. Otherwise key on the title text outside the [tags]; the
|
|
// reporters' headings are stable per problem, and digits are kept
|
|
// because they identify it (enp3s0, cable C07). Manual tickets
|
|
// are already keyed on their full title, so they are left as-is.
|
|
$checkCode = isset($data['check_code']) && is_string($data['check_code']) ? $data['check_code'] : '';
|
|
if (stripos($title, '[auto]') === false) {
|
|
// manual: unchanged hash
|
|
} elseif ($checkCode !== '' && preg_match('/^[A-Za-z0-9_.:-]{1,80}$/', $checkCode)) {
|
|
$issueSubtype = 'net_check_' . strtolower($checkCode);
|
|
} else {
|
|
$heading = trim(preg_replace('/\[[^\]]*\]/', '', $title), " -\t");
|
|
$slug = trim(preg_replace('/[^a-z0-9]+/', '_', strtolower($heading)), '_');
|
|
if ($slug !== '') {
|
|
$issueSubtype = 'net_' . substr($slug, 0, 80);
|
|
}
|
|
}
|
|
} 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 || preg_match('/osd\.\d+\s+is\s+DOWN/i', $title)) {
|
|
// Include the specific OSD ID so each individual OSD gets its own ticket
|
|
if (preg_match('/osd\.(\d+)/i', $title, $osdMatch)) {
|
|
$issueSubtype = 'osd_down_' . $osdMatch[1];
|
|
} else {
|
|
$issueSubtype = 'osd_down';
|
|
}
|
|
} elseif (stripos($title, 'HEALTH_ERR') !== false) {
|
|
$issueSubtype = 'health_err';
|
|
} else {
|
|
// Any other warning gets its own ticket. Previously every other
|
|
// HEALTH_WARN shared one empty subtype, so unrelated warnings
|
|
// (noout, nearfull, insecure keys, ...) collapsed onto a single
|
|
// ticket and overwrote its title in turn every run (#112).
|
|
// Prefer Ceph's own health-check code when the reporter sends
|
|
// it; otherwise key on the warning text with counts stripped.
|
|
$checkCode = isset($data['check_code']) && is_string($data['check_code']) ? $data['check_code'] : '';
|
|
if ($checkCode !== '' && preg_match('/^[A-Z0-9_]{1,64}$/', $checkCode)) {
|
|
$issueSubtype = 'check_' . strtolower($checkCode);
|
|
} elseif (preg_match('/HEALTH_WARN:\s*([^\[]+)/i', $title, $warnMatch)) {
|
|
$warnText = strtolower(explode(':', $warnMatch[1])[0]);
|
|
$warnText = trim(preg_replace('/[^a-z]+/', '_', $warnText), '_');
|
|
if ($warnText !== '') {
|
|
$issueSubtype = 'warn_' . $warnText;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Include source type so automated tickets never collide with manual ones
|
|
$sourceType = stripos($title, '[auto]') !== false ? 'auto' : 'manual';
|
|
|
|
// Build stable components
|
|
$stableComponents = [
|
|
'source_type' => $sourceType,
|
|
'issue_category' => $issueCategory,
|
|
'issue_subtype' => $issueSubtype,
|
|
'environment_tags' => (function () use ($title) {
|
|
// Extract each [bracketed] tag, then keep the known environment ones.
|
|
// (explode('][') leaves brackets stuck to the first/last tag, so e.g.
|
|
// "[production] ..." never matched and the env tag was dropped from the
|
|
// dedup hash — letting prod and staging issues collide onto one ticket.)
|
|
preg_match_all('/\[([^\]]+)\]/', $title, $m);
|
|
return array_values(array_filter(
|
|
$m[1],
|
|
fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node', 'cluster-wide'], true)
|
|
));
|
|
})(),
|
|
];
|
|
|
|
// Manual tickets should be unique by title (so different software installs don't collide)
|
|
if ($sourceType === 'manual') {
|
|
$stableComponents['title'] = $title;
|
|
}
|
|
|
|
// Include hostname for node-specific issues
|
|
if (!$isClusterWide) {
|
|
$stableComponents['hostname'] = $hostname;
|
|
}
|
|
|
|
// Include drive identifier for drive-specific tickets.
|
|
// Use serial when available (stable across reboots/reshuffles); fall back to
|
|
// device path for tickets created before serial was added to the payload.
|
|
if ($isDriveTicket) {
|
|
$stableComponents['drive'] = $serial ?? ($deviceMatches[0] ?? '');
|
|
}
|
|
|
|
sort($stableComponents['environment_tags']);
|
|
|
|
return $stableComponents;
|
|
}
|
|
|
|
/** SHA-256 dedup hash of the payload's stable components. */
|
|
public static function generate(array $data): string
|
|
{
|
|
return hash('sha256', json_encode(self::components($data), JSON_UNESCAPED_SLASHES));
|
|
}
|
|
}
|