1
0
mirror of git://f0xx.org/ac/ac-deploy synced 2026-07-29 04:19:00 +03:00

added 'monitoring' path - ask devs why it's there

This commit is contained in:
Anton Afanasyeu
2026-06-28 22:01:59 +02:00
parent 3c5233c903
commit 2b2c32cff5
13 changed files with 1146 additions and 0 deletions

View 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/

View 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>
&mdash; <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()

View 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']

View File

@@ -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

View File

@@ -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"

View 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 =

View 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'

View 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."

View 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.24.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."