psiloceyeben's picture
Upload folder using huggingface_hub
74456ba verified
Raw History Blame Contribute Delete
38 kB
#!/usr/bin/env python3
"""o7infra_agent - Oracle7-infrastructure: Oracle7 pointed at a system (Ben 2026-10-12: "it gets pointed at anything and it can
become the sensors and run it intelligently"; "we want to be infrastructure"). One file, Python standard library only (3.10+).
DISCOVER with no configuration: systemd services (long-running) and jobs (oneshot / timer-run), nginx sites and the TLS
certificates they serve, listening ports, containers (if docker is present)
SENSE host (load, cpu, memory, swap, processes), every real disk (space, inodes), network, every service (state,
memory, cpu, RESTART RATE), every job (last run ok), journal errors per unit, certificate DAYS LEFT, site
reachability and latency; plus any source in /etc/o7infra/sources.json (http_json / prometheus / command).
Every value is a FACT (time, entity, signal, value, source) - the receipt says where it was read.
STORE SQLite with a HARD SIZE CAP: raw 24 h, 10-minute means 30 days, pruned every cycle.
READ readers generated for every signal: current, trend, time-to-threshold, anomaly vs its own history, what changed
before an event. `ask` answers plain questions from them.
THE GATE = THE FUTURE. Forecasts are logged when made and scored when their time arrives; a method is ADMITTED for a kind
of signal only after >= 30 scored forecasts beat "stays the same" by >= 10%.
ACT actions are CHECKED OPERATIONS: precondition, predicted effect, dry run. Ladder: recommend -> dry-run -> a human
second key (`approve <id>`) -> autonomous ONLY for an action class whose observed effect matched its prediction
5 times, and only when O7INFRA_AUTONOMOUS=1. Default: recommend + dry-run, nothing changes the system.
usage: o7infra_agent.py run | brief | status | forecast | gate | why <entity> | signals [f] | discover | plan
| approve <id> | execute <id> | ask "<question>" """
import glob
import json
import os
import re
import socket
import sqlite3
import ssl
import subprocess
import sys
import time
import urllib.request
HOME = os.environ.get("O7INFRA_HOME", "/var/lib/o7infra")
DB = os.path.join(HOME, "facts.sqlite3")
SOURCES = os.environ.get("O7INFRA_SOURCES", "/etc/o7infra/sources.json")
STEP = int(os.environ.get("O7INFRA_STEP", "60"))
CAP_MB = float(os.environ.get("O7INFRA_CAP_MB", "60"))
AUTONOMOUS = os.environ.get("O7INFRA_AUTONOMOUS") == "1"
RAW_KEEP, AGG_KEEP = 24 * 3600, 30 * 86400
FORECAST_EVERY, SLOW_EVERY, DISCOVER_EVERY = 600, 300, 3600
HORIZONS = (3600, 6 * 3600)
REAL_FS = {"ext2", "ext3", "ext4", "xfs", "btrfs", "zfs", "f2fs", "vfat", "ntfs"}
# (warn, critical); "low" = lower is worse
THRESH = {"used_pct": (90.0, 97.0, "high"), "swap_used_pct": (80.0, 95.0, "high"), "mem_used_pct": (90.0, 97.0, "high"),
"inode_pct": (85.0, 95.0, "high"), "cert_days_left": (14.0, 5.0, "low"), "restarts_per_h": (6.0, 30.0, "high"),
"up": (0.5, 0.5, "low"), "last_run_ok": (0.5, 0.5, "low")}
# ------------------------------------------------------------------------------------------------------------------ store
def db():
os.makedirs(HOME, exist_ok=True)
c = sqlite3.connect(DB, timeout=30)
c.executescript("""
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS fact (ts INTEGER, entity TEXT, signal TEXT, value REAL, source TEXT);
CREATE INDEX IF NOT EXISTS fact_es ON fact(entity, signal, ts);
CREATE INDEX IF NOT EXISTS fact_ts ON fact(ts);
CREATE TABLE IF NOT EXISTS agg (ts INTEGER, entity TEXT, signal TEXT, value REAL);
CREATE INDEX IF NOT EXISTS agg_es ON agg(entity, signal, ts);
CREATE TABLE IF NOT EXISTS event (ts INTEGER, entity TEXT, kind TEXT, detail TEXT);
CREATE TABLE IF NOT EXISTS forecast (made INTEGER, target INTEGER, entity TEXT, signal TEXT, method TEXT, value REAL,
scored INTEGER DEFAULT 0, actual REAL);
CREATE INDEX IF NOT EXISTS forecast_t ON forecast(scored, target);
CREATE TABLE IF NOT EXISTS action (id INTEGER PRIMARY KEY, made INTEGER, entity TEXT, kind TEXT, command TEXT,
precondition TEXT, predicted TEXT, dry_run TEXT, status TEXT, approved INTEGER,
executed INTEGER, executed_at INTEGER, observed TEXT, matched INTEGER);
CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT);
""")
return c
def meta_get(c, k, d=None):
r = c.execute("SELECT v FROM meta WHERE k=?", (k,)).fetchone()
return json.loads(r[0]) if r else d
def meta_set(c, k, v):
c.execute("INSERT OR REPLACE INTO meta VALUES (?,?)", (k, json.dumps(v)))
def _read(p):
try:
with open(p) as f:
return f.read()
except OSError:
return ""
def _run(cmd, timeout=20):
try:
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout).stdout
except (OSError, subprocess.TimeoutExpired):
return ""
# --------------------------------------------------------------------------------------------------------------- discover
def discover():
"""what is on this system, found with no configuration"""
d = {"services": [], "jobs": [], "sites": [], "certs": {}, "ports": [], "containers": []}
names = [l.split()[0] for l in _run(["systemctl", "list-units", "--type=service", "--all", "--no-legend", "--plain"]).splitlines()
if l.split() and l.split()[0].endswith(".service")]
timers = {l.split()[-2] if len(l.split()) >= 2 else "" for l in _run(["systemctl", "list-timers", "--all", "--no-legend", "--plain"]).splitlines()}
if names:
txt = _run(["systemctl", "show", "-p", "Id,Type,ActiveState,LoadState,TriggeredBy"] + names, timeout=40)
for block in txt.strip().split("\n\n"):
kv = dict(l.split("=", 1) for l in block.splitlines() if "=" in l)
if kv.get("LoadState") != "loaded" or kv.get("ActiveState") not in ("active", "failed", "activating", "deactivating"):
continue
uid = kv.get("Id", "")
if uid.startswith(("user@", "user-runtime-dir@")):
continue # per-user session managers are not the system's services
job = kv.get("Type") == "oneshot" or ".timer" in kv.get("TriggeredBy", "") or uid in timers
(d["jobs"] if job else d["services"]).append(uid[:-8])
for conf in glob.glob("/etc/nginx/sites-enabled/*") + glob.glob("/etc/nginx/conf.d/*.conf"):
txt = _read(conf)
for block in re.split(r"\bserver\s*\{", txt)[1:]:
names_ = re.findall(r"\bserver_name\s+([^;]+);", block)
cert = re.findall(r"\bssl_certificate\s+([^;]+);", block)
for n in (names_[0].split() if names_ else []):
if n not in ("_", "localhost") and "." in n and not n.startswith("*") and not re.fullmatch(r"[\d.]+", n):
if n not in d["sites"]:
d["sites"].append(n)
if cert:
d["certs"].setdefault(cert[0].strip(), []).append(n)
ss = _run(["ss", "-ltnH"])
for line in ss.splitlines():
p = line.split()
if len(p) >= 4:
port = p[3].rsplit(":", 1)[-1]
if port.isdigit() and port not in d["ports"]:
d["ports"].append(port)
if os.path.exists("/usr/bin/docker"):
d["containers"] = [l.strip() for l in _run(["docker", "ps", "--format", "{{.Names}}"]).splitlines() if l.strip()]
return d
# ---------------------------------------------------------------------------------------------------------------- sensors
def sense_host(prev):
out = []
la = _read("/proc/loadavg").split()
if la:
out += [("host", "load1", float(la[0]), "/proc/loadavg"), ("host", "load5", float(la[1]), "/proc/loadavg")]
st = _read("/proc/stat").splitlines()
if st:
f = [int(x) for x in st[0].split()[1:]]
idle, total = f[3] + (f[4] if len(f) > 4 else 0), sum(f)
if "cpu" in prev and total > prev["cpu"][1]:
out.append(("host", "cpu_pct", round(100.0 * (1 - (idle - prev["cpu"][0]) / (total - prev["cpu"][1])), 2), "/proc/stat"))
prev["cpu"] = (idle, total)
mi = {}
for line in _read("/proc/meminfo").splitlines():
k, _, v = line.partition(":")
mi[k] = int(v.split()[0]) if v.split() else 0
if mi.get("MemTotal"):
avail = mi.get("MemAvailable", 0)
out += [("host", "mem_used_pct", round(100.0 * (1 - avail / mi["MemTotal"]), 2), "/proc/meminfo"),
("host", "mem_avail_mb", round(avail / 1024, 1), "/proc/meminfo")]
if mi.get("SwapTotal"):
out.append(("host", "swap_used_pct", round(100.0 * (1 - mi.get("SwapFree", 0) / mi["SwapTotal"]), 2), "/proc/meminfo"))
out.append(("host", "procs", float(len([d for d in os.listdir("/proc") if d.isdigit()])), "/proc"))
return out
def sense_disks():
out, seen = [], set()
for line in _read("/proc/mounts").splitlines():
p = line.split()
if len(p) < 3 or p[2] not in REAL_FS or p[0] in seen:
continue
seen.add(p[0])
try:
s = os.statvfs(p[1])
except OSError:
continue
if not s.f_blocks:
continue
ent = "disk:" + p[1]
out += [(ent, "used_pct", round(100.0 * (1 - s.f_bavail / s.f_blocks), 2), "statvfs " + p[1]),
(ent, "free_gb", round(s.f_bavail * s.f_frsize / 1e9, 3), "statvfs " + p[1])]
if s.f_files:
out.append((ent, "inode_pct", round(100.0 * (1 - s.f_favail / s.f_files), 2), "statvfs " + p[1]))
return out
def sense_net(prev, dt):
out = []
for line in _read("/proc/net/dev").splitlines()[2:]:
name, _, rest = line.partition(":")
name = name.strip()
if name == "lo" or not rest.split():
continue
f = rest.split()
rx, tx = int(f[0]), int(f[8])
key = "net:" + name
if key in prev and dt > 0:
out += [(key, "rx_kbps", round((rx - prev[key][0]) / dt / 1024, 2), "/proc/net/dev"),
(key, "tx_kbps", round((tx - prev[key][1]) / dt / 1024, 2), "/proc/net/dev")]
prev[key] = (rx, tx)
return out
def sense_units(prev, dt, disc):
out, events = [], []
jobs = set(disc.get("jobs", []))
names = [n + ".service" for n in disc.get("services", []) + disc.get("jobs", [])]
if not names:
return out, events
txt = _run(["systemctl", "show", "-p", "Id,ActiveState,NRestarts,MemoryCurrent,CPUUsageNSec,Result"] + names, timeout=40)
now = time.time()
for block in txt.strip().split("\n\n"):
kv = dict(l.split("=", 1) for l in block.splitlines() if "=" in l)
uid = kv.get("Id", "")[:-8]
if not uid:
continue
ent = ("job:" if uid in jobs else "unit:") + uid
state = kv.get("ActiveState", "")
if uid in jobs:
ok = 0.0 if (state == "failed" or kv.get("Result", "success") not in ("success", "")) else 1.0
out.append((ent, "last_run_ok", ok, "systemctl show (Result=%s)" % kv.get("Result", "")))
else:
out.append((ent, "up", 1.0 if state == "active" else 0.0, "systemctl show (ActiveState=%s)" % state))
last = prev.get("state:" + ent)
if last and last != state:
events.append((ent, "state", "%s -> %s" % (last, state)))
prev["state:" + ent] = state
try:
r = int(kv.get("NRestarts", "0"))
hist = prev.setdefault("rh:" + ent, [])
hist.append((now, r))
while hist and hist[0][0] < now - 3600:
hist.pop(0)
if len(hist) >= 2 and hist[-1][0] > hist[0][0]:
rate = (hist[-1][1] - hist[0][1]) / (hist[-1][0] - hist[0][0]) * 3600
out.append((ent, "restarts_per_h", round(rate, 2), "systemctl show NRestarts=%d" % r))
if "restarts:" + ent in prev and r > prev["restarts:" + ent] and r - prev["restarts:" + ent] < 1000:
events.append((ent, "restart", "NRestarts %d -> %d" % (prev["restarts:" + ent], r)))
prev["restarts:" + ent] = r
except ValueError:
pass
try:
mem = int(kv.get("MemoryCurrent", ""))
if mem < 1 << 60:
out.append((ent, "mem_mb", round(mem / 1048576, 1), "systemctl show"))
except ValueError:
pass
try:
cpu = int(kv.get("CPUUsageNSec", ""))
if cpu < 1 << 62 and ("cpu:" + ent) in prev and dt > 0:
out.append((ent, "cpu_pct", round(100.0 * (cpu - prev["cpu:" + ent]) / 1e9 / dt, 2), "systemctl show"))
if cpu < 1 << 62:
prev["cpu:" + ent] = cpu
except ValueError:
pass
return out, events
def sense_journal(since):
counts = {}
for line in _run(["journalctl", "--since", "@%d" % since, "-p", "3", "-o", "json", "--no-pager", "-q"], timeout=30).splitlines():
try:
j = json.loads(line)
except ValueError:
continue
u = j.get("_SYSTEMD_UNIT") or j.get("SYSLOG_IDENTIFIER") or "kernel"
counts[u] = counts.get(u, 0) + 1
out = [("unit:" + (u[:-8] if u.endswith(".service") else u), "errors", float(n), "journalctl -p 3") for u, n in counts.items()]
out.append(("host", "errors", float(sum(counts.values())), "journalctl -p 3"))
return out
def sense_certs(disc):
out = []
now = time.time()
for path, sites in disc.get("certs", {}).items():
txt = _run(["openssl", "x509", "-enddate", "-noout", "-in", path])
m = re.search(r"notAfter=(.+)", txt)
if not m:
continue
try:
end = time.mktime(time.strptime(m.group(1).strip().replace(" GMT", ""), "%b %d %H:%M:%S %Y")) - time.timezone
except ValueError:
continue
out.append(("cert:" + sites[0], "cert_days_left", round((end - now) / 86400, 2), "openssl x509 %s" % path))
return out
def sense_sites(disc):
out = []
ctx = ssl.create_default_context()
for name in disc.get("sites", [])[:60]:
t0 = time.time()
code = 0
for method in ("HEAD", "GET"): # a server that does not implement HEAD (405 / 501) is asked by GET
try:
req = urllib.request.Request("https://%s/" % name, method=method, headers={"User-Agent": "o7infra/2"})
with urllib.request.urlopen(req, timeout=8, context=ctx) as r:
code = r.status
except urllib.error.HTTPError as e:
code = e.code
except Exception: # noqa: BLE001 - unreachable, TLS failure, timeout
code = 0
if code not in (405, 501):
break
ms = (time.time() - t0) * 1000
up = 1.0 if 0 < code < 500 else 0.0
out += [("site:" + name, "up", up, "HTTPS HEAD status %d" % code), ("site:" + name, "latency_ms", round(ms, 1), "HTTPS HEAD")]
return out
def sense_sources():
"""any system: /etc/o7infra/sources.json = [{"type": "http_json", "url", "entity", "signals": {"name": "a.b.c"}},
{"type": "prometheus", "url", "entity", "match": "regex"}, {"type": "command", "cmd": [...], "entity", "regex": "name=(value)"}]"""
out = []
try:
srcs = json.load(open(SOURCES))
except (OSError, ValueError):
return out
for s in srcs:
ent = s.get("entity", "source")
try:
if s["type"] == "http_json":
d = json.loads(urllib.request.urlopen(s["url"], timeout=8).read())
for name, path in s.get("signals", {}).items():
v = d
for k in path.split("."):
v = v[int(k)] if isinstance(v, list) else v[k]
out.append((ent, name, float(v), "GET %s %s" % (s["url"], path)))
elif s["type"] == "prometheus":
rx = re.compile(s.get("match", ".*"))
for line in urllib.request.urlopen(s["url"], timeout=8).read().decode().splitlines():
if line.startswith("#") or not line.strip():
continue
name, _, val = line.rpartition(" ")
if rx.search(name):
out.append((ent, name[:120], float(val), "GET %s" % s["url"]))
elif s["type"] == "command":
txt = _run(s["cmd"], timeout=20)
for name, val in re.findall(s.get("regex", r"(\w+)=([-\d.]+)"), txt):
out.append((ent, name, float(val), "command %s" % " ".join(s["cmd"])[:80]))
except Exception: # noqa: BLE001 - one broken source never stops the others
out.append((ent, "source_ok", 0.0, "source %s" % s.get("url", s.get("cmd", "?"))))
return out
# --------------------------------------------------------------------------------------------------------------- readers
def series(c, entity, signal, since):
rows = c.execute("SELECT ts, value FROM fact WHERE entity=? AND signal=? AND ts>=? ORDER BY ts", (entity, signal, since)).fetchall()
if not rows:
rows = c.execute("SELECT ts, value FROM agg WHERE entity=? AND signal=? AND ts>=? ORDER BY ts", (entity, signal, since)).fetchall()
return rows
def slope(rows):
if len(rows) < 6:
return None
pts = rows[:: max(1, len(rows) // 40)]
ss = sorted((b[1] - a[1]) / (b[0] - a[0]) for i, a in enumerate(pts) for b in pts[i + 1:] if b[0] > a[0])
return ss[len(ss) // 2] if ss else None
def thresholds(signal):
for k in sorted(THRESH, key=len, reverse=True):
if signal == k or signal.endswith(k):
return THRESH[k]
return None
def level(signal, v):
th = thresholds(signal)
if not th:
return None
w, cr, d = th
if d == "high":
return "critical" if v >= cr else ("warning" if v >= w else None)
return "critical" if v < cr or (signal in ("up", "last_run_ok") and v < 0.5) else ("warning" if v < w else None)
# ------------------------------------------------------------------------------------------------- forecasting + the gate
def make_forecasts(c, now):
for ent, s in c.execute("SELECT DISTINCT entity, signal FROM fact WHERE ts>=? AND (signal LIKE '%used_pct' OR signal LIKE '%inode_pct' "
"OR signal IN ('mem_mb','mem_avail_mb','free_gb','cert_days_left'))", (now - 3600,)).fetchall():
rows = series(c, ent, s, now - 6 * 3600)
if len(rows) < 6:
continue
last = rows[-1][1]
# a trend is extrapolated only from >= 3 h of history (from less it measured worse than "nothing changes" everywhere)
sl = slope(rows) if rows[-1][0] - rows[0][0] >= 3 * 3600 else None
for h in HORIZONS:
c.execute("INSERT INTO forecast (made, target, entity, signal, method, value) VALUES (?,?,?,?,?,?)", (now, now + h, ent, s, "naive", last))
if sl is not None:
for m, k in METHODS.items():
c.execute("INSERT INTO forecast (made, target, entity, signal, method, value) VALUES (?,?,?,?,?,?)",
(now, now + h, ent, s, m, last + k * sl * h))
def score_forecasts(c, now):
for rid, target, ent, s in c.execute("SELECT rowid, target, entity, signal FROM forecast WHERE scored=0 AND target<=?", (now,)).fetchall():
r = c.execute("SELECT value FROM fact WHERE entity=? AND signal=? AND ts BETWEEN ? AND ? ORDER BY ABS(ts-?) LIMIT 1",
(ent, s, target - STEP * 3, target + STEP * 3, target)).fetchone()
if r:
c.execute("UPDATE forecast SET scored=1, actual=? WHERE rowid=?", (r[0], rid))
elif target < now - 3600:
c.execute("UPDATE forecast SET scored=2 WHERE rowid=?", (rid,))
METHODS = {"trend6h": 1.0, "damped": 0.5} # candidate methods; the gate admits whichever beats "nothing changes"
def kind_of(signal):
return signal # each signal keeps its own record (disk is not judged with memory)
def gate(c):
acc = {}
for s, h, m, e in c.execute("SELECT signal, target-made, method, ABS(value-actual) FROM forecast WHERE scored=1").fetchall():
acc.setdefault((kind_of(s), int(h)), {}).setdefault(m, []).append(e)
out = {}
for k, ms in acc.items():
nv = ms.get("naive", [])
if not nv:
continue
mn = sum(nv) / len(nv)
rec = {"n_naive": len(nv), "mae_naive": round(mn, 4), "methods": {}, "admitted": None}
for m in METHODS:
tr = ms.get(m, [])
if tr:
mt = sum(tr) / len(tr)
ok = len(tr) >= 30 and mt <= 0.9 * mn
rec["methods"][m] = {"n": len(tr), "mae": round(mt, 4), "admitted": ok}
if ok and (rec["admitted"] is None or mt < rec["methods"][rec["admitted"]]["mae"]):
rec["admitted"] = m
out["%s@%dh" % (k[0], k[1] // 3600)] = rec
return out
# ---------------------------------------------------------------------------------------------------------------- actions
SAFE_DISK = [ # (kind, command, dry-run command reporting reclaimable MB)
("journal_vacuum", ["journalctl", "--vacuum-size=200M"], ["journalctl", "--disk-usage"]),
("apt_clean", ["apt-get", "clean"], ["du", "-sm", "/var/cache/apt/archives"]),
]
def plan_actions(c, now):
"""CHECKED OPERATIONS proposed from the current facts; dry-run results attached; nothing executes here"""
t = c.execute("SELECT MAX(ts) FROM fact").fetchone()[0]
if not t:
return []
_, rows = latest_values(c)
rows = [(e, s, v) for e, s, v, _, _ in rows]
loops = {e: v for e, s, v in rows if s == "restarts_per_h"}
existing = {r[0] for r in c.execute("SELECT entity FROM action WHERE status IN ('proposed','approved')").fetchall()}
proposals = []
for e, s, v in rows:
lv = level(s, v)
if not lv or e in existing: # one open proposal per entity; dry runs only for new ones
continue
if e.startswith("disk:") and s == "used_pct":
for kind, cmd, dry in SAFE_DISK:
out = _run(dry, timeout=30).strip().splitlines()
proposals.append((e, kind, " ".join(cmd), "%s used %.1f%% (%s)" % (e, v, lv),
"free_gb rises by the reclaimable amount; used_pct falls", out[-1][:160] if out else "no output"))
elif e.startswith("unit:") and s == "restarts_per_h" and lv == "critical":
u = e[5:]
tail = _run(["journalctl", "-u", u, "-n", "3", "--no-pager", "-q", "-o", "cat"], timeout=15).strip().replace("\n", " | ")
proposals.append((e, "investigate_crash_loop", "systemctl status %s; journalctl -u %s -n 50" % (u, u),
"%s restarting %.0f times per hour" % (u, v), "read-only: shows the failing cause", tail[:200]))
elif e.startswith("unit:") and s == "up" and v == 0 and loops.get(e, 0) < 6:
u = e[5:] # a crash-looping service is investigated, never just restarted
proposals.append((e, "restart_service", "systemctl restart %s" % u, "%s is not active" % u,
"up becomes 1 within 60 s and restarts_per_h stays < 6", "dry run: unit file %s" %
("present" if _run(["systemctl", "cat", u + ".service"], timeout=10) else "missing")))
elif e.startswith("cert:") and s == "cert_days_left":
proposals.append((e, "renew_certificate", "certbot renew --cert-name %s" % e[5:], "%s expires in %.1f days" % (e[5:], v),
"cert_days_left jumps to ~89", (_run(["certbot", "renew", "--dry-run", "--cert-name", e[5:]], timeout=120) or "certbot not runnable")[-200:]))
elif e.startswith("job:") and s == "last_run_ok" and v == 0:
u = e[4:]
tail = _run(["journalctl", "-u", u, "-n", "3", "--no-pager", "-q", "-o", "cat"], timeout=15).strip().replace("\n", " | ")
proposals.append((e, "investigate_job", "journalctl -u %s -n 50" % u, "job %s last run failed" % u, "read-only", tail[:200]))
for e, kind, cmd, pre, pred, dry in proposals:
c.execute("INSERT INTO action (made, entity, kind, command, precondition, predicted, dry_run, status, approved, executed) "
"VALUES (?,?,?,?,?,?,?, 'proposed', 0, 0)", (now, e, kind, cmd, pre, pred, dry))
return proposals
EFFECT = { # action kind -> (signal, test on (before, after)) - what "the effect matched the prediction" means
"restart_service": ("up", lambda b, a: a == 1.0),
"journal_vacuum": ("free_gb", lambda b, a: b is not None and a > b),
"apt_clean": ("free_gb", lambda b, a: b is not None and a >= b),
"renew_certificate": ("cert_days_left", lambda b, a: a > 30),
}
def verify_actions(c, now):
"""an executed action's predicted effect is checked against the readings that follow it (>= 2 minutes later)"""
for aid, ent, kind, exe, obs in c.execute("SELECT id, entity, kind, executed_at, observed FROM action "
"WHERE executed=1 AND matched IS NULL").fetchall():
if kind not in EFFECT or not exe or now - exe < 120:
continue
sig, test = EFFECT[kind]
b = c.execute("SELECT value FROM fact WHERE entity=? AND signal=? AND ts<? ORDER BY ts DESC LIMIT 1", (ent, sig, exe)).fetchone()
a = c.execute("SELECT value FROM fact WHERE entity=? AND signal=? AND ts>? ORDER BY ts DESC LIMIT 1", (ent, sig, exe + 60)).fetchone()
if a is None:
continue
ok = bool(test(b[0] if b else None, a[0]))
c.execute("UPDATE action SET observed=?, matched=? WHERE id=?",
(json.dumps({"signal": sig, "before": b[0] if b else None, "after": a[0]}), 1 if ok else 0, aid))
def admitted_actions(c):
"""an action CLASS is admitted for autonomy after 5 executions whose observed effect matched the prediction, none failed"""
out = {}
for kind, n, ok in c.execute("SELECT kind, COUNT(*), SUM(matched) FROM action WHERE executed=1 GROUP BY kind").fetchall():
out[kind] = {"executed": n, "matched": ok or 0, "admitted": n >= 5 and (ok or 0) == n}
return out
# -------------------------------------------------------------------------------------------------------------- retention
def retain(c, now):
cut = now - RAW_KEEP
last_agg = meta_get(c, "last_agg", 0)
for b in range(max(last_agg, cut - RAW_KEEP) // 600 * 600, cut // 600 * 600, 600):
c.execute("INSERT INTO agg SELECT ?, entity, signal, AVG(value) FROM fact WHERE ts>=? AND ts<? GROUP BY entity, signal", (b, b, b + 600))
meta_set(c, "last_agg", b + 600)
c.execute("DELETE FROM fact WHERE ts<?", (cut,))
c.execute("DELETE FROM agg WHERE ts<?", (now - AGG_KEEP,))
c.execute("DELETE FROM forecast WHERE made<?", (now - AGG_KEEP,))
c.execute("DELETE FROM event WHERE ts<?", (now - AGG_KEEP,))
if os.path.exists(DB) and os.path.getsize(DB) / 1e6 > CAP_MB:
c.execute("DELETE FROM agg WHERE ts < (SELECT MIN(ts) + (MAX(ts)-MIN(ts))/4 FROM agg)")
c.execute("DELETE FROM fact WHERE ts < (SELECT MIN(ts) + (MAX(ts)-MIN(ts))/4 FROM fact)")
c.commit()
c.execute("VACUUM")
# ------------------------------------------------------------------------------------------------------------------- loop
def run():
c = db()
prev, last_t = {}, time.time()
last_fc = last_slow = last_disc = 0
disc = {}
slow = []
while True:
t0 = time.time()
now, dt = int(t0), t0 - last_t
if now - last_disc >= DISCOVER_EVERY or not disc:
disc = discover()
meta_set(c, "discovery", disc)
last_disc = now
facts = sense_host(prev) + sense_disks() + sense_net(prev, dt)
uf, ev = sense_units(prev, dt, disc)
facts += uf + sense_journal(int(last_t))
if now - last_slow >= SLOW_EVERY:
slow = sense_certs(disc) + sense_sites(disc) + sense_sources()
facts += slow
last_slow = now
c.executemany("INSERT INTO fact VALUES (?,?,?,?,?)", [(now, e, s, v, src) for e, s, v, src in facts])
c.executemany("INSERT INTO event VALUES (?,?,?,?)", [(now, e, k, d) for e, k, d in ev])
if now - last_fc >= FORECAST_EVERY:
score_forecasts(c, now)
make_forecasts(c, now)
plan_actions(c, now)
verify_actions(c, now)
retain(c, now)
last_fc = now
c.commit()
last_t = t0
time.sleep(max(5, STEP - (time.time() - t0)))
# ------------------------------------------------------------------------------------------------------------------ query
def latest_values(c):
"""the newest value of every (entity, signal) in the last 15 minutes (slow sensors run every 5)"""
t = c.execute("SELECT MAX(ts) FROM fact").fetchone()[0]
if not t:
return None, []
rows = c.execute("SELECT f.entity, f.signal, f.value, f.source, f.ts FROM fact f JOIN (SELECT entity, signal, MAX(ts) m FROM fact "
"WHERE ts>=? GROUP BY entity, signal) x ON f.entity=x.entity AND f.signal=x.signal AND f.ts=x.m", (t - 900,)).fetchall()
return t, rows
def status(c):
t, rows = latest_values(c)
if not t:
return {"facts": 0}
crit, warn = [], []
for e, s, v, src, ts in rows:
lv = level(s, v)
if lv:
(crit if lv == "critical" else warn).append({"entity": e, "signal": s, "value": v, "receipt": src})
ev = c.execute("SELECT ts, entity, kind, detail FROM event WHERE ts>=? ORDER BY ts DESC LIMIT 20", (t - 86400,)).fetchall()
return {"at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(t)), "critical": crit, "warning": warn,
"events_24h": [{"at": time.strftime("%H:%M", time.gmtime(a)), "entity": b, "kind": k, "detail": d} for a, b, k, d in ev]}
def forecast_view(c):
now = int(time.time())
g = gate(c)
out = []
for ent, s in c.execute("SELECT DISTINCT entity, signal FROM fact WHERE ts>=? AND (signal LIKE '%used_pct' OR signal LIKE '%inode_pct')",
(now - 3600,)).fetchall():
rows = series(c, ent, s, now - 6 * 3600)
sl = slope(rows)
th = thresholds(s)
if not rows or sl is None or sl <= 0 or not th or rows[-1][1] >= th[1]:
continue
rec = g.get("%s@6h" % kind_of(s), {})
m = rec.get("admitted")
k = METHODS.get(m, 1.0)
out.append({"entity": ent, "signal": s, "now": rows[-1][1], "per_hour": round(k * sl * 3600, 4), "reaches": th[1],
"in_hours": round((th[1] - rows[-1][1]) / (k * sl) / 3600, 1), "admitted": bool(m), "method": m or "trend6h",
"record": rec or "not yet scored"})
out.sort(key=lambda x: x["in_hours"])
return out
def why(c, entity):
t, _ = latest_values(c)
if not t:
return []
out = []
for e, s in c.execute("SELECT DISTINCT entity, signal FROM fact WHERE ts>=?", (t - 3600,)).fetchall():
a = [v for _, v in series(c, e, s, t - 3600)]
if len(a) < 20:
continue
x, y = a[:len(a) // 2], a[len(a) // 2:]
mx, my = sum(x) / len(x), sum(y) / len(y)
sd = (sum((v - mx) ** 2 for v in x) / len(x)) ** 0.5 or 1e-9
z = (my - mx) / sd
if abs(z) >= 3:
out.append({"entity": e, "signal": s, "before": round(mx, 3), "after": round(my, 3), "shift_sd": round(z, 1)})
out.sort(key=lambda d: (d["entity"] != entity, -abs(d["shift_sd"])))
return out[:15]
def ask(c, q):
"""plain questions answered by the generated readers; every answer cites the readers' facts"""
ql = q.lower()
if re.search(r"\b(fill|full|space|disk)\b", ql):
return {"answer": forecast_view(c) or "no disk is trending toward its threshold", "reader": "forecast"}
if re.search(r"\b(cert|certificate|tls|ssl|expire)", ql):
_, rows = latest_values(c)
return {"answer": sorted([{"site": e[5:], "days_left": v, "receipt": src} for e, s, v, src, _ in rows if s == "cert_days_left"],
key=lambda x: x["days_left"]), "reader": "cert_days_left"}
m = re.search(r"\bwhy\b.*?\b([a-z0-9][\w.:-]+)\??$", ql)
if m:
return {"answer": why(c, m.group(1)), "reader": "why"}
if re.search(r"\b(wrong|broken|fail|down|problem|status|health)", ql):
return {"answer": status(c), "reader": "status"}
if re.search(r"\b(do|fix|action|should)\b", ql):
return {"answer": [dict(zip(("id", "entity", "kind", "command", "precondition", "predicted", "dry_run", "status"), r))
for r in c.execute("SELECT id, entity, kind, command, precondition, predicted, dry_run, status FROM action "
"WHERE status='proposed' ORDER BY id DESC LIMIT 20").fetchall()], "reader": "plan"}
return {"answer": "ask about disks, certificates, failures, why <service>, or what to do", "reader": None}
def main():
cmd = sys.argv[1] if len(sys.argv) > 1 else "brief"
if cmd == "run":
return run()
c = db()
if cmd == "status":
print(json.dumps(status(c), indent=1))
elif cmd == "forecast":
print(json.dumps(forecast_view(c), indent=1))
elif cmd == "gate":
print(json.dumps({"forecasts": gate(c), "actions": admitted_actions(c)}, indent=1))
elif cmd == "why":
print(json.dumps(why(c, sys.argv[2] if len(sys.argv) > 2 else "host"), indent=1))
elif cmd == "discover":
d = discover()
print(json.dumps({k: (v if k != "certs" else {p: s for p, s in v.items()}) for k, v in d.items()}, indent=1))
elif cmd == "plan":
plan_actions(c, int(time.time()))
c.commit()
for r in c.execute("SELECT id, entity, kind, command, precondition, predicted, dry_run FROM action WHERE status='proposed' ORDER BY id").fetchall():
print("#%d %s %s\n do: %s\n because: %s\n expect: %s\n dry run: %s" % r)
elif cmd == "approve":
c.execute("UPDATE action SET status='approved', approved=? WHERE id=? AND status='proposed'", (int(time.time()), int(sys.argv[2])))
c.commit()
print("approved #%s (executes only with `execute %s`)" % (sys.argv[2], sys.argv[2]))
elif cmd == "execute":
r = c.execute("SELECT id, kind, command, status FROM action WHERE id=?", (int(sys.argv[2]),)).fetchone()
if not r or r[3] != "approved":
print("refused: action #%s is not approved" % sys.argv[2])
return 1
if r[1].startswith("investigate"):
print(_run(["sh", "-c", r[2]], timeout=60))
else:
res = subprocess.run(["sh", "-c", r[2]], capture_output=True, text=True, timeout=600)
print(res.stdout[-2000:], res.stderr[-1000:])
c.execute("UPDATE action SET status='executed', executed=1, executed_at=? WHERE id=?", (int(time.time()), r[0]))
c.commit()
elif cmd == "ask":
print(json.dumps(ask(c, " ".join(sys.argv[2:])), indent=1, default=str))
elif cmd == "signals":
f = sys.argv[2] if len(sys.argv) > 2 else ""
_, rows = latest_values(c)
print(json.dumps([{"entity": e, "signal": s, "value": v, "receipt": src} for e, s, v, src, _ in rows if f in e + s], indent=1))
elif cmd == "brief":
s = status(c)
n = c.execute("SELECT COUNT(*) FROM fact").fetchone()[0]
disc = meta_get(c, "discovery", {})
print("%s at %s facts %d store %.1f MB | discovered: %d services, %d jobs, %d sites, %d certs, %d ports" % (
socket.gethostname(), s.get("at"), n, os.path.getsize(DB) / 1e6, len(disc.get("services", [])), len(disc.get("jobs", [])),
len(disc.get("sites", [])), len(disc.get("certs", {})), len(disc.get("ports", []))))
for x in s.get("critical", []):
print(" CRITICAL %-34s %-15s %-8s [%s]" % (x["entity"], x["signal"], x["value"], x["receipt"][:60]))
for x in s.get("warning", [])[:10]:
print(" warning %-34s %-15s %-8s [%s]" % (x["entity"], x["signal"], x["value"], x["receipt"][:60]))
for x in forecast_view(c)[:3]:
print(" forecast %-34s %-15s %s now, %+.3f/h -> %s in %sh [%s]" % (x["entity"], x["signal"], x["now"], x["per_hour"],
x["reaches"], x["in_hours"], "ADMITTED" if x["admitted"] else "unproven"))
g = gate(c)
print(" gate: %d signal forecasts scored, %d with an admitted method; actions proposed %d, autonomy %s" % (
len(g), sum(1 for v in g.values() if v.get("admitted")),
c.execute("SELECT COUNT(*) FROM action WHERE status='proposed'").fetchone()[0], "ON" if AUTONOMOUS else "off"))
else:
print(__doc__)
if __name__ == "__main__":
main()