687 lines
24 KiB
Python
Executable File
687 lines
24 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import datetime as dt
|
|
import http.client
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import re
|
|
import ssl
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib.parse
|
|
from collections import Counter
|
|
|
|
|
|
DEFAULT_ENDPOINTS = [
|
|
"https://traefik.sherlockhomeless.net",
|
|
"https://auth.sherlockhomeless.net",
|
|
"https://n8n.sherlockhomeless.net/healthz",
|
|
"https://nextcloud.sherlockhomeless.net",
|
|
"https://paperless.sherlockhomeless.net",
|
|
"https://onlyoffice.sherlockhomeless.net",
|
|
"https://memos.sherlockhomeless.net",
|
|
"https://vikunja.sherlockhomeless.net",
|
|
"https://karakeep.sherlockhomeless.net",
|
|
"https://ots.sherlockhomeless.net",
|
|
"https://dozzle.sherlockhomeless.net",
|
|
"https://beszel.sherlockhomeless.net",
|
|
]
|
|
|
|
DEFAULT_TARGET_CONTAINERS = [
|
|
"traefik",
|
|
"n8n",
|
|
"nextcloud",
|
|
"paperless",
|
|
"mail-relay",
|
|
"openclaw-openclaw-gateway-1",
|
|
]
|
|
|
|
ERROR_PATTERN = re.compile(
|
|
r"error|exception|fatal|panic|critical|denied|timeout|refused|unhealthy|failed|oauth|401",
|
|
re.IGNORECASE,
|
|
)
|
|
HOST_RULE_RE = re.compile(r"Host\(([^)]*)\)")
|
|
QUOTED_RE = re.compile(r"`([^`]+)`|'([^']+)'|\"([^\"]+)\"")
|
|
SABLIER_RE = re.compile(r"sablier-([A-Za-z0-9_.-]+)@file")
|
|
|
|
|
|
def env_int(name: str, default: int, minimum: int = 0) -> int:
|
|
raw = os.environ.get(name)
|
|
try:
|
|
value = int(raw) if raw is not None else default
|
|
except ValueError:
|
|
return default
|
|
return value if value >= minimum else default
|
|
|
|
|
|
def env_bool(name: str, default: bool) -> bool:
|
|
raw = os.environ.get(name)
|
|
if raw is None:
|
|
return default
|
|
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
def now_utc() -> str:
|
|
return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
|
def section(title: str) -> None:
|
|
print(f"\n## {title}")
|
|
|
|
|
|
def run(cmd, *, timeout=30, check=False, input_text=None):
|
|
try:
|
|
return subprocess.run(
|
|
cmd,
|
|
text=True,
|
|
input=input_text,
|
|
capture_output=True,
|
|
timeout=timeout,
|
|
check=check,
|
|
)
|
|
except Exception as exc:
|
|
completed = subprocess.CompletedProcess(cmd, 1, "", str(exc))
|
|
return completed
|
|
|
|
|
|
def command_exists(name: str) -> bool:
|
|
return run(["sh", "-lc", f"command -v {name} >/dev/null 2>&1"]).returncode == 0
|
|
|
|
|
|
def print_command(cmd, *, timeout=30) -> None:
|
|
proc = run(cmd, timeout=timeout)
|
|
out = proc.stdout.rstrip("\n")
|
|
err = proc.stderr.rstrip("\n")
|
|
if out:
|
|
print(out)
|
|
if err:
|
|
print(err)
|
|
|
|
|
|
def parse_list_env(raw: str | None, default: list[str]) -> list[str]:
|
|
if not raw:
|
|
return default
|
|
items = []
|
|
for part in re.split(r"[,;\n]", raw):
|
|
part = part.strip()
|
|
if part:
|
|
items.append(part)
|
|
return items
|
|
|
|
|
|
def resolve_docker_since(since: str) -> str:
|
|
if re.match(r"^\d+[smhd]$", since):
|
|
return since
|
|
proc = run(["date", "-d", since, "--iso-8601=seconds"])
|
|
if proc.returncode == 0 and proc.stdout.strip():
|
|
return proc.stdout.strip()
|
|
return "24h"
|
|
|
|
|
|
def docker_names(all_containers=False) -> list[str]:
|
|
cmd = ["docker", "ps", "--format", "{{.Names}}"]
|
|
if all_containers:
|
|
cmd.insert(2, "-a")
|
|
proc = run(cmd)
|
|
if proc.returncode != 0:
|
|
return []
|
|
return [line.strip() for line in proc.stdout.splitlines() if line.strip()]
|
|
|
|
|
|
def docker_inspect_labels(container: str) -> dict:
|
|
proc = run(["docker", "inspect", "--format", "{{json .Config.Labels}}", container])
|
|
if proc.returncode != 0 or not proc.stdout.strip():
|
|
return {}
|
|
try:
|
|
return json.loads(proc.stdout)
|
|
except json.JSONDecodeError:
|
|
return {}
|
|
|
|
|
|
def add_host_rule(rule: str, domains: set[str]) -> None:
|
|
if not isinstance(rule, str):
|
|
return
|
|
for host_match in HOST_RULE_RE.finditer(rule):
|
|
body = host_match.group(1)
|
|
for quoted_match in QUOTED_RE.finditer(body):
|
|
domain = next((item for item in quoted_match.groups() if item), "")
|
|
domain = domain.strip().lower()
|
|
if domain and "*" not in domain and "{" not in domain and "}" not in domain:
|
|
domains.add(domain)
|
|
|
|
|
|
def dynamic_files():
|
|
for base in (pathlib.Path("/opt/traefik/dynamic"), pathlib.Path("/etc/traefik/dynamic")):
|
|
if not base.exists():
|
|
continue
|
|
for path in base.rglob("*"):
|
|
if path.is_file() and path.suffix.lower() in {".yml", ".yaml", ".toml"}:
|
|
yield path
|
|
|
|
|
|
def active_tls_domains() -> set[str]:
|
|
domains: set[str] = set()
|
|
for container in docker_names():
|
|
labels = docker_inspect_labels(container)
|
|
for key, value in labels.items():
|
|
if key.startswith("traefik.http.routers.") and key.endswith(".rule"):
|
|
add_host_rule(value, domains)
|
|
for path in dynamic_files():
|
|
try:
|
|
add_host_rule(path.read_text(encoding="utf-8", errors="ignore"), domains)
|
|
except Exception:
|
|
continue
|
|
return domains
|
|
|
|
|
|
def sablier_managed_containers() -> set[str]:
|
|
names = set()
|
|
for path in dynamic_files():
|
|
try:
|
|
content = path.read_text(encoding="utf-8", errors="ignore")
|
|
except Exception:
|
|
continue
|
|
for match in SABLIER_RE.finditer(content):
|
|
names.add(match.group(1))
|
|
return names
|
|
|
|
|
|
def health_request(url: str, max_time: int, follow_redirects: bool, max_redirects=8) -> dict:
|
|
current_url = url
|
|
redirects = 0
|
|
start = time.monotonic()
|
|
last_code = 0
|
|
context = ssl.create_default_context()
|
|
|
|
while True:
|
|
parsed = urllib.parse.urlparse(current_url)
|
|
scheme = parsed.scheme or "https"
|
|
host = parsed.hostname
|
|
if not host:
|
|
raise ValueError(f"invalid URL: {current_url}")
|
|
port = parsed.port or (443 if scheme == "https" else 80)
|
|
path = urllib.parse.urlunparse(("", "", parsed.path or "/", parsed.params, parsed.query, ""))
|
|
conn_cls = http.client.HTTPSConnection if scheme == "https" else http.client.HTTPConnection
|
|
kwargs = {"timeout": max_time}
|
|
if scheme == "https":
|
|
kwargs["context"] = context
|
|
conn = conn_cls(host, port, **kwargs)
|
|
try:
|
|
conn.request("GET", path, headers={"User-Agent": "ops-report/1.0"})
|
|
response = conn.getresponse()
|
|
last_code = response.status
|
|
response.read()
|
|
if follow_redirects and last_code in {301, 302, 303, 307, 308} and redirects < max_redirects:
|
|
location = response.getheader("Location")
|
|
if location:
|
|
current_url = urllib.parse.urljoin(current_url, location)
|
|
redirects += 1
|
|
continue
|
|
elapsed = time.monotonic() - start
|
|
return {
|
|
"http_code": last_code,
|
|
"curl_time_total_seconds": f"{elapsed:.6f}",
|
|
"effective_url": current_url,
|
|
}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def cmd_health(_args) -> int:
|
|
max_time = env_int("HEALTH_MAX_TIME", 12, 1)
|
|
retries = env_int("HEALTH_RETRIES", 2, 1)
|
|
retry_delay = env_int("HEALTH_RETRY_DELAY", 8, 0)
|
|
follow_redirects = env_bool("HEALTH_FOLLOW_REDIRECTS", True)
|
|
endpoints = parse_list_env(os.environ.get("HEALTH_ENDPOINTS"), DEFAULT_ENDPOINTS)
|
|
|
|
results = []
|
|
for endpoint in endpoints:
|
|
start_ms = int(time.time() * 1000)
|
|
status = "down"
|
|
http_code = 0
|
|
effective_url = ""
|
|
time_total = ""
|
|
error = ""
|
|
attempts = 0
|
|
for attempt in range(1, retries + 1):
|
|
attempts = attempt
|
|
try:
|
|
response = health_request(endpoint, max_time, follow_redirects)
|
|
http_code = int(response["http_code"])
|
|
effective_url = response["effective_url"]
|
|
time_total = response["curl_time_total_seconds"]
|
|
if 200 <= http_code < 400:
|
|
status = "up"
|
|
error = ""
|
|
break
|
|
error = f"HTTP {http_code}"
|
|
except Exception as exc:
|
|
http_code = 0
|
|
effective_url = ""
|
|
time_total = ""
|
|
error = str(exc)
|
|
if attempt < retries:
|
|
time.sleep(retry_delay)
|
|
latency_ms = int(time.time() * 1000) - start_ms
|
|
item = {
|
|
"endpoint": endpoint,
|
|
"status": status,
|
|
"http_code": http_code,
|
|
"latency_ms": latency_ms,
|
|
"attempts": attempts,
|
|
}
|
|
if time_total:
|
|
item["curl_time_total_seconds"] = time_total
|
|
if effective_url:
|
|
item["effective_url"] = effective_url
|
|
if status == "down":
|
|
item["error"] = error
|
|
results.append(item)
|
|
|
|
up_count = sum(1 for item in results if item["status"] == "up")
|
|
down_count = sum(1 for item in results if item["status"] == "down")
|
|
endpoints_count = up_count + down_count
|
|
overall = "up"
|
|
if endpoints_count == 0:
|
|
overall = "down"
|
|
elif down_count:
|
|
overall = "degraded"
|
|
print(json.dumps({
|
|
"generated_at_utc": now_utc(),
|
|
"overall_status": overall,
|
|
"endpoints_count": endpoints_count,
|
|
"up_count": up_count,
|
|
"down_count": down_count,
|
|
"max_time_seconds": max_time,
|
|
"retries": retries,
|
|
"retry_delay_seconds": retry_delay,
|
|
"results": results,
|
|
}, indent=2))
|
|
return 0
|
|
|
|
|
|
def proc_text(path: pathlib.Path) -> str:
|
|
try:
|
|
return path.read_text(errors="ignore")
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
def top_swap_processes(limit: int) -> list[tuple[int, str, str]]:
|
|
rows = []
|
|
if limit <= 0:
|
|
return rows
|
|
for status_file in pathlib.Path("/proc").glob("[0-9]*/status"):
|
|
data = proc_text(status_file)
|
|
if not data:
|
|
continue
|
|
name = ""
|
|
pid = status_file.parent.name
|
|
swap = 0
|
|
for line in data.splitlines():
|
|
if line.startswith("Name:"):
|
|
name = line.split(None, 1)[1]
|
|
elif line.startswith("Pid:"):
|
|
pid = line.split(None, 1)[1]
|
|
elif line.startswith("VmSwap:"):
|
|
parts = line.split()
|
|
if len(parts) >= 2:
|
|
try:
|
|
swap = int(parts[1])
|
|
except ValueError:
|
|
swap = 0
|
|
if swap > 0:
|
|
rows.append((swap, name, pid))
|
|
rows.sort(reverse=True)
|
|
return rows[:limit]
|
|
|
|
|
|
def cert_end_date_from_file(path: pathlib.Path) -> str:
|
|
proc = run(["openssl", "x509", "-enddate", "-noout", "-in", str(path)])
|
|
if proc.returncode != 0:
|
|
return ""
|
|
return proc.stdout.strip().split("=", 1)[-1]
|
|
|
|
|
|
def cert_end_date_from_pem(pem: str) -> str:
|
|
proc = run(["openssl", "x509", "-enddate", "-noout"], input_text=pem)
|
|
if proc.returncode != 0:
|
|
return ""
|
|
return proc.stdout.strip().split("=", 1)[-1]
|
|
|
|
|
|
def parse_cert_epoch(end_raw: str) -> int | None:
|
|
if not end_raw:
|
|
return None
|
|
proc = run(["date", "-d", end_raw, "+%s"])
|
|
if proc.returncode != 0:
|
|
return None
|
|
try:
|
|
return int(proc.stdout.strip())
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def print_cert_line(domain: str, end_raw: str, warn_days: int) -> None:
|
|
if not end_raw:
|
|
print(f"{domain}: unable to parse cert")
|
|
return
|
|
epoch = parse_cert_epoch(end_raw)
|
|
if epoch is None:
|
|
print(f"{domain}: enddate={end_raw}")
|
|
return
|
|
days_left = int((epoch - time.time()) / 86400)
|
|
warn = " [WARN]" if days_left <= warn_days else ""
|
|
print(f"{domain}: expires={end_raw} days_left={days_left}{warn}")
|
|
|
|
|
|
def acme_entries(paths: list[str]) -> list[tuple[str, str]]:
|
|
entries = []
|
|
for raw_path in paths:
|
|
path = pathlib.Path(raw_path)
|
|
if not path.exists():
|
|
continue
|
|
try:
|
|
data = json.loads(path.read_text())
|
|
except Exception:
|
|
continue
|
|
for payload in data.values():
|
|
if not isinstance(payload, dict):
|
|
continue
|
|
for cert in payload.get("Certificates", []):
|
|
domain = (cert.get("domain") or {}).get("main")
|
|
encoded = cert.get("certificate")
|
|
if domain and encoded:
|
|
entries.append((domain, encoded))
|
|
return entries
|
|
|
|
|
|
def cmd_summary(_args) -> int:
|
|
since_hours = env_int("SINCE_HOURS", 24, 1)
|
|
top_error_lines = env_int("TOP_ERROR_LINES", 20, 0)
|
|
cert_warn_days = env_int("CERT_WARN_DAYS", 30, 0)
|
|
top_swap = env_int("TOP_SWAP_PROCESSES", 10, 0)
|
|
show_overlays = env_bool("SHOW_DF_OVERLAYS", False)
|
|
show_running = env_bool("SHOW_RUNNING_CONTAINERS", False)
|
|
active_only = env_bool("ACTIVE_CERT_DOMAINS_ONLY", True)
|
|
exclude_domains = {d.lower() for d in parse_list_env(
|
|
os.environ.get("CERT_EXCLUDE_DOMAINS", "portainer.thehomelesssherlock.com uptimekuma.sherlockhomeless.net notas.sherlockhomeless.net").replace(" ", "\n"),
|
|
[],
|
|
)}
|
|
|
|
print("# Server Daily Summary")
|
|
print(f"generated_at_utc: {now_utc()}")
|
|
hostname = run(["hostname", "-f"]).stdout.strip() or run(["hostname"]).stdout.strip()
|
|
print(f"host: {hostname}")
|
|
print(f"kernel: {run(['uname', '-r']).stdout.strip()}")
|
|
|
|
section("Uptime & Load")
|
|
uptime = run(["uptime", "-p"]).stdout.strip() or run(["uptime"]).stdout.strip()
|
|
print(f"uptime: {uptime}")
|
|
loadavg = pathlib.Path("/proc/loadavg").read_text().split()[:3]
|
|
print(f"loadavg: {' '.join(loadavg)}")
|
|
|
|
section("Memory")
|
|
print_command(["free", "-h"])
|
|
if top_swap > 0:
|
|
print()
|
|
print("top_swap_processes:")
|
|
print("SWAP_KIB\tCOMMAND\tPID")
|
|
for swap, name, pid in top_swap_processes(top_swap):
|
|
print(f"{swap}\t{name}\t{pid}")
|
|
|
|
section("Disk")
|
|
df_cmd = ["df", "-hT", "-x", "tmpfs", "-x", "devtmpfs"]
|
|
if not show_overlays:
|
|
df_cmd += ["-x", "overlay"]
|
|
print_command(df_cmd)
|
|
if not show_overlays:
|
|
print("overlay_mounts: omitted set SHOW_DF_OVERLAYS=1 to include")
|
|
|
|
section("Docker Overview")
|
|
if not command_exists("docker"):
|
|
print("docker command not available")
|
|
else:
|
|
sablier = sablier_managed_containers()
|
|
running = docker_names()
|
|
unhealthy = run(["docker", "ps", "--filter", "health=unhealthy", "-q"]).stdout.splitlines()
|
|
exited = run(["docker", "ps", "-a", "--filter", "status=exited", "-q"]).stdout.splitlines()
|
|
restarting = run(["docker", "ps", "-a", "--filter", "status=restarting", "-q"]).stdout.splitlines()
|
|
print(f"containers_running: {len(running)}")
|
|
print(f"containers_unhealthy: {len(unhealthy)}")
|
|
print(f"containers_exited: {len(exited)}")
|
|
print(f"containers_restarting: {len(restarting)}")
|
|
print()
|
|
if show_running:
|
|
print("running_containers:")
|
|
print_command(["docker", "ps", "--format", "table {{.Names}}\t{{.Status}}\t{{.Networks}}"])
|
|
else:
|
|
print(f"running_containers: omitted count={len(running)} set SHOW_RUNNING_CONTAINERS=1 to include")
|
|
if unhealthy:
|
|
print()
|
|
print("unhealthy_containers:")
|
|
print_command(["docker", "ps", "--filter", "health=unhealthy", "--format", "table {{.Names}}\t{{.Status}}\t{{.Image}}"])
|
|
if exited or restarting:
|
|
print()
|
|
print("non_running_containers:")
|
|
non_running = run(["docker", "ps", "-a", "--filter", "status=exited", "--filter", "status=restarting", "--format", "{{.Names}}\t{{.Status}}\t{{.Image}}"]).stdout.splitlines()
|
|
print("NAMES\tSTATUS\tIMAGE")
|
|
parsed = []
|
|
for line in non_running:
|
|
parts = line.split("\t")
|
|
if len(parts) == 3:
|
|
parsed.append(tuple(parts))
|
|
print(line)
|
|
if sablier:
|
|
print()
|
|
print("non_running_sablier_managed_containers:")
|
|
print("NAMES\tSTATUS\tIMAGE")
|
|
for name, status, image in parsed:
|
|
if name in sablier:
|
|
print(f"{name}\t{status}\t{image}")
|
|
print()
|
|
print("non_running_other_containers:")
|
|
print("NAMES\tSTATUS\tIMAGE")
|
|
for name, status, image in parsed:
|
|
if name not in sablier:
|
|
print(f"{name}\t{status}\t{image}")
|
|
|
|
section("TLS Certificates")
|
|
if not command_exists("openssl"):
|
|
print("openssl not available")
|
|
else:
|
|
found = False
|
|
seen = set()
|
|
active = active_tls_domains() if active_only else set()
|
|
if active_only:
|
|
if active:
|
|
print(f"tls_scope: active_domains_only active_domains={len(active)} excluded_domains={len(exclude_domains)}")
|
|
else:
|
|
print(f"tls_scope: active_domains_only requested but no active domains detected; fallback=all_discovered excluded_domains={len(exclude_domains)}")
|
|
live = pathlib.Path("/etc/letsencrypt/live")
|
|
if live.exists():
|
|
for cert in sorted(live.glob("*/cert.pem")):
|
|
domain = cert.parent.name
|
|
domain_lc = domain.lower()
|
|
if domain_lc in exclude_domains:
|
|
continue
|
|
if active_only and active and domain_lc not in active:
|
|
continue
|
|
if domain in seen:
|
|
continue
|
|
seen.add(domain)
|
|
found = True
|
|
print_cert_line(domain, cert_end_date_from_file(cert), cert_warn_days)
|
|
for domain, encoded in acme_entries(["/opt/traefik/letsencrypt/acme.json", "/opt/traefik/acme/acme.json"]):
|
|
domain_lc = domain.lower()
|
|
if domain_lc in exclude_domains:
|
|
continue
|
|
if active_only and active and domain_lc not in active:
|
|
continue
|
|
if domain in seen:
|
|
continue
|
|
seen.add(domain)
|
|
found = True
|
|
try:
|
|
pem = base64.b64decode(encoded).decode()
|
|
except Exception:
|
|
print(f"{domain}: unable to decode traefik cert")
|
|
continue
|
|
print_cert_line(domain, cert_end_date_from_pem(pem), cert_warn_days)
|
|
if not found:
|
|
print("no local TLS certificates found")
|
|
|
|
section("SSH Protection")
|
|
if not command_exists("fail2ban-client"):
|
|
print("fail2ban-client not available")
|
|
else:
|
|
proc = run(["fail2ban-client", "status", "sshd"])
|
|
text = proc.stdout
|
|
if text:
|
|
def extract(label):
|
|
m = re.search(rf"{re.escape(label)}:\s*(\d+)", text)
|
|
return m.group(1) if m else "0"
|
|
print(f"fail2ban_sshd: active total_failed={extract('Total failed')} current_banned={extract('Currently banned')} total_banned={extract('Total banned')}")
|
|
print("note: SSH probes can still generate auth/journal log entries before or while bans are applied; that alone does not mean fail2ban is inactive.")
|
|
else:
|
|
print("fail2ban_sshd: no status available")
|
|
|
|
section("OpenClaw OAuth")
|
|
report_openclaw_oauth()
|
|
|
|
section(f"Top Journal Errors (last {since_hours}h)")
|
|
if command_exists("journalctl") and top_error_lines > 0:
|
|
proc = run(["journalctl", "-p", "err..alert", "--since", f"{since_hours} hours ago", "--no-pager", "-o", "short-iso"], timeout=30)
|
|
for line in proc.stdout.splitlines()[-top_error_lines:]:
|
|
print(line)
|
|
elif not command_exists("journalctl"):
|
|
print("journalctl not available")
|
|
return 0
|
|
|
|
|
|
def report_openclaw_oauth() -> None:
|
|
auth_file = pathlib.Path(os.environ.get("OPENCLAW_AUTH_FILE", "/home/felidae/.openclaw/agents/main/agent/auth-profiles.json"))
|
|
warn_hours = env_int("OPENCLAW_WARN_HOURS", 72, 0)
|
|
critical_hours = env_int("OPENCLAW_CRITICAL_HOURS", 24, 0)
|
|
action = os.environ.get("OPENCLAW_REAUTH_CMD", "codex-reauth")
|
|
if not auth_file.exists():
|
|
print("openclaw_codex_oauth: auth file or jq unavailable")
|
|
return
|
|
try:
|
|
data = json.loads(auth_file.read_text())
|
|
profiles = data.get("profiles", {})
|
|
expiries = [
|
|
int(value.get("expires") or 0)
|
|
for value in profiles.values()
|
|
if isinstance(value, dict) and value.get("provider") == "openai-codex"
|
|
]
|
|
expiry_ms = max(expiries) if expiries else 0
|
|
except Exception:
|
|
expiry_ms = 0
|
|
if expiry_ms <= 0:
|
|
print("openclaw_codex_oauth: no oauth profile expiry found")
|
|
return
|
|
now_ms = int(time.time() * 1000)
|
|
diff_ms = expiry_ms - now_ms
|
|
expiry_iso = dt.datetime.fromtimestamp(expiry_ms / 1000, dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
if diff_ms <= 0:
|
|
print(f'openclaw_codex_oauth: CRITICAL expires_utc={expiry_iso} reason=expired action="{action}"')
|
|
return
|
|
hours_left = diff_ms // 3_600_000
|
|
mins_left = (diff_ms % 3_600_000) // 60_000
|
|
severity = "OK"
|
|
if hours_left <= critical_hours:
|
|
severity = "CRITICAL"
|
|
elif hours_left <= warn_hours:
|
|
severity = "WARN"
|
|
print(f'openclaw_codex_oauth: {severity} expires_utc={expiry_iso} hours_left={hours_left}h{mins_left}m critical_hours={critical_hours} warn_hours={warn_hours} action="{action}"')
|
|
|
|
|
|
def cmd_logs(_args) -> int:
|
|
since = os.environ.get("SINCE", "24 hours ago")
|
|
max_journal = env_int("MAX_JOURNAL_LINES", 120, 0)
|
|
max_docker = env_int("MAX_DOCKER_LINES_PER_SERVICE", 40, 0)
|
|
targets = parse_list_env(os.environ.get("TARGET_CONTAINERS"), DEFAULT_TARGET_CONTAINERS)
|
|
docker_since = resolve_docker_since(since)
|
|
|
|
print("# Log Digest")
|
|
print(f"generated_at_utc: {now_utc()}")
|
|
print(f"window: {since}")
|
|
print(f"docker_since: {docker_since}")
|
|
|
|
section("System Errors (journalctl err..alert)")
|
|
if command_exists("journalctl"):
|
|
proc = run(["journalctl", "--since", since, "-p", "err..alert", "--no-pager", "-o", "short-iso"], timeout=30)
|
|
for line in proc.stdout.splitlines()[-max_journal:]:
|
|
print(line)
|
|
else:
|
|
print("journalctl not available")
|
|
|
|
section("System Warnings (journalctl warning)")
|
|
if command_exists("journalctl"):
|
|
proc = run(["journalctl", "--since", since, "-p", "warning", "--no-pager", "-o", "short-iso"], timeout=30)
|
|
for line in proc.stdout.splitlines()[-max_journal:]:
|
|
print(line)
|
|
else:
|
|
print("journalctl not available")
|
|
|
|
section("Docker Service Error Highlights")
|
|
if command_exists("docker"):
|
|
all_names = set(docker_names(all_containers=True))
|
|
for svc in targets:
|
|
if svc not in all_names:
|
|
continue
|
|
print()
|
|
print(f"### {svc}")
|
|
proc = run(["docker", "logs", "--since", docker_since, svc], timeout=30)
|
|
lines = (proc.stdout + proc.stderr).splitlines()
|
|
for line in [line for line in lines if ERROR_PATTERN.search(line)][-max_docker:]:
|
|
print(line)
|
|
else:
|
|
print("docker command not available")
|
|
|
|
section("Top Error Patterns")
|
|
if command_exists("journalctl"):
|
|
proc = run(["journalctl", "--since", since, "--no-pager", "-o", "cat"], timeout=30)
|
|
counts = Counter()
|
|
for line in proc.stdout.splitlines():
|
|
if re.search(r"error|exception|fatal|panic|critical|denied|timeout|failed", line, re.IGNORECASE):
|
|
normalized = re.sub(r"\s+", " ", line.strip())[:180]
|
|
counts[normalized] += 1
|
|
for line, count in counts.most_common(20):
|
|
print(f"{count:7d} {line}")
|
|
else:
|
|
print("journalctl not available")
|
|
return 0
|
|
|
|
|
|
def cmd_all(_args) -> int:
|
|
print("__N8N_LOG_DIGEST_BEGIN__")
|
|
rc_logs = cmd_logs(_args)
|
|
print("\n__N8N_SERVER_SUMMARY_BEGIN__")
|
|
rc_summary = cmd_summary(_args)
|
|
print("\n__N8N_HEALTH_RESULTS_BEGIN__")
|
|
rc_health = cmd_health(_args)
|
|
print(f"\n__N8N_STATUS__ log={rc_logs} summary={rc_summary} health={rc_health}")
|
|
return 0 if rc_logs == rc_summary == rc_health == 0 else 1
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Personal ops report helper")
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
sub.add_parser("health").set_defaults(func=cmd_health)
|
|
sub.add_parser("summary").set_defaults(func=cmd_summary)
|
|
sub.add_parser("logs").set_defaults(func=cmd_logs)
|
|
sub.add_parser("all").set_defaults(func=cmd_all)
|
|
args = parser.parse_args()
|
|
return args.func(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|