mirror of
git://f0xx.org/ac/ac-deploy
synced 2026-07-29 03:17:55 +03:00
added 'monitoring' path - ask devs why it's there
This commit is contained in:
89
monitoring/README.md
Normal file
89
monitoring/README.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# AC Cluster Monitoring — Deploy Guide
|
||||
|
||||
**Stack:** Prometheus 3.5 · Grafana 12.4 · Alertmanager 0.32 · blackbox_exporter · node_exporter · mysqld_exporter · postgres_exporter
|
||||
**SPEC:** [ac-docs/specs/20260626_cluster_monitoring.md](../../ac-docs/specs/20260626_cluster_monitoring.md)
|
||||
**DR:** [ac-docs/DRs/20260626_cluster_monitoring.md](../../ac-docs/DRs/20260626_cluster_monitoring.md)
|
||||
**Status:** R0 live on cast04 as of 2026-06-26
|
||||
|
||||
---
|
||||
|
||||
## Topology
|
||||
|
||||
| Node | IP | Role | Exporters |
|
||||
|------|----|------|-----------|
|
||||
| cast04 | 10.7.16.239 | Monitoring host | Prometheus + Grafana + Alertmanager + blackbox + node |
|
||||
| cast01 | 10.7.16.236 | App + Gitea + PostgreSQL | node + postgres_exporter |
|
||||
| cast02 | 10.7.16.237 | App | node_exporter |
|
||||
| cast03 | 10.7.16.238 | App | node_exporter |
|
||||
| artc0 | 10.7.16.128 | BE + MariaDB + Docker | node + mysqld_exporter (Docker) |
|
||||
|
||||
## Access
|
||||
|
||||
- **Grafana (public):** https://apps.f0xx.org/app/androidcast_project/monitor/
|
||||
Login via existing PHP session (auto-SSO). Redirects to `/login` if not authenticated.
|
||||
- **Grafana (direct internal):** http://10.7.16.239:3000/ (admin/acMonitor2026!)
|
||||
- **Prometheus UI:** http://10.7.16.239:9090/ (no auth; internal only)
|
||||
- **Alertmanager UI:** http://10.7.16.239:9093/ (no auth; internal only)
|
||||
|
||||
## Config files (on cast04)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `/etc/prometheus/prometheus.yml` | Scrape targets |
|
||||
| `/etc/prometheus/rules/node_alerts.yml` | HW/OS alert rules (10 rules) |
|
||||
| `/etc/prometheus/rules/service_alerts.yml` | Service/HTTP/DB alert rules (10 rules) |
|
||||
| `/etc/alertmanager/alertmanager.yml` | Alert routing (email + Telegram stub) |
|
||||
| `/etc/grafana/grafana.ini` | Grafana config (subpath, auth proxy) |
|
||||
| `/var/lib/grafana/provisioning/` | Auto-provisioned datasource + dashboards dir |
|
||||
| `/etc/conf.d/grafana` | Grafana runtime overrides (bind address, provisioning path) |
|
||||
|
||||
## Config files (on artc0/BE)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `/etc/nginx/conf.d/apps.conf` | BE nginx — includes monitoring proxy location |
|
||||
| `/var/www/.../api/grafana-auth-check.php` | PHP session → X-WEBAUTH-USER auth bridge |
|
||||
| `/etc/mysqld-exporter.cnf` | MariaDB exporter credentials |
|
||||
|
||||
## Services management (cast04)
|
||||
|
||||
```sh
|
||||
# Status
|
||||
for svc in prometheus node-exporter blackbox-exporter alertmanager grafana; do
|
||||
echo "$svc: $(rc-service $svc status 2>&1 | grep -o 'started\|stopped')"
|
||||
done
|
||||
|
||||
# Restart all
|
||||
for svc in prometheus node-exporter blackbox-exporter alertmanager grafana; do
|
||||
sudo rc-service $svc restart
|
||||
done
|
||||
```
|
||||
|
||||
## Grafana dashboards (imported)
|
||||
|
||||
| Dashboard | URL |
|
||||
|-----------|-----|
|
||||
| Node Exporter Full | `/d/rYdddlPWk/node-exporter-full` |
|
||||
| MySQL Exporter Quickstart | `/d/...` |
|
||||
| Prometheus Blackbox Exporter | `/d/xtkCtBkiz/prometheus-blackbox-exporter` |
|
||||
| Node Exporter EN Stats | `/d/xfpJB9FGz/...` |
|
||||
|
||||
## TODO before production
|
||||
|
||||
1. **Email alerts:** Fill in Gmail app password in `/etc/alertmanager/alertmanager.yml`
|
||||
2. **Telegram alerts:** Add `bot_token` + `chat_id` to `multi-critical` receiver
|
||||
3. **postgres_exporter:** Verify cast01 `pg_up 1` after next restart
|
||||
4. **UptimeRobot:** Create account + add HTTP monitor for `apps.f0xx.org/app/androidcast_project/`
|
||||
5. **Grafana admin password:** Change from default `acMonitor2026!` after setting up auth proxy
|
||||
|
||||
## Alert thresholds
|
||||
|
||||
| Metric | Warning | Critical |
|
||||
|--------|---------|----------|
|
||||
| CPU | >85% for 10m | >95% for 5m |
|
||||
| RAM free | <15% for 5m | <5% for 2m |
|
||||
| Disk free | <20% for 15m | <10% for 10m |
|
||||
| Load (per vCPU) | >1.5× for 5m | >3.0× for 5m |
|
||||
| HTTP probe | >5s for 5m | down >3m |
|
||||
| SSL cert | <14 days | <7 days |
|
||||
| MariaDB | high connections | down >2m |
|
||||
52
monitoring/artc0/grafana-auth-check.php
Normal file
52
monitoring/artc0/grafana-auth-check.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
/**
|
||||
* Grafana auth_request endpoint — nginx calls this to validate PHP session
|
||||
* before proxying to cast04:3000 (Grafana).
|
||||
*
|
||||
* If session is valid: HTTP 200 + X-WEBAUTH-USER header (username)
|
||||
* If not authenticated: HTTP 401 (nginx redirects to /app/androidcast_project/login)
|
||||
*
|
||||
* Deploy to: /var/www/localhost/htdocs/apps/app/androidcast_project/api/grafana-auth-check.php
|
||||
*/
|
||||
|
||||
// Only allow calls from nginx itself (127.0.0.1) — block direct browser access
|
||||
if (!in_array($_SERVER['REMOTE_ADDR'] ?? '', ['127.0.0.1', '::1'], true)) {
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Session handling — must match the main app's session settings
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_set_cookie_params([
|
||||
'path' => '/app/androidcast_project/',
|
||||
'secure' => isset($_SERVER['HTTPS']),
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
session_start();
|
||||
}
|
||||
|
||||
// Check if user is logged in — Auth.php stores array in $_SESSION['user']
|
||||
// with at least { 'id' => int, 'username' => string, ... }
|
||||
$user = $_SESSION['user'] ?? null;
|
||||
|
||||
if (empty($user) || empty($user['id'])) {
|
||||
http_response_code(401);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Require full login (not just 2FA pending)
|
||||
if (isset($user['pending_2fa']) && $user['pending_2fa']) {
|
||||
http_response_code(401);
|
||||
exit;
|
||||
}
|
||||
|
||||
$username = (string)($user['username'] ?? $user['email'] ?? 'user_' . $user['id']);
|
||||
|
||||
// Sanitize — Grafana username must be a valid identifier
|
||||
$grafana_user = preg_replace('/[^a-zA-Z0-9._@-]/', '_', $username);
|
||||
|
||||
http_response_code(200);
|
||||
header('X-WEBAUTH-USER: ' . $grafana_user);
|
||||
header('Content-Type: text/plain');
|
||||
echo 'ok';
|
||||
53
monitoring/artc0/mysqld-exporter-docker.sh
Normal file
53
monitoring/artc0/mysqld-exporter-docker.sh
Normal file
@@ -0,0 +1,53 @@
|
||||
#!/bin/sh
|
||||
# Deploy mysqld_exporter as Docker container on artc0
|
||||
# Run once as root / sudo after creating the MariaDB monitoring user below.
|
||||
#
|
||||
# PREREQUISITE — run in mysql:
|
||||
# CREATE USER 'exporter'@'localhost' IDENTIFIED BY 'ExporterPass2026!' WITH MAX_USER_CONNECTIONS 3;
|
||||
# GRANT PROCESS, REPLICATION CLIENT, SELECT ON *.* TO 'exporter'@'localhost';
|
||||
# FLUSH PRIVILEGES;
|
||||
#
|
||||
# Then run this script.
|
||||
|
||||
set -e
|
||||
|
||||
CONTAINER_NAME="mysqld_exporter"
|
||||
CNF_FILE="/etc/mysqld-exporter.cnf"
|
||||
|
||||
# Write config file if not present
|
||||
if [ ! -f "$CNF_FILE" ]; then
|
||||
cat > "$CNF_FILE" << 'EOF'
|
||||
[client]
|
||||
user=exporter
|
||||
password=ExporterPass2026!
|
||||
socket=/run/mysqld/mysqld.sock
|
||||
EOF
|
||||
chmod 644 "$CNF_FILE"
|
||||
fi
|
||||
|
||||
# Get socket GID to allow container user to access it
|
||||
SOCK_GID=$(stat -c '%g' /run/mysqld/mysqld.sock)
|
||||
|
||||
# Stop existing container if running
|
||||
docker stop "$CONTAINER_NAME" 2>/dev/null || true
|
||||
docker rm "$CONTAINER_NAME" 2>/dev/null || true
|
||||
|
||||
# Run with Unix socket mount — MariaDB uses skip-networking (no TCP)
|
||||
docker run -d \
|
||||
--name "$CONTAINER_NAME" \
|
||||
--restart unless-stopped \
|
||||
--user "65534:${SOCK_GID}" \
|
||||
-v "${CNF_FILE}:/etc/.my.cnf:ro" \
|
||||
-v "/run/mysqld/mysqld.sock:/run/mysqld/mysqld.sock:ro" \
|
||||
-p 9104:9104 \
|
||||
prom/mysqld-exporter:latest \
|
||||
--config.my-cnf=/etc/.my.cnf \
|
||||
--collect.info_schema.innodb_metrics \
|
||||
--collect.global_status \
|
||||
--collect.global_variables \
|
||||
--web.listen-address=":9104"
|
||||
|
||||
echo "mysqld_exporter started, listening on :9104"
|
||||
sleep 3
|
||||
docker logs "$CONTAINER_NAME" 2>&1 | tail -5
|
||||
curl -s http://localhost:9104/metrics | grep 'mysql_up'
|
||||
77
monitoring/artc0/nginx-monitor-location.conf
Normal file
77
monitoring/artc0/nginx-monitor-location.conf
Normal file
@@ -0,0 +1,77 @@
|
||||
# Grafana monitoring proxy — add inside the `server { listen 80; }` block in apps.conf
|
||||
# Place BEFORE the generic location /app/androidcast_project/ block.
|
||||
#
|
||||
# Access flow:
|
||||
# Browser → FE nginx (TLS) → artc0 BE nginx :80 → cast04:3000 (Grafana)
|
||||
# auth_request validates PHP session on each request.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Internal auth_request endpoint (not directly accessible) ────────────
|
||||
location = /app/androidcast_project/api/grafana-auth-check.php {
|
||||
internal;
|
||||
include fastcgi_params;
|
||||
fastcgi_pass unix:/run/php-fpm.socket;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/api/grafana-auth-check.php;
|
||||
fastcgi_param SCRIPT_NAME /app/androidcast_project/api/grafana-auth-check.php;
|
||||
fastcgi_param REQUEST_URI $request_uri;
|
||||
# Forward the original session cookie so the PHP script can read it
|
||||
fastcgi_param HTTP_COOKIE $http_cookie;
|
||||
}
|
||||
|
||||
# ── Redirect bare paths to trailing slash ────────────────────────────────
|
||||
location = /app/androidcast_project/monitor {
|
||||
return 301 /app/androidcast_project/monitor/;
|
||||
}
|
||||
|
||||
location = /app/androidcast_project/alertmanager {
|
||||
return 301 /app/androidcast_project/alertmanager/;
|
||||
}
|
||||
|
||||
# ── Alertmanager UI at cast04:9093 via auth_request ──────────────────────
|
||||
location /app/androidcast_project/alertmanager/ {
|
||||
auth_request /app/androidcast_project/api/grafana-auth-check.php;
|
||||
auth_request_set $grafana_user $upstream_http_x_webauth_user;
|
||||
error_page 401 = @monitor_login_redirect;
|
||||
|
||||
proxy_pass http://10.7.16.239:9093/;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_read_timeout 120;
|
||||
proxy_send_timeout 120;
|
||||
}
|
||||
|
||||
# ── Grafana at cast04:3000 via auth_request ───────────────────────────────
|
||||
location /app/androidcast_project/monitor/ {
|
||||
# Validate PHP session before allowing through
|
||||
auth_request /app/androidcast_project/api/grafana-auth-check.php;
|
||||
|
||||
# Capture username from auth response header and forward to Grafana
|
||||
auth_request_set $grafana_user $upstream_http_x_webauth_user;
|
||||
|
||||
# Redirect to login if not authenticated
|
||||
error_page 401 = @monitor_login_redirect;
|
||||
|
||||
proxy_pass http://10.7.16.239:3000/;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# Pass authenticated user to Grafana auth proxy
|
||||
proxy_set_header X-WEBAUTH-USER $grafana_user;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_read_timeout 300;
|
||||
proxy_send_timeout 300;
|
||||
# WebSocket support (Grafana live)
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
|
||||
# ── Login redirect for unauthenticated monitor access ────────────────────
|
||||
location @monitor_login_redirect {
|
||||
return 302 /app/androidcast_project/login?redirect=$request_uri;
|
||||
}
|
||||
24
monitoring/cast04/alert-notifier.env
Normal file
24
monitoring/cast04/alert-notifier.env
Normal file
@@ -0,0 +1,24 @@
|
||||
# alert-notifier.env — config for alert-notifier.py on cast04
|
||||
# Keep this file readable only by the service user (chmod 600).
|
||||
|
||||
# ── URL shortener ──────────────────────────────────────────────────────────
|
||||
SHORTENER_INTERNAL=http://10.7.16.128
|
||||
SHORTENER_HOST_HDR=s.f0xx.org
|
||||
SHORTENER_BEARER=3b7031ef38d9a5cb33f6b2e789c27fe8ae32c743f6aa8df7
|
||||
SHORTENER_TTL=86400
|
||||
|
||||
# ── SMTP (Gmail app password) ──────────────────────────────────────────────
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=bestcastr@gmail.com
|
||||
SMTP_PASS=wnxv caln tcmt ldpn
|
||||
EMAIL_FROM=Android Cast Alerts <bestcastr@gmail.com>
|
||||
EMAIL_TO=a.afanasieff@gmail.com
|
||||
|
||||
# ── Telegram ───────────────────────────────────────────────────────────────
|
||||
TELEGRAM_BOT=8841511259:AAGNhJICFiyID-3C8LvoMMmnpfw-x6bkVpA
|
||||
TELEGRAM_CHAT=5616327561
|
||||
|
||||
# ── Service ────────────────────────────────────────────────────────────────
|
||||
LISTEN_PORT=9099
|
||||
FALLBACK_URL=https://apps.f0xx.org/app/androidcast_project/alertmanager/
|
||||
371
monitoring/cast04/alert-notifier.py
Normal file
371
monitoring/cast04/alert-notifier.py
Normal file
@@ -0,0 +1,371 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Alertmanager webhook receiver — alert-notifier.py
|
||||
Runs on cast04:9099. Receives JSON from Alertmanager, then for each batch:
|
||||
1. Shortens the alert URL via the project URL shortener (s.f0xx.org)
|
||||
2. Fetches QR PNG from the shortener's built-in QR endpoint
|
||||
3. Sends HTML e-mail with the QR embedded as inline image
|
||||
4. Sends Telegram message with the QR image attached
|
||||
|
||||
Config is read from alert-notifier.env in the same directory as this script.
|
||||
"""
|
||||
|
||||
import http.server
|
||||
import json
|
||||
import smtplib
|
||||
import ssl
|
||||
import subprocess
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import email.mime.multipart
|
||||
import email.mime.text
|
||||
import email.mime.image
|
||||
import email.utils
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import pathlib
|
||||
|
||||
# ── Config defaults (overridden by .env file) ───────────────────────────────
|
||||
SHORTENER_INTERNAL = os.environ.get('SHORTENER_INTERNAL', 'http://10.7.16.128')
|
||||
SHORTENER_HOST_HDR = os.environ.get('SHORTENER_HOST_HDR', 's.f0xx.org')
|
||||
SHORTENER_BEARER = os.environ.get('SHORTENER_BEARER', '')
|
||||
SHORTENER_TTL = int(os.environ.get('SHORTENER_TTL', '86400')) # 1 day default
|
||||
|
||||
SMTP_HOST = os.environ.get('SMTP_HOST', 'smtp.gmail.com')
|
||||
SMTP_PORT = int(os.environ.get('SMTP_PORT', '587'))
|
||||
SMTP_USER = os.environ.get('SMTP_USER', 'bestcastr@gmail.com')
|
||||
SMTP_PASS = os.environ.get('SMTP_PASS', '')
|
||||
EMAIL_FROM = os.environ.get('EMAIL_FROM', 'Android Cast Alerts <bestcastr@gmail.com>')
|
||||
EMAIL_TO = os.environ.get('EMAIL_TO', 'a.afanasieff@gmail.com')
|
||||
|
||||
TELEGRAM_BOT = os.environ.get('TELEGRAM_BOT', '')
|
||||
TELEGRAM_CHAT = int(os.environ.get('TELEGRAM_CHAT', '0'))
|
||||
|
||||
LISTEN_PORT = int(os.environ.get('LISTEN_PORT', '9099'))
|
||||
|
||||
# Default URL to shorten when no alert-specific URL is available
|
||||
FALLBACK_URL = os.environ.get(
|
||||
'FALLBACK_URL',
|
||||
'https://apps.f0xx.org/app/androidcast_project/alertmanager/'
|
||||
)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s %(levelname)s %(message)s',
|
||||
stream=sys.stdout,
|
||||
)
|
||||
log = logging.getLogger('alert-notifier')
|
||||
|
||||
|
||||
# ── URL shortener ────────────────────────────────────────────────────────────
|
||||
|
||||
def shorten(url: str) -> dict:
|
||||
"""
|
||||
POST the destination URL (no ?src=qr) to the project URL shortener.
|
||||
Returns dict with keys:
|
||||
short_url – plain https://s.f0xx.org/SLUG (used as click-link in email/TG)
|
||||
qr_url – https://s.f0xx.org/SLUG?src=qr (encoded into the QR image)
|
||||
slug – bare slug
|
||||
Raises on failure.
|
||||
"""
|
||||
payload = json.dumps({
|
||||
'url': url,
|
||||
'bearer': SHORTENER_BEARER,
|
||||
'ttl': SHORTENER_TTL,
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
f'{SHORTENER_INTERNAL}/api/v1/shorten',
|
||||
data=payload,
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'Host': SHORTENER_HOST_HDR,
|
||||
},
|
||||
method='POST',
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
data = json.loads(resp.read())
|
||||
if data.get('code', '') != '0':
|
||||
raise RuntimeError(f"shortener error: {data}")
|
||||
short_url = data['u']
|
||||
slug = short_url.rsplit('/', 1)[-1]
|
||||
# The QR image encodes the short URL WITH ?src=qr so every mobile scan
|
||||
# is counted separately from normal link clicks on the backend.
|
||||
qr_url = short_url + ('&src=qr' if '?' in short_url else '?src=qr')
|
||||
return {
|
||||
'short_url': short_url,
|
||||
'qr_url': qr_url,
|
||||
'slug': slug,
|
||||
}
|
||||
|
||||
|
||||
def make_qr_png(url: str) -> bytes:
|
||||
"""
|
||||
Generate a QR code PNG for *url* using the system qrencode binary.
|
||||
Returns raw PNG bytes. Raises on failure.
|
||||
"""
|
||||
result = subprocess.run(
|
||||
['qrencode', '-t', 'PNG', '-o', '-', '-s', '6', '--', url],
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
|
||||
# ── E-mail ───────────────────────────────────────────────────────────────────
|
||||
|
||||
SEVERITY_EMOJI = {
|
||||
'critical': '🔴',
|
||||
'warning': '🟡',
|
||||
'info': '🔵',
|
||||
}
|
||||
|
||||
STATUS_EMOJI = {
|
||||
'firing': '🔥',
|
||||
'resolved': '✅',
|
||||
}
|
||||
|
||||
|
||||
def _alert_html_row(alert: dict) -> str:
|
||||
status = alert.get('status', 'firing')
|
||||
labels = alert.get('labels', {})
|
||||
anns = alert.get('annotations', {})
|
||||
name = labels.get('alertname', '(unknown)')
|
||||
instance = labels.get('instance', '')
|
||||
severity = labels.get('severity', '')
|
||||
summary = anns.get('summary', name)
|
||||
desc = anns.get('description', '')
|
||||
starts = alert.get('startsAt', '')[:19].replace('T', ' ')
|
||||
s_emoji = STATUS_EMOJI.get(status, '')
|
||||
sv_emoji = SEVERITY_EMOJI.get(severity, '')
|
||||
color = '#c0392b' if severity == 'critical' else '#e67e22' if severity == 'warning' else '#2980b9'
|
||||
return f"""
|
||||
<tr>
|
||||
<td style="padding:8px;border-bottom:1px solid #eee;">
|
||||
<b style="color:{color};">{s_emoji} {sv_emoji} {status.upper()}</b>
|
||||
— <b>{name}</b>
|
||||
{f'<span style="color:#666;font-size:0.9em"> @ {instance}</span>' if instance else ''}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:4px 8px 12px 8px;border-bottom:1px solid #eee;color:#333;">
|
||||
{f'<b>Summary:</b> {summary}<br>' if summary else ''}
|
||||
{f'<b>Detail:</b> {desc}<br>' if desc else ''}
|
||||
{f'<b>Fired:</b> {starts} UTC' if starts else ''}
|
||||
</td>
|
||||
</tr>"""
|
||||
|
||||
|
||||
def send_email(subject: str, alerts: list, short_url: str, qr_png: bytes) -> None:
|
||||
msg = email.mime.multipart.MIMEMultipart('related')
|
||||
msg['From'] = EMAIL_FROM
|
||||
msg['To'] = EMAIL_TO
|
||||
msg['Subject'] = subject
|
||||
msg['Date'] = email.utils.formatdate(localtime=False)
|
||||
msg['Message-ID'] = email.utils.make_msgid(domain='cast04.intra.raptor.org')
|
||||
|
||||
rows = ''.join(_alert_html_row(a) for a in alerts)
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html><body style="font-family:Arial,sans-serif;max-width:640px;margin:auto;">
|
||||
<div style="background:#1a1a2e;padding:16px;border-radius:6px 6px 0 0;">
|
||||
<h2 style="color:#fff;margin:0;">Android Cast — Monitoring Alert</h2>
|
||||
</div>
|
||||
<div style="border:1px solid #ddd;border-top:none;padding:16px;border-radius:0 0 6px 6px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0">{rows}</table>
|
||||
<hr style="margin:16px 0;">
|
||||
<p style="margin:0;">
|
||||
<b>View in Alertmanager:</b>
|
||||
<a href="{short_url}">{short_url}</a>
|
||||
</p>
|
||||
<p style="margin:8px 0 4px 0;color:#555;font-size:0.85em;">
|
||||
Scan QR to open on mobile (tracked via url-shortener):
|
||||
</p>
|
||||
<img src="cid:alert_qr" alt="QR code" width="200" height="200"
|
||||
style="display:block;border:1px solid #ccc;padding:4px;border-radius:4px;">
|
||||
</div>
|
||||
<p style="color:#999;font-size:0.75em;text-align:center;margin-top:8px;">
|
||||
cast04 monitoring · <a href="https://apps.f0xx.org/app/androidcast_project/monitor/">Grafana</a>
|
||||
</p>
|
||||
</body></html>"""
|
||||
|
||||
alternative = email.mime.multipart.MIMEMultipart('alternative')
|
||||
alternative.attach(email.mime.text.MIMEText(html, 'html', 'utf-8'))
|
||||
msg.attach(alternative)
|
||||
|
||||
qr_img = email.mime.image.MIMEImage(qr_png, 'png')
|
||||
qr_img.add_header('Content-ID', '<alert_qr>')
|
||||
qr_img.add_header('Content-Disposition', 'inline', filename='alert-qr.png')
|
||||
msg.attach(qr_img)
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=15) as s:
|
||||
s.ehlo()
|
||||
s.starttls(context=ctx)
|
||||
s.ehlo()
|
||||
s.login(SMTP_USER, SMTP_PASS)
|
||||
s.sendmail(SMTP_USER, EMAIL_TO, msg.as_bytes())
|
||||
log.info('email sent to %s subject=%r', EMAIL_TO, subject)
|
||||
|
||||
|
||||
# ── Telegram ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def send_telegram(text: str, qr_png: bytes) -> None:
|
||||
if not TELEGRAM_BOT or not TELEGRAM_CHAT:
|
||||
return
|
||||
|
||||
import io, email.generator
|
||||
|
||||
boundary = '----WebKitFormBoundary7MA4YWxkTrZu0gW'
|
||||
|
||||
# Build multipart/form-data body manually
|
||||
body = (
|
||||
f'--{boundary}\r\n'
|
||||
f'Content-Disposition: form-data; name="chat_id"\r\n\r\n'
|
||||
f'{TELEGRAM_CHAT}\r\n'
|
||||
f'--{boundary}\r\n'
|
||||
f'Content-Disposition: form-data; name="caption"\r\n\r\n'
|
||||
f'{text}\r\n'
|
||||
f'--{boundary}\r\n'
|
||||
f'Content-Disposition: form-data; name="parse_mode"\r\n\r\n'
|
||||
f'HTML\r\n'
|
||||
f'--{boundary}\r\n'
|
||||
f'Content-Disposition: form-data; name="photo"; filename="alert-qr.png"\r\n'
|
||||
f'Content-Type: image/png\r\n\r\n'
|
||||
).encode() + qr_png + f'\r\n--{boundary}--\r\n'.encode()
|
||||
|
||||
req = urllib.request.Request(
|
||||
f'https://api.telegram.org/bot{TELEGRAM_BOT}/sendPhoto',
|
||||
data=body,
|
||||
headers={'Content-Type': f'multipart/form-data; boundary={boundary}'},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
result = json.loads(resp.read())
|
||||
if not result.get('ok'):
|
||||
raise RuntimeError(f"Telegram error: {result}")
|
||||
log.info('telegram photo sent to chat_id=%s', TELEGRAM_CHAT)
|
||||
|
||||
|
||||
# ── HTTP handler ─────────────────────────────────────────────────────────────
|
||||
|
||||
class WebhookHandler(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt, *args): # suppress default access log
|
||||
log.debug(fmt, *args)
|
||||
|
||||
def do_POST(self):
|
||||
if self.path != '/webhook':
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
length = int(self.headers.get('Content-Length', 0))
|
||||
body = self.rfile.read(length)
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
self.send_response(400)
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
|
||||
# Process asynchronously (we already returned 200)
|
||||
try:
|
||||
self._handle(payload)
|
||||
except Exception as exc:
|
||||
log.error('webhook handler error: %s', exc, exc_info=True)
|
||||
|
||||
def _handle(self, payload: dict) -> None:
|
||||
alerts = payload.get('alerts', [])
|
||||
status = payload.get('status', 'firing')
|
||||
ext_url = payload.get('externalURL', FALLBACK_URL)
|
||||
|
||||
if not alerts:
|
||||
return
|
||||
|
||||
# ── shorten & QR ────────────────────────────────────────────────────
|
||||
try:
|
||||
short = shorten(ext_url)
|
||||
# QR image encodes short_url?src=qr — generated locally via qrencode
|
||||
qr_png = make_qr_png(short['qr_url'])
|
||||
log.info('shortened %s → %s qr_url=%s (%d B)',
|
||||
ext_url, short['short_url'], short['qr_url'], len(qr_png))
|
||||
except Exception as exc:
|
||||
log.error('shorten/qr failed: %s', exc)
|
||||
return
|
||||
|
||||
# ── subject / Telegram text ──────────────────────────────────────────
|
||||
firing_names = [a['labels'].get('alertname', '?') for a in alerts if a.get('status') == 'firing']
|
||||
resolved_names = [a['labels'].get('alertname', '?') for a in alerts if a.get('status') == 'resolved']
|
||||
|
||||
if status == 'resolved':
|
||||
subject = f'[RESOLVED] {", ".join(resolved_names or firing_names)}'
|
||||
else:
|
||||
subject = f'[ALERT] {", ".join(firing_names or [a["labels"].get("alertname","?") for a in alerts])}'
|
||||
|
||||
tg_lines = []
|
||||
for a in alerts:
|
||||
s = a.get('status', 'firing')
|
||||
lbl = a.get('labels', {})
|
||||
ann = a.get('annotations', {})
|
||||
emoji = STATUS_EMOJI.get(s, '') + ' ' + SEVERITY_EMOJI.get(lbl.get('severity',''), '')
|
||||
tg_lines.append(
|
||||
f'{emoji} <b>{s.upper()}</b> {lbl.get("alertname","?")} @ {lbl.get("instance","")}\n'
|
||||
f'{ann.get("summary","")}\n'
|
||||
f'{ann.get("description","")}'
|
||||
)
|
||||
|
||||
tg_text = '\n\n'.join(tg_lines) + f'\n\n🔗 {short["short_url"]}'
|
||||
|
||||
# ── send ─────────────────────────────────────────────────────────────
|
||||
try:
|
||||
send_email(subject, alerts, short['short_url'], qr_png)
|
||||
except Exception as exc:
|
||||
log.error('email failed: %s', exc)
|
||||
|
||||
try:
|
||||
send_telegram(tg_text, qr_png)
|
||||
except Exception as exc:
|
||||
log.error('telegram failed: %s', exc)
|
||||
|
||||
|
||||
# ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
def load_env(path: str) -> None:
|
||||
"""Load KEY=VALUE pairs from an .env file into os.environ."""
|
||||
try:
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#') or '=' not in line:
|
||||
continue
|
||||
k, _, v = line.partition('=')
|
||||
os.environ.setdefault(k.strip(), v.strip())
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
env_file = pathlib.Path(__file__).with_suffix('.env')
|
||||
load_env(str(env_file))
|
||||
|
||||
# Re-read globals that may have been overridden by .env
|
||||
SHORTENER_INTERNAL = os.environ.get('SHORTENER_INTERNAL', SHORTENER_INTERNAL)
|
||||
SHORTENER_HOST_HDR = os.environ.get('SHORTENER_HOST_HDR', SHORTENER_HOST_HDR)
|
||||
SHORTENER_BEARER = os.environ.get('SHORTENER_BEARER', SHORTENER_BEARER)
|
||||
SHORTENER_TTL = int(os.environ.get('SHORTENER_TTL', str(SHORTENER_TTL)))
|
||||
SMTP_HOST = os.environ.get('SMTP_HOST', SMTP_HOST)
|
||||
SMTP_PORT = int(os.environ.get('SMTP_PORT', str(SMTP_PORT)))
|
||||
SMTP_USER = os.environ.get('SMTP_USER', SMTP_USER)
|
||||
SMTP_PASS = os.environ.get('SMTP_PASS', SMTP_PASS)
|
||||
EMAIL_FROM = os.environ.get('EMAIL_FROM', EMAIL_FROM)
|
||||
EMAIL_TO = os.environ.get('EMAIL_TO', EMAIL_TO)
|
||||
TELEGRAM_BOT = os.environ.get('TELEGRAM_BOT', TELEGRAM_BOT)
|
||||
TELEGRAM_CHAT = int(os.environ.get('TELEGRAM_CHAT', str(TELEGRAM_CHAT)))
|
||||
LISTEN_PORT = int(os.environ.get('LISTEN_PORT', str(LISTEN_PORT)))
|
||||
FALLBACK_URL = os.environ.get('FALLBACK_URL', FALLBACK_URL)
|
||||
|
||||
server = http.server.HTTPServer(('127.0.0.1', LISTEN_PORT), WebhookHandler)
|
||||
log.info('alert-notifier listening on 127.0.0.1:%d', LISTEN_PORT)
|
||||
server.serve_forever()
|
||||
61
monitoring/cast04/alertmanager.yml
Normal file
61
monitoring/cast04/alertmanager.yml
Normal file
@@ -0,0 +1,61 @@
|
||||
# Alertmanager config — cast04 (updated 2026-06-26)
|
||||
# Track A SPEC: ac-docs/specs/20260626_cluster_monitoring.md §7.4
|
||||
#
|
||||
# Outbound notifications are handled by alert-notifier.py (localhost:9099),
|
||||
# which shortens URLs via s.f0xx.org, generates QR codes, and sends
|
||||
# HTML email + Telegram with the QR image attached.
|
||||
|
||||
global:
|
||||
# No direct SMTP here — alert-notifier handles email delivery.
|
||||
# smtp_* kept as comment for reference:
|
||||
# smtp_smarthost: 'smtp.gmail.com:587'
|
||||
# smtp_auth_username: 'bestcastr@gmail.com'
|
||||
# smtp_auth_password: 'wnxv caln tcmt ldpn'
|
||||
|
||||
templates:
|
||||
- '/etc/alertmanager/templates/*.tmpl'
|
||||
|
||||
route:
|
||||
group_by: ['alertname', 'instance', 'severity']
|
||||
group_wait: 30s
|
||||
group_interval: 5m
|
||||
repeat_interval: 4h
|
||||
receiver: 'webhook-default'
|
||||
|
||||
routes:
|
||||
- match:
|
||||
severity: critical
|
||||
receiver: 'webhook-critical'
|
||||
group_wait: 10s
|
||||
repeat_interval: 1h
|
||||
|
||||
- match:
|
||||
severity: warning
|
||||
receiver: 'webhook-default'
|
||||
repeat_interval: 6h
|
||||
|
||||
- match:
|
||||
alertname: Watchdog
|
||||
receiver: 'null'
|
||||
|
||||
receivers:
|
||||
- name: 'null'
|
||||
|
||||
- name: 'webhook-default'
|
||||
webhook_configs:
|
||||
- url: 'http://127.0.0.1:9099/webhook'
|
||||
send_resolved: true
|
||||
max_alerts: 10
|
||||
|
||||
- name: 'webhook-critical'
|
||||
webhook_configs:
|
||||
- url: 'http://127.0.0.1:9099/webhook'
|
||||
send_resolved: true
|
||||
max_alerts: 10
|
||||
|
||||
inhibit_rules:
|
||||
- source_match:
|
||||
severity: 'critical'
|
||||
target_match:
|
||||
severity: 'warning'
|
||||
equal: ['instance', 'alertname']
|
||||
@@ -0,0 +1,13 @@
|
||||
# Dashboard provisioning config — Grafana reads JSON dashboards from this folder
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: 'ac-cluster'
|
||||
orgId: 1
|
||||
type: file
|
||||
disableDeletion: false
|
||||
updateIntervalSeconds: 30
|
||||
allowUiUpdates: true
|
||||
options:
|
||||
path: /etc/grafana/provisioning/dashboards
|
||||
foldersFromFilesStructure: false
|
||||
@@ -0,0 +1,12 @@
|
||||
# Auto-provision Prometheus datasource on first Grafana start
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://localhost:9090
|
||||
isDefault: true
|
||||
editable: false
|
||||
jsonData:
|
||||
timeInterval: "15s"
|
||||
89
monitoring/cast04/grafana.ini
Normal file
89
monitoring/cast04/grafana.ini
Normal file
@@ -0,0 +1,89 @@
|
||||
# Grafana config — cast04
|
||||
# Served at: https://apps.f0xx.org/app/androidcast_project/monitor/
|
||||
# Auth: nginx auth_request validates PHP session → X-WEBAUTH-USER header → Grafana auth proxy
|
||||
|
||||
[DEFAULT]
|
||||
|
||||
[paths]
|
||||
data = /var/lib/grafana
|
||||
logs = /var/log/grafana
|
||||
plugins = /var/lib/grafana/plugins
|
||||
provisioning = /etc/grafana/provisioning
|
||||
|
||||
[server]
|
||||
protocol = http
|
||||
http_addr = 0.0.0.0
|
||||
http_port = 3000
|
||||
domain = apps.f0xx.org
|
||||
root_url = https://apps.f0xx.org/app/androidcast_project/monitor/
|
||||
serve_from_sub_path = true
|
||||
enable_gzip = true
|
||||
|
||||
[database]
|
||||
type = sqlite3
|
||||
path = grafana.db
|
||||
|
||||
[session]
|
||||
provider = file
|
||||
|
||||
[analytics]
|
||||
reporting_enabled = false
|
||||
check_for_updates = true
|
||||
|
||||
[security]
|
||||
# Change after first login; used for direct access bypassing the PHP session
|
||||
admin_user = admin
|
||||
admin_password = acMonitor2026!
|
||||
secret_key = acMonitorSecret2026changeme
|
||||
disable_initial_admin_creation = false
|
||||
allow_embedding = true
|
||||
|
||||
[users]
|
||||
allow_sign_up = false
|
||||
allow_org_create = false
|
||||
auto_assign_org = true
|
||||
auto_assign_org_role = Viewer
|
||||
default_theme = dark
|
||||
|
||||
# ── Auth proxy (PHP session integration) ────────────────────────────────────
|
||||
# nginx validates the PHP session cookie and sets X-WEBAUTH-USER if valid.
|
||||
# Grafana trusts this header and auto-creates/logs in the user.
|
||||
[auth.proxy]
|
||||
enabled = true
|
||||
header_name = X-WEBAUTH-USER
|
||||
header_property = username
|
||||
auto_sign_up = true
|
||||
# Only trust the header from artc0 nginx (10.7.16.128):
|
||||
whitelist = 10.7.16.128
|
||||
# Sync roles from header (optional, set X-WEBAUTH-ROLE in nginx if needed):
|
||||
# headers = Role:X-WEBAUTH-ROLE
|
||||
|
||||
[auth.anonymous]
|
||||
enabled = false
|
||||
|
||||
[auth.basic]
|
||||
# Keep enabled for direct admin access (curl / provisioning scripts)
|
||||
enabled = true
|
||||
|
||||
[smtp]
|
||||
enabled = false # Grafana own SMTP not needed; alertmanager handles mail
|
||||
|
||||
[log]
|
||||
mode = file
|
||||
level = info
|
||||
|
||||
[log.file]
|
||||
log_rotate = true
|
||||
max_lines = 1000000
|
||||
max_size_shift = 28
|
||||
daily_rotate = true
|
||||
max_days = 7
|
||||
|
||||
[alerting]
|
||||
enabled = false # Using Prometheus Alertmanager, not Grafana's legacy alerting
|
||||
|
||||
[unified_alerting]
|
||||
enabled = false # Unified alerting also off; Alertmanager is the source of truth
|
||||
|
||||
[feature_toggles]
|
||||
enable =
|
||||
105
monitoring/cast04/prometheus.yml
Normal file
105
monitoring/cast04/prometheus.yml
Normal file
@@ -0,0 +1,105 @@
|
||||
# Prometheus main config — cast04 (10.7.16.239)
|
||||
# Track A monitoring SPEC: ac-docs/specs/20260626_cluster_monitoring.md
|
||||
# Generated: 2026-06-26
|
||||
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
evaluation_interval: 15s
|
||||
external_labels:
|
||||
cluster: 'ac-cluster0'
|
||||
monitor: 'cast04'
|
||||
|
||||
rule_files:
|
||||
- "/etc/prometheus/rules/*.yml"
|
||||
|
||||
alerting:
|
||||
alertmanagers:
|
||||
- static_configs:
|
||||
- targets: ['localhost:9093']
|
||||
|
||||
scrape_configs:
|
||||
|
||||
# ── node_exporter on all cluster nodes ──────────────────────────────────────
|
||||
- job_name: 'node'
|
||||
static_configs:
|
||||
- targets:
|
||||
- '10.7.16.236:9100' # cast01
|
||||
- '10.7.16.237:9100' # cast02
|
||||
- '10.7.16.238:9100' # cast03
|
||||
- '10.7.16.239:9100' # cast04 (self)
|
||||
- '10.7.16.128:9100' # artc0 (BE/MariaDB)
|
||||
labels:
|
||||
env: 'production'
|
||||
relabel_configs:
|
||||
- source_labels: [__address__]
|
||||
regex: '(10\.7\.16\.236):.*'
|
||||
target_label: instance
|
||||
replacement: 'cast01'
|
||||
- source_labels: [__address__]
|
||||
regex: '(10\.7\.16\.237):.*'
|
||||
target_label: instance
|
||||
replacement: 'cast02'
|
||||
- source_labels: [__address__]
|
||||
regex: '(10\.7\.16\.238):.*'
|
||||
target_label: instance
|
||||
replacement: 'cast03'
|
||||
- source_labels: [__address__]
|
||||
regex: '(10\.7\.16\.239):.*'
|
||||
target_label: instance
|
||||
replacement: 'cast04'
|
||||
- source_labels: [__address__]
|
||||
regex: '(10\.7\.16\.128):.*'
|
||||
target_label: instance
|
||||
replacement: 'artc0'
|
||||
|
||||
# ── blackbox_exporter — HTTP probes (internal BE; FE port 443 unreachable from cluster) ─
|
||||
# External HTTPS availability → UptimeRobot (SaaS external probe)
|
||||
- job_name: 'blackbox_http'
|
||||
metrics_path: /probe
|
||||
params:
|
||||
module: [http_2xx]
|
||||
static_configs:
|
||||
- targets:
|
||||
- 'http://10.7.16.128/app/androidcast_project/' # BE main app
|
||||
- 'http://10.7.16.128/app/androidcast_project/issues/' # crash reporter
|
||||
- 'http://10.7.16.239:3000/api/health' # Grafana health
|
||||
labels:
|
||||
env: 'production'
|
||||
relabel_configs:
|
||||
- source_labels: [__address__]
|
||||
target_label: __param_target
|
||||
- source_labels: [__param_target]
|
||||
target_label: instance
|
||||
- target_label: __address__
|
||||
replacement: 'localhost:9115'
|
||||
|
||||
# TCP probes removed: MariaDB uses skip-networking (socket only), PostgreSQL binds loopback only.
|
||||
# Database health is covered by mysqld_exporter (job: mysqld) and postgres_exporter (job: postgres).
|
||||
|
||||
# ── MariaDB exporter (artc0) ─────────────────────────────────────────────────
|
||||
- job_name: 'mysqld'
|
||||
static_configs:
|
||||
- targets: ['10.7.16.128:9104']
|
||||
labels:
|
||||
instance: 'artc0-mariadb'
|
||||
|
||||
# ── PostgreSQL exporter (cast01) ─────────────────────────────────────────────
|
||||
- job_name: 'postgres'
|
||||
static_configs:
|
||||
- targets: ['10.7.16.236:9187']
|
||||
labels:
|
||||
instance: 'cast01-postgres'
|
||||
|
||||
# ── prometheus self-scrape ───────────────────────────────────────────────────
|
||||
- job_name: 'prometheus'
|
||||
static_configs:
|
||||
- targets: ['localhost:9090']
|
||||
labels:
|
||||
instance: 'cast04-prometheus'
|
||||
|
||||
# ── alertmanager self-scrape ─────────────────────────────────────────────────
|
||||
- job_name: 'alertmanager'
|
||||
static_configs:
|
||||
- targets: ['localhost:9093']
|
||||
labels:
|
||||
instance: 'cast04-alertmanager'
|
||||
113
monitoring/cast04/rules/node_alerts.yml
Normal file
113
monitoring/cast04/rules/node_alerts.yml
Normal file
@@ -0,0 +1,113 @@
|
||||
# Node-level alert rules — hardware / OS
|
||||
# Thresholds per SPEC ac-docs/specs/20260626_cluster_monitoring.md §4.1
|
||||
|
||||
groups:
|
||||
- name: node_health
|
||||
rules:
|
||||
|
||||
# ── instance down ────────────────────────────────────────────────────────
|
||||
- alert: InstanceDown
|
||||
expr: up{job="node"} == 0
|
||||
for: 2m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "Instance {{ $labels.instance }} is down"
|
||||
description: "node_exporter on {{ $labels.instance }} has been unreachable for >2 min."
|
||||
|
||||
# ── CPU ─────────────────────────────────────────────────────────────────
|
||||
- alert: HighCpuWarning
|
||||
expr: >
|
||||
100 - (avg by(instance)(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 85
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "High CPU on {{ $labels.instance }}"
|
||||
description: "CPU usage is {{ $value | printf \"%.1f\" }}% (threshold: 85%) on {{ $labels.instance }}."
|
||||
|
||||
- alert: HighCpuCritical
|
||||
expr: >
|
||||
100 - (avg by(instance)(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 95
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "Critical CPU on {{ $labels.instance }}"
|
||||
description: "CPU usage is {{ $value | printf \"%.1f\" }}% (threshold: 95%) on {{ $labels.instance }}."
|
||||
|
||||
# ── memory ──────────────────────────────────────────────────────────────
|
||||
- alert: LowMemoryWarning
|
||||
expr: >
|
||||
(node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100 < 15
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Low memory on {{ $labels.instance }}"
|
||||
description: "Available RAM is {{ $value | printf \"%.1f\" }}% (threshold: 15%) on {{ $labels.instance }}."
|
||||
|
||||
- alert: LowMemoryCritical
|
||||
expr: >
|
||||
(node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100 < 5
|
||||
for: 2m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "Critical memory on {{ $labels.instance }}"
|
||||
description: "Available RAM is {{ $value | printf \"%.1f\" }}% (threshold: 5%) on {{ $labels.instance }}."
|
||||
|
||||
# ── disk ────────────────────────────────────────────────────────────────
|
||||
- alert: LowDiskWarning
|
||||
expr: >
|
||||
(node_filesystem_avail_bytes{fstype!~"tmpfs|devtmpfs|overlay"} /
|
||||
node_filesystem_size_bytes{fstype!~"tmpfs|devtmpfs|overlay"}) * 100 < 20
|
||||
for: 15m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Low disk on {{ $labels.instance }} mountpoint {{ $labels.mountpoint }}"
|
||||
description: "Free disk is {{ $value | printf \"%.1f\" }}% on {{ $labels.instance }}:{{ $labels.mountpoint }} (threshold: 20%)."
|
||||
|
||||
- alert: LowDiskCritical
|
||||
expr: >
|
||||
(node_filesystem_avail_bytes{fstype!~"tmpfs|devtmpfs|overlay"} /
|
||||
node_filesystem_size_bytes{fstype!~"tmpfs|devtmpfs|overlay"}) * 100 < 10
|
||||
for: 10m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "Critical disk on {{ $labels.instance }}:{{ $labels.mountpoint }}"
|
||||
description: "Free disk is {{ $value | printf \"%.1f\" }}% (threshold: 10%) on {{ $labels.instance }}:{{ $labels.mountpoint }}."
|
||||
|
||||
# ── load ────────────────────────────────────────────────────────────────
|
||||
- alert: HighLoadWarning
|
||||
expr: >
|
||||
node_load1 / on(instance) group_left() count(node_cpu_seconds_total{mode="idle"}) by (instance) > 1.5
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "High load on {{ $labels.instance }}"
|
||||
description: "Load average 1m is {{ $value | printf \"%.2f\" }}x per vCPU (threshold: 1.5×) on {{ $labels.instance }}."
|
||||
|
||||
- alert: HighLoadCritical
|
||||
expr: >
|
||||
node_load1 / on(instance) group_left() count(node_cpu_seconds_total{mode="idle"}) by (instance) > 3.0
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "Critical load on {{ $labels.instance }}"
|
||||
description: "Load average 1m is {{ $value | printf \"%.2f\" }}x per vCPU (threshold: 3.0×) on {{ $labels.instance }}."
|
||||
|
||||
# ── network anomaly ─────────────────────────────────────────────────────
|
||||
- alert: NetworkReceiveSaturation
|
||||
expr: >
|
||||
rate(node_network_receive_bytes_total{device!~"lo|dummy.*"}[5m]) > 100 * 1024 * 1024
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "High inbound traffic on {{ $labels.instance }}"
|
||||
description: "{{ $labels.device }} on {{ $labels.instance }} receiving >100 MB/s for 5 min."
|
||||
87
monitoring/cast04/rules/service_alerts.yml
Normal file
87
monitoring/cast04/rules/service_alerts.yml
Normal file
@@ -0,0 +1,87 @@
|
||||
# Service-level alert rules — HTTP probes, DB
|
||||
# Alpine uses OpenRC (not systemd) — nginx availability covered by blackbox HTTP probes
|
||||
# Thresholds per SPEC ac-docs/specs/20260626_cluster_monitoring.md §4.2–4.3
|
||||
|
||||
groups:
|
||||
- name: blackbox_probes
|
||||
rules:
|
||||
|
||||
# ── HTTP endpoint down ───────────────────────────────────────────────────
|
||||
- alert: HttpEndpointDown
|
||||
expr: probe_success{job="blackbox_http"} == 0
|
||||
for: 3m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "HTTP endpoint down: {{ $labels.instance }}"
|
||||
description: "blackbox probe to {{ $labels.instance }} is failing for >3 min."
|
||||
|
||||
- alert: HttpSlowResponse
|
||||
expr: probe_duration_seconds{job="blackbox_http"} > 5
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Slow HTTP response: {{ $labels.instance }}"
|
||||
description: "HTTP probe to {{ $labels.instance }} takes >5s (current: {{ $value | printf \"%.1f\" }}s)."
|
||||
|
||||
- alert: SslCertExpiringSoon
|
||||
expr: probe_ssl_earliest_cert_expiry{job="blackbox_http"} - time() < 14 * 86400
|
||||
for: 1h
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "SSL cert expiring soon: {{ $labels.instance }}"
|
||||
description: "TLS certificate for {{ $labels.instance }} expires in {{ $value | humanizeDuration }}."
|
||||
|
||||
- alert: SslCertExpiring7d
|
||||
expr: probe_ssl_earliest_cert_expiry{job="blackbox_http"} - time() < 7 * 86400
|
||||
for: 1h
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "SSL cert expires in <7 days: {{ $labels.instance }}"
|
||||
description: "TLS certificate for {{ $labels.instance }} expires in {{ $value | humanizeDuration }} — renew immediately."
|
||||
|
||||
# ── MariaDB ─────────────────────────────────────────────────────────────────
|
||||
- name: mariadb
|
||||
rules:
|
||||
- alert: MariaDBDown
|
||||
expr: mysql_up == 0
|
||||
for: 2m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "MariaDB is down on {{ $labels.instance }}"
|
||||
description: "mysqld_exporter cannot connect to MariaDB on {{ $labels.instance }}."
|
||||
|
||||
- alert: MariaDBHighConnections
|
||||
expr: mysql_global_status_threads_connected / mysql_global_variables_max_connections > 0.80
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "MariaDB connection pool high on {{ $labels.instance }}"
|
||||
description: "{{ $value | printf \"%.0f\" }}% of max_connections used on {{ $labels.instance }}."
|
||||
|
||||
- alert: MariaDBSlowQueries
|
||||
expr: rate(mysql_global_status_slow_queries[5m]) > 1
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "MariaDB slow queries on {{ $labels.instance }}"
|
||||
description: "More than 1 slow query/s detected on {{ $labels.instance }}."
|
||||
|
||||
# ── disk I/O saturation ──────────────────────────────────────────────────────
|
||||
- name: disk_io
|
||||
rules:
|
||||
- alert: DiskIoSaturation
|
||||
expr: >
|
||||
rate(node_disk_io_time_seconds_total[5m]) > 0.9
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Disk I/O saturation on {{ $labels.instance }}"
|
||||
description: "Disk {{ $labels.device }} on {{ $labels.instance }} is >90% I/O utilized for >10 min."
|
||||
Reference in New Issue
Block a user