Add unit tests for Bird department gate phone call logic, Edge Broker configuration, and gateway heartbeat status. Refactor warnings handling, Slack notifications, Bird client usage, and Edge gateway probes for improved maintainability and error clarity.

This commit is contained in:
Jeppe Bundgaard
2026-04-09 08:53:10 +02:00
parent 45b7250480
commit 9e9372db05
25 changed files with 2924 additions and 201 deletions
@@ -34,6 +34,8 @@ class superuser_system_status_service
}
}
$warningEntries = $this->normalizeWarningEntries($warnings);
return [
'overall_status' => self::reduceOverallStatus($statuses),
'generated_at' => date('c'),
@@ -42,7 +44,11 @@ class superuser_system_status_service
'dependencies' => $dependencies,
'modules' => $modules,
'sessions' => $sessions,
'warnings' => array_values(array_unique($warnings)),
'warnings' => array_map(
static fn(array $warningEntry): string => (string)($warningEntry['message'] ?? ''),
$warningEntries
),
'warning_entries' => $warningEntries,
];
}
@@ -203,6 +209,78 @@ class superuser_system_status_service
return 'ok';
}
protected function moduleReason(string $key, array $params, string $message): array
{
return [
'status_reason_key' => $key,
'status_reason_params' => $params,
'status_reason' => $message,
];
}
protected function pushWarning(array &$warnings, string $key, array $params, string $message): void
{
$warnings[] = [
'key' => $key,
'params' => $params,
'message' => $message,
];
}
protected function normalizeWarningEntries(array $warnings): array
{
$entries = [];
$seen = [];
foreach ($warnings as $warning) {
$entry = $this->normalizeWarningEntry($warning);
if ($entry === null) {
continue;
}
$signature = md5(json_encode($entry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
if (isset($seen[$signature])) {
continue;
}
$seen[$signature] = true;
$entries[] = $entry;
}
return $entries;
}
protected function normalizeWarningEntry(mixed $warning): ?array
{
if (is_string($warning)) {
$message = trim($warning);
if ($message === '') {
return null;
}
return [
'key' => null,
'params' => [],
'message' => $message,
];
}
if (!is_array($warning)) {
return null;
}
$message = trim((string)($warning['message'] ?? ''));
if ($message === '') {
return null;
}
return [
'key' => isset($warning['key']) ? (string)$warning['key'] : null,
'params' => isset($warning['params']) && is_array($warning['params']) ? $warning['params'] : [],
'message' => $message,
];
}
private function collectRuntime(array &$warnings): array
{
return [
@@ -219,10 +297,20 @@ class superuser_system_status_service
$minio = $this->probeMinio();
if (($redis['status'] ?? '') === 'down') {
$warnings[] = 'Redis is unavailable; module probe caching is bypassed.';
$this->pushWarning(
$warnings,
'redis_cache_bypass',
[],
'Redis is unavailable; module probe caching is bypassed.'
);
}
if (($minio['status'] ?? '') === 'degraded') {
$warnings[] = 'MinIO is reachable, but one or more expected buckets are missing or inaccessible.';
$this->pushWarning(
$warnings,
'minio_missing_buckets',
[],
'MinIO is reachable, but one or more expected buckets are missing or inaccessible.'
);
}
return [
@@ -263,7 +351,12 @@ class superuser_system_status_service
}
if ($load !== null) {
$usagePercent = round(min(100, max(0, ($load / $cpuCount) * 100)), 2);
$warnings[] = 'CPU usage fell back to load average because /proc/stat sampling was unavailable.';
$this->pushWarning(
$warnings,
'cpu_loadavg_fallback',
[],
'CPU usage fell back to load average because /proc/stat sampling was unavailable.'
);
return [
'status' => self::statusFromUsagePercent($usagePercent),
'usage_percent' => $usagePercent,
@@ -273,7 +366,12 @@ class superuser_system_status_service
}
}
$warnings[] = 'CPU usage metrics are unavailable in this runtime.';
$this->pushWarning(
$warnings,
'cpu_unavailable',
[],
'CPU usage metrics are unavailable in this runtime.'
);
return [
'status' => 'down',
'usage_percent' => null,
@@ -323,7 +421,12 @@ class superuser_system_status_service
$availableBytes = (int)$availableMatch[1] * 1024;
$usedBytes = max(0, $totalBytes - $availableBytes);
$usagePercent = round(($usedBytes / $totalBytes) * 100, 2);
$warnings[] = 'Memory usage fell back to /proc/meminfo because cgroup limits were unavailable.';
$this->pushWarning(
$warnings,
'memory_proc_fallback',
[],
'Memory usage fell back to /proc/meminfo because cgroup limits were unavailable.'
);
return [
'status' => self::statusFromUsagePercent($usagePercent, 90, 99),
'usage_percent' => $usagePercent,
@@ -335,7 +438,12 @@ class superuser_system_status_service
}
}
$warnings[] = 'Memory metrics are unavailable in this runtime.';
$this->pushWarning(
$warnings,
'memory_unavailable',
[],
'Memory metrics are unavailable in this runtime.'
);
return [
'status' => 'down',
'usage_percent' => null,
@@ -420,13 +528,15 @@ class superuser_system_status_service
'probe_supported' => isset($descriptor['probe']),
'status' => 'configured',
'status_reason' => null,
'status_reason_key' => null,
'status_reason_params' => [],
'checked_at' => date('c'),
];
if (!$enabled) {
$result['configured'] = false;
$result['status'] = 'disabled';
$result['status_reason'] = 'Module is disabled.';
$result = array_merge($result, $this->moduleReason('module_disabled', [], 'Module is disabled.'));
$results[] = $result;
continue;
}
@@ -434,6 +544,10 @@ class superuser_system_status_service
if (!$configured) {
$result['status'] = 'not_configured';
$result['status_reason'] = (string)($configuration['reason'] ?? ('Missing required configuration: ' . implode(', ', $missingRequired)));
$result['status_reason_key'] = $configuration['reason_key'] ?? 'missing_config';
$result['status_reason_params'] = isset($configuration['reason_params']) && is_array($configuration['reason_params'])
? $configuration['reason_params']
: ['variables' => implode(', ', $missingRequired), 'variables_list' => $missingRequired];
$results[] = $result;
continue;
}
@@ -441,7 +555,14 @@ class superuser_system_status_service
if (!isset($descriptor['probe'])) {
$modulesWithoutProbes[] = $descriptor['key'];
$result['status'] = 'configured';
$result['status_reason'] = 'Configuration is present, but no safe read-only probe is available.';
$result = array_merge(
$result,
$this->moduleReason(
'safe_probe_unavailable',
[],
'Configuration is present, but no safe read-only probe is available.'
)
);
$results[] = $result;
continue;
}
@@ -449,12 +570,24 @@ class superuser_system_status_service
$probeResult = $this->resolveModuleProbeResult($descriptor, $moduleConfig, $force, $warnings);
$result['status'] = (string)($probeResult['status'] ?? 'configured');
$result['status_reason'] = $probeResult['status_reason'] ?? null;
$result['status_reason_key'] = $probeResult['status_reason_key'] ?? null;
$result['status_reason_params'] = isset($probeResult['status_reason_params']) && is_array($probeResult['status_reason_params'])
? $probeResult['status_reason_params']
: [];
$result['checked_at'] = (string)($probeResult['checked_at'] ?? $result['checked_at']);
$results[] = $result;
}
if (!empty($modulesWithoutProbes)) {
$warnings[] = 'Some modules expose configuration-only status because no safe read-only probe exists: ' . implode(', ', $modulesWithoutProbes) . '.';
$this->pushWarning(
$warnings,
'modules_without_probes',
[
'modules' => implode(', ', $modulesWithoutProbes),
'module_keys' => $modulesWithoutProbes,
],
'Some modules expose configuration-only status because no safe read-only probe exists: ' . implode(', ', $modulesWithoutProbes) . '.'
);
}
return $results;
@@ -475,7 +608,15 @@ class superuser_system_status_service
}
}
} catch (Throwable $throwable) {
$warnings[] = 'Redis cache lookup failed for module probe ' . $descriptor['key'] . ': ' . $throwable->getMessage();
$this->pushWarning(
$warnings,
'redis_cache_lookup_failed',
[
'module' => $descriptor['key'],
'error' => $throwable->getMessage(),
],
'Redis cache lookup failed for module probe ' . $descriptor['key'] . ': ' . $throwable->getMessage()
);
}
}
@@ -489,6 +630,8 @@ class superuser_system_status_service
$probeResult = [
'status' => 'down',
'status_reason' => 'Probe returned an invalid payload.',
'status_reason_key' => 'invalid_probe_payload',
'status_reason_params' => [],
'checked_at' => date('c'),
];
}
@@ -505,7 +648,15 @@ class superuser_system_status_service
self::MODULE_PROBE_TTL_SECONDS
);
} catch (Throwable $throwable) {
$warnings[] = 'Redis cache write failed for module probe ' . $descriptor['key'] . ': ' . $throwable->getMessage();
$this->pushWarning(
$warnings,
'redis_cache_write_failed',
[
'module' => $descriptor['key'],
'error' => $throwable->getMessage(),
],
'Redis cache write failed for module probe ' . $descriptor['key'] . ': ' . $throwable->getMessage()
);
}
}
@@ -523,6 +674,10 @@ class superuser_system_status_service
'configured' => count($missing) === 0,
'missing' => $missing,
'reason' => count($missing) === 0 ? null : 'Missing required configuration: ' . implode(', ', $missing),
'reason_key' => count($missing) === 0 ? null : 'missing_config',
'reason_params' => count($missing) === 0
? []
: ['variables' => implode(', ', $missing), 'variables_list' => $missing],
];
}
@@ -590,6 +745,8 @@ class superuser_system_status_service
'configured' => false,
'missing' => ['mailersend_enabled'],
'reason' => 'MailerSend must be enabled because default SMTP delivery is not implemented.',
'reason_key' => 'email_delivery_not_implemented',
'reason_params' => [],
];
}
@@ -600,6 +757,10 @@ class superuser_system_status_service
'configured' => count($missing) === 0,
'missing' => $missing,
'reason' => count($missing) === 0 ? null : 'Missing required configuration: ' . implode(', ', $missing),
'reason_key' => count($missing) === 0 ? null : 'missing_config',
'reason_params' => count($missing) === 0
? []
: ['variables' => implode(', ', $missing), 'variables_list' => $missing],
];
}
@@ -669,6 +830,8 @@ class superuser_system_status_service
return [
'status' => 'down',
'status_reason' => 'e-conomic credentials are missing from runtime environment configuration.',
'status_reason_key' => 'economic_credentials_missing',
'status_reason_params' => [],
'checked_at' => date('c'),
];
}
@@ -727,6 +890,8 @@ class superuser_system_status_service
return [
'status' => 'ok',
'status_reason' => 'Backup store connectivity confirmed.',
'status_reason_key' => 'backup_connectivity_confirmed',
'status_reason_params' => [],
'checked_at' => $checkedAt,
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
];
@@ -734,6 +899,8 @@ class superuser_system_status_service
return [
'status' => 'down',
'status_reason' => 'Backup store probe failed: ' . $throwable->getMessage(),
'status_reason_key' => 'backup_probe_failed',
'status_reason_params' => ['error' => $throwable->getMessage()],
'checked_at' => $checkedAt,
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
];
@@ -896,6 +1063,8 @@ class superuser_system_status_service
return [
'status' => 'configured',
'status_reason' => 'Shelly cloud credentials are configured, but no known device id is available for a safe read-only probe.',
'status_reason_key' => 'shelly_no_device_id',
'status_reason_params' => [],
'checked_at' => date('c'),
];
}
@@ -924,6 +1093,8 @@ class superuser_system_status_service
return [
'status' => 'down',
'status_reason' => 'Self-serve machine minutes configuration is invalid.',
'status_reason_key' => 'selfserve_minutes_invalid',
'status_reason_params' => [],
'checked_at' => $checkedAt,
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
];
@@ -934,6 +1105,8 @@ class superuser_system_status_service
return [
'status' => 'down',
'status_reason' => 'Self-serve minute product configuration is invalid.',
'status_reason_key' => 'selfserve_minute_product_invalid',
'status_reason_params' => [],
'checked_at' => $checkedAt,
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
];
@@ -944,6 +1117,8 @@ class superuser_system_status_service
return [
'status' => 'down',
'status_reason' => 'Self-serve minute product #' . $minuteProductId . ' does not exist.',
'status_reason_key' => 'selfserve_minute_product_missing',
'status_reason_params' => ['productId' => $minuteProductId],
'checked_at' => $checkedAt,
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
];
@@ -952,6 +1127,8 @@ class superuser_system_status_service
return [
'status' => 'ok',
'status_reason' => 'Self-serve schema and minute product configuration confirmed.',
'status_reason_key' => 'selfserve_configuration_confirmed',
'status_reason_params' => [],
'checked_at' => $checkedAt,
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
];
@@ -959,6 +1136,8 @@ class superuser_system_status_service
return [
'status' => 'down',
'status_reason' => 'Self-serve probe failed: ' . $throwable->getMessage(),
'status_reason_key' => 'selfserve_probe_failed',
'status_reason_params' => ['error' => $throwable->getMessage()],
'checked_at' => $checkedAt,
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
];
@@ -1024,12 +1203,24 @@ class superuser_system_status_service
{
$checkedAt = date('c');
if ($url === '') {
return ['status' => 'down', 'status_reason' => $label . ' probe could not run because the endpoint is missing.', 'checked_at' => $checkedAt];
return [
'status' => 'down',
'status_reason' => $label . ' probe could not run because the endpoint is missing.',
'status_reason_key' => 'http_endpoint_missing',
'status_reason_params' => ['label' => $label],
'checked_at' => $checkedAt,
];
}
$curl = curl_init($url);
if ($curl === false) {
return ['status' => 'down', 'status_reason' => $label . ' probe could not initialize cURL.', 'checked_at' => $checkedAt];
return [
'status' => 'down',
'status_reason' => $label . ' probe could not initialize cURL.',
'status_reason_key' => 'http_curl_init_failed',
'status_reason_params' => ['label' => $label],
'checked_at' => $checkedAt,
];
}
curl_setopt_array($curl, [
@@ -1062,7 +1253,14 @@ class superuser_system_status_service
];
if ($body === false && $error !== '') {
return ['status' => 'down', 'status_reason' => $label . ' probe failed: ' . $error, 'checked_at' => $checkedAt, 'latency_ms' => $httpResponse['latency_ms']];
return [
'status' => 'down',
'status_reason' => $label . ' probe failed: ' . $error,
'status_reason_key' => 'http_probe_failed',
'status_reason_params' => ['label' => $label, 'error' => $error],
'checked_at' => $checkedAt,
'latency_ms' => $httpResponse['latency_ms'],
];
}
if ($responseEvaluator !== null) {
@@ -1095,6 +1293,8 @@ class superuser_system_status_service
return [
'status' => 'down',
'status_reason' => $label . ' probe failed: ' . $error,
'status_reason_key' => 'http_probe_failed',
'status_reason_params' => ['label' => $label, 'error' => $error],
'checked_at' => $checkedAt,
'latency_ms' => $latencyMs,
];
@@ -1104,6 +1304,8 @@ class superuser_system_status_service
return [
'status' => 'down',
'status_reason' => $label . ' did not return an HTTP response.',
'status_reason_key' => 'http_no_response',
'status_reason_params' => ['label' => $label],
'checked_at' => $checkedAt,
'latency_ms' => $latencyMs,
];
@@ -1113,6 +1315,8 @@ class superuser_system_status_service
return [
'status' => 'ok',
'status_reason' => $label . ' connectivity confirmed.',
'status_reason_key' => 'http_ok',
'status_reason_params' => ['label' => $label],
'checked_at' => $checkedAt,
'latency_ms' => $latencyMs,
'http_status' => $httpStatus,
@@ -1123,6 +1327,8 @@ class superuser_system_status_service
return [
'status' => 'degraded',
'status_reason' => $label . ' probe was rate limited (HTTP 429).',
'status_reason_key' => 'http_rate_limited',
'status_reason_params' => ['label' => $label, 'httpStatus' => $httpStatus],
'checked_at' => $checkedAt,
'latency_ms' => $latencyMs,
'http_status' => $httpStatus,
@@ -1133,6 +1339,8 @@ class superuser_system_status_service
return [
'status' => 'degraded',
'status_reason' => $label . ' returned HTTP ' . $httpStatus . '.',
'status_reason_key' => 'http_status',
'status_reason_params' => ['label' => $label, 'httpStatus' => $httpStatus],
'checked_at' => $checkedAt,
'latency_ms' => $latencyMs,
'http_status' => $httpStatus,
@@ -1143,6 +1351,8 @@ class superuser_system_status_service
return [
'status' => 'down',
'status_reason' => $label . ' returned HTTP ' . $httpStatus . '.',
'status_reason_key' => 'http_status',
'status_reason_params' => ['label' => $label, 'httpStatus' => $httpStatus],
'checked_at' => $checkedAt,
'latency_ms' => $latencyMs,
'http_status' => $httpStatus,
@@ -1152,6 +1362,8 @@ class superuser_system_status_service
return [
'status' => 'degraded',
'status_reason' => $label . ' returned an unexpected HTTP response.',
'status_reason_key' => 'http_unexpected_response',
'status_reason_params' => ['label' => $label],
'checked_at' => $checkedAt,
'latency_ms' => $latencyMs,
'http_status' => $httpStatus,
@@ -1170,6 +1382,8 @@ class superuser_system_status_service
return [
'status' => 'degraded',
'status_reason' => $label . ' returned an unreadable response payload.',
'status_reason_key' => 'recaptcha_unreadable_payload',
'status_reason_params' => ['label' => $label],
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
'latency_ms' => $httpResponse['latency_ms'] ?? null,
'http_status' => $httpResponse['http_status'] ?? null,
@@ -1185,6 +1399,8 @@ class superuser_system_status_service
return [
'status' => 'ok',
'status_reason' => $label . ' connectivity confirmed.',
'status_reason_key' => 'http_ok',
'status_reason_params' => ['label' => $label],
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
'latency_ms' => $httpResponse['latency_ms'] ?? null,
'http_status' => $httpResponse['http_status'] ?? null,
@@ -1195,6 +1411,8 @@ class superuser_system_status_service
return [
'status' => 'down',
'status_reason' => $label . ' credentials were rejected by Google.',
'status_reason_key' => 'recaptcha_credentials_rejected',
'status_reason_params' => ['label' => $label],
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
'latency_ms' => $httpResponse['latency_ms'] ?? null,
'http_status' => $httpResponse['http_status'] ?? null,
@@ -1204,6 +1422,8 @@ class superuser_system_status_service
return [
'status' => 'degraded',
'status_reason' => $label . ' returned unexpected validation errors: ' . implode(', ', $errorCodes),
'status_reason_key' => 'recaptcha_validation_errors',
'status_reason_params' => ['label' => $label, 'errors' => implode(', ', $errorCodes)],
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
'latency_ms' => $httpResponse['latency_ms'] ?? null,
'http_status' => $httpResponse['http_status'] ?? null,