#!/usr/bin/env python3 """rmm-search engine. Reads the GuruRMM agents JSON array on stdin; reads QUERY/CLIENT/ONLINE/JSON/LISTC/LIMIT from the environment. Flexible, forgiving multi-field search — see rmm-search.sh for usage. Kept as a sidecar (not a heredoc) because the agents payload is too large to pass as a CLI argument.""" import sys, os, json, re from datetime import datetime, timezone raw = sys.stdin.read() agents = json.loads(raw) if raw.strip() else [] query = os.environ.get("QUERY", "").strip() client = os.environ.get("CLIENT", "").strip() online_only = os.environ.get("ONLINE") == "1" as_json = os.environ.get("JSON") == "1" list_clients = os.environ.get("LISTC") == "1" try: limit = int(os.environ.get("LIMIT", "0") or 0) except ValueError: limit = 0 def norm(s): return re.sub(r'[^a-z0-9]', '', (s or '').lower()) def is_subseq(t, v): it = iter(v) return all(c in it for c in t) def field_score(val, term, allow_subseq): """How well a single query word matches one normalized field value.""" if not val or not term: return 0 if val == term: return 100 if val.startswith(term): return 80 if term in val: return 60 if allow_subseq and len(term) >= 3 and is_subseq(term, val): return 25 return 0 # field -> (key, weight, allow_subsequence) FIELDS = [ ('hostname', 1.00, True), ('client_name', 0.75, False), ('site_name', 0.60, False), ('os_type', 0.55, False), ('id', 0.40, False), ] def fresh(last_seen): if not last_seen: return False try: dt = datetime.fromisoformat(last_seen.replace('Z', '+00:00')) return (datetime.now(timezone.utc) - dt).total_seconds() < 300 except Exception: return False if list_clients: for n in sorted({(a.get('client_name') or 'Unassigned') for a in agents}): print(n) sys.exit(0) # --- explicit hard client scope (normalized; "valleywide" -> "Valley Wide Plastering") --- if client: cn = norm(client) matched = sorted({(a.get('client_name') or '') for a in agents if cn and cn in norm(a.get('client_name'))} - {''}) if not matched: print(f"[ERROR] no client matches '{client}'. Try: rmm-search.sh --list-clients", file=sys.stderr) sys.exit(2) if len(matched) > 1: exact = [m for m in matched if norm(m) == cn] if len(exact) == 1: matched = exact else: print(f"[AMBIGUOUS] '{client}' matches {len(matched)} clients - narrow --client:", file=sys.stderr) for m in matched: print(" - " + m, file=sys.stderr) sys.exit(3) agents = [a for a in agents if (a.get('client_name') or '') == matched[0]] if online_only: agents = [a for a in agents if fresh(a.get('last_seen'))] terms = [t for t in (norm(x) for x in query.split()) if t] joined = norm(query) def score_agent(a): if not terms: return 1 # no query (e.g. "all machines for client X") nf = [(norm(a.get(k)), w, sub) for k, w, sub in FIELDS] total = 0.0 for term in terms: best = max(field_score(val, term, sub) * w for val, w, sub in nf) if best == 0: return 0 # AND: every word must hit some field total += best if joined and joined in norm(a.get('hostname')): total += 60 # whole query lands in hostname -> clearly THE machine return total results = sorted(((score_agent(a), a) for a in agents), key=lambda t: (-t[0], (t[1].get('hostname') or '').lower())) results = [(s, a) for s, a in results if s > 0] if limit > 0: results = results[:limit] if as_json: print(json.dumps([{ 'hostname': a.get('hostname'), 'id': a.get('id'), 'client': a.get('client_name'), 'site': a.get('site_name'), 'os': a.get('os_type'), 'online': fresh(a.get('last_seen')), 'last_seen': a.get('last_seen'), 'score': round(s, 1), } for s, a in results], indent=2)) sys.exit(0) scope = f" in '{(agents[0].get('client_name') if (client and agents) else client)}'" if client else "" if not results: print(f"No machines match '{query}'{scope}. (try fewer/looser words, or --list-clients)") sys.exit(0) hdr = f"{'HOSTNAME':<22} {'CLIENT':<26} {'SITE':<16} {'OS':<8} {'STAT':<7} ID" print(f"{len(results)} match(es) for '{query}'{scope} (best first):") print(hdr) print("-" * len(hdr)) for s, a in results: st = 'online' if fresh(a.get('last_seen')) else 'offline' print(f"{(a.get('hostname') or '')[:21]:<22} {(a.get('client_name') or '?')[:25]:<26} " f"{(a.get('site_name') or '')[:15]:<16} {(a.get('os_type') or '')[:7]:<8} {st:<7} {a.get('id')}")