From 9ac96cee9afe4478be6f604a95413bdba3503740 Mon Sep 17 00:00:00 2001 From: Jake Kasper Date: Fri, 24 Apr 2026 14:15:58 -0400 Subject: [PATCH] started log ingestion and analysis --- Dockerfile | 16 +- app/__pycache__/config.cpython-314.pyc | Bin 3354 -> 6188 bytes app/__pycache__/main.cpython-314.pyc | Bin 3341 -> 4347 bytes app/config.py | 37 ++ app/main.py | 21 +- app/routers/__pycache__/logs.cpython-314.pyc | Bin 0 -> 2386 bytes app/routers/__pycache__/query.cpython-314.pyc | Bin 2206 -> 2403 bytes app/routers/logs.py | 36 ++ app/routers/query.py | 13 +- app/services/__pycache__/ai.cpython-314.pyc | Bin 24317 -> 33371 bytes .../__pycache__/alertmanager.cpython-314.pyc | Bin 3045 -> 7408 bytes .../cluster_inventory.cpython-314.pyc | Bin 11066 -> 11841 bytes .../__pycache__/log_analyzer.cpython-314.pyc | Bin 19936 -> 18958 bytes .../__pycache__/log_ingest.cpython-314.pyc | Bin 0 -> 24687 bytes .../__pycache__/log_rules.cpython-314.pyc | Bin 0 -> 2239 bytes app/services/__pycache__/pls.cpython-314.pyc | Bin 5598 -> 9342 bytes app/services/ai.py | 134 ++++- app/services/alertmanager.py | 76 ++- app/services/cluster_inventory.py | 19 +- app/services/log_analyzer.py | 228 ++++---- app/services/log_ingest.py | 499 ++++++++++++++++++ app/services/log_rules.py | 37 ++ app/services/pls.py | 52 +- app/ui/index.html | 185 ++++++- config/log_rules.json | 149 ++++++ config/marvis.env.example | 16 + config/p5g-marvis.service | 29 + 27 files changed, 1368 insertions(+), 179 deletions(-) create mode 100644 app/routers/__pycache__/logs.cpython-314.pyc create mode 100644 app/routers/logs.py create mode 100644 app/services/__pycache__/log_ingest.cpython-314.pyc create mode 100644 app/services/__pycache__/log_rules.cpython-314.pyc create mode 100644 app/services/log_ingest.py create mode 100644 app/services/log_rules.py create mode 100644 config/log_rules.json diff --git a/Dockerfile b/Dockerfile index 3e5facf..48631d7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,16 +10,30 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* \ && pip install --no-cache-dir -r requirements.txt +RUN mkdir -p /app/data + COPY app/ ./app/ COPY config/ ./config/ -EXPOSE 8100 +EXPOSE 8100 5514 # Appliance-style defaults for host-network deployment. Override via env or # systemd EnvironmentFile when needed. ENV MARVIS_PROMETHEUS_URL=http://127.0.0.1:9090 ENV MARVIS_PROMETHEUS_PREFIX=/prometheus ENV MARVIS_ALERTMANAGER_URL=http://127.0.0.1:9093 +ENV MARVIS_LOG_INGEST_ENABLED=true +ENV MARVIS_LOG_AUTO_CONFIGURE=true +ENV MARVIS_LOG_RECEIVER_BIND_HOST=0.0.0.0 +ENV MARVIS_LOG_RECEIVER_PORT=5514 +ENV MARVIS_LOG_RECEIVER_FORMAT=json_lines +ENV MARVIS_LOG_BUFFER_LINES=1000 +ENV MARVIS_LOG_TRACE_BUFFER_LINES=5000 +ENV MARVIS_LOG_ALERT_CONTEXT_BEFORE=5 +ENV MARVIS_LOG_ALERT_CONTEXT_AFTER=5 +ENV MARVIS_LOG_ALERT_CONTEXT_DB_PATH=/app/data/marvis-alert-context.db +ENV MARVIS_LOG_ALERT_CONTEXT_DB_MAX_ROWS=500 +ENV MARVIS_LOG_FLUENTBIT_MATCH=* ENV MARVIS_AI_MODE=rule ENV MARVIS_CONTAINER_RUNTIME=docker ENV MARVIS_UERANSIM_ENV_FILE=/app/config/ueransim.env diff --git a/app/__pycache__/config.cpython-314.pyc b/app/__pycache__/config.cpython-314.pyc index 7ac8b77b60942ea14720fa952c8f505ba19a4446..3a854e45953c4e4e1bfc8f015e040096bb8985ce 100644 GIT binary patch literal 6188 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 J>pkZ({|lBHhJpY9 delta 984 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 SQKp^GK^8-_>mTIo%KjHrdwH$^ diff --git a/app/config.py b/app/config.py index 26a89fe..7b06802 100644 --- a/app/config.py +++ b/app/config.py @@ -8,6 +8,24 @@ def _env_bool(name: str, default: bool) -> bool: return value.lower() in {"1", "true", "yes", "on"} +def _env_int(name: str, default: int) -> int: + value = os.getenv(name) + if value is None: + return default + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _env_list(name: str, default: list[str]) -> list[str]: + value = os.getenv(name) + if value is None: + return default + parsed = [item.strip() for item in value.split(",") if item.strip()] + return parsed or default + + # Defaults assume the appliance-style deployment model where Marvis runs with # host networking and talks to sibling services over host loopback. PROMETHEUS_URL = os.getenv("MARVIS_PROMETHEUS_URL", "http://127.0.0.1:9090") @@ -21,6 +39,25 @@ PLS_PASSWORD = os.getenv("MARVIS_PLS_PASSWORD", "") PLS_AUTH_BACKEND = os.getenv("MARVIS_PLS_AUTH_BACKEND", "local") PLS_VERIFY_TLS = _env_bool("MARVIS_PLS_VERIFY_TLS", False) +# Fluent Bit ingestion and retention. +LOG_INGEST_ENABLED = _env_bool("MARVIS_LOG_INGEST_ENABLED", True) +LOG_AUTO_CONFIGURE = _env_bool("MARVIS_LOG_AUTO_CONFIGURE", True) +LOG_RECEIVER_BIND_HOST = os.getenv("MARVIS_LOG_RECEIVER_BIND_HOST", "0.0.0.0") +LOG_RECEIVER_HOST = os.getenv("MARVIS_LOG_RECEIVER_HOST", "") +LOG_RECEIVER_PORT = _env_int("MARVIS_LOG_RECEIVER_PORT", 5514) +LOG_RECEIVER_FORMAT = os.getenv("MARVIS_LOG_RECEIVER_FORMAT", "json_lines") +LOG_BUFFER_LINES = _env_int("MARVIS_LOG_BUFFER_LINES", 1000) +LOG_ALERT_CONTEXT_BEFORE = _env_int("MARVIS_LOG_ALERT_CONTEXT_BEFORE", 5) +LOG_ALERT_CONTEXT_AFTER = _env_int("MARVIS_LOG_ALERT_CONTEXT_AFTER", 5) +LOG_ALERT_CONTEXT_DB_PATH = os.getenv("MARVIS_LOG_ALERT_CONTEXT_DB_PATH", "/app/data/marvis-alert-context.db") +LOG_ALERT_CONTEXT_DB_MAX_ROWS = _env_int("MARVIS_LOG_ALERT_CONTEXT_DB_MAX_ROWS", 500) +LOG_TRACE_BUFFER_LINES = _env_int("MARVIS_LOG_TRACE_BUFFER_LINES", 5000) +LOG_FLUENTBIT_MATCH = os.getenv("MARVIS_LOG_FLUENTBIT_MATCH", "*") +LOG_ALLOWED_NFS = [item.upper() for item in _env_list( + "MARVIS_LOG_ALLOWED_NFS", + ["AMF", "SMF", "UPF", "UDM", "UDR", "NRF", "AUSF", "PCF", "MME", "SGWC", "DRA", "DSM", "AAA", "BMSC", "CHF", "SMSF", "EIR"], +)] + # AI backend: "rule" | "openai" | "ollama" AI_MODE = os.getenv("MARVIS_AI_MODE", "rule") OPENAI_API_KEY = os.getenv("MARVIS_OPENAI_API_KEY", "") diff --git a/app/main.py b/app/main.py index a2208c6..5471437 100644 --- a/app/main.py +++ b/app/main.py @@ -3,7 +3,15 @@ from fastapi.responses import FileResponse from fastapi.middleware.cors import CORSMiddleware from pathlib import Path -from app.routers import network, alerts, query as query_router, actions as actions_router, emulated_session as emulated_session_router +from app.routers import ( + actions as actions_router, + alerts, + emulated_session as emulated_session_router, + logs as logs_router, + network, + query as query_router, +) +from app.services import log_ingest app = FastAPI(title="P5G Marvis", version="1.0.0", docs_url="/api/docs") @@ -18,6 +26,7 @@ app.include_router(network.router, prefix="/api") app.include_router(alerts.router, prefix="/api") app.include_router(query_router.router, prefix="/api") app.include_router(actions_router.router, prefix="/api") +app.include_router(logs_router.router, prefix="/api") app.include_router(emulated_session_router.router, prefix="/api") UI = Path(__file__).parent / "ui" / "index.html" @@ -51,6 +60,16 @@ async def actions_page(): return FileResponse(str(ACTIONS_UI)) +@app.on_event("startup") +async def _startup() -> None: + await log_ingest.startup() + + +@app.on_event("shutdown") +async def _shutdown() -> None: + await log_ingest.shutdown() + + # Catch-all: serve the SPA for any unmatched path (supports deep-linking) @app.get("/{full_path:path}") async def spa(full_path: str): diff --git a/app/routers/__pycache__/logs.cpython-314.pyc b/app/routers/__pycache__/logs.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2a3982e2ff2e56cf252a7dec8f962234e55bd7ef GIT binary patch literal 2386 zcma)7O>7fa5T3VhcjNygCXP)U2;kCSnkF;>xl(DXs6c{*qGT1pCB$;=O|q3^yKmPh za0(QuNEK2KJ@h~;s`db?R3Wtoj&P|snj})_3aLe<2h@wxAlj-ubY^#L2c(F;)y|uJ zGjHCTnQvxai-ZG!BmV90#*ZA}SNdp-HjmjHWB|)B3mQ8KJzePD@9|`uxzEEaQ?NtfRzg0wM|&E`s_GUiXuX2niiir{M=MY0Tk zf}g1yL>>ksb&m7nK=@Pn>cP5#{8JVDp(}-}fqEN#`5+h}*@hp#1eIx?0^1mr0tXKZ z$hdgWUW$k_>`Y2y8P5)euVD=)x7zkrTL>DGJPdJLb+dU>7Ex3am{Smh1QA1Gt1d{= z+yz>Iu;XqJbYanp#KFg_INXRe@#X3Dp-Pz4-atb5s&|0D^Rt8N(f*Za{|)`l@LF{0 zfv=k5QhO!}sxR69A zsyaTWndn9yrvy%0lJBJ3AxRRD66Kt+6Kunw-=y5x&1M7gs0IqYPBsZB|2Lbbu_I?Z zD|AVx$|c*^aQP;4@9R~47gc@9%I1o?u(F<#Na2Bw4_-Pt8s*-wBKD(34{4ECl#pLS z-PZ!^iOfnOb6a0a9IFM7*W}|~=zDtEh4yCX=ZyI(9*Y~BgR0bV^-E-2SCcOVZlke} zfd(INfF-ufPbmRNK^vTs_rQ$Yu&+X47xjt?%ryG}_6+JP&X|T~+mD3hRtvh7Gm2@&MR^QE~bOn z*Vtb93!Z}OwD1+8=j-5TfPWVsRrp;+LH2Hdqa+=++valUe8mtLcW_-HxY{B7TMU}I zIA`Q_(~X4c{yc1sAaZ^bf;?~#WXqK@c1`k1P%@kva3kP5x>zn$i~2ia7zG4q^A-}k zHe)}-$bA_34N{N%(NB`gYF!1%_i@j~o+}fpEV&^uc3_i()LSR6?%OJS)Y<*HdHus1 z(`%i>58}xOv7JbtQ8)E=AXiU#J8GfcRkrJ=1FzqD|Mt|Kk+lP3&zzDa)!k(L1aqw? YFD7f@-K(r`Lz3ATBl(^`9M7fK6rSKT9}q0`F-1{&wL(0BZe7(Fg`dwPq<#sfTJ5UU)LUd;{>OdK)po}z3 zVq`<>8g}+7pHTOfnMA>i-J=rX#5ShHm@Uf7Q0J>qB*rk#yo>#o=u;X1?#)Z_T!Hh>olEdomD z6C!v7uUU9?sJ>faN5S!nK;nyHtv@n;B}s>0atkR54 z#Tla7GD07vLF7P34v5wGJ(<7}$>^bhIg^T-d4!sdY(A%tYmC8@7?ft(b5r~mjYTBZ z4!Qt95TFyFLjacqbnOsQugmfUfPcVG2zVzddmD}}k_XOv&T2Sb3&dAwd+F$W>}9j^ z`@qR71M~dW?6uiH_deZPZBEr1Ql<2&r)6GVh%O#{8eUFSjt^FbPFLKy3eBxaD0tvS zL+Ue!i4A6tZANE4;rV}z z04p2-7_sA>YXCP!t?j>bK>ZdSVL?&=wF|FPD1#59B35xPLB@OOc4`Q6TG9)Rut`oW z$82>~kqRG3y-E6lDvc52H@ngSe5*xApQ<|c6N^x{MW&5GjAId(@!X!6bLxexKF+k< z7=wN>U3pEtQZth!qlIxFt33hi1WC^&#WbC|jdNn5fBFHxLqq)U=VoR39(M%57?tXF4eg=t+h zE)1thiv_ONd)Rss%vOCkA(Scqc&0EmJ*gdHKZs8lES?tdkzkBpp>JQI?XOX~iqgi2 W98yN`Izcw&qYVc0lSlM{73p7cq{?1yzaP@ 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 D(>f@j diff --git a/app/services/__pycache__/cluster_inventory.cpython-314.pyc b/app/services/__pycache__/cluster_inventory.cpython-314.pyc index d8105187fe0268782f49a6506969bb48c41d34f8..1524df56aff438c37bc78f5568f991248518c6f5 100644 GIT binary patch delta 4299 zcmbVPdu*H46~EX1{B|72?<7v_B#zspiJQ38gHWhW6LWV=!q*&BAE;Rt?z3AJYU19Tk-AkDYVvBs4=* z+9&yU&%Nhy?>+aNbFY6BKR@PJ=cuw0_=YOTcNedmM$vWp%;Z-6|`%hl^n9U>*Ui-CD{!l9Wdg7wlhc9+tn)BE8Esn)e$3d zY$sK#WuKqOem}`NRi_*Px>nFNavjk1f_BM4pc@43mK%W%3ECrvfo>ACS8fKnMbJK3 z*FxIXKg2iC-Hi{~#xp53{LS!aA)nT&6WOGqCNlYh>AYHChxrNmnV}K3)--oq_cS$n zfn+w`FWpW9{D`!{-v9%e4HPEQEWrxdG>f4$e?xkI)Q`e-C?Me+tRBcV%7V}+Z~~;6 zp*BPF0y>f52AC$FkQnuTgfMfLXjtf!v6fErI^$@#mXIJBlR!{>x6m~eYSM_Q@J zb3yCn+iZS!Q`rU~W6etxOy`l})nl#v+rfZyX%^WXx5=KdCjNr0yJE%SkfpID7n90X z3vLTvXLmS@&bU+2#hoq0L~hfM82J`^i^&Nz9l6R6*ozP*m+cYS$!XPBnmh~-O><4A z)kG>grKk`yikeiX6lMeli*mUtW$#8ADVtGL7USouI%p05uqsIVxXlqYZb7+T-sfoP zz6Hqu0tSv|Sq@sI2KwREA8X(xNBxG~z!QP?f{u`XK&buQ|D&gU!P9Zk(J{U4 z9h>WNb>k<*;Iu9xT;jiV_-M=AMaO2HyT&*@yl5x>@bu10HqQmgbLHziT+>`S0%pHc zJ2FTgqa#hy<2In4vjhAp1aRI{HxiM~$Ban#Y%z_rNiW@kotG(eV$v(-dg#2;LPs`8 zuS6PvewEUZUg=fcmOkkGZooe>V0_J6h1iUfytZ*D3e6uPPQ+e1VuEJCFfzzruG#9r z5Y$Zhi9|k`OEbt8^jL!{whnWVp?hzbihKA$*G)7uch+^Qu3z}P57h2rH>2{3<2Pb= z6M$x#${)xV4&^m-CJ#rNPo-HuZ}jdB;sK48=hJDrSeHhN3V5DG!*C#vdV8tD=e^OJ zdtqo0$5|R-0-#OO4B5h=G-Fs>*&P6ybbld}Z?mxkj-U=p@@`)Ty_4_p)eeZ&jl-w{ zg@8n((WMv=g+_BHl9Q7xJ(*O~3Gfa^UYaQ&xo7U2FG8gdkoXa^r23GAaC=)O@Xez=u3J=>2)zk!ekQ1k%N@N~Hz-p_05 zqV&7Gziyy}t{-KnTy}j5DdGPWH|bdxMl~s)eo!sjmzY?zSnmUr^2NG|vRgi+8}BRoLwJ2J2#L~u!$|re+C+b0QLmz& zj}T_s7qK~4CweTz7wc=CYvK5(NsJU}oH)tW&vz2Xs3;gjh%;DH@7WT)#`t-;0D@cN;6xcR|H8#*HwXw7Pj?tHmSh`|l-W5_}yb zJK*q^nNZ7oOZBb7?pa~mB-zk|kqO_@?4vMyyfV9~XdE+@*@WD4PscJ%$Wl=)n&W2J zh-FF_!;QcNShS2;mURY}%<_{#Z^;tZmtPZcx(ma2)fd+A9#4w&TNj+RlYr6bIIk5V1OrM`nAmY1838X~6MNobe0YgKiUz=6r zoMukvSthkVJuc!*vxrDa6^e)1!zj8L0fSp}Lyj>uJucEM%?@T#X+^Ug%qW?CS&ThJ zB(j3fv!z^Z4?|%#&;>*5q{3Jg*aRR8ALhT2kR@9gfgyI&l9{iZiNM;H7% z=Q~Gd*i+_5%uiZ@JZit--??Zcfu_?BpL}@czH_cO{XPFONFnQc{+<*1OVzI9hmIXO zaq#=4naF%~^kW05TKn$0*lgd~zH_~^n?(QU*N$&Hw(Z2$Ghbb>hc4P%=k2X;+1I@7 z?^!euODVWWz}m-F;%_)J_NRtT3%<=qZEweu3-$ZvW- zH$_Uvu=_0l&7@4_a!Gc0rQ&M_wV)=mOa^wFoeiPrZ3w#&3;3o|-b5vdjJlpKr#l}!3i6E+(D3rLSm8B?{o=ew9B_hSU zC7)Ig71)8Lcgraddt&a*=3zP{ZVO!c7v2_okpWfbOJBgVJQ=|&;|Gz0v>=uyRwe4c zcB2zQss5w-oMBqdm0&H4PRt%2DxJC&mbkX2v z;tZj@qPI%Wi7H|jpK~9MiG6?h#MkeHt;{Z5Q~E!x&fx5;R*5&<>Q%1V7tRhD>_SQW zN5@KVSraBS#d^d}@tw}>2qJET$MG&;c<;6OwfaONRmhKL)J!3t%qEigJd_9QD2aq- zN+iY$DIlzg#Ke?3#nOoc!+W}1H=2RctjS_A1|}vl+}9o|VW`1h-0Kaoov71_um&N9 zAPNpX4a=9`5u`BH872rkSmaW{{=G*s_g&( delta 3672 zcmbVPeQZXGX&=%kXsxFG14wOnYt?-SsZ;-0XotFPn!0oD ziwPhK(w>yxJ?GwY&pG$pbIy&=4*x3a?smHz1ino(9~RBwqwY}6N(q@JD$&RuqSEbj z(4?7SNSn5sHM1BcO@h(9-7;v^tYS^tZX1*}dC;!eV??#ABx+lUsxpiT811TUB~=~3 zwFBpbu>(d$wZg0mxFm3H7&~D+`qNl#g`zoBpK4!0HD@nT(>tlAsQw^P13@z3)m&;L z&_O}F)ez84g7&D*K!*kGRU<&R2zr?s1v)0^1~m?JtDt?VDM3;_kN4g?F;&$HEIwMS z<|_vZd^i0vKTrQ{X@S7R=FXX3q*gzW%)uw5`)QEBF0BeiU_y81isi8a%dyHtf%Tv? zrs5r=2gJalFrSCN z>mMq9nh^T&FdwoGNlro{{5RGRweZ&*KK_9zkUTT`Gu`(QDR=Ue=S z8~G;tDjMMX?LofL9_MH5$wklpYTvL(t#l-*!uQJoGZh*QU!QR}n=nM;%eq%%q~M$M zWaV8gaJJy}a|Lg^TXv{Q)~&j;i%ONc2H`hd?r*41tQ#am^(@Ny_;F{bJ7yRoWH?@@ zc3JAw(wOxOCwPA}l3#KmWPnu_?gQ%vF9)nS|_G?P{lKzT4GDef(KhGwtB7xbn(MP^C)~#j3`7c+{Py zUcSQ}p=?Nmg{#bW(v2cKQmR(k?eKGl>-(^E9#kxn~-IuivrYi-UnNN3|#q?221 znV|GW4>E780wyAz+Ykoke1K+JrSpv~NUx%qcIo`;t!sh#eRnX^X?@e?LiD5lH+u)# zVEAK$7qOipc3MoCG#_l(=EthkZRN3Cd8AZeA>bfnKWgah#)4uP-@6v-ZvLN!FVg5- zuWyrST|e$v1MmP_kK|J1??HAmfNq;Af1_MESk~oYxvGtn^98n+zZm#BG$%ra40|Ts z)VorrlT`>fNyb@(#~RnrGS4<9H}t{OW}Ii(0@x@(O42P8m4gMwFuPa=K$jk^6w4_m zdjw~256kgiHm1L-;-aluUkGD18s!g#2DXSkhc=Ap3F{)RBBIz8?$q_3;O~XHAeeH~UYf=@ z4uLeDDP?A3sPrL(34p2@AQ_)F+U7Vv-khY<{I|{PYyGe=Brd@?(FpLyMJ&b3%pQeV zT`Cv8sTukuCY%=jR&a+fA^`<)=rp)DC zUNT9u#A_zo9^o%UHpVBVtmGwMvrL(K$&_guJT#M*;p?63!9M<^)6cgw$$VEdP_tys zDouhO=!K~FmM?3r_p%t-kweo_kW7Hy82n9IiX>%XTVRuJE{|1H79$NrT4K0Z0s#{= zWffVe%Y`y4=En=8;sW&h>>|eb%H(50jmY1s6*d^@Xxv0&ZbZ{Y^5ZvM$W)ab0yz=E zB7Y{{t;_k(;_H$tfVu0H z9^;PI2qbl9>!+0V@z%uXa#3oSjz+z|Y!cb6n;>NMibu`k{1=HXI>Rp~j&_SJSnS3Y z_9T!u3*(Nb0Z#Gf+uPTC8`;MJpxCEMr4i`kXi)SE-J(^rkqPz{m`Ew?UYs95c$ojA zJraPYkcdzk?Zk}ieje}WTCdxeUIn=P#l|9v22CwlsAB5W`v>-~LmeT@_h9bW+)T%I zIv`2|&EK|7h;qRqxKpoqiFbBluTVP=(3+?+;R1FI?WGO%YLpp2~_1elmO!w4=aH$;uTekSUA^u%s`;k3} za0mfQPh_a*R^kOQvRK|&s*Fxe6gIOTg9tt<)iVGKW=iR0vib^X`x|lpiP*1@==)^( yf|Xd5i!L@V$rl~t^Ai7cPd))ZOs);nHo*I-jrLyi!&7z5@1fRD+P%~#Z2b?EdGoUX diff --git a/app/services/__pycache__/log_analyzer.cpython-314.pyc b/app/services/__pycache__/log_analyzer.cpython-314.pyc index a5ac56281f2f47a413a609a9fef30843cbaefff6..aa8de51e0aa518147093de417439078e92146c48 100644 GIT binary patch delta 8624 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 delta 2246 zcma)7YiLwQ6rS0;d-wgwKC-TP-8@WojWK4m(Igs8ZcJhuLR=x0K3vw#t*+hdhPgMQ zp(Ux($B#m_L!qTql=g@IuoV1XNN$k+DTMk%TtZuDp%niZR8ra^J!f`fZ7HM!_slov z%sF%JoX0#F{k}ck?(@0`jL1j#bGw{`{Dg~*A(a7rxJ)j`Pt2zxDSMQp98r=F^ArSuo{=l~2e*DL>$Vz+u=|1*>3XUj)`6Scfa?>Xg(>64i6biLqnK zz{C+Ho6l(lLwT*pl=^IbRyQ=JWC~NtjK(?&#VJjh%IVqSS&g0N=ji*qP}|4DQeE%{ zNzjrETGJUrr|=t1{F)^aE_u@FOrcOTGKQ8;voHvJJT0|a9+F${NG`1omA8C8j?$A#KcgTh41Nb1G-2EeYhPwYa$Jx^~zIW8?l%16n|p7 zDv?RTugTLAous_V-askuve!wLNz1LH_QN!Zt}`2uQh;T0@MHK>I^4>v&RUTzozjew z^YFQ>He=+9g@nYvb;Q~+9~7l>C2~4nJe4c3E?|luSpo1XIQ5I&d+{s}JHMH~0-Ou= zLm_(24dkW;$XD?pN*1?JwcfE9lMvVLQCn`qYBn}A?oy%<;^mySKa>8eXi zE8U}l%OZGk7L#pbH~@%1KcM`C`;ae1QxG{1Nm@GTjKr_Fqixb8nZWq{Yow$-nCviM zw=`qrtL{!}Mug=T)s$_og;#}YsGq+T-YPecNqM3Q;Gl2pOFJ1$!Pg6) zo8Pn_ph14$zHMF}FL?)MjnhSzo6BHA%-ILE41-!^Q8fL{;QR|dwVPg|g9ojb-GDxe16h>gY71H1Nmc4t?355E zt5v&g$w5`gvE*z&IzqO;>EBl`9mA|_y752YMYmhhA)Pj`> zd6@YyuR$on|!Dtw*Lrv1NV&=#fE^CtZ0or5AYvv zoJaViv9r0Xrn93U9^;=h^f<==CFpn}%45wFG|ta7_fLw_E*H75i}7W6o8m`=GK3%n z43Q9r$_e4vKV6)f&1?PaI7qNl>1g~nC>u&w$v&>M#5&hyD;=Yceee{nC!t?HRwz9} UA3H2`&$_EZl5505lj4c|3t!{lxc~qF diff --git a/app/services/ai.py b/app/services/ai.py index ca1b9d5..ce4d3da 100644 --- a/app/services/ai.py +++ b/app/services/ai.py @@ -6,6 +6,7 @@ Phase 2: swap MARVIS_AI_MODE=openai or MARVIS_AI_MODE=ollama to route through LL """ from datetime import datetime +import re from app.config import ( AI_MODE, CONTAINER_RUNTIME, @@ -17,23 +18,25 @@ from app.config import ( ) -async def answer(query: str, network_state: dict, alerts: list) -> str: +async def answer(query: str, network_state: dict, alerts: list, logs: list[dict] | None = None) -> str: if AI_MODE == "openai": - return await _call_openai(query, network_state, alerts) + return await _call_openai(query, network_state, alerts, logs or []) if AI_MODE == "ollama": - return await _call_ollama(query, network_state, alerts) - return _rule_based(query, network_state, alerts) + return await _call_ollama(query, network_state, alerts, logs or []) + return _rule_based(query, network_state, alerts, logs or []) # ── Rule-based engine ────────────────────────────────────────────────────── -def _rule_based(query: str, network_state: dict, alerts: list) -> str: +def _rule_based(query: str, network_state: dict, alerts: list, logs: list[dict]) -> str: q = query.lower() nfs = network_state.get("nfs", []) cluster = network_state.get("cluster", {}) up = [n for n in nfs if n["state"] == "up"] down = [n for n in nfs if n["state"] == "down"] + log_hits = _find_log_hits(q, logs) + if any(w in q for w in ["hello", "hi ", "hey", "howdy"]): return ("Hello! I'm **P5G Marvis**, your AI network assistant for HPE Private 5G.\n" "Ask me about network health, specific functions, alerts, or performance.") @@ -53,22 +56,25 @@ def _rule_based(query: str, network_state: dict, alerts: list) -> str: from app.config import ALL_NFS for nf_name in ALL_NFS: if nf_name.lower() in q: - return _nf_detail(nf_name, nfs, alerts) + return _nf_detail(nf_name, nfs, alerts, log_hits) if any(w in q for w in ["alert", "alarm", "warning", "critical", "incident", "problem", "issue"]): return _alerts_summary(alerts) + if any(w in q for w in ["log", "trace", "journal", "message", "error"]): + return _log_summary(log_hits, logs) + if any(w in q for w in ["subscriber", "ue ", "device", "phone", "handset", "registration", "attach"]): - return _subscriber_analysis(nfs, alerts, cluster) + return _subscriber_analysis(nfs, alerts, cluster, log_hits) if any(w in q for w in ["session", "pdu", "bearer", "user plane", "traffic", "throughput"]): - return _session_analysis(nfs, alerts, cluster) + return _session_analysis(nfs, alerts, cluster, log_hits) # Default → health summary - return _health_summary(up, down, alerts, cluster) + return _health_summary(up, down, alerts, cluster, log_hits) -def _health_summary(up: list, down: list, alerts: list, cluster: dict) -> str: +def _health_summary(up: list, down: list, alerts: list, cluster: dict, log_hits: list[dict]) -> str: ts = datetime.now().strftime("%H:%M:%S") crit = [a for a in alerts if a.get("severity") == "critical"] warn = [a for a in alerts if a.get("severity") != "critical"] @@ -104,13 +110,21 @@ def _health_summary(up: list, down: list, alerts: list, cluster: dict) -> str: if not down and not alerts: lines.append("\n🟢 All systems nominal.") + if log_hits: + lines.append(f"\n🧾 **Relevant log hits ({len(log_hits)})**") + for hit in log_hits[:4]: + lines.append( + f" • {hit.get('timestamp','')} — {hit.get('node','unknown')} {hit.get('nf','SYSTEM')}: " + f"{_trim_message(hit.get('message',''))}" + ) return "\n".join(lines) -def _nf_detail(nf_name: str, nfs: list, alerts: list) -> str: +def _nf_detail(nf_name: str, nfs: list, alerts: list, log_hits: list[dict]) -> str: nf = next((n for n in nfs if n["name"] == nf_name), None) nf_alerts = [a for a in alerts if nf_name in a.get("name", "") or nf_name.lower() in a.get("instance", "").lower()] + nf_logs = [hit for hit in log_hits if hit.get("nf") == nf_name] if not nf or nf["state"] == "unknown": return (f"ℹ️ No Prometheus data found for **{nf_name}**.\n" @@ -132,6 +146,13 @@ def _nf_detail(nf_name: str, nfs: list, alerts: list) -> str: lines.append(f" → {a['name']}: {a.get('summary', '')}") else: lines.append("No active alerts for this function.") + if nf_logs: + lines.append(f"\n🧾 Recent {nf_name} log evidence:") + for hit in nf_logs[:4]: + lines.append( + f" • {hit.get('timestamp','')} on {hit.get('node','unknown')}: " + f"{_trim_message(hit.get('message',''))}" + ) return "\n".join(lines) @@ -151,7 +172,7 @@ def _alerts_summary(alerts: list) -> str: return "\n".join(lines) -def _subscriber_analysis(nfs: list, alerts: list, cluster: dict) -> str: +def _subscriber_analysis(nfs: list, alerts: list, cluster: dict, log_hits: list[dict]) -> str: amf = next((n for n in nfs if n["name"] == "AMF"), None) smf = next((n for n in nfs if n["name"] == "SMF"), None) lines = ["**Subscriber & Registration Analysis**\n"] @@ -163,11 +184,18 @@ def _subscriber_analysis(nfs: list, alerts: list, cluster: dict) -> str: lines.append(f"\n⚠️ {len(sub_alerts)} subscriber-related alert(s) active.") else: lines.append("\nNo subscriber-related alerts detected.") + sub_logs = [hit for hit in log_hits if any(key in hit.get("message", "").lower() for key in ["imsi", "supi", "registration", "attach", "subscriber"])] + if sub_logs: + lines.append("\nRecent subscriber-related log evidence:") + for hit in sub_logs[:4]: + lines.append( + f"• {hit.get('nf','SYSTEM')} on {hit.get('node','unknown')}: {_trim_message(hit.get('message',''))}" + ) lines.append(_cluster_scope(cluster)) return "\n".join(lines) -def _session_analysis(nfs: list, alerts: list, cluster: dict) -> str: +def _session_analysis(nfs: list, alerts: list, cluster: dict, log_hits: list[dict]) -> str: smf = next((n for n in nfs if n["name"] == "SMF"), None) upf = next((n for n in nfs if n["name"] == "UPF"), None) lines = ["**PDU Session & Data Plane Analysis**\n"] @@ -177,10 +205,38 @@ def _session_analysis(nfs: list, alerts: list, cluster: dict) -> str: lines.append("\n⚡ **Impact**: PDU sessions will fail until both SMF and UPF are operational.") else: lines.append("\nBoth SMF and UPF operational — sessions should be establishing normally.") + session_logs = [hit for hit in log_hits if hit.get("nf") in {"SMF", "UPF"}] + if session_logs: + lines.append("\nRecent session/data-plane log evidence:") + for hit in session_logs[:4]: + lines.append( + f"• {hit.get('nf','SYSTEM')} on {hit.get('node','unknown')}: {_trim_message(hit.get('message',''))}" + ) lines.append(_cluster_scope(cluster)) return "\n".join(lines) +def _log_summary(log_hits: list[dict], logs: list[dict]) -> str: + if not logs: + return "ℹ️ No ingested logs are currently available." + if not log_hits: + latest = max(logs, key=lambda event: event.get("epoch", 0.0), default=None) + if latest: + return ( + "ℹ️ I do not see direct log matches for that question.\n\n" + f"Latest ingested log: {latest.get('timestamp','')} on {latest.get('node','unknown')} " + f"{latest.get('nf','SYSTEM')} — {_trim_message(latest.get('message',''))}" + ) + return "ℹ️ No relevant log matches were found." + lines = [f"🧾 **Relevant log matches ({len(log_hits)})**\n"] + for hit in log_hits[:8]: + lines.append( + f"• {hit.get('timestamp','')} — {hit.get('node','unknown')} {hit.get('nf','SYSTEM')}: " + f"{_trim_message(hit.get('message',''))}" + ) + return "\n".join(lines) + + def _nf_label(nf: dict) -> str: placements = nf.get("nodes", []) if not placements: @@ -207,24 +263,30 @@ def _cluster_scope(cluster: dict) -> str: # ── LLM backends ────────────────────────────────────────────────────────── -def _build_context(network_state: dict, alerts: list) -> str: +def _build_context(network_state: dict, alerts: list, logs: list[dict]) -> str: nfs = network_state.get("nfs", []) up = [n["name"] for n in nfs if n["state"] == "up"] down = [n["name"] for n in nfs if n["state"] == "down"] nodes = network_state.get("cluster", {}).get("nodes", []) node_summary = ", ".join(f"{node['hostname']} ({node.get('role', 'AP')})" for node in nodes) or "none" + recent_logs = logs[-10:] if logs else [] + log_summary = "; ".join( + f"{entry.get('timestamp','')} {entry.get('node','unknown')} {entry.get('nf','SYSTEM')}: {_trim_message(entry.get('message',''), 120)}" + for entry in recent_logs + ) or "none" return ( f"NFs UP: {', '.join(up) or 'none'}\n" f"NFs DOWN: {', '.join(down) or 'none'}\n" f"Cluster nodes: {node_summary}\n" - f"Active alerts: {', '.join(a.get('name','') for a in alerts[:5]) or 'none'}" + f"Active alerts: {', '.join(a.get('name','') for a in alerts[:5]) or 'none'}\n" + f"Recent logs: {log_summary}" ) -async def _call_openai(query: str, network_state: dict, alerts: list) -> str: +async def _call_openai(query: str, network_state: dict, alerts: list, logs: list[dict]) -> str: try: import httpx - ctx = _build_context(network_state, alerts) + ctx = _build_context(network_state, alerts, logs) messages = [ {"role": "system", "content": f"You are P5G Marvis, an AI network assistant for HPE Private 5G.\n" @@ -247,13 +309,13 @@ async def _call_openai(query: str, network_state: dict, alerts: list) -> str: # some reasoning models put the answer in content, others in reasoning_content return msg.get("content") or msg.get("reasoning_content") or "(empty response)" except Exception as e: - return f"LLM error: {e}\n\n" + _rule_based(query, network_state, alerts) + return f"LLM error: {e}\n\n" + _rule_based(query, network_state, alerts, logs) -async def _call_ollama(query: str, network_state: dict, alerts: list) -> str: +async def _call_ollama(query: str, network_state: dict, alerts: list, logs: list[dict]) -> str: try: import httpx - ctx = _build_context(network_state, alerts) + ctx = _build_context(network_state, alerts, logs) prompt = (f"You are P5G Marvis, an AI network assistant.\n" f"Network state:\n{ctx}\n\nUser: {query}\nAssistant:") async with httpx.AsyncClient(timeout=60) as client: @@ -263,4 +325,34 @@ async def _call_ollama(query: str, network_state: dict, alerts: list) -> str: ) return resp.json().get("response", "No response.") except Exception as e: - return f"Ollama error: {e}\n\n" + _rule_based(query, network_state, alerts) + return f"Ollama error: {e}\n\n" + _rule_based(query, network_state, alerts, logs) + + +def _find_log_hits(query: str, logs: list[dict]) -> list[dict]: + terms = [term for term in re.findall(r"[a-z0-9_-]+", query.lower()) if len(term) >= 3] + if not logs or not terms: + return [] + hits = [] + for event in logs: + haystack = " ".join( + [ + str(event.get("nf", "")).lower(), + str(event.get("node", "")).lower(), + str(event.get("source", "")).lower(), + str(event.get("message", "")).lower(), + ] + ) + score = sum(1 for term in terms if term in haystack) + if score: + event_copy = dict(event) + event_copy["_score"] = score + hits.append(event_copy) + hits.sort(key=lambda event: (event.get("_score", 0), event.get("epoch", 0.0)), reverse=True) + return hits + + +def _trim_message(message: str, limit: int = 160) -> str: + message = " ".join(str(message).split()) + if len(message) <= limit: + return message + return message[: limit - 3] + "..." diff --git a/app/services/alertmanager.py b/app/services/alertmanager.py index c3c02ed..bb2aa0e 100644 --- a/app/services/alertmanager.py +++ b/app/services/alertmanager.py @@ -1,14 +1,31 @@ -"""Alertmanager client.""" +"""Alert sources: Alertmanager plus log-derived alerts.""" +import asyncio +import json import httpx from app.config import ALERTMANAGER_URL -from app.services import cluster_inventory +from app.services import cluster_inventory, log_ingest _BASE = ALERTMANAGER_URL.rstrip("/") async def get_alerts() -> list: - """Return normalised list of active alerts from Alertmanager.""" + """Return normalised list of active alerts from Alertmanager and log analysis.""" + cluster = await cluster_inventory.get_cluster_inventory() + alertmanager_task = asyncio.create_task(_get_alertmanager_alerts(cluster)) + log_task = asyncio.to_thread(_get_log_alerts, cluster) + am_alerts, log_alerts = await asyncio.gather(alertmanager_task, log_task, return_exceptions=True) + if isinstance(am_alerts, Exception): + am_alerts = [] + if isinstance(log_alerts, Exception): + log_alerts = [] + return sorted( + [*am_alerts, *log_alerts], + key=lambda alert: (_severity_rank(alert.get("severity")), alert.get("timestamp", "")), + ) + + +async def _get_alertmanager_alerts(cluster: dict) -> list: try: async with httpx.AsyncClient(timeout=5) as client: r = await client.get(f"{_BASE}/api/v2/alerts", params={"active": "true", "silenced": "false"}) @@ -17,7 +34,6 @@ async def get_alerts() -> list: except Exception: return [] - cluster = await cluster_inventory.get_cluster_inventory() alerts = [] for a in raw: labels = a.get("labels", {}) @@ -33,10 +49,62 @@ async def get_alerts() -> list: "summary": summary, "nf": nf_name, "nodes": nodes, + "source": "alertmanager", + "timestamp": a.get("startsAt", ""), }) return alerts +def _get_log_alerts(cluster: dict) -> list: + node_map = {} + for node in cluster.get("nodes", []): + if node.get("hostname"): + node_map[node["hostname"]] = node + if node.get("address"): + node_map[node["address"]] = node + + alerts = [] + for ctx in log_ingest.recent_alert_context(limit=50): + before = _decode_context(ctx.get("before_context")) + after = _decode_context(ctx.get("after_context")) + node_name = ctx.get("node", "") + nodes = [] + if node_name and node_name in node_map: + nodes = [node_map[node_name]] + alerts.append({ + "name": f"{ctx.get('nf') or 'System'} log anomaly", + "severity": ctx.get("severity", "warning"), + "instance": ctx.get("source", ""), + "summary": ctx.get("description", "Log-derived alert"), + "nf": ctx.get("nf", ""), + "nodes": nodes, + "source": "logs", + "timestamp": ctx.get("event_ts", ""), + "context_id": ctx.get("id"), + "node": node_name, + "match_message": ctx.get("match_message", ""), + "context_preview": { + "before": before[-3:], + "after": after[:3], + }, + }) + return alerts + + +def _decode_context(value: str | None) -> list[dict]: + if not value: + return [] + try: + data = json.loads(value) + return data if isinstance(data, list) else [] + except Exception: + return [] + + +def _severity_rank(severity: str | None) -> int: + return {"critical": 0, "warning": 1, "info": 2}.get((severity or "warning").lower(), 3) + + def _infer_nf(name: str, summary: str, instance: str) -> str: text = f"{name} {summary} {instance}".upper() for nf_name in ["AMF", "SMF", "UPF", "UDM", "UDR", "NRF", "AUSF", "PCF", "MME", "SGWC", "DRA", "DSM"]: diff --git a/app/services/cluster_inventory.py b/app/services/cluster_inventory.py index 7451cca..4febc8d 100644 --- a/app/services/cluster_inventory.py +++ b/app/services/cluster_inventory.py @@ -8,6 +8,8 @@ import re from app.config import ALL_NFS from app.services import pls, prometheus +_last_inventory: dict | None = None + ROLE_NF_MAP = { "5GALL": {"amf", "smf", "pcf", "udr", "udm", "nrf", "eir", "ausf", "dra", "upf", "chf", "smsf", "aaa", "bmsc"}, "CP": {"amf", "smf", "pcf", "udr", "udm", "nrf", "eir", "ausf", "dra", "chf", "smsf", "aaa", "bmsc"}, @@ -41,9 +43,10 @@ def _infer_role(hostname: str) -> str: async def get_cluster_inventory() -> dict: + global _last_inventory cluster = await pls.get_cluster_status() if not cluster: - return { + return _last_inventory or { "enabled": False, "current_node": None, "fully_established": False, @@ -78,12 +81,14 @@ async def get_cluster_inventory() -> dict: } ) - return { + inventory = { "enabled": True, "current_node": cluster.get("current_node"), "fully_established": bool(cluster.get("fully_established")), "nodes": nodes, } + _last_inventory = inventory + return inventory def _aggregate_nf_state(nf_name: str, nodes: list[dict], prom_states: dict[str, dict]) -> dict: @@ -137,8 +142,14 @@ def _attach_node_nf_status(nodes: list[dict]) -> list[dict]: enriched = [] for node in nodes: node_copy = dict(node) - expected_nfs = node_copy.get("expected_nfs", []) - node_copy["nfs"] = [_node_nf_state(node_copy, nf_name.upper()) for nf_name in expected_nfs] + expected_nfs = {nf.upper() for nf in node_copy.get("expected_nfs", [])} + started_nf_services = { + svc.get("name", "").upper() + for svc in node_copy.get("services", []) + if svc.get("type") == "nf" and svc.get("name") + } + visible_nfs = sorted(expected_nfs | started_nf_services) + node_copy["nfs"] = [_node_nf_state(node_copy, nf_name.upper()) for nf_name in visible_nfs] enriched.append(node_copy) return enriched diff --git a/app/services/log_analyzer.py b/app/services/log_analyzer.py index 6b4a95c..4920e2b 100644 --- a/app/services/log_analyzer.py +++ b/app/services/log_analyzer.py @@ -1,8 +1,4 @@ -""" -log_analyzer.py — Reads P5G NF container logs and active Prometheus/Alertmanager -data to produce a structured list of recommended remediation actions, grouped -by category. This is the data backend powering the /api/actions endpoint. -""" +"""Structured issue generation from ingested cross-node log events and state.""" import asyncio import re @@ -10,7 +6,13 @@ import time from collections import deque from datetime import datetime -from app.config import CONTAINER_HOST, CONTAINER_RUNTIME +from app.config import ( + CONTAINER_HOST, + CONTAINER_RUNTIME, + LOG_ALERT_CONTEXT_AFTER, + LOG_ALERT_CONTEXT_BEFORE, +) +from app.services.log_rules import load_category_patterns # ── In-memory history (up to 96 snapshots ≈ 48 min at 30 s refresh) ──────── _history: deque = deque(maxlen=96) @@ -29,99 +31,6 @@ CATEGORY_COLORS: dict[str, str] = { ALL_CATEGORIES = ["Registration", "Authentication", "Security", "Sessions", "Connectivity", "Policy"] -# ── Log-pattern definitions ────────────────────────────────────────────────── -# Each entry: (regex, affected_nf, severity, short_description, remediation) -CATEGORY_PATTERNS: dict[str, list[tuple]] = { - "Registration": [ - (r"RegistrationFailure|UeRegistrationFailed|N1.*[Rr]egistration.*[Ff]ail", - "AMF", "critical", - "UE registration failure", - "Check AMF logs for NGAP errors; verify UE credentials and NRF registration."), - (r"N2SetupFail|NgapSetupFail|N2.*[Tt]imeout|NgapProcedure.*failed", - "AMF", "critical", - "N2 interface setup failure", - "Verify gNB connectivity to AMF; check SCTP transport and NGAP PLMN config."), - (r"InitialContextSetupFail|UeContextRelease.*[Aa]bnormal", - "AMF", "warning", - "UE context setup failure", - "Review AMF-SMF N11 interface; check subscriber profile in UDM/UDR."), - (r"PagingFail|UeUnreachable|UeNotFound", - "AMF", "warning", - "UE paging failure", - "Verify UE is registered; check AMF tracking area configuration."), - ], - "Sessions": [ - (r"PduSessionEstablishmentReject|PduSession.*[Ff]ail|CreateSessionResponse.*[Ff]ail", - "SMF", "critical", - "PDU session establishment failure", - "Check SMF-UPF N4 path; verify DNN/APN config and UPF N3/N9 interfaces."), - (r"N4Session.*[Ff]ail|PfcpSession.*[Ee]rror|N4.*[Tt]imeout|PfcpAssociation.*[Ff]ail", - "UPF", "critical", - "N4/PFCP session error", - "Restart PFCP association between SMF and UPF; check N4 IP reachability."), - (r"IpAllocationFail|AddressPoolExhausted|NoIpAvailable", - "SMF", "critical", - "IP address pool exhausted", - "Expand UE IP address pool in SMF config; review active session count."), - (r"SessionModification.*[Ff]ail|BearerModification.*[Ee]rror", - "SMF", "warning", - "Session modification failure", - "Check PCF policy consistency; verify QoS parameters match UPF capabilities."), - ], - "Authentication": [ - (r"AuthenticationFailure|AuthReject|EapFailure|5g-aka.*[Ff]ail|EapAkaFailure", - "AUSF", "critical", - "UE authentication failure", - "Verify USIM credentials match UDM subscriber data; check AUSF-UDM N12 link."), - (r"UdmAuthReq.*[Ee]rror|SuciDeconceal.*[Ff]ail|UdmUeAuth.*[Ee]rror", - "UDM", "critical", - "UDM authentication error", - "Check UDM-UDR N35 connectivity; verify Home Network Public Key configuration."), - (r"AuthVectorFetch.*[Ff]ail|AusfUeAuth.*[Rr]eject|HssAuth.*[Ff]ail", - "AUSF", "warning", - "Auth vector fetch failure", - "Review UDR data integrity for affected SUPI; check AUSF-UDM TLS certificates."), - ], - "Connectivity": [ - (r"NfDiscovery.*[Ff]ail|NrfRegistration.*[Ff]ail|NfDeregistration.*unexpect", - "NRF", "warning", - "NF service discovery failure", - "Verify NRF is reachable from all NFs; check NRF registration TTL and heartbeat."), - (r"ServiceUnavailable.*NF|HTTP.*503.*NF|NfProfile.*expired", - "NRF", "warning", - "NF service unavailable", - "Check NF pod health and SBI listen port; review NRF subscription notifications."), - (r"SbiRequest.*[Tt]imeout|SbiConn.*[Ff]ail|Http2.*[Ee]rror", - "NRF", "warning", - "SBI interface timeout", - "Inspect inter-NF network MTU and TLS handshake; check load balancer config."), - ], - "Policy": [ - (r"PcfSmPolicy.*[Ee]rror|PolicyDecision.*[Ff]ail|SmPolicy.*[Rr]eject", - "PCF", "warning", - "Policy decision failure", - "Review PCF policy rules and subscriber group config; check PCF-UDR N36 link."), - (r"QosEnforce.*[Ff]ail|ChargingRule.*[Ee]rror|PccRule.*[Rr]eject", - "PCF", "warning", - "QoS policy enforcement failure", - "Verify QoS profiles match UPF capabilities; check PCF-CHF N40 charging path."), - ], - "Security": [ - (r"SecurityMode.*[Ff]ail|IntegrityCheck.*[Ff]ail|NasIntegrity.*[Ee]rror", - "AMF", "critical", - "NAS security mode failure", - "Check AMF cipher/integrity algorithm priority list matches UE capabilities."), - (r"TlsHandshake.*[Ff]ail|Certificate.*[Ee]xpir|x509.*[Ee]rror|CertVerify.*[Ff]ail", - "AMF", "critical", - "TLS/certificate error", - "Renew expired certificates; verify trust chain between NFs; check SBI TLS config."), - (r"SuciProtection.*[Ff]ail|PrivacyProtection.*[Ee]rror|HomeNetworkKey.*[Ee]rror", - "UDM", "warning", - "SUCI privacy protection error", - "Verify Home Network Public Key provisioning on UDM; check SUPI revealing config."), - ], -} - # ── NF → possible container name fragments (tried in order) ───────────────── NF_CONTAINER_HINTS: dict[str, list[str]] = { "AMF": ["amf"], @@ -191,13 +100,13 @@ async def _read_logs(container: str, tail: int = 400) -> str: return "" -def _match_count(text: str, pattern: str) -> int: - if not text: - return 0 +def _rule_matches(message: str, pattern: str) -> bool: + if not message: + return False try: - return len(re.findall(pattern, text, re.IGNORECASE | re.MULTILINE)) + return bool(re.search(pattern, message, re.IGNORECASE | re.MULTILINE)) except re.error: - return 0 + return False # ── Category/NF mapping for Alertmanager alerts ────────────────────────────── @@ -235,50 +144,104 @@ async def analyze_logs() -> dict: Gather log-pattern issues + Prometheus NF status + Alertmanager alerts. Returns a fully structured dict ready for JSON serialisation. """ - from app.services import alertmanager, prometheus, cluster_inventory + from app.services import alertmanager, cluster_inventory, log_ingest, prometheus # Kick off all I/O in parallel containers_f = asyncio.create_task(_discover_containers()) alerts_f = asyncio.create_task(alertmanager.get_alerts()) nf_status_f = asyncio.create_task(prometheus.get_nf_status()) cluster_f = asyncio.create_task(cluster_inventory.get_cluster_inventory()) + events_f = asyncio.to_thread(log_ingest.get_events) containers = await containers_f - alerts, nf_statuses, cluster = await asyncio.gather(alerts_f, nf_status_f, cluster_f, - return_exceptions=True) + alerts, nf_statuses, cluster, events = await asyncio.gather( + alerts_f, nf_status_f, cluster_f, events_f, return_exceptions=True + ) if isinstance(alerts, Exception): alerts = [] if isinstance(nf_statuses, Exception): nf_statuses = [] if isinstance(cluster, Exception): cluster = {"enabled": False, "nodes": []} + if isinstance(events, Exception): + events = [] - # Read all container logs concurrently - log_tasks = {nf: asyncio.create_task(_read_logs(cname)) - for nf, cname in containers.items()} - log_texts: dict[str, str] = {} - if log_tasks: - log_results = await asyncio.gather(*log_tasks.values(), return_exceptions=True) - for nf, result in zip(log_tasks.keys(), log_results): - log_texts[nf] = result if isinstance(result, str) else "" - + events = sorted( + [event for event in events if isinstance(event, dict)], + key=lambda event: event.get("epoch", 0.0), + ) issues: list[dict] = [] + grouped_log_issues: dict[tuple[str, str, str, str], dict] = {} - # 1. Log-pattern analysis - for category, patterns in CATEGORY_PATTERNS.items(): - for (pat_re, nf, severity, description, remediation) in patterns: - count = _match_count(log_texts.get(nf, ""), pat_re) - if count: - issues.append({ - "id": f"log-{nf}-{len(issues)}", - "category": category, - "nf": nf, - "severity": severity, - "count": count, - "description": description, - "remediation": remediation, - "source": "log", - }) + # 1. Time-ordered log-pattern analysis across all nodes. + for idx, event in enumerate(events): + message = event.get("message", "") + event_nf = str(event.get("nf", "")).upper() + event_node = event.get("node", "") + for category, patterns in load_category_patterns().items(): + for rule in patterns: + rule_nf = str(rule["nf"]).upper() + if event_nf != rule_nf: + continue + if not _rule_matches(message, rule["pattern"]): + continue + + before_context = events[max(0, idx - LOG_ALERT_CONTEXT_BEFORE):idx] + after_context = events[idx + 1:idx + 1 + LOG_ALERT_CONTEXT_AFTER] + context_id = log_ingest.record_alert_context( + category=category, + nf=rule_nf, + node=event_node, + severity=rule["severity"], + description=rule["description"], + remediation=rule["remediation"], + source="fluentbit", + event=event, + before_context=before_context, + after_context=after_context, + ) + + issue_key = (category, rule_nf, event_node, rule["description"]) + if issue_key not in grouped_log_issues: + grouped_log_issues[issue_key] = { + "id": f"log-{rule_nf}-{len(grouped_log_issues)}", + "category": category, + "nf": rule_nf, + "node": event_node, + "severity": rule["severity"], + "count": 0, + "description": rule["description"], + "remediation": rule["remediation"], + "source": "fluentbit", + "context_id": context_id, + } + grouped_log_issues[issue_key]["count"] += 1 + + issues.extend(grouped_log_issues.values()) + + # Fallback to local container logs until Fluent Bit has populated the buffer. + if not issues and not events: + log_tasks = {nf: asyncio.create_task(_read_logs(cname)) for nf, cname in containers.items()} + if log_tasks: + log_results = await asyncio.gather(*log_tasks.values(), return_exceptions=True) + log_texts = { + nf: result if isinstance(result, str) else "" + for nf, result in zip(log_tasks.keys(), log_results) + } + for category, patterns in load_category_patterns().items(): + for rule in patterns: + nf = rule["nf"] + if _rule_matches(log_texts.get(nf, ""), rule["pattern"]): + issues.append({ + "id": f"log-{nf}-{len(issues)}", + "category": category, + "nf": nf, + "severity": rule["severity"], + "count": 1, + "description": rule["description"], + "remediation": rule["remediation"], + "source": "local-log-fallback", + }) # 2. NF-down events from Prometheus for nf_st in nf_statuses: @@ -337,7 +300,8 @@ async def analyze_logs() -> dict: "total": total, "categories": categories, "timestamp": datetime.now().isoformat(), - "log_sources": list(containers.keys()), + "log_sources": sorted({f"{event.get('node', 'unknown')}:{event.get('nf', 'SYSTEM')}" for event in events}) or list(containers.keys()), + "log_ingest": log_ingest.receiver_status(), "cluster": cluster, } diff --git a/app/services/log_ingest.py b/app/services/log_ingest.py new file mode 100644 index 0000000..b925bff --- /dev/null +++ b/app/services/log_ingest.py @@ -0,0 +1,499 @@ +"""Fluent Bit log ingestion, buffering, and alert-context persistence.""" + +from __future__ import annotations + +import asyncio +import json +import sqlite3 +from collections import deque +from datetime import UTC, datetime +from hashlib import sha1 +from pathlib import Path +from typing import Any + +from app.config import ( + ALL_NFS, + LOG_ALERT_CONTEXT_AFTER, + LOG_ALERT_CONTEXT_BEFORE, + LOG_ALLOWED_NFS, + LOG_ALERT_CONTEXT_DB_MAX_ROWS, + LOG_ALERT_CONTEXT_DB_PATH, + LOG_BUFFER_LINES, + LOG_AUTO_CONFIGURE, + LOG_FLUENTBIT_MATCH, + LOG_INGEST_ENABLED, + LOG_RECEIVER_BIND_HOST, + LOG_RECEIVER_FORMAT, + LOG_RECEIVER_HOST, + LOG_RECEIVER_PORT, + LOG_TRACE_BUFFER_LINES, +) +from app.services import pls + +_server: asyncio.base_events.Server | None = None +_events: deque[dict[str, Any]] = deque(maxlen=max(LOG_BUFFER_LINES, 1)) +_trace_events: deque[dict[str, Any]] = deque(maxlen=max(LOG_TRACE_BUFFER_LINES, LOG_BUFFER_LINES, 1)) +_ingested_total = 0 +_parse_errors = 0 +_last_event_at: str | None = None +_db_initialized = False +_allowed_nfs = {nf.upper() for nf in LOG_ALLOWED_NFS} + + +def _db_path() -> Path: + return Path(LOG_ALERT_CONTEXT_DB_PATH) + + +def _ensure_db() -> None: + global _db_initialized + if _db_initialized: + return + path = _db_path() + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(path) + try: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS alert_context ( + id TEXT PRIMARY KEY, + fingerprint TEXT UNIQUE, + created_at TEXT NOT NULL, + event_ts TEXT NOT NULL, + category TEXT NOT NULL, + nf TEXT, + node TEXT, + severity TEXT, + description TEXT, + remediation TEXT, + source TEXT, + match_message TEXT, + before_context TEXT, + after_context TEXT + ) + """ + ) + conn.commit() + finally: + conn.close() + _db_initialized = True + + +def _trim_db(conn: sqlite3.Connection) -> None: + conn.execute( + """ + DELETE FROM alert_context + WHERE id NOT IN ( + SELECT id + FROM alert_context + ORDER BY event_ts DESC, created_at DESC + LIMIT ? + ) + """, + (max(LOG_ALERT_CONTEXT_DB_MAX_ROWS, 1),), + ) + + +def _parse_timestamp(value: Any) -> tuple[float, str]: + if value is None: + now = datetime.now(UTC) + return now.timestamp(), now.isoformat() + + if isinstance(value, (int, float)): + raw = float(value) + if raw > 1_000_000_000_000: + raw = raw / 1_000_000.0 + elif raw > 10_000_000_000: + raw = raw / 1000.0 + dt = datetime.fromtimestamp(raw, UTC) + return raw, dt.isoformat() + + text = str(value).strip() + if text.isdigit(): + return _parse_timestamp(int(text)) + + normalized = text.replace("Z", "+00:00") + for candidate in (normalized, normalized.replace(" ", "T")): + try: + dt = datetime.fromisoformat(candidate) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=UTC) + else: + dt = dt.astimezone(UTC) + return dt.timestamp(), dt.isoformat() + except ValueError: + continue + + now = datetime.now(UTC) + return now.timestamp(), now.isoformat() + + +def _candidate_fields(payload: dict[str, Any]) -> list[str]: + candidates = [] + for key in ( + "message", + "MESSAGE", + "log", + "msg", + "systemd_unit", + "_SYSTEMD_UNIT", + "syslog_identifier", + "SYSLOG_IDENTIFIER", + "_COMM", + "comm", + "_EXE", + "container_name", + "tag", + ): + value = payload.get(key) + if value not in (None, ""): + candidates.append(str(value)) + return candidates + + +def _infer_nf(payload: dict[str, Any], message: str) -> str: + haystack = " ".join(_candidate_fields(payload) + [message]).lower() + aliases = { + "upf": "UPF", + "amf": "AMF", + "smf": "SMF", + "udm": "UDM", + "udr": "UDR", + "nrf": "NRF", + "ausf": "AUSF", + "pcf": "PCF", + "mme": "MME", + "sgwc": "SGWC", + "dra": "DRA", + "dsm": "DSM", + "aaa": "AAA", + "bmsc": "BMSC", + "chf": "CHF", + "smsf": "SMSF", + "eir": "EIR", + "licensed": "LICENSED", + "prometheus": "PROMETHEUS", + "alertmanager": "ALERTMANAGER", + "fluent-bit": "FLUENT-BIT", + } + for needle, label in aliases.items(): + if needle in haystack: + return label + return "SYSTEM" + + +def _normalize_event(payload: dict[str, Any], remote_host: str) -> dict[str, Any]: + ts_value = ( + payload.get("timestamp") + or payload.get("@timestamp") + or payload.get("time") + or payload.get("date") + or payload.get("_SOURCE_REALTIME_TIMESTAMP") + ) + epoch, ts_iso = _parse_timestamp(ts_value) + + node = ( + payload.get("hostname") + or payload.get("host") + or payload.get("_HOSTNAME") + or payload.get("syslog_hostname") + or remote_host + ) + source = ( + payload.get("systemd_unit") + or payload.get("_SYSTEMD_UNIT") + or payload.get("syslog_identifier") + or payload.get("SYSLOG_IDENTIFIER") + or payload.get("_COMM") + or payload.get("tag") + or "unknown" + ) + message = ( + payload.get("message") + or payload.get("MESSAGE") + or payload.get("log") + or payload.get("msg") + or "" + ) + message = str(message).strip() + tag = str(payload.get("tag", "")) + nf = _infer_nf(payload, message) + fingerprint = sha1(f"{ts_iso}|{node}|{nf}|{source}|{message}".encode("utf-8")).hexdigest() + return { + "id": fingerprint, + "timestamp": ts_iso, + "epoch": epoch, + "node": str(node), + "nf": nf, + "source": str(source), + "tag": tag, + "message": message, + "raw": payload, + } + + +async def _ingest_payload(payload: dict[str, Any], remote_host: str) -> None: + global _ingested_total, _last_event_at + event = _normalize_event(payload, remote_host) + if event.get("nf", "").upper() not in _allowed_nfs: + return + _events.append(event) + _trace_events.append(event) + _ingested_total += 1 + _last_event_at = event["timestamp"] + + +async def _handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + global _parse_errors + peer = writer.get_extra_info("peername") + remote_host = peer[0] if isinstance(peer, tuple) and peer else "unknown" + try: + while not reader.at_eof(): + line = await reader.readline() + if not line: + break + text = line.decode("utf-8", errors="replace").strip() + if not text: + continue + try: + payload = json.loads(text) + if isinstance(payload, dict): + await _ingest_payload(payload, remote_host) + elif isinstance(payload, list): + for item in payload: + if isinstance(item, dict): + await _ingest_payload(item, remote_host) + except Exception: + _parse_errors += 1 + finally: + writer.close() + await writer.wait_closed() + + +async def startup() -> None: + global _server + _ensure_db() + if not LOG_INGEST_ENABLED or _server is not None: + return + _server = await asyncio.start_server(_handle_client, LOG_RECEIVER_BIND_HOST, LOG_RECEIVER_PORT) + if LOG_AUTO_CONFIGURE: + try: + await configure_site_output() + except Exception: + pass + + +async def shutdown() -> None: + global _server + if _server is None: + return + _server.close() + await _server.wait_closed() + _server = None + + +def receiver_status() -> dict[str, Any]: + return { + "enabled": LOG_INGEST_ENABLED, + "bind_host": LOG_RECEIVER_BIND_HOST, + "receiver_host": LOG_RECEIVER_HOST, + "port": LOG_RECEIVER_PORT, + "format": LOG_RECEIVER_FORMAT, + "allowed_nfs": sorted(_allowed_nfs), + "buffer_lines": LOG_BUFFER_LINES, + "trace_buffer_lines": LOG_TRACE_BUFFER_LINES, + "context_before": LOG_ALERT_CONTEXT_BEFORE, + "context_after": LOG_ALERT_CONTEXT_AFTER, + "db_path": str(_db_path()), + "ingested_total": _ingested_total, + "parse_errors": _parse_errors, + "last_event_at": _last_event_at, + "current_buffer_size": len(_events), + } + + +def current_output_config(receiver_host: str) -> dict[str, Any]: + return { + "name": "tcp", + "match": LOG_FLUENTBIT_MATCH, + "host": receiver_host, + "port": LOG_RECEIVER_PORT, + "format": LOG_RECEIVER_FORMAT, + } + + +def default_input_config() -> dict[str, Any]: + return { + "name": "systemd", + "path": "/var/log/journal", + "tag": "marvis.systemd", + "read_from_tail": "on", + "strip_underscores": "off", + } + + +async def _resolve_receiver_host() -> str: + if LOG_RECEIVER_HOST: + return LOG_RECEIVER_HOST + + cluster = await pls.get_cluster_status() + if isinstance(cluster, dict): + current_node = cluster.get("current_node") + if isinstance(current_node, str) and current_node: + return pls.node_host(current_node) + + system = await pls.get_system_info() + if isinstance(system, dict) and system.get("hostname"): + return str(system["hostname"]) + + return "127.0.0.1" + + +def _merged_fluentbit_config(config: dict[str, Any], receiver_host: str) -> dict[str, Any]: + merged = dict(config or {}) + pipeline = dict(merged.get("pipeline") or {}) + inputs = list(pipeline.get("inputs") or []) + outputs = list(pipeline.get("outputs") or []) + desired = current_output_config(receiver_host) + + if not inputs: + inputs = [default_input_config()] + + filtered = [] + for output in outputs: + if not isinstance(output, dict): + continue + is_existing_marvis = ( + output.get("name") == "tcp" + and output.get("port") == LOG_RECEIVER_PORT + and output.get("format") == LOG_RECEIVER_FORMAT + ) + if not is_existing_marvis: + filtered.append(output) + + filtered.append(desired) + pipeline["inputs"] = inputs + pipeline["outputs"] = filtered + merged["pipeline"] = pipeline + if "parsers" not in merged: + merged["parsers"] = list(config.get("parsers") or []) if isinstance(config, dict) else [] + return merged + + +async def configure_site_output() -> dict[str, Any]: + current = await pls.get_fluentbit_config() + if not isinstance(current, dict): + raise RuntimeError("Could not read current Fluent Bit config from PLS") + receiver_host = await _resolve_receiver_host() + desired = _merged_fluentbit_config(current, receiver_host) + updated = await pls.put_fluentbit_config(desired) + if not isinstance(updated, dict): + raise RuntimeError("PLS rejected Fluent Bit config update") + return { + "receiver_host": receiver_host, + "receiver_port": LOG_RECEIVER_PORT, + "match": LOG_FLUENTBIT_MATCH, + "config": updated, + } + + +def get_events(limit: int | None = None, node: str | None = None, nf: str | None = None, imsi: str | None = None) -> list[dict[str, Any]]: + events = list(_trace_events if imsi else _events) + if node: + node_l = node.lower() + events = [event for event in events if event.get("node", "").lower() == node_l] + if nf: + nf_u = nf.upper() + events = [event for event in events if event.get("nf", "").upper() == nf_u] + if imsi: + needle = imsi.strip() + events = [event for event in events if needle and needle in event.get("message", "")] + events.sort(key=lambda event: event.get("epoch", 0.0)) + if limit is not None: + return events[-limit:] + return events + + +def record_alert_context( + *, + category: str, + nf: str, + node: str, + severity: str, + description: str, + remediation: str, + source: str, + event: dict[str, Any], + before_context: list[dict[str, Any]], + after_context: list[dict[str, Any]], +) -> str: + _ensure_db() + fingerprint = sha1( + "|".join( + [ + category, + nf, + node, + severity, + description, + remediation, + event.get("timestamp", ""), + event.get("message", ""), + ] + ).encode("utf-8") + ).hexdigest() + alert_id = sha1(f"{fingerprint}|{source}".encode("utf-8")).hexdigest() + conn = sqlite3.connect(_db_path()) + try: + conn.execute( + """ + INSERT OR REPLACE INTO alert_context ( + id, fingerprint, created_at, event_ts, category, nf, node, severity, + description, remediation, source, match_message, before_context, after_context + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + alert_id, + fingerprint, + datetime.now(UTC).isoformat(), + event.get("timestamp", ""), + category, + nf, + node, + severity, + description, + remediation, + source, + event.get("message", ""), + json.dumps(before_context), + json.dumps(after_context), + ), + ) + _trim_db(conn) + conn.commit() + finally: + conn.close() + return alert_id + + +def recent_alert_context(limit: int = 20) -> list[dict[str, Any]]: + _ensure_db() + conn = sqlite3.connect(_db_path()) + conn.row_factory = sqlite3.Row + try: + rows = conn.execute( + """ + SELECT id, created_at, event_ts, category, nf, node, severity, description, + remediation, source, match_message, before_context, after_context + FROM alert_context + ORDER BY event_ts DESC, created_at DESC + LIMIT ? + """, + (limit,), + ).fetchall() + return [dict(row) for row in rows] + finally: + conn.close() + + +def known_nfs() -> list[str]: + return list(ALL_NFS) diff --git a/app/services/log_rules.py b/app/services/log_rules.py new file mode 100644 index 0000000..fbf2f63 --- /dev/null +++ b/app/services/log_rules.py @@ -0,0 +1,37 @@ +"""JSON-backed log rule loading for runtime-editable log analysis.""" + +from __future__ import annotations + +import json +from pathlib import Path + +RULES_PATH = Path(__file__).resolve().parents[2] / "config" / "log_rules.json" +_rules_cache: dict[str, list[dict]] | None = None +_rules_cache_mtime: float | None = None + + +def load_category_patterns() -> dict[str, list[dict]]: + global _rules_cache, _rules_cache_mtime + try: + stat = RULES_PATH.stat() + if _rules_cache is not None and _rules_cache_mtime == stat.st_mtime: + return _rules_cache + + data = json.loads(RULES_PATH.read_text()) + categories = data.get("categories", {}) + loaded: dict[str, list[dict]] = {} + for category, rules in categories.items(): + loaded[category] = [] + for rule in rules: + if not all( + key in rule + for key in ("pattern", "nf", "severity", "description", "remediation") + ): + continue + loaded[category].append(rule) + + _rules_cache = loaded + _rules_cache_mtime = stat.st_mtime + return loaded + except Exception: + return {} diff --git a/app/services/pls.py b/app/services/pls.py index 8e62242..5005af8 100644 --- a/app/services/pls.py +++ b/app/services/pls.py @@ -1,4 +1,4 @@ -"""PLS API client for cluster and per-node discovery.""" +"""PLS API client for cluster, per-node discovery, and site-wide config.""" from __future__ import annotations @@ -11,6 +11,10 @@ from app.config import PLS_AUTH_BACKEND, PLS_BASE_URL, PLS_PASSWORD, PLS_USERNAM _token: str | None = None +class PlsRequestError(RuntimeError): + pass + + def _base_url_for_host(host: str | None = None) -> str: if not host: return PLS_BASE_URL.rstrip("/") @@ -18,9 +22,9 @@ def _base_url_for_host(host: str | None = None) -> str: return urlunsplit((parts.scheme, host, parts.path.rstrip("/"), "", "")) -async def _login() -> str | None: +async def _login(force: bool = False) -> str | None: global _token - if _token: + if _token and not force: return _token if not PLS_USERNAME or not PLS_PASSWORD: return None @@ -48,17 +52,45 @@ async def _get(path: str, host: str | None = None) -> dict | list | None: if not token: return None - headers = {"Authorization": f"Bearer {token}"} url = f"{_base_url_for_host(host)}/{path.lstrip('/')}" try: async with httpx.AsyncClient(timeout=5, verify=PLS_VERIFY_TLS) as client: - response = await client.get(url, headers=headers) + response = await client.get(url, headers={"Authorization": f"Bearer {token}"}) + if response.status_code in {401, 403}: + refreshed = await _login(force=True) + if not refreshed: + return None + response = await client.get(url, headers={"Authorization": f"Bearer {refreshed}"}) response.raise_for_status() return response.json() except Exception: return None +async def _put(path: str, payload: dict, host: str | None = None) -> dict | list | None: + token = await _login() + if not token: + raise PlsRequestError("PLS authentication is not configured or login failed") + + url = f"{_base_url_for_host(host)}/{path.lstrip('/')}" + try: + async with httpx.AsyncClient(timeout=8, verify=PLS_VERIFY_TLS) as client: + response = await client.put(url, headers={"Authorization": f"Bearer {token}"}, json=payload) + if response.status_code in {401, 403}: + refreshed = await _login(force=True) + if not refreshed: + raise PlsRequestError("PLS token expired and re-login failed") + response = await client.put(url, headers={"Authorization": f"Bearer {refreshed}"}, json=payload) + if response.is_error: + detail = response.text.strip() + raise PlsRequestError(f"HTTP {response.status_code}: {detail or 'unknown PLS validation error'}") + return response.json() + except PlsRequestError: + raise + except Exception as exc: + raise PlsRequestError(str(exc)) from exc + + def node_host(node_name: str) -> str: return node_name.split("@", 1)[1] if "@" in node_name else node_name @@ -76,3 +108,13 @@ async def get_system_info(host: str | None = None) -> dict | None: async def get_services(host: str | None = None) -> list[dict]: data = await _get("services", host=host) return data if isinstance(data, list) else [] + + +async def get_fluentbit_config() -> dict | None: + data = await _get("fluent-bit/config") + return data if isinstance(data, dict) else None + + +async def put_fluentbit_config(config: dict) -> dict | None: + data = await _put("fluent-bit/config", config) + return data if isinstance(data, dict) else None diff --git a/app/ui/index.html b/app/ui/index.html index e7d149e..942d91c 100644 --- a/app/ui/index.html +++ b/app/ui/index.html @@ -174,14 +174,28 @@ header h1 span { color: var(--muted); font-weight: 400; } padding: 9px 12px; margin-bottom: 7px; border-left: 3px solid var(--yellow); } .alert-row.critical { border-left-color: var(--red); } +.alert-row.logs { border-left-color: var(--blue); } .alert-row-name { font-size: 12px; font-weight: 600; } .alert-row-desc { font-size: 11px; color: var(--muted); margin-top: 2px; } .alert-row-node { font-size: 10px; color: var(--blue); margin-top: 5px; } +.alert-row-meta { display: flex; gap: 6px; align-items: center; margin-top: 6px; flex-wrap: wrap; } +.alert-badge { + font-size: 9px; text-transform: uppercase; letter-spacing: .08em; + border-radius: 999px; padding: 2px 6px; border: 1px solid var(--border); color: var(--muted); +} +.alert-badge.logs { color: var(--blue); border-color: rgba(59,130,246,.4); background: rgba(59,130,246,.12); } +.alert-badge.alertmanager { color: var(--yellow); border-color: rgba(245,158,11,.4); background: rgba(245,158,11,.12); } +.alert-context { + margin-top: 7px; font-family: ui-monospace,SFMono-Regular,Menlo,monospace; + font-size: 10px; line-height: 1.45; color: #c8d1e3; + background: rgba(0,0,0,.18); border: 1px solid rgba(255,255,255,.05); + border-radius: 6px; padding: 7px 8px; white-space: pre-wrap; +} /* ── Chat panel ─────────────────────────────────────────────────── */ -.chat { display: flex; flex-direction: column; overflow: hidden; } +.chat { display: grid; grid-template-rows: auto auto minmax(0,1fr) auto; overflow: hidden; } .messages { - flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 14px; + min-height: 0; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 14px; } .messages::-webkit-scrollbar { width: 4px; } .messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } @@ -241,9 +255,62 @@ header h1 span { color: var(--muted); font-weight: 400; } .send:hover { opacity: .85; } .send:disabled { opacity: .35; cursor: default; } +/* Trace panel */ +.trace-panel { + background: linear-gradient(180deg, rgba(30,37,53,.65), rgba(15,17,23,.95)); + flex-shrink: 0; + display: flex; + flex-direction: column; + min-height: 220px; + max-height: 280px; + border-bottom: 1px solid var(--border); +} +.trace-header { + padding: 12px 20px 10px; + display: flex; align-items: center; justify-content: space-between; gap: 10px; + border-bottom: 1px solid rgba(255,255,255,.04); +} +.trace-title { + font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .1em; color: var(--muted); +} +.trace-status { font-size: 11px; color: var(--muted); } +.trace-controls { + padding: 10px 20px; + display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; +} +.trace-controls select, +.trace-controls input, +.trace-controls button { + background: var(--card); color: var(--text); border: 1px solid var(--border); + border-radius: 8px; padding: 8px 10px; font: inherit; min-width: 0; +} +.trace-controls button { cursor: pointer; } +.trace-controls button:hover { border-color: var(--purple); } +.trace-log { + flex: 1; overflow: auto; padding: 0 20px 16px; +} +.trace-log::-webkit-scrollbar { width: 4px; height: 4px; } +.trace-log::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } +.trace-empty { + color: var(--muted); font-size: 12px; padding-top: 16px; +} +.trace-pre { + font-family: ui-monospace,SFMono-Regular,Menlo,monospace; + font-size: 11px; line-height: 1.55; color: #dbe5f5; white-space: pre-wrap; +} +.trace-line { + display: block; padding: 2px 0; +} +.trace-line .t-ts { color: var(--muted); } +.trace-line .t-node { color: var(--blue); } +.trace-line .t-nf { color: var(--green); } +.trace-line .t-src { color: var(--yellow); } + @media (max-width: 680px) { .layout { grid-template-columns: 1fr; } .left { max-height: 260px; } + .trace-controls { grid-template-columns: 1fr 1fr; } + .trace-panel { max-height: 320px; } } @@ -282,7 +349,25 @@ header h1 span { color: var(--muted); font-weight: 400; }
-
+
+
+
Live Log Trace
+
Waiting for log stream…
+
+
+ + + + +
+
+
No trace data yet.
+
+
@@ -291,6 +376,7 @@ header h1 span { color: var(--muted); font-weight: 400; }
+
@@ -312,6 +398,9 @@ const ROLE_LABELS = { 'COMBOCP': 'Combo CP', 'COMBODCP': 'Combo DCP', }; +let latestCluster = { nodes: [] }; +let allowedTraceNfs = []; +let tracePollHandle = null; function md(text) { // minimal markdown: **bold**, `code`, newlines @@ -342,6 +431,7 @@ function addMsg(role, html, isTyping=false) { async function loadNFs() { try { const d = await (await fetch('./api/network/status')).json(); + latestCluster = d.cluster || { nodes: [] }; const grid = $('nfGrid'); grid.innerHTML = ''; (d.nfs||[]).forEach(nf => { @@ -353,6 +443,7 @@ async function loadNFs() { grid.appendChild(c); }); renderNodes(d.cluster); + populateTraceFilters(d.cluster); $('dot').className = 'dot'; $('connLabel').textContent = 'Live'; } catch { @@ -363,6 +454,25 @@ async function loadNFs() { } } +function populateTraceFilters(cluster) { + const nodes = cluster?.nodes || []; + const nodeSel = $('traceNode'); + const nfSel = $('traceNf'); + const currentNode = nodeSel.value; + const currentNf = nfSel.value; + + const nodeOptions = [''] + .concat(nodes.map(node => ``)); + nodeSel.innerHTML = nodeOptions.join(''); + nodeSel.value = nodes.some(node => node.hostname === currentNode) ? currentNode : ''; + + const nfs = new Set(allowedTraceNfs); + nfSel.innerHTML = [''] + .concat([...nfs].sort().map(nf => ``)) + .join(''); + nfSel.value = nfs.has(currentNf) ? currentNf : ''; +} + function toggleNodeCard(button) { button.closest('.node-card')?.classList.toggle('open'); } @@ -424,10 +534,15 @@ async function loadAlerts() { el.innerHTML = '
No active alerts
'; } else { el.innerHTML = d.alerts.slice(0,10).map(a => - `
+ `
${a.name}
${a.summary||a.instance||''}
${(a.nodes||[]).length ? 'Node: ' + a.nodes.map(n => n.hostname).join(', ') : 'Node: unresolved'}
+
+ ${a.source || 'alertmanager'} + ${a.severity || 'warning'} +
+ ${a.source === 'logs' && a.match_message ? `
${escapeHtml(a.match_message)}
` : ''}
` ).join(''); } @@ -436,7 +551,66 @@ async function loadAlerts() { } } -async function refresh() { await Promise.all([loadNFs(), loadAlerts()]); } +async function loadTraces() { + try { + const limit = Math.max(10, Math.min(1000, parseInt($('traceLines').value || '80', 10) || 80)); + const params = new URLSearchParams({ limit: String(limit) }); + if ($('traceNode').value) params.set('node', $('traceNode').value); + if ($('traceNf').value) params.set('nf', $('traceNf').value); + const d = await (await fetch(`./api/logs/events?${params.toString()}`)).json(); + allowedTraceNfs = (d.status?.allowed_nfs || []).map(nf => String(nf).toUpperCase()); + populateTraceFilters(latestCluster); + const events = d.events || []; + $('traceStatus').textContent = d.status?.last_event_at + ? `Last event ${formatFullDateTime(d.status.last_event_at)}` + : 'Waiting for log stream…'; + if (!events.length) { + $('traceLog').innerHTML = '
No log events match the selected filters.
'; + return; + } + $('traceLog').innerHTML = `
${ + events.map(evt => `${escapeHtml(shortTs(evt.timestamp))} ${escapeHtml(evt.node || 'unknown')} ${escapeHtml(evt.nf || 'SYSTEM')} ${escapeHtml(evt.source || 'unknown')} ${escapeHtml(evt.message || '')}`).join('') + }
`; + $('traceLog').scrollTop = $('traceLog').scrollHeight; + } catch { + $('traceStatus').textContent = 'Trace unavailable'; + $('traceLog').innerHTML = '
Cannot reach trace API.
'; + } +} + +function shortTs(value) { + if (!value) return '--:--:--'; + const dt = new Date(value); + return Number.isNaN(dt.getTime()) + ? value + : dt.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit', second:'2-digit'}); +} + +function formatFullDateTime(value) { + if (!value) return 'unknown'; + const dt = new Date(value); + return Number.isNaN(dt.getTime()) + ? value + : dt.toLocaleString([], { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} + +function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +async function refresh() { await Promise.all([loadNFs(), loadAlerts(), loadTraces()]); } // ── Chat ────────────────────────────────────────────────────────────────── async function send() { @@ -478,6 +652,7 @@ function ask(btn) { $('inp').value = btn.textContent; send(); } )); await refresh(); setInterval(refresh, 30000); + tracePollHandle = setInterval(loadTraces, 5000); })(); diff --git a/config/log_rules.json b/config/log_rules.json new file mode 100644 index 0000000..d6c4dfb --- /dev/null +++ b/config/log_rules.json @@ -0,0 +1,149 @@ +{ + "categories": { + "Registration": [ + { + "pattern": "RegistrationFailure|UeRegistrationFailed|N1.*[Rr]egistration.*[Ff]ail", + "nf": "AMF", + "severity": "critical", + "description": "UE registration failure", + "remediation": "Check AMF logs for NGAP errors; verify UE credentials and NRF registration." + }, + { + "pattern": "N2SetupFail|NgapSetupFail|N2.*[Tt]imeout|NgapProcedure.*failed", + "nf": "AMF", + "severity": "critical", + "description": "N2 interface setup failure", + "remediation": "Verify gNB connectivity to AMF; check SCTP transport and NGAP PLMN config." + }, + { + "pattern": "InitialContextSetupFail|UeContextRelease.*[Aa]bnormal", + "nf": "AMF", + "severity": "warning", + "description": "UE context setup failure", + "remediation": "Review AMF-SMF N11 interface; check subscriber profile in UDM/UDR." + }, + { + "pattern": "PagingFail|UeUnreachable|UeNotFound", + "nf": "AMF", + "severity": "warning", + "description": "UE paging failure", + "remediation": "Verify UE is registered; check AMF tracking area configuration." + } + ], + "Sessions": [ + { + "pattern": "PduSessionEstablishmentReject|PduSession.*[Ff]ail|CreateSessionResponse.*[Ff]ail", + "nf": "SMF", + "severity": "critical", + "description": "PDU session establishment failure", + "remediation": "Check SMF-UPF N4 path; verify DNN/APN config and UPF N3/N9 interfaces." + }, + { + "pattern": "N4Session.*[Ff]ail|PfcpSession.*[Ee]rror|N4.*[Tt]imeout|PfcpAssociation.*[Ff]ail", + "nf": "UPF", + "severity": "critical", + "description": "N4/PFCP session error", + "remediation": "Restart PFCP association between SMF and UPF; check N4 IP reachability." + }, + { + "pattern": "IpAllocationFail|AddressPoolExhausted|NoIpAvailable", + "nf": "SMF", + "severity": "critical", + "description": "IP address pool exhausted", + "remediation": "Expand UE IP address pool in SMF config; review active session count." + }, + { + "pattern": "SessionModification.*[Ff]ail|BearerModification.*[Ee]rror", + "nf": "SMF", + "severity": "warning", + "description": "Session modification failure", + "remediation": "Check PCF policy consistency; verify QoS parameters match UPF capabilities." + } + ], + "Authentication": [ + { + "pattern": "AuthenticationFailure|AuthReject|EapFailure|5g-aka.*[Ff]ail|EapAkaFailure", + "nf": "AUSF", + "severity": "critical", + "description": "UE authentication failure", + "remediation": "Verify USIM credentials match UDM subscriber data; check AUSF-UDM N12 link." + }, + { + "pattern": "UdmAuthReq.*[Ee]rror|SuciDeconceal.*[Ff]ail|UdmUeAuth.*[Ee]rror", + "nf": "UDM", + "severity": "critical", + "description": "UDM authentication error", + "remediation": "Check UDM-UDR N35 connectivity; verify Home Network Public Key configuration." + }, + { + "pattern": "AuthVectorFetch.*[Ff]ail|AusfUeAuth.*[Rr]eject|HssAuth.*[Ff]ail", + "nf": "AUSF", + "severity": "warning", + "description": "Auth vector fetch failure", + "remediation": "Review UDR data integrity for affected SUPI; check AUSF-UDM TLS certificates." + } + ], + "Connectivity": [ + { + "pattern": "NfDiscovery.*[Ff]ail|NrfRegistration.*[Ff]ail|NfDeregistration.*unexpect", + "nf": "NRF", + "severity": "warning", + "description": "NF service discovery failure", + "remediation": "Verify NRF is reachable from all NFs; check NRF registration TTL and heartbeat." + }, + { + "pattern": "ServiceUnavailable.*NF|HTTP.*503.*NF|NfProfile.*expired", + "nf": "NRF", + "severity": "warning", + "description": "NF service unavailable", + "remediation": "Check NF pod health and SBI listen port; review NRF subscription notifications." + }, + { + "pattern": "SbiRequest.*[Tt]imeout|SbiConn.*[Ff]ail|Http2.*[Ee]rror", + "nf": "NRF", + "severity": "warning", + "description": "SBI interface timeout", + "remediation": "Inspect inter-NF network MTU and TLS handshake; check load balancer config." + } + ], + "Policy": [ + { + "pattern": "PcfSmPolicy.*[Ee]rror|PolicyDecision.*[Ff]ail|SmPolicy.*[Rr]eject", + "nf": "PCF", + "severity": "warning", + "description": "Policy decision failure", + "remediation": "Review PCF policy rules and subscriber group config; check PCF-UDR N36 link." + }, + { + "pattern": "QosEnforce.*[Ff]ail|ChargingRule.*[Ee]rror|PccRule.*[Rr]eject", + "nf": "PCF", + "severity": "warning", + "description": "QoS policy enforcement failure", + "remediation": "Verify QoS profiles match UPF capabilities; check PCF-CHF N40 charging path." + } + ], + "Security": [ + { + "pattern": "SecurityMode.*[Ff]ail|IntegrityCheck.*[Ff]ail|NasIntegrity.*[Ee]rror", + "nf": "AMF", + "severity": "critical", + "description": "NAS security mode failure", + "remediation": "Check AMF cipher/integrity algorithm priority list matches UE capabilities." + }, + { + "pattern": "TlsHandshake.*[Ff]ail|Certificate.*[Ee]xpir|x509.*[Ee]rror|CertVerify.*[Ff]ail", + "nf": "AMF", + "severity": "critical", + "description": "TLS/certificate error", + "remediation": "Renew expired certificates; verify trust chain between NFs; check SBI TLS config." + }, + { + "pattern": "SuciProtection.*[Ff]ail|PrivacyProtection.*[Ee]rror|HomeNetworkKey.*[Ee]rror", + "nf": "UDM", + "severity": "warning", + "description": "SUCI privacy protection error", + "remediation": "Verify Home Network Public Key provisioning on UDM; check SUPI revealing config." + } + ] + } +} diff --git a/config/marvis.env.example b/config/marvis.env.example index d809e6b..bfbd102 100644 --- a/config/marvis.env.example +++ b/config/marvis.env.example @@ -10,6 +10,22 @@ MARVIS_PLS_PASSWORD= MARVIS_PLS_AUTH_BACKEND=local MARVIS_PLS_VERIFY_TLS=false +# Fluent Bit log ingestion. +MARVIS_LOG_INGEST_ENABLED=true +MARVIS_LOG_AUTO_CONFIGURE=true +MARVIS_LOG_RECEIVER_BIND_HOST=0.0.0.0 +MARVIS_LOG_RECEIVER_HOST= +MARVIS_LOG_RECEIVER_PORT=5514 +MARVIS_LOG_RECEIVER_FORMAT=json_lines +MARVIS_LOG_BUFFER_LINES=1000 +MARVIS_LOG_TRACE_BUFFER_LINES=5000 +MARVIS_LOG_ALERT_CONTEXT_BEFORE=5 +MARVIS_LOG_ALERT_CONTEXT_AFTER=5 +MARVIS_LOG_ALERT_CONTEXT_DB_PATH=/app/data/marvis-alert-context.db +MARVIS_LOG_ALERT_CONTEXT_DB_MAX_ROWS=500 +MARVIS_LOG_FLUENTBIT_MATCH=* +MARVIS_LOG_ALLOWED_NFS=AMF,SMF,UPF,UDM,UDR,NRF,AUSF,PCF,MME,SGWC,DRA,DSM,AAA,BMSC,CHF,SMSF,EIR + # AI backend configuration. MARVIS_AI_MODE=rule MARVIS_OPENAI_API_KEY= diff --git a/config/p5g-marvis.service b/config/p5g-marvis.service index f65d368..d964277 100644 --- a/config/p5g-marvis.service +++ b/config/p5g-marvis.service @@ -16,6 +16,20 @@ Environment=MARVIS_PLS_USERNAME= Environment=MARVIS_PLS_PASSWORD= Environment=MARVIS_PLS_AUTH_BACKEND=local Environment=MARVIS_PLS_VERIFY_TLS=false +Environment=MARVIS_LOG_INGEST_ENABLED=true +Environment=MARVIS_LOG_AUTO_CONFIGURE=true +Environment=MARVIS_LOG_RECEIVER_BIND_HOST=0.0.0.0 +Environment=MARVIS_LOG_RECEIVER_HOST= +Environment=MARVIS_LOG_RECEIVER_PORT=5514 +Environment=MARVIS_LOG_RECEIVER_FORMAT=json_lines +Environment=MARVIS_LOG_BUFFER_LINES=1000 +Environment=MARVIS_LOG_TRACE_BUFFER_LINES=5000 +Environment=MARVIS_LOG_ALERT_CONTEXT_BEFORE=5 +Environment=MARVIS_LOG_ALERT_CONTEXT_AFTER=5 +Environment=MARVIS_LOG_ALERT_CONTEXT_DB_PATH=/app/data/marvis-alert-context.db +Environment=MARVIS_LOG_ALERT_CONTEXT_DB_MAX_ROWS=500 +Environment=MARVIS_LOG_FLUENTBIT_MATCH=* +Environment=MARVIS_LOG_ALLOWED_NFS=AMF,SMF,UPF,UDM,UDR,NRF,AUSF,PCF,MME,SGWC,DRA,DSM,AAA,BMSC,CHF,SMSF,EIR Environment=MARVIS_AI_MODE=rule Environment=MARVIS_OPENAI_API_KEY= Environment=MARVIS_OPENAI_BASE_URL=https://api.openai.com @@ -28,6 +42,7 @@ ExecStartPre=-/usr/bin/docker rm -f p5g-marvis ExecStart=/usr/bin/docker run \ --name p5g-marvis \ --network host \ + --volume /var/lib/p5g-marvis:/app/data \ --env MARVIS_PROMETHEUS_URL \ --env MARVIS_PROMETHEUS_PREFIX \ --env MARVIS_ALERTMANAGER_URL \ @@ -36,6 +51,20 @@ ExecStart=/usr/bin/docker run \ --env MARVIS_PLS_PASSWORD \ --env MARVIS_PLS_AUTH_BACKEND \ --env MARVIS_PLS_VERIFY_TLS \ + --env MARVIS_LOG_INGEST_ENABLED \ + --env MARVIS_LOG_AUTO_CONFIGURE \ + --env MARVIS_LOG_RECEIVER_BIND_HOST \ + --env MARVIS_LOG_RECEIVER_HOST \ + --env MARVIS_LOG_RECEIVER_PORT \ + --env MARVIS_LOG_RECEIVER_FORMAT \ + --env MARVIS_LOG_BUFFER_LINES \ + --env MARVIS_LOG_TRACE_BUFFER_LINES \ + --env MARVIS_LOG_ALERT_CONTEXT_BEFORE \ + --env MARVIS_LOG_ALERT_CONTEXT_AFTER \ + --env MARVIS_LOG_ALERT_CONTEXT_DB_PATH \ + --env MARVIS_LOG_ALERT_CONTEXT_DB_MAX_ROWS \ + --env MARVIS_LOG_FLUENTBIT_MATCH \ + --env MARVIS_LOG_ALLOWED_NFS \ --env MARVIS_AI_MODE \ --env MARVIS_OPENAI_API_KEY \ --env MARVIS_OPENAI_BASE_URL \