From 475bce46f6dfe83707658b213d439ab0b027a68f Mon Sep 17 00:00:00 2001 From: yuyr Date: Wed, 3 Dec 2025 14:31:39 +0800 Subject: [PATCH] =?UTF-8?q?[#2]=20=E5=A2=9E=E5=8A=A0ruijie=E6=94=AF?= =?UTF-8?q?=E6=8C=81=EF=BC=8C=E6=B3=A8=E5=86=8C=E6=8E=A5=E5=8F=A3=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0vendor=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 58 +++- ...jie_netconf_components_with_transceiver.md | 300 ++++++++++++++++++ pytest.ini | 1 + scripts/register_device.sh | 26 ++ scripts/setup_port_forward.sh | 6 +- src/exporter/__pycache__/api.cpython-312.pyc | Bin 7941 -> 8569 bytes .../__pycache__/config.cpython-312.pyc | Bin 6290 -> 6567 bytes .../netconf_client.cpython-312.pyc | Bin 7579 -> 11637 bytes .../__pycache__/scraper.cpython-312.pyc | Bin 5176 -> 5254 bytes .../__pycache__/sqlite_store.cpython-312.pyc | Bin 8490 -> 9193 bytes src/exporter/api.py | 17 +- src/exporter/config.py | 10 +- src/exporter/netconf_client.py | 150 ++++++++- src/exporter/scraper.py | 6 +- src/exporter/sqlite_store.py | 25 +- ...t_api_devices.cpython-312-pytest-9.0.1.pyc | Bin 17525 -> 22331 bytes .../test_config.cpython-312-pytest-9.0.1.pyc | Bin 8753 -> 13587 bytes ...st_connection.cpython-312-pytest-9.0.1.pyc | Bin 14572 -> 16630 bytes ...lassification.cpython-312-pytest-9.0.1.pyc | Bin 7529 -> 8502 bytes ..._e2e_exporter.cpython-312-pytest-9.0.1.pyc | Bin 18559 -> 23764 bytes ...logging_utils.cpython-312-pytest-9.0.1.pyc | Bin 7370 -> 9324 bytes tests/test_api_devices.py | 64 +++- tests/test_api_sqlite_errors.py | 86 +++++ tests/test_config.py | 62 ++++ tests/test_connection.py | 18 ++ tests/test_error_classification.py | 5 + tests/test_http_e2e_exporter.py | 70 +++- tests/test_logging_utils.py | 27 ++ .../test_netconf_parser_vendor_none_ruijie.py | 58 ++++ tests/test_ruijie_live_netconf.py | 117 +++++++ tests/test_sqlite_vendor_column.py | 160 ++++++++++ 31 files changed, 1227 insertions(+), 39 deletions(-) create mode 100644 docs/ruijie_netconf_components_with_transceiver.md create mode 100644 scripts/register_device.sh create mode 100644 tests/test_api_sqlite_errors.py create mode 100644 tests/test_netconf_parser_vendor_none_ruijie.py create mode 100644 tests/test_ruijie_live_netconf.py create mode 100644 tests/test_sqlite_vendor_column.py diff --git a/README.md b/README.md index eb6c40b..47b4c32 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,17 @@ global: log_file: "" # 若非空,则写入指定文件 devices: [] # 静态设备先留空,通过 API 动态注册 + +# 如需在配置文件中声明静态设备,可使用如下结构: +# devices: +# - name: h3c-static-1 +# host: 192.168.1.10 +# port: 830 +# username: netconf_user +# password: "******" +# enabled: true +# supports_xpath: false +# vendor: "h3c" # 可选,多厂商解析时用于选择 H3C 解析策略 ``` 注意: @@ -204,7 +215,7 @@ curl -s http://127.0.0.1:19100/healthz --- -## 7. 通过 curl 注册 H3C 设备(runtime device) +## 7. 通过 curl 注册设备(runtime device) 假设已经准备好 H3C 的 NETCONF 代理: @@ -226,7 +237,9 @@ curl -s -X POST \ "username": "netconf_user", "password": "NASPLab123!", "enabled": true, - "supports_xpath": false + "supports_xpath": false, + "scrape_interval_seconds": null, + "vendor": "h3c" }' \ http://127.0.0.1:19100/api/v1/devices ``` @@ -241,7 +254,8 @@ curl -s -X POST \ "enabled": true, "scrape_interval_seconds": null, "supports_xpath": false, - "source": "runtime" + "source": "runtime", + "vendor": "h3c" } ``` @@ -251,7 +265,43 @@ curl -s -X POST \ curl -s -H "X-API-Token: changeme" http://127.0.0.1:19100/api/v1/devices ``` -确认设备已注册(包含 `source: "runtime"`)。 +确认设备已注册(包含 `source: "runtime"` 和 `vendor: "h3c"`)。 + +### 7.1 注册 Ruijie 设备(示例) + +如果已经在 `.env` 中配置了 Ruijie 的 NETCONF 代理(例如:`RUIJIE_NETCONF_HOST=127.0.0.1`、`RUIJIE_NETCONF_PORT=9830` 等),可以类似地注册一个 Ruijie 设备: + +```bash +curl -s -X POST \ + -H "Content-Type: application/json" \ + -H "X-API-Token: changeme" \ + -d '{ + "name": "ruijie-live-1", + "host": "127.0.0.1", + "port": 9830, + "username": "ruijie1-admin", + "password": "******", + "enabled": true, + "supports_xpath": false, + "vendor": " Ruijie " + }' \ + http://127.0.0.1:19100/api/v1/devices +``` + +API 会自动将 `vendor` 规范化为小写、去掉首尾空格,返回类似: + +```json +{ + "name": "ruijie-live-1", + "host": "127.0.0.1", + "port": 9830, + "enabled": true, + "scrape_interval_seconds": null, + "supports_xpath": false, + "source": "runtime", + "vendor": "ruijie" +} +``` --- diff --git a/docs/ruijie_netconf_components_with_transceiver.md b/docs/ruijie_netconf_components_with_transceiver.md new file mode 100644 index 0000000..28ebb86 --- /dev/null +++ b/docs/ruijie_netconf_components_with_transceiver.md @@ -0,0 +1,300 @@ +./run_yangcli.sh "sget /oc-platform:components/oc-platform:component/oc-transceiver:transceiver" + +rpc-reply { + data { + components { + component TRANSCEIVER-1/0/1-Te0/1 { + name TRANSCEIVER-1/0/1-Te0/1 + transceiver { + config { + enabled true + } + state { + enabled true + present NOT_PRESENT + } + } + } + component TRANSCEIVER-1/0/2-Te0/2 { + name TRANSCEIVER-1/0/2-Te0/2 + transceiver { + config { + enabled true + } + state { + enabled true + present NOT_PRESENT + } + } + } + component { + name TRANSCEIVER-1/0/129-FH0/1:1 + transceiver { + config { + enabled true + } + state { + enabled true + present PRESENT + form-factor openconfig-transport-types:QSFP112 + connector-type oc-opt-types:MPO_CONNECTOR + vendor H3C + vendor-part EQ854HG01M3-H3C + vendor-rev 03 + ethernet-pmd oc-opt-types:ETH_UNDEFINED + serial-no G80231AM995701FK + date-code 2025-07-09T00:00:00Z-08:00 + supply-voltage { + instant 3.31 + } + output-power { + instant -40.00 + } + input-power { + instant 1.50 + } + laser-bias-current { + instant 0.0 + } + voltage { + instant 3.31 + } + } + physical-channels { + channel 1 { + index 1 + state { + index 1 + description TRANSCEIVER-1/0/129/1-FH0/1:1 + output-power { + instant -40.00 + } + input-power { + instant 1.50 + } + laser-bias-current { + instant 0.0 + } + } + } + channel 2 { + index 2 + state { + index 2 + description TRANSCEIVER-1/0/129/2-FH0/1:1 + output-power { + instant -40.00 + } + input-power { + instant 1.50 + } + laser-bias-current { + instant 0.0 + } + } + } + channel 3 { + index 3 + state { + index 3 + description TRANSCEIVER-1/0/129/3-FH0/1:1 + output-power { + instant -40.00 + } + input-power { + instant 1.50 + } + laser-bias-current { + instant 0.0 + } + } + } + channel 4 { + index 4 + state { + index 4 + description TRANSCEIVER-1/0/129/4-FH0/1:1 + output-power { + instant -40.00 + } + input-power { + instant 1.50 + } + laser-bias-current { + instant 0.0 + } + } + } + } + } + } + component { + name TRANSCEIVER-1/0/130-FH0/1:2 + transceiver { + config { + enabled true + } + state { + enabled true + present PRESENT + form-factor openconfig-transport-types:QSFP112 + connector-type oc-opt-types:MPO_CONNECTOR + vendor H3C + vendor-part EQ854HG01M3-H3C + vendor-rev 03 + ethernet-pmd oc-opt-types:ETH_UNDEFINED + serial-no G80231AM995701FK + date-code 2025-07-09T00:00:00Z-08:00 + supply-voltage { + instant 3.31 + } + output-power { + instant -40.00 + } + input-power { + instant 1.50 + } + laser-bias-current { + instant 0.0 + } + voltage { + instant 3.31 + } + } + physical-channels { + channel 1 { + index 1 + state { + index 1 + description TRANSCEIVER-1/0/130/1-FH0/1:2 + output-power { + instant -40.00 + } + input-power { + instant 1.50 + } + laser-bias-current { + instant 0.0 + } + } + } + channel 2 { + index 2 + state { + index 2 + description TRANSCEIVER-1/0/130/2-FH0/1:2 + output-power { + instant -40.00 + } + input-power { + instant 1.50 + } + laser-bias-current { + instant 0.0 + } + } + } + channel 3 { + index 3 + state { + index 3 + description TRANSCEIVER-1/0/130/3-FH0/1:2 + output-power { + instant -40.00 + } + input-power { + instant 1.50 + } + laser-bias-current { + instant 0.0 + } + } + } + channel 4 { + index 4 + state { + index 4 + description TRANSCEIVER-1/0/130/4-FH0/1:2 + output-power { + instant -40.00 + } + input-power { + instant 1.50 + } + laser-bias-current { + instant 0.0 + } + } + } + } + } + } + component { + name TRANSCEIVER-1/0/131-FH0/2:1 + transceiver { + config { + enabled true + } + state { + enabled true + present PRESENT + form-factor openconfig-transport-types:QSFP112 + connector-type oc-opt-types:MPO_CONNECTOR + vendor H3C + vendor-part EQ854HG01M3-H3C + vendor-rev 03 + ethernet-pmd oc-opt-types:ETH_UNDEFINED + serial-no G80231AM995701J8 + date-code 2025-07-09T00:00:00Z-08:00 + supply-voltage { + instant 3.32 + } + output-power { + instant -40.00 + } + input-power { + instant 1.47 + } + laser-bias-current { + instant 0.0 + } + voltage { + instant 3.32 + } + } + physical-channels { + channel 1 { + index 1 + state { + index 1 + description TRANSCEIVER-1/0/131/1-FH0/2:1 + output-power { + instant -40.00 + } + input-power { + instant 1.47 + } + laser-bias-current { + instant 0.0 + } + } + } + channel 2 { + index 2 + state { + index 2 + description TRANSCEIVER-1/0/131/2-FH0/2:1 + output-power { + instant -40.00 + } + input-power { + instant 1.47 + } + laser-bias-current { + instant 0.0 + } + } + } + channel 3 { + index 3 + state { \ No newline at end of file diff --git a/pytest.ini b/pytest.ini index c5937a6..e45ba9e 100644 --- a/pytest.ini +++ b/pytest.ini @@ -2,3 +2,4 @@ markers = h3c_live: tests that talk to a live H3C NETCONF device http_e2e: end-to-end tests that start the full HTTP server in a subprocess + ruijie_live: tests that talk to a live Ruijie NETCONF device diff --git a/scripts/register_device.sh b/scripts/register_device.sh new file mode 100644 index 0000000..5bed24e --- /dev/null +++ b/scripts/register_device.sh @@ -0,0 +1,26 @@ + + +curl -s -X POST -H "Content-Type: application/json" -H "X-API-Token: changeme" -d '{ + "name": "h3c-live-1", + "host": "127.0.0.1", + "port": 8830, + "username": "netconf_user", + "password": "NASPLab123!", + "enabled": true, + "supports_xpath": false, + "scrape_interval_seconds": null, + "vendor": "h3c" + }' http://127.0.0.1:19100/api/v1/devices + + +curl -s -X POST -H "Content-Type: application/json" -H "X-API-Token: changeme" -d '{ + "name": "ruijie-live-1", + "host": "127.0.0.1", + "port": 9830, + "username": "ruijie1-admin", + "password": "1qw2#ER$_ruijie", + "enabled": true, + "supports_xpath": false, + "vendor": " Ruijie " + }' http://127.0.0.1:19100/api/v1/devices + diff --git a/scripts/setup_port_forward.sh b/scripts/setup_port_forward.sh index 9c32ce0..c30acaf 100755 --- a/scripts/setup_port_forward.sh +++ b/scripts/setup_port_forward.sh @@ -1,2 +1,6 @@ + # 本地8830 转发到h3c交换机830端口,经过c1服务器 -ssh -L 8830:192.168.19.11:830 yuyr@c1 +# 本地9830 转发到锐捷交换机830端口,经过c1服务器 +ssh -L 8830:192.168.19.11:830 \ + -L 9830:192.168.19.152:830 \ + yuyr@c1 diff --git a/src/exporter/__pycache__/api.cpython-312.pyc b/src/exporter/__pycache__/api.cpython-312.pyc index 47c62b0bb87f7d14de0e236cdfade0722f97e70f..4bc1b1b98a4e0efb3b835bae0645f634d03d64c8 100644 GIT binary patch delta 2865 zcmZp*`{~4YnwOW0fq{X+XQN)`M)8S!5{wEH)%EM6*i#r%SaLXWIiompxuUpoxudvq zd7^l7d82q48B!QhSabMt`J?!A1)>DNe6}3HT%jnTT;V8TFrPg~Bv&*_G*>K2jFBOQ zBZV_ZJXa!0B3Cj>63pkyk;;{hk_NN6b7XR5qh!Hso*cPc`KWMtMg}GZcZL++7KRkQ zRH4<(AU85FL@9tp`CAxL1dv1(nHW;JQybICKob>%QD^KbV@8v_03O7&AG*&mYJH95?_{>lbMoOl3z6W3gc8R z7Kn+ApO;K7X3Dop;fvx(5lm%H5wc-mU|?cMOh z%c>+@Qp+-vQ$6$a(4}M;7#NE67#J8D7(TEu@bGq6-VheQBOpA%aWdOHIR+L&&T9j9$&dR{Rpvg12klD;z zo`Hd(NEt+^fCx1Zp#dTUKsvaSa}tY-b5lz)@>7Zo85kH=G87qsWDP)s6pRoAxiEdQ zAd8*vWfsF5G71fD9ln=Y6dtmQOo+J5DtCib;1d%ItL$fH1~x$uA^(wug;f?}6v&ih zkXvCG6i^JHc!`@_%o19k!jsCJ!V5|Tm3*4~w>Tl;6>4)r2X=oHCIQT3EG9h|2nTk9nzvUEXG?>iFWy4L3JMKH29tStH5tPvoAa*a25A=t zMS=k18IqY8!K`uyO(wsU z97r((jufz~;89ToicoMAXmS^@C zf`rN#7#MyuFgy^Fx-O)CQAqu=kY)$_2MGo-*>B=J0$d-&7zBmBh)zDtZxL`qTw;2~ z#ENf%oVZJe*uVKQZtKa22yLFfdGW)N*2G(PSVk{DTP7_y`$Z{!dbW?sXx8e9P|Fw`=0GIX+K$xcq>6Q2BDkej8G zHA@}J;bGLA{70~#Q47SkWX=L*4M;e{$u+F7phpgHWrh+^>IbXJQkZ;5NS9G*@Al;0=EP`LP7%KO$;pKo&EjXd_0}CLyAN|vfx|_P7Angocvwbs~%jOibGOp zY7v@!Mc{-2N;WF{h`N6xmGPAYm+! z3ktOk?K z$plFSpqSI-E#d^J0M!bxawfH?2$cSdDnSyU{8VH;Sw~vWwH9Ot4>)?juDr#WoS&1E znp~1!ROAOrwakeH1x5ZKU11;sR8<$HgIJ*WDXIstLMP9c78L`>Bo{O$L7Lhb7#M6P zpOT&_z{ko3s&vvBKQQsJa(z&kTr8u@ugS{wnU|G~?E{Y{E7ymx$?IjDqCo||CR-6G zK#CebMtgz?FbfnSw^+cz!v%4!I7ki@vPBXgRx5}Q0}&v17m0#cAWh(~&{Uc1Bdg0) zlsmahHn6@3T(=4q6y<|z*3{DC_~e|-)Vz`+P>2-?fxOL@oS&DLnGVS}V6(v92b&L$ zQjqPpc!~>iGD}k9i%as0QX$R&M++!Mz|~0+C=iMel^H0Ie#LUwobNN(-e=Ii&*1Q!LGLbu=3NGryA0Z2 z*aR76J1W002r|lk(coZ|n!x#mfrC-%qr~Lda(XtdOpN?D_+&mXgBTCEWj?Ti7$4Xe d1XMn-gP9x*g0det!Avd&Hr_AXASSpb1pph{hYbJ# delta 2255 zcmezA)N03fnwOW0fq{Wxl7MEWfY?Mn2}Y5L>iYG$98ny(oKc*)Tv1%P+)>=QJW)K1 z3@HpLEIGWnd{KP4{89X1K5LFZu3(fPn9Y_Wlq(!1oGTI~!pM-qp2CqMnkyD1mMb15 z4(4;_NaRXJNrKs2Ia0aOQPN;GcaBW1Y?Le`0~3QgLkdp|Ly9+Vs^Ds7kh>TdqU6BB zd@T$q{79nmObn@9DFP{iYlK!aK~$y)q%m@@Vqj;&e$Z%S=x7FD)sO0x6OP5i%e`7DRw8R0OfWwyJdNX~C^CHrqF zkkVUx@kObHrI|&k@g@1$sd+`V3=9mallwTF7_~QFs3zo90hpC%Jn z#VyXnf=q}GO{OAi1_p*(Tt9tH*m zVFm`q&%YQZzvDM%jGC+@u!3(5<7#$LgfcKpWa^2T{7yiup1lYZVnyIs0Y?ZpF5uBn zqz`fjXOTZBOh5rijB@6pK#=la5D@}OETGg4b|EO(1dx3^xmQq2I|`&GpMinlM+3tH zA*t&^>KBF7FAHgQuzwI^5R?5T$}Pb4L4-k2=!@{=M}iif-}pIr`93j&L>ag_xqg0P z;1=L2W@BJrnC2+&Xu+b%ToeYf+X6&{GcYhDGeUd~Cb?lj^NwkAmQWI-NDA{BmerhK zIfhzhPKHjlEa}MwA|jJ@M7UTwS+i839BxMS$rd90=Ac9fQ4J^8u)+d1m?4>oks+C> zma&{cnV|#}u3%MJa+71EBq!^NicA(36=o_?o*X6`>}(DS5cZYf}d=EW#Ad7hZ8C^$O7@ymXT z4U*1^f+t@PlV=Q>{6S2dQD-uTxGh^2D01v3dy7xzlKCRc#n08j{6S*!d+|t-FZ>(= zTpt)XKrvv=Ann8WyP7Z`S{81w}cEE zBw9A#m5^dm1_gj7QxRAQoFn)lNez@JZ!xE*mK5nvmXbCWNC0^)5kw?`2&u`j(&nZh zMMdCL21;+YI8rMzi%T-|(u>kTYCtKkCJlD6udn78DdYfpmF-h)@s#%0ES!AXX8G@BsN4Q~)F-C?tUW zt)Spg1TOoyl8aIkOH$)OhEy{!Fc^W9$}uu9{BB_QBE-Nw*+H&LfR~l)GcyBQI^zc> zURJITa+7b#>GG?ya((7yWn=rmqt43pA!xFgyi+u&_|;?s7luW}Ae(JK1egU1w_7aW zFy(^yMhqkeijE?25UUbIh=K@^w~9nSERZH}SZOLv-Yl;>`Ki2L{VlJc1D2-oL?B& z83jIyP3BkBvvFc#O=ZIxLL8CR?*?XVl&NnZ=Zm&6t6Kp@@6(eRfI4 zw#goBIr7|5>?s1N%qfC43=9k@LQxzkjH%2i!XREHx2DMCqin_5MLG-&47b?IQu9*s zi*B(Mmn4>CCf{N!&Mz%WPSxZr(gPW)4_M1GB-B$X6j6#E3hwo(;g&RUr4Q?IIS6HNqz*@{U-)HAzVl5cFy&Gm9O{fKZe31zELVautllOpFX5b4xfO(hLkp<};-* zO`anlX~rzcz`y{rtArb-5{Iicw{gzH&h#xgSWd}U#% zVGL%_{etCN^M+4Xu41}Ak;&ws0Sr{ zO^%{)kcm8?yu?~uQj}S6i!~>|Jhi9@oCo+pl4>AHw#0&h)Vvf;wp$!UiRJM*nZ+eV zphRAj0g~kci9xa=*z{Y>DXC?}Yzzzx3SdwKPPP%CWGezqwv$&0@dl@Y^4Dty28ITP z2WlD}>^Fp^KQRb%Ds(vCkd&D(K2v;w#AQjX4zCCNk{9?DZ%E2cWP2hhGb3uY{B=pa zi;{YmB@M1inq8DMyDVvONz!45%LKN`7lmrnWp9Yfe-Y&t=Ie0&Ai=;Z((l*lH^cZU zkNgL9VNQh)8VmyB9ehQ`n+t{aGBUbM)))0x2D>m4=p|=25xaD z=Oh*v=cblq2KczG$)vl<0vap1YF*l?12L^6N>CemzOxz!sKwKFHChqS{fsBlN9~eL+ LSn4BK0BjHdw`@h< delta 1311 zcmZ2(JjsynG%qg~0|Ntt3zKH%Xa0?RIxLJ;ldV~{GwN*q%wo#OXf#=qO^&f*vIkp^ zpgBW1F;N2gb|1^1`(z(LW_Zcq1a{eMs{1l8$wbIZXM27Sfq-;3QZ^L zu}N-L;`qwQXgpbhOM%gDvn7`^Bd-es1A``0krT)Wm&u*nHf+8iX29gr+&VH~(|s5i z7^(!E^YhX&)AiDd@^j-;GLuVOCJXb#F&Zr{;$dQRpIpq>!Du%54_^%%SWC#{I(`X8 z|IO3+nHhD;7#SF*Go&-rGS)D-FvMF1dQO-!9NO4ZIKbK%7#LPVoq$6n zCrrMCkeOU5+%PkxV46~R*6^-|YOH0hVa(#5{E$buo*5?3$k4Nyg`tKqm_d`zugDA( z;^rX20z?Rc2ul!Q1tLHRAfyPM0^EvhK~ctut_xKILOnReXtEWVGBDIL6!Cyeg zCcB9FLlRW@ z#gp00stzUwohx8C_gbq@&=NR~cJW0rFcVD41Bl zo+zpU34lepA)>ipFMzxnjj9)_*%_pa32e|U7Qg(wR80Z&+zCpeMd={(Ah`#WTW_&| zj0A_?E$-x;#Ny)I)RK(+l%hb813)?H77rxXL&ZZt;-K&>3J0;!GPVOq3`CeuR*=ZC z=4TcE%*?6H2JuM zv7Q%*33f2J!~kh4LbdT1hfQvNN@-52T~X6y4M`tkPDbeu44jP8pP3n$xIZv~xDpIZ W+~1kJ7#aCKFn~y~)JL!Y*f{{8H3#DW diff --git a/src/exporter/__pycache__/netconf_client.cpython-312.pyc b/src/exporter/__pycache__/netconf_client.cpython-312.pyc index f6e3cbfd78b5f51a15a89eb196a0946af6e137be..25ea9458aa9e30a47541138590f564f12ba79efa 100644 GIT binary patch delta 6652 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 J>>xJSEdZMzrJ?`; diff --git a/src/exporter/__pycache__/scraper.cpython-312.pyc b/src/exporter/__pycache__/scraper.cpython-312.pyc index b4086f76acc4031d8291745ba44d6c7cd34a1527..ae56ef9ed40a3b03fea29b2c0c023cd67104263a 100644 GIT binary patch delta 831 zcmdm?(Wc3FnwOW0fq{YH^J=|JcA<@YXE+$ECg0|GKluTNcs(CT1{JSiUd;>XgDiMV685nA~Y8Xm{;XJ|Bj1V@Gm=H{C4a;g4h-|HJjc^TX4VMiA149aHEl&w6 zM4W+PvKXh#aCbMM}hB{2CFMn`?PXBw(Uh(l8c+PGPIzs^Nu& z4+!g8GTve+rI-2My<3`O!FB4Bd9psaWZh#3eXf*2SWG)0P3KxFbK+i5CpM= g7$l@W2!mK643aV*L_sVu1~#rQ;`WSOMW9Fl0Mjg#F8}}l delta 749 zcmZqE+@ZmDnwOW0fq{X+T1hi=zu-o`GaQV5lW%jpPgumkk30LkdeR&*XXBw&r{gIR=Ir z(HgOMgbfTeV(|iSIo=wvc!YgSS*nvyaY-|(P1fTPuV-7$22sbzP|2jsP$C53F)*kw z6xr5_l!(IkH6k!K)$*2z!9=qpVJrll!dk;s!@HUpBErZ}!<;1zVIz|@Tp;r%*Ko&E z?h8>QUz9UcFh??!GitI=j^c5fyozTYqsU}WUc<@tyw7;`85kIDu@;vk7L^p4OfKT< zWZXIV51&1w+GHDkUBMlk2h1)jdR^p@>)@K;^+Z@>b1%OeqlT&r1H%ypPFFVOBb>&r zjLb)DSY0_8jxw^kvN0cJV+OG~9i`n=CjS=L&h5v*z)&O)BK#-s6_gbZ1~CIbL?8nL zgQiH43Wyyv`JjN7Dzw8Hg5Q~FBQtJaJh{eOeqw|3m#NuP%)%?H@VhJz^ n%6uN@jECT~0Lk(k= z;N*k6GAuQWS;8O&ZzYE^LkUEhfnjn!zo?7~Ly>MRQwc9ju!aeyKZ}3zL0&1dTILkC z8s;*FVwMzk5M9KW$JoRW$&kWP%T&kI69b{k8Os?em?Ig=8I?iSf^1V|<}9_HoW!Za zuK#TImS@v8Ozz|KV077hopTo>cUfv)N`8?-h-*a1U!DTFvW z`M4^iq?TnSrxq(Xy0|Df`}>6Y`Y9l3vaS+HDJ{s!OinCGRY=ayDb3BR;!DgaN=;0u zR7kDJEG{Yb)8v}W#G~dV;9rnhlvt9PpO={9T2z!@RHV$nz;KH-C%-(k=oVXYer|4N z$t~98oc!WcO_m}NkR(fTeqLUYIEcxdTby3Z!oa|wpfK5k$AEGF`-#^m}!B_4@qyBFrEn zeO*}lqOkTS1~y))J7SVElqY9Q&zqQcSxjvL^8;bk6~Y&VbuJh<9B{s9;C@3y;(>_d z7gh#Q$=@G1*?6UXe-Hv03pVXnv8g!&1H)QlXEBC@ih@qU373=9m#a~K#Hel#$A zU{apkC=w>mHIwUxg!FulnH)DH3ls6iI*tK;~5GOg<73QRWS6`8CnB`?dH!cfas!_%l;!U>C*8Xi~{pS+(}hAE2|l+Wuc z7>k)089=H_K#>ntT*FesS|SK$GB7aIu)*}zu-EXG2*brW;o@9KqTFy%z7(c4%&WnL z76U^qe+_e%C`>P$mSU*khiPDB=$XmFP{SO|pvmG_bz$-?shJ!)u;fxKZE3*;2@wxa zKzf1*FAyOMB78uEFNp915&od~WGl`uElN(+1@Jd|XC{M~p#8 z?31W8ugXUTuF3ypq?~RDh&_;&pKmwQ?z*($MQOtg`j@4hCUD%4Q<}hiLs;^Hu<9Kp z)dgvbwXZ7~UsN)_tYms!$@-#_^<^d7OG>T>L?(Do)|J&VR=Xjs@n8M z$gxt8%=wi~om2Wl&}3n`$#S4_r%F6H&?mDb)dgJ02bbg*rRsqT;3Jcd$SE;8Pkt_E zZ3ND5;K)${#gHn*PH)C6F;2D-;hLPylfvw$$v#<;S51K} zIX^cyv*Z?Qa!!76swPvB2m=GdEtcf`yu4y21_p-76L}5TGZ+{c8W^5T))SCq6rKEt zSJjMHP~;0UKQG@01qNQhe(z529^VH%0{vc{UcEj)I$R)(U&TBO3=C@>nVlFJ4lxQj z32)xZC&ege!@$6x$qccGDJ`$acJd2;D@M1?$^vT{x%e0u7=#%Z7(XXXz9sl%@)aQo ze(u#EhcGZOFf!CIWC=rk%_j_UDJX0hR2Yhcz;fbHInK%6!h9@xObiT@3q?dH7YVPP z93(0}*+!%iOrIAy!N@athG>LDku?JYLy-{3%N#}dIXOv*$=RALMYfX##6tLk85kIf zr!p`w{AghKz@#*}SS(C}Vw*>d_e;KAVLX5_)V@8w`261 zyjR?mO_@{dgAe26H&U9L6(v?P>cU-Hy9*=sp!IcvFUxjXqLi^v$)^OSIcL!Nh#)rrZbvNI=N zleNs?gm}jl6bf!2!W~4&f(TC#;RPbRL6N~$oL^d$oT|xDWCh}Kq@|W5XC&t26zPMM zD1Zo%r-~pkmz1BMbBisppdd9bMU%hCc5<(rggZFoP_4SfUyxW_T%KQ)5}%w|kdazc zglcUO$SJp&Q&P*oabFDb%YYw7V{Ccv0GLgZ^b{rwJUBU&w12sC*IT5*M1l{6UOCP+~gYM7|ltR|S+m z=!)^Ge9&VMk?!y-k^@CO3nZ}wPIgt0=LM%fNM0}UpIoTmE8GRLTMClczp^QDN`LT} zd{JSt94NC^i3bPzWR|45q?TnSrv{hg7p3Zfi;K;Zn-rB89VRbTv}W|3{7_L;1{@~J zpwLqR5vm}YnTo*dWKNJ+3tel}xc zmHaHA!>af>hLML=@w1FNtIC(4$uE@D*+9inQP5;jEP$&-|ArGl6k`9Cm#$j{6S MOxz!tKuoZ=0igUekpKVy diff --git a/src/exporter/api.py b/src/exporter/api.py index 0ed3673..545bd82 100644 --- a/src/exporter/api.py +++ b/src/exporter/api.py @@ -1,10 +1,11 @@ from __future__ import annotations from typing import Any, Dict, List, Optional +import sqlite3 from fastapi import Depends, FastAPI, Header, HTTPException, status from fastapi.responses import JSONResponse, PlainTextResponse -from pydantic import BaseModel +from pydantic import BaseModel, field_validator from prometheus_client import CollectorRegistry, generate_latest from .config import DeviceConfig, GlobalConfig @@ -22,6 +23,15 @@ class DeviceIn(BaseModel): enabled: bool = True supports_xpath: bool = False scrape_interval_seconds: Optional[int] = None + vendor: Optional[str] = None + + @field_validator("vendor") + @classmethod + def normalize_vendor(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return None + v_str = str(v).strip().lower() + return v_str or None class DeviceOut(BaseModel): @@ -32,6 +42,7 @@ class DeviceOut(BaseModel): scrape_interval_seconds: Optional[int] supports_xpath: bool source: str + vendor: Optional[str] def _require_token( @@ -99,6 +110,7 @@ def create_app( scrape_interval_seconds=d.scrape_interval_seconds, supports_xpath=d.supports_xpath, source=d.source, + vendor=d.vendor, ) for d in devices ] @@ -127,6 +139,7 @@ def create_app( enabled=device.enabled, scrape_interval_seconds=device.scrape_interval_seconds, supports_xpath=device.supports_xpath, + vendor=device.vendor, source="runtime", ) # 持久化并注册到 registry @@ -148,6 +161,7 @@ def create_app( scrape_interval_seconds=cfg.scrape_interval_seconds, supports_xpath=cfg.supports_xpath, source=cfg.source, + vendor=cfg.vendor, ) @app.delete( @@ -178,4 +192,3 @@ def create_app( return JSONResponse(status_code=status.HTTP_204_NO_CONTENT, content=None) return app - diff --git a/src/exporter/config.py b/src/exporter/config.py index 75936a0..4b42fdb 100644 --- a/src/exporter/config.py +++ b/src/exporter/config.py @@ -49,6 +49,8 @@ class DeviceConfig: enabled: bool = True scrape_interval_seconds: Optional[int] = None supports_xpath: bool = False + # 设备厂商标识,例如 "h3c"、"ruijie"、"huawei" 等;若未设置则为 None + vendor: Optional[str] = None source: str = "static" # "static" | "runtime" @@ -100,6 +102,12 @@ class Config: def _load_devices(raw_list: list[dict[str, Any]]) -> List[DeviceConfig]: devices: List[DeviceConfig] = [] for raw in raw_list: + raw_vendor = raw.get("vendor") + vendor: Optional[str] + if raw_vendor is None: + vendor = None + else: + vendor = str(raw_vendor).strip().lower() or None dev = DeviceConfig( name=str(raw["name"]), host=str(raw["host"]), @@ -109,6 +117,7 @@ class Config: enabled=bool(raw.get("enabled", True)), scrape_interval_seconds=raw.get("scrape_interval_seconds"), supports_xpath=bool(raw.get("supports_xpath", False)), + vendor=vendor, source="static", ) devices.append(dev) @@ -128,4 +137,3 @@ class Config: UserWarning, stacklevel=2, ) - diff --git a/src/exporter/netconf_client.py b/src/exporter/netconf_client.py index e911568..c0c9bbc 100644 --- a/src/exporter/netconf_client.py +++ b/src/exporter/netconf_client.py @@ -1,5 +1,8 @@ from __future__ import annotations +import logging +import re +import threading import xml.etree.ElementTree as ET from typing import Iterable, List, Tuple @@ -15,6 +18,20 @@ NS = { } +logger = logging.getLogger(__name__) + +_RE_RUIJIE_CHANNEL = re.compile( + r"^TRANSCEIVER-(?P.+)/(?P\d+)-(?P.+):(?P\d+)$" +) + +_RE_RUIJIE_COMPONENT = re.compile( + r"^TRANSCEIVER-(?P.+?)-(?P.+):(?P\d+)$" +) + +_ruijie_warning_issued: set[str] = set() +_ruijie_warning_lock = threading.Lock() + + def build_transceiver_filter() -> str: """ 构造 subtree filter 的 XML 片段(不包含外层 元素), @@ -30,17 +47,12 @@ def build_transceiver_filter() -> str: ) -def parse_port_and_channel( +def _parse_port_channel_h3c_or_default( description: str | None, component_name: str, channel_index: int, ) -> Tuple[str, str]: - """ - 从 description 中解析 (logical_port, logical_channel),并在异常时提供安全 fallback。 - - 正常格式: "1/0/66:1" -> ("1/0/66", "1/0/66:1") - - description 为空/缺失: 使用 (component_name, f"{component_name}:ch{index}") - - 其他格式: logical_port = description; logical_channel = f"{description}:ch{index}" - """ + """H3C 及默认设备的端口/通道解析策略.""" if not description: logical_port = component_name logical_channel = f"{component_name}:ch{channel_index}" @@ -60,6 +72,91 @@ def parse_port_and_channel( return logical_port, logical_channel +def _parse_port_channel_ruijie( + description: str | None, + component_name: str, + channel_index: int, + device_name: str | None = None, +) -> Tuple[str, str]: + """Ruijie 设备的端口/通道解析策略.""" + if not description: + return _parse_port_channel_h3c_or_default(description, component_name, channel_index) + + m = _RE_RUIJIE_CHANNEL.match(description) + if not m: + # 不符合 Ruijie 预期模式,退回默认策略 + return _parse_port_channel_h3c_or_default(description, component_name, channel_index) + + ch_from_desc = int(m.group("ch")) + ifname = m.group("ifname") + subport = m.group("subport") + + # 若 description 中的 ch 与 XML index 不一致,则记录 warning,便于定位数据异常 + if ch_from_desc != channel_index: + logger.warning( + "Ruijie channel index mismatch: description='%s' (ch=%d, subport=%s), xml_index=%d", + description, + ch_from_desc, + subport, + channel_index, + extra={"device": device_name or "-"}, + ) + + logical_port = ifname or component_name + logical_channel = f"{logical_port}:{channel_index}" + return logical_port, logical_channel + + +def parse_transceiver_port_from_component_name( + component_name: str, + vendor: str | None, +) -> str: + """根据 component_name 与厂商信息解析 transceiver 的 logical_port。""" + vendor_norm = (vendor or "").strip().lower() + + if vendor_norm == "ruijie": + m = _RE_RUIJIE_COMPONENT.match(component_name) + if m: + ifname = m.group("ifname") + return ifname or component_name + + # 默认/H3C:尝试提取形如 "1/0/1" 的端口模式 + m = re.search(r"\d+/\d+/\d+", component_name) + if m: + return m.group(0) + + return component_name + + +def parse_port_and_channel( + description: str | None, + component_name: str, + channel_index: int, + vendor: str | None = None, + device_name: str | None = None, +) -> Tuple[str, str]: + """ + 从 description 中解析 (logical_port, logical_channel),并在异常时提供安全 fallback。 + + - H3C/默认: 与现有逻辑保持一致; + - Ruijie: 使用专用正则解析 TRANSCEIVER- 前缀结构。 + """ + vendor_norm = (vendor or "").strip().lower() + + if vendor_norm in ("", "h3c"): + return _parse_port_channel_h3c_or_default(description, component_name, channel_index) + if vendor_norm == "ruijie": + return _parse_port_channel_ruijie(description, component_name, channel_index, device_name) + + # 未知厂商:给出 warning,回退到 H3C 默认策略 + logger.warning( + "Unknown vendor '%s' for device, using default H3C strategy", + vendor, + extra={"device": device_name or "-"}, + ) + return _parse_port_channel_h3c_or_default(description, component_name, channel_index) + + def _get_text(elem: ET.Element | None) -> str | None: if elem is None: return None @@ -83,12 +180,32 @@ def _parse_float(elem: ET.Element | None) -> float | None: def parse_netconf_response( xml_str: str, device_name: str, + vendor: str | None = None, ) -> Tuple[List[TransceiverRecord], List[TransceiverChannelRecord]]: """ 解析 NETCONF `` RPC 返回的 XML,生成 transceiver 与 channel 记录。 """ root = ET.fromstring(xml_str) + # vendor 参数表示“设备厂商”,用于端口/通道解析策略, + # 不应与 transceiver 模块自身的 vendor 字段混用。 + device_vendor = vendor + device_vendor_norm = (device_vendor or "").strip().lower() + + # 对疑似 Ruijie 但 vendor 未显式设置的情况给出一次性 warning(线程安全) + if device_vendor_norm in ("", "h3c"): + with _ruijie_warning_lock: + if device_name not in _ruijie_warning_issued: + if "TRANSCEIVER-" in xml_str and re.search(r"TRANSCEIVER-\d+/\d+/\d+", xml_str): + logger.warning( + "Device '%s' response looks like Ruijie (TRANSCEIVER-*), " + "but vendor is not set to 'ruijie'. Using default H3C parsing strategy; " + "labels may be suboptimal.", + device_name, + extra={"device": device_name}, + ) + _ruijie_warning_issued.add(device_name) + tx_records: List[TransceiverRecord] = [] ch_records: List[TransceiverChannelRecord] = [] @@ -111,7 +228,7 @@ def parse_netconf_response( present = _get_text( tx_state.find("oc-transceiver:present", NS) if tx_state is not None else None ) - vendor = _get_text( + module_vendor = _get_text( tx_state.find("oc-transceiver:vendor", NS) if tx_state is not None else None ) serial = _get_text( @@ -149,7 +266,7 @@ def parse_netconf_response( ) channel_elems: Iterable[ET.Element] = comp.findall(channels_path, NS) - # logical_port 以第一个 channel 的 description 为主,fallback 到 component_name + # logical_port 以第一个成功解析的 channel 为主,fallback 到 component_name logical_port_for_tx: str | None = None for ch in channel_elems: @@ -166,7 +283,11 @@ def parse_netconf_response( desc_elem = ch.find("oc-transceiver:state/oc-transceiver:description", NS) description = _get_text(desc_elem) logical_port, logical_channel = parse_port_and_channel( - description, component_name, ch_index + description, + component_name, + ch_index, + vendor=device_vendor, + device_name=device_name, ) if logical_port_for_tx is None: logical_port_for_tx = logical_port @@ -208,7 +329,12 @@ def parse_netconf_response( ) # transceiver record(逻辑端口) - logical_port_tx = logical_port_for_tx or component_name + if logical_port_for_tx is None: + logical_port_tx = parse_transceiver_port_from_component_name( + component_name, device_vendor + ) + else: + logical_port_tx = logical_port_for_tx tx_records.append( TransceiverRecord( @@ -219,7 +345,7 @@ def parse_netconf_response( oper_status=oper_status, temperature_c=temperature_c, supply_voltage_v=supply_voltage_v, - vendor=vendor, + vendor=module_vendor, serial=serial, part_number=part_number, hardware_rev=hardware_rev, diff --git a/src/exporter/scraper.py b/src/exporter/scraper.py index 3f22c18..8740680 100644 --- a/src/exporter/scraper.py +++ b/src/exporter/scraper.py @@ -112,7 +112,11 @@ def scrape_device( # 构造 filter 并调用外部提供的 RPC 函数 flt = build_transceiver_filter() xml_reply = netconf_get_rpc(mgr, flt) - tx_records, ch_records = parse_netconf_response(xml_reply, device) + tx_records, ch_records = parse_netconf_response( + xml_reply, + device, + vendor=state.cfg.vendor, + ) snapshot = DeviceMetricsSnapshot( device=device, diff --git a/src/exporter/sqlite_store.py b/src/exporter/sqlite_store.py index 96c0ded..7d36d5a 100644 --- a/src/exporter/sqlite_store.py +++ b/src/exporter/sqlite_store.py @@ -41,7 +41,7 @@ class SQLiteDeviceStore: lock: threading.Lock = field(default_factory=threading.Lock, repr=False) def init_db(self) -> None: - """初始化 DB:设置 WAL 模式并创建 devices 表.""" + """初始化 DB:设置 WAL 模式并创建/更新 devices 表.""" conn = sqlite3.connect(self.db_path, timeout=self.timeout) try: conn.execute("PRAGMA journal_mode=WAL;") @@ -57,11 +57,22 @@ class SQLiteDeviceStore: enabled INTEGER NOT NULL, scrape_interval_seconds INTEGER, supports_xpath INTEGER NOT NULL DEFAULT 0, + vendor TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); """ ) + # 防御性补列:针对旧版本 DB 中尚未包含 vendor 列的情况 + try: + conn.execute("ALTER TABLE devices ADD COLUMN vendor TEXT;") + except sqlite3.OperationalError as exc: # noqa: PERF203 + msg = str(exc).lower() + # 对于“重复列”等情况忽略,其它错误抛出 + if "duplicate column" in msg or "already exists" in msg: + pass + else: + raise conn.commit() finally: conn.close() @@ -86,11 +97,11 @@ class SQLiteDeviceStore: """ INSERT OR REPLACE INTO devices ( name, host, port, username, password_cipher, enabled, - scrape_interval_seconds, supports_xpath, + scrape_interval_seconds, supports_xpath, vendor, created_at, updated_at ) VALUES ( ?, ?, ?, ?, ?, ?, - ?, ?, + ?, ?, ?, COALESCE( (SELECT created_at FROM devices WHERE name=?), ? @@ -107,6 +118,7 @@ class SQLiteDeviceStore: int(cfg.enabled), cfg.scrape_interval_seconds, int(cfg.supports_xpath), + cfg.vendor, cfg.name, now_ts, now_ts, @@ -143,7 +155,7 @@ class SQLiteDeviceStore: """ SELECT name, host, port, username, password_cipher, - enabled, scrape_interval_seconds, supports_xpath + enabled, scrape_interval_seconds, supports_xpath, vendor FROM devices; """ ) @@ -158,8 +170,12 @@ class SQLiteDeviceStore: enabled, scrape_interval_seconds, supports_xpath, + vendor, ) in rows: password = self.encryptor.decrypt(password_cipher) + vendor_norm = None + if vendor is not None: + vendor_norm = str(vendor).strip().lower() or None dev = DeviceConfig( name=name, host=host, @@ -169,6 +185,7 @@ class SQLiteDeviceStore: enabled=bool(enabled), scrape_interval_seconds=scrape_interval_seconds, supports_xpath=bool(supports_xpath), + vendor=vendor_norm, source="runtime", ) devices.append(dev) diff --git a/tests/__pycache__/test_api_devices.cpython-312-pytest-9.0.1.pyc b/tests/__pycache__/test_api_devices.cpython-312-pytest-9.0.1.pyc index 877b5bc8165e64789950712972ecb89415569276..b641c512db927a0c2ca8697f399ca7bed3095a45 100644 GIT binary patch delta 5819 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 F9stCGtW^L2 diff --git a/tests/__pycache__/test_config.cpython-312-pytest-9.0.1.pyc b/tests/__pycache__/test_config.cpython-312-pytest-9.0.1.pyc index 181447386ccb3266aabf2112a95112f6f1671b5e..62377c559ae0a161967b533f248721b8a23f4503 100644 GIT binary patch delta 3819 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 delta 74 zcmey?$oQs^?=&wj7Xt$W!%RNS%*DJD`6L+6O;kV6Qpu>vw~2A48Iz{i7^;t)hQM*Whfq?-4DWVh3 diff --git a/tests/__pycache__/test_error_classification.cpython-312-pytest-9.0.1.pyc b/tests/__pycache__/test_error_classification.cpython-312-pytest-9.0.1.pyc index 8df7977b56fb34dbf2c2c97d616b0abcc7d499f0..9a73731848a78e47ee0c63af3e066fd5b7c7ebc1 100644 GIT binary patch delta 579 zcmaE9watm|G%qg~0|NuY;|+S5EzA@7b~1ev*?4jRzd?yINEn1uBui94Gzh0LW-_d1 z2C*0zYB^J+Y8jXqYB*~cRx?gM$RRH*S<76*RKuLD!N5?46i(_|E#{E=IZr-pMiBgBv*(aD0m^1N_<9%G91=0tu@Mn;***#i2EvXdWjm`_e% zTeSHC-%Li9N(N22&4R)$Oh&f^gHwx(GxPJDbMlK*U5kqHi*B(cgV-szL@}g7DhpC4 zPZgDC6rQ|QbdT^YmXgYX)LWdc70IauC7Jnonp~5M#mc#hq!<_&ia`OQF!{Y$Ew>Q^ z0|O^WXg}-ba&cpp`Y&t@vWgcJO}12S$T{G2S;qYWi{uRhiwi7)7p3$!a9ozMyucFJ zV|PKy@-ny81(v`Ic7Y(0+vntF2jV15`SOmg_lNUi+A-u~7CPd&mOW-Az zz>o`4`VjtQmOwcB3QOPy4v6q2kgz3!&25EX+kxzcaFNLelUGZhWt5*>DPzniJ9({4 sfegskD;bK685kIf3>g?0esS33=BJeAq}mnPO}3O(=j37ZVFXJ90EZv7e*gdg delta 166 zcmdny^wNs&G%qg~0|Ntto{DDXI;M$yJDFUBHlAF-&y*rLSyot|QEKu-4)e(gY>PHu z;G4Gh4IGZ3i-2)GLzjDj2WdSS1A<8fRwCcDAHqKU?|dIU|{&gVUwGmQks)$S7bSv OO;MebozaI8EDZoRtt+hn diff --git a/tests/__pycache__/test_http_e2e_exporter.cpython-312-pytest-9.0.1.pyc b/tests/__pycache__/test_http_e2e_exporter.cpython-312-pytest-9.0.1.pyc index 8e847e6bcb8516126a0632b1227de301f1e4192e..ed3459482cf2e4542854241eec16adaa6ac5f14a 100644 GIT binary patch delta 6063 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 delta 3424 zcmcbzlkxuqM!wU$yj%`h#b67OE zQpBnl7#M1KQp6|EV#!pMSR=Wb5u}lUA%=;8p_Z?fzr+~AXJAN?s^Oc>kRm-JMyy6WjcGLt#3d;zlNYenGpbBhU|-0qI)}B2je((7GDR&#eeyYW zcSn&5###^-tzfJbt6;2^s1?s+=46<_*mE*PGes*!J4Gi&H$^Z-Zw_0nbc%ioTZ+LP zrdk;$h8pe^!xWewNM?!^V}N8A z)mpV0)vVCT2NQV)T-C2)~cnr)GF4fXUQ;NfLe_d*ILaK@mg^vh8m3; z%^LC5Ob|u2S~ZF_T4fBivNf`kH^?Y*yMd$39V9xrfJc*C5$3QI4^X;dRG(}hpvkKa z6U}2x@tCZ@F3+ej*@0c1QFHPHc7w?t?DCUmOK?i0c+O!1Cnc#AuN3bZDQH}4gW_6S z8)gKIhWZ>F*Au6k;jrvqN^l$ECfg}WGbT>Hz#`8Gv4s`cmPm$5k;(HE_2mh>Q)V)U zk}JdvSrnI+GvK$8nRSiie{JN2~8-w3{rdF`rEnB%n2Umqr<**;g+z&eUcii*IVDgkK&`K*d3sj?)sSfNJ2FF!A}NN@67U1>(c$vbsr7)v%^(@kWY zY@?sds5^Oqehj1IfE)B$t*6p#h87Iv8*U-vX`Mf3&<^#CmQOq z7de2;m!14TU6!Zl1Oo$uAOizKu@L*@PliUi91JqLH`Fz5D68Di(7Ge9w7`6J%?){_ z8kF3nXTpjEmIVRhho9Co-l+6(Cti8^Wa-AdPDhHSg;lRm@ zAW;bKGJ<)9BV~p#MD#jG$^`|V6JaMjE`&s1h)K8*oqRzKWFB1lI!DSij+Dv!Ocgzh z7WiG@FuK66u)yykztMFL5V{H#fbcGZc@Xv`4xEi0xaa~X4Y4vXFeoS} zC^Qr~Ot$q4XI!v(svj34H@H~f1|{Z#$*cU8nToqoDi-DmCEU5)j zp#viHL4+m9L=LElAXCBVw#aevwm>_k8w``b1-5ZJF)%Q^v;nEsn%o{_!RR{qK#)P8 z8%V$%M0kLRV2~nq5GxHtc!7v`5CO_(MF}8QI*0&e`J!A9D+5I2fe1Aakw4inSXtef zfq_Aj5u_4Sb~|Wt6kP*Jg7f)xP$)2G=9Ls_PwozutS9W?fM=h+6|9>Oe$2 zh-d&2jUb|lfq|K!sChC+h*|w~kiZNOF#$wO1QC-!#AFaL1w>2*5z|1#To5r2M9c>f z3qZs|5U~j45!s@}AU3FKEQ$nqfTgrJwWuf>BzO}nMY<6|R;1upq^fmoYB#BLC=2SkA5V;_hGPH?iIVsN6N z46{R#!{oS74c6xj3=DmnyF+)eGCrK_7Ol=WX>wk)u{_xKeIRY1h$sSkvKho}1rZ&S z4@9f5JpnN*Cclc-(*_law>UD4ljCzT%TkL#hOT7z42pJe8vMm!lbfGXnv-f*^lh?p hjF(U)Bcs}9W(Fp)4@@BDhYIG&^J5HIzA`W{006svU^M^$ diff --git a/tests/__pycache__/test_logging_utils.cpython-312-pytest-9.0.1.pyc b/tests/__pycache__/test_logging_utils.cpython-312-pytest-9.0.1.pyc index 0764dad4f9334365092b4e05c3dc0d59dabaff64..a475b711cc7540b7ae17fc12491ab58c5e322c71 100644 GIT binary patch delta 1496 zcmX?Q`No6qG%qg~0|NsC<3_zqPu_`q5{zvV)f2S2Q`lNqq6AZTQrKHqqJ&a-QaD;z zqJ&d;QaD>!qC`@7Qn*@JqC_j1G_IovcC9fuFotC3=B=$x{SXjvq%^* zuAFQxQNtKMd5gpX#`w*ll2S}e$&8afaEYWCFf%YrXQ*LFVXWe2V5ns*VFWn?gj1MO zm`j*JED)|?Tn&{;VM$?K!?v0gB+9^0%TxkoF)-9H!PGD^l(0kO85puSCQoFM_GGSM z&H{T8CJv!p82VX~nbH}OnPN2=7;0Hccp>5p3^gnzY%n&m&dJ{yWtEvAGR2HN8Z|5v znR>KY7;0H-S!!5|Q&ZSeI8r!E1Sk6m%S?6<=ACTEDm&SLhl{01v_y2Wov7?&c_B`T zJjN8xOvWlU28LR;5^0EO3=AcrlOM9mPR2caTUI%-5;>T1nBpQyuv26tFs1SsQ+U9VvXf5>axy`g z|5!Q2Qh4VugB{F??%;Y(n87f*i7|z*g0YspmZOp{nTe4hnW>hgoI#Vn)SrQY;pyJV zFI#(`?_H;mlb;@+mYI{P@ND|F_j`Jt?waznWA@YCyPi#Jf3kaa${g%C~^l89t;c&w}fEU#^)wh#3xmjq!!;2g^ML6CTEux#3$#M=9N_O zB^DQ_7L_QdY7|r&XciZFf%Nl&oE#5zTyZiZ!{lvZrS(OAAPYeL(_}6R0;yxnC<J9Vg&_-A_cRwRfB1UXkYD8D4JBr`AFEi)(80}}K_ zMj&Zp5TOquOhAMoh%f~aW*`D&k0#eGj*{Gh`1*pxl8hoyC>OiD6MJNZ1xc*n#{7%A5*t01m8M%0z@yQ8ohuLjcGGS4M_y&)uVLrnUPpvZ)X$+9=3$Ni^vmhq3hf- z7rA9FbIUcjd|+b`k(h2f(e@iNlPKE_KK>Gf>!pa9FBoG&BA gRSqh!K^`}mJXuDU|FeuFBik1X2}ZUeDFy}x00Sy^lK=n! delta 163 zcmaFkamteKG%qg~0|NuY6$Q;qHLi(#5{z{d)f1GtQrKEpq6Aa8Q`lQrqJ&boQ#e{! zqJ&epQ#e~#qC_g0GfuTuTm$7OxkAxAU=45+`8phzs zdn6VxMs1Fjlwz99uI$1S&A`BLi^VTLFI7`?a=7w(#;D0WDpFi&3=9m63=9m#`jZV* Rbh*CBNHVe&Nii@m008KhCT#!! diff --git a/tests/test_api_devices.py b/tests/test_api_devices.py index 690d561..5e9941a 100644 --- a/tests/test_api_devices.py +++ b/tests/test_api_devices.py @@ -5,7 +5,7 @@ import sqlite3 import pytest from fastapi.testclient import TestClient -from exporter.api import create_app +from exporter.api import create_app, DeviceIn from exporter.config import DeviceConfig, GlobalConfig from exporter.metrics import TransceiverCollector from exporter.models import DeviceHealthState, DeviceMetricsSnapshot @@ -46,6 +46,17 @@ def app_with_registry(global_cfg) -> Tuple[TestClient, DeviceRegistry]: return _make_app_and_registry(global_cfg) +def test_devicein_vendor_validator_accepts_none(): + # vendor 省略时应保持为 None,走 validator 的 None 分支 + d = DeviceIn( + name="dev1", + host="192.0.2.1", + username="u", + password="p", + ) + assert d.vendor is None + + def test_get_devices_requires_auth(app_with_registry): client, _ = app_with_registry resp = client.get("/api/v1/devices") @@ -86,6 +97,35 @@ def test_post_device_creates_runtime_device(app_with_registry): assert any(d.name == "new-device" and d.source == "runtime" for d in devices) +def test_post_device_accepts_vendor_and_normalizes(app_with_registry): + client, registry = app_with_registry + + device_data = { + "name": "rj-dev", + "host": "192.168.1.200", + "port": 830, + "username": "admin", + "password": "secret", + "enabled": True, + "vendor": " Ruijie ", + } + + resp = client.post( + "/api/v1/devices", + headers={"X-API-Token": "changeme"}, + json=device_data, + ) + assert resp.status_code == 201 + body = resp.json() + # API 返回的 vendor 应已被 strip + lower + assert body["vendor"] == "ruijie" + + # registry 中也应保存规范化后的 vendor + devices = registry.list_devices() + dev = next(d for d in devices if d.name == "rj-dev") + assert dev.vendor == "ruijie" + + def test_post_duplicate_device_returns_409(app_with_registry): client, _ = app_with_registry @@ -152,6 +192,7 @@ def test_delete_static_device_fails(app_with_registry): port=830, username="u", password="p", + vendor="h3c", source="static", ) registry.register_static_device(static_dev) @@ -181,3 +222,24 @@ def test_metrics_endpoint_returns_prometheus_format(app_with_registry): assert "# HELP" in resp.text assert "netconf_scrape_success" in resp.text + +def test_get_devices_when_api_token_disabled(tmp_path): + # 当 global.api_token 为空时,/api/v1/devices 不应要求鉴权 + gc = GlobalConfig() + gc.api_token = "" + gc.runtime_db_path = str(tmp_path / "devices.db") + gc.password_secret = VALID_FERNET_KEY + + encryptor = PasswordEncryptor(gc.password_secret) + store = SQLiteDeviceStore(gc.runtime_db_path, encryptor) + store.init_db() + + registry = DeviceRegistry(global_scrape_interval=gc.scrape_interval_seconds) + cache: dict[str, DeviceMetricsSnapshot] = {} + health: dict[str, DeviceHealthState] = {} + collector = TransceiverCollector(cache, health) + app = create_app(registry, store, collector, gc) + client = TestClient(app) + + resp = client.get("/api/v1/devices") + assert resp.status_code == 200 diff --git a/tests/test_api_sqlite_errors.py b/tests/test_api_sqlite_errors.py new file mode 100644 index 0000000..577b809 --- /dev/null +++ b/tests/test_api_sqlite_errors.py @@ -0,0 +1,86 @@ +import sqlite3 +from typing import Any, Dict, List + +from fastapi.testclient import TestClient + +from exporter.api import create_app +from exporter.config import DeviceConfig, GlobalConfig +from exporter.metrics import TransceiverCollector +from exporter.models import DeviceHealthState, DeviceMetricsSnapshot +from exporter.registry import DeviceRegistry + + +VALID_FERNET_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + + +class DummyStore: + """简单的测试用存储,实现 API 需要的接口,并在指定操作上抛 OperationalError。""" + + def __init__(self, fail_on: str | None = None) -> None: + self.fail_on = fail_on + + # 与 SQLiteDeviceStore 接口对齐 + def init_db(self) -> None: # pragma: no cover - 在这些测试中不会调用 + return + + def load_runtime_devices(self) -> List[DeviceConfig]: + return [] + + def save_device(self, cfg: DeviceConfig) -> None: + if self.fail_on == "save": + raise sqlite3.OperationalError("database is locked") + + def delete_device(self, name: str) -> None: + if self.fail_on == "delete": + raise sqlite3.OperationalError("database is locked") + + def close(self) -> None: # pragma: no cover - 这里无需验证 + return + + +def _build_app_with_dummy_store(fail_on: str | None) -> TestClient: + gc = GlobalConfig() + gc.api_token = "token" + gc.runtime_db_path = ":memory:" + gc.password_secret = VALID_FERNET_KEY + + registry = DeviceRegistry(global_scrape_interval=gc.scrape_interval_seconds) + cache: Dict[str, DeviceMetricsSnapshot] = {} + health: Dict[str, DeviceHealthState] = {} + collector = TransceiverCollector(cache, health) + + store = DummyStore(fail_on=fail_on) + app = create_app(registry, store, collector, gc) + return TestClient(app) + + +def test_post_device_sqlite_operational_error_returns_503(): + client = _build_app_with_dummy_store(fail_on="save") + + payload = { + "name": "dev-save-error", + "host": "192.0.2.10", + "port": 830, + "username": "u", + "password": "p", + "enabled": True, + } + resp = client.post( + "/api/v1/devices", + headers={"X-API-Token": "token"}, + json=payload, + ) + assert resp.status_code == 503 + assert "database is locked" in resp.json()["detail"] + + +def test_delete_device_sqlite_operational_error_returns_503(): + client = _build_app_with_dummy_store(fail_on="delete") + + resp = client.delete( + "/api/v1/devices/nonexistent", + headers={"X-API-Token": "token"}, + ) + assert resp.status_code == 503 + assert "database is locked" in resp.json()["detail"] + diff --git a/tests/test_config.py b/tests/test_config.py index 821438e..8818382 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,4 +1,5 @@ from pathlib import Path +import base64 import pytest @@ -58,6 +59,31 @@ def test_config_invalid_fernet_key_raises(): Config.from_dict(data) +def test_config_missing_password_secret_raises(): + data = { + "global": { + "runtime_db_path": "./devices.db", + # password_secret 缺失 + } + } + with pytest.raises(ValueError, match="global.password_secret must be configured"): + Config.from_dict(data) + + +def test_config_invalid_fernet_key_length_raises(): + # 构造一个合法 base64 但长度不是 32 字节的 key + bad_key_bytes = b"too-short" + bad_key = base64.urlsafe_b64encode(bad_key_bytes).decode() + data = { + "global": { + "runtime_db_path": "./devices.db", + "password_secret": bad_key, + } + } + with pytest.raises(ValueError, match="Invalid Fernet key length"): + Config.from_dict(data) + + def test_shutdown_timeout_too_small_warns(): data = { "global": { @@ -84,3 +110,39 @@ def test_shutdown_timeout_too_small_warns(): with pytest.warns(UserWarning): Config.from_dict(data) + +def test_deviceconfig_vendor_parsed_and_normalized_from_yaml(tmp_path: Path): + yaml_content = f""" + global: + runtime_db_path: "./devices.db" + password_secret: "{VALID_FERNET_KEY}" + devices: + - name: rj-1 + host: 192.0.2.10 + port: 830 + username: u + password: p + enabled: true + supports_xpath: false + vendor: " Ruijie " + - name: h3c-1 + host: 198.51.100.10 + port: 830 + username: u2 + password: p2 + enabled: true + supports_xpath: false + """ + cfg_file = tmp_path / "config_vendor.yaml" + cfg_file.write_text(yaml_content) + + cfg = Config.from_file(cfg_file) + assert len(cfg.devices) == 2 + + rj = next(d for d in cfg.devices if d.name == "rj-1") + h3c = next(d for d in cfg.devices if d.name == "h3c-1") + + # vendor 显式配置应被 strip + lower + assert rj.vendor == "ruijie" + # 未配置 vendor 时应为 None + assert h3c.vendor is None diff --git a/tests/test_connection.py b/tests/test_connection.py index 7ac789f..67af384 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -123,3 +123,21 @@ def test_close_all_closes_all_sessions(monkeypatch, global_cfg): assert mgr_instances[0].closed is True assert mgr_instances[1].closed is True + +def test_acquire_session_reuses_existing_manager(monkeypatch, global_cfg, device_cfg): + calls: list[dict] = [] + + def fake_connect(**kwargs): + calls.append(kwargs) + return DummyManager() + + monkeypatch.setattr("exporter.connection.ncclient.manager.connect", fake_connect) + + cm = ConnectionManager(global_cfg) + # 第一次会触发 connect + sess1 = cm.acquire_session(device_cfg) + # 第二次在会话仍有效时应复用,不再调用 connect + sess2 = cm.acquire_session(device_cfg) + + assert sess1 is sess2 + assert len(calls) == 1 diff --git a/tests/test_error_classification.py b/tests/test_error_classification.py index c9ba711..dbea5c1 100644 --- a/tests/test_error_classification.py +++ b/tests/test_error_classification.py @@ -13,4 +13,9 @@ def test_classify_error_from_exception(): assert classify_error(ET.ParseError()) == "XMLParseError" assert classify_error(PermissionError()) == "AuthenticationError" assert classify_error(RuntimeError("filter failed")) == "FilterError" + + # SessionCloseError / SessionError 通过类名匹配 + SessionCloseErrorType = type("SessionCloseError", (Exception,), {}) + assert classify_error(SessionCloseErrorType("closed")) == "SessionCloseError" + assert classify_error(RuntimeError("something else")) == "UnknownError" diff --git a/tests/test_http_e2e_exporter.py b/tests/test_http_e2e_exporter.py index d470265..5fafe8b 100644 --- a/tests/test_http_e2e_exporter.py +++ b/tests/test_http_e2e_exporter.py @@ -29,7 +29,8 @@ def test_exporter_http_end_to_end(tmp_path) -> None: config: Dict[str, Any] = { "global": { - "http_listen": "127.0.0.1:19100", + # 使用 29200 端口,避免与独立部署冲突 + "http_listen": "127.0.0.1:29200", "scrape_interval_seconds": 2, "rpc_timeout_seconds": 2, "shutdown_timeout_seconds": 10, @@ -57,12 +58,12 @@ def test_exporter_http_end_to_end(tmp_path) -> None: text=True, ) - base_url = "http://127.0.0.1:19100" + base_url = "http://127.0.0.1:29200" def _http_request(path: str, method: str = "GET", body: bytes | None = None, headers: Dict[str, str] | None = None): import http.client - conn = http.client.HTTPConnection("127.0.0.1", 19100, timeout=5) + conn = http.client.HTTPConnection("127.0.0.1", 29200, timeout=5) try: conn.request(method, path, body=body, headers=headers or {}) resp = conn.getresponse() @@ -93,29 +94,72 @@ def test_exporter_http_end_to_end(tmp_path) -> None: # server 可能尚未 ready,稍后重试 time.sleep(0.5) - # 4. 通过 API 注册一个 runtime 设备 - device_payload = { - "name": "e2e-device-1", + # 4. 通过 API 注册两个 runtime 设备:一个 H3C、一个 Ruijie + # 使用带时间戳的名称,避免受残留 runtime DB 状态影响 + base_name = f"e2e-{int(time.time() * 1000)}" + h3c_name = f"{base_name}-h3c" + ruijie_name = f"{base_name}-ruijie" + + headers = { + "Content-Type": "application/json", + "X-API-Token": "changeme", + } + + # H3C 设备(不显式设置 vendor 或设置为 h3c) + h3c_payload = { + "name": h3c_name, "host": "192.0.2.10", "port": 830, "username": "netconf_user", "password": "secret", "enabled": True, - } - headers = { - "Content-Type": "application/json", - "X-API-Token": "changeme", + "vendor": "h3c", } status, _, data = _http_request( "/api/v1/devices", method="POST", - body=json.dumps(device_payload).encode("utf-8"), + body=json.dumps(h3c_payload).encode("utf-8"), headers=headers, ) - assert status == 201, f"unexpected status for POST /api/v1/devices: {status}, body={data!r}" + assert status == 201, f"unexpected status for POST /api/v1/devices (h3c): {status}, body={data!r}" body_json = json.loads(data.decode("utf-8")) - assert body_json["name"] == "e2e-device-1" + assert body_json["name"] == h3c_name assert body_json["source"] == "runtime" + assert body_json.get("vendor") == "h3c" + + # Ruijie 设备(vendor 应被规范化为 ruijie) + ruijie_payload = { + "name": ruijie_name, + "host": "192.0.2.11", + "port": 830, + "username": "ruijie", + "password": "secret", + "enabled": True, + "vendor": " Ruijie ", + } + status, _, data = _http_request( + "/api/v1/devices", + method="POST", + body=json.dumps(ruijie_payload).encode("utf-8"), + headers=headers, + ) + assert status == 201, f"unexpected status for POST /api/v1/devices (ruijie): {status}, body={data!r}" + body_json = json.loads(data.decode("utf-8")) + assert body_json["name"] == ruijie_name + assert body_json["source"] == "runtime" + assert body_json.get("vendor") == "ruijie" + + # 验证 GET /api/v1/devices 能同时看到 H3C 和 Ruijie 两个设备 + status, _, data = _http_request( + "/api/v1/devices", + method="GET", + headers=headers, + ) + assert status == 200 + devices = json.loads(data.decode("utf-8")) + names = {d["name"] for d in devices} + assert h3c_name in names + assert ruijie_name in names # 5. 访问 /metrics,验证 Prometheus 输出可用 status, headers_list, data = _http_request("/metrics") diff --git a/tests/test_logging_utils.py b/tests/test_logging_utils.py index 92797f0..5d3ba31 100644 --- a/tests/test_logging_utils.py +++ b/tests/test_logging_utils.py @@ -75,3 +75,30 @@ def test_init_logging_configures_root_logger_handlers() -> None: for handler in root.handlers for flt in handler.filters ) + + +def test_init_logging_with_file_handler(tmp_path) -> None: + """当配置 log_file 时,应创建文件 handler 并挂载 DeviceFieldFilter。""" + log_file = tmp_path / "exporter.log" + gc = GlobalConfig( + log_level="INFO", + log_to_stdout=False, + log_file=str(log_file), + log_file_max_bytes=1024, + log_file_backup_count=1, + ) + + init_logging(gc) + + root = logging.getLogger() + # 应至少存在一个 RotatingFileHandler + file_handlers = [ + h for h in root.handlers if isinstance(h, logging.handlers.RotatingFileHandler) + ] + assert file_handlers + # 并且这些 handler 上也应安装 DeviceFieldFilter + assert any( + isinstance(flt, DeviceFieldFilter) + for h in file_handlers + for flt in h.filters + ) diff --git a/tests/test_netconf_parser_vendor_none_ruijie.py b/tests/test_netconf_parser_vendor_none_ruijie.py new file mode 100644 index 0000000..156d4af --- /dev/null +++ b/tests/test_netconf_parser_vendor_none_ruijie.py @@ -0,0 +1,58 @@ +import xml.etree.ElementTree as ET + +import pytest + +from exporter.netconf_client import parse_netconf_response + + +RUJIE_SAMPLE_XML = """\ + + + + + TRANSCEIVER-1/0/129-FH0/1:1 + + TRANSCEIVER + + + + ABC123 + + + + 1 + + TRANSCEIVER-1/0/129/1-FH0/1:1 + + + + + + + + +""" + + +def test_vendor_none_with_ruijie_sample_uses_h3c_strategy(): + txs, chs = parse_netconf_response(RUJIE_SAMPLE_XML, "dev-rj", vendor=None) + assert len(chs) == 1 + ch = chs[0] + # H3C 默认策略:冒号前为端口 + assert ch.logical_port == "TRANSCEIVER-1/0/129/1-FH0/1" + assert ch.logical_channel == "TRANSCEIVER-1/0/129/1-FH0/1:1" + + +def test_vendor_none_to_ruijie_changes_labels(): + _, chs_none = parse_netconf_response(RUJIE_SAMPLE_XML, "dev-rj", vendor=None) + _, chs_ruijie = parse_netconf_response(RUJIE_SAMPLE_XML, "dev-rj", vendor="ruijie") + + assert len(chs_none) == len(chs_ruijie) == 1 + ch_none = chs_none[0] + ch_rj = chs_ruijie[0] + + assert ch_none.logical_port.startswith("TRANSCEIVER-") + # Ruijie 策略应清洗出短端口 FH0/1 + assert ch_rj.logical_port == "FH0/1" + assert ch_rj.logical_channel == "FH0/1:1" + diff --git a/tests/test_ruijie_live_netconf.py b/tests/test_ruijie_live_netconf.py new file mode 100644 index 0000000..dc9d27b --- /dev/null +++ b/tests/test_ruijie_live_netconf.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +""" +与真实 Ruijie 设备联调的“活体”测试用例。 + +说明: +- 连接参数通过环境变量注入(你已经在 .env 中配置): + - RUIJIE_NETCONF_HOST + - RUIJIE_NETCONF_PORT + - RUIJIE_NETCONF_USER + - RUIJIE_NETCONF_PASSWORD + +默认行为: +- 若未设置 RUIJIE_NETCONF_PASSWORD,或无法建立到指定 host:port 的 TCP 连接, + 则使用 pytest.skip() 自动跳过,不影响普通单元测试/CI。 +- 仅在本地联调时、显式设置上述环境变量后,此测试才会真正访问设备。 +""" + +import os +import socket + +import pytest +from ncclient import manager + +from exporter.netconf_client import build_transceiver_filter, parse_netconf_response + + +RUIJIE_HOST = os.getenv("RUIJIE_NETCONF_HOST", "127.0.0.1") +RUIJIE_PORT = int(os.getenv("RUIJIE_NETCONF_PORT", "9830")) +RUIJIE_USER = os.getenv("RUIJIE_NETCONF_USER", "ruijie1-admin") +RUIJIE_PASSWORD = os.getenv("RUIJIE_NETCONF_PASSWORD", "") + + +def _can_connect(host: str, port: int, timeout: float = 2.0) -> bool: + """快速探测 host:port 是否可连,用于决定是否跳过 live 测试。""" + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.settimeout(timeout) + sock.connect((host, port)) + return True + except OSError: + return False + finally: + sock.close() + + +@pytest.mark.ruijie_live +def test_ruijie_live_transceiver_rpc_and_parse() -> None: + """ + 使用真实 Ruijie 设备验证: + - ncclient 能与设备建立 NETCONF 会话; + - build_transceiver_filter() 构造的 subtree filter 在设备上可用; + - parse_netconf_response(..., vendor='ruijie') 能正确解析设备返回的 XML。 + """ + if not RUIJIE_PASSWORD: + pytest.skip("RUIJIE_NETCONF_PASSWORD 未设置,跳过 Ruijie live 测试") + + if not _can_connect(RUIJIE_HOST, RUIJIE_PORT): + pytest.skip(f"Ruijie NETCONF {RUIJIE_HOST}:{RUIJIE_PORT} 不可达,跳过 live 测试") + + flt = build_transceiver_filter() + + with manager.connect( + host=RUIJIE_HOST, + port=RUIJIE_PORT, + username=RUIJIE_USER, + password=RUIJIE_PASSWORD, + hostkey_verify=False, + timeout=30, + allow_agent=False, + look_for_keys=False, + ) as m: + reply = m.get(filter=("subtree", flt)) + xml_str = str(reply) + + # vendor="ruijie" 走厂商感知解析路径 + transceivers, channels = parse_netconf_response( + xml_str, + device_name=f"ruijie-{RUIJIE_HOST}", + vendor="ruijie", + ) + + # 只要返回非空结果,就说明 "连接 + filter + 解析" 在真实设备上可以工作 + assert transceivers or channels, "Ruijie 设备未返回任何 transceiver/channel 数据" + + # 至少有一个 transceiver 拥有对应的 channel + tx_by_component = {t.component_name: t for t in transceivers} + ch_by_component = {} + for ch in channels: + ch_by_component.setdefault(ch.component_name, []).append(ch) + + has_tx_with_channel = any( + comp in tx_by_component and len(ch_list) > 0 + for comp, ch_list in ch_by_component.items() + ) + assert has_tx_with_channel, ( + "Ruijie live 数据中未发现“同时存在 transceiver 与 channel”的组件," + "请检查设备返回的 transceiver/physical-channels 数据是否完整" + ) + + # 至少有一个 channel 具有 rx 或 tx power 数值 + channels_with_power = [ + ch + for ch in channels + if ch.rx_power_dbm is not None or ch.tx_power_dbm is not None + ] + assert channels_with_power, ( + "Ruijie live 数据中未发现带 rx/tx power 的通道," + "请检查设备是否开启了相关光功率采集" + ) + + # 额外 sanity 检查:至少有一个端口 label 不以 TRANSCEIVER- 开头,验证清洗逻辑生效 + ports = {t.logical_port for t in transceivers} | {c.logical_port for c in channels} + assert any(not p.startswith("TRANSCEIVER-") for p in ports), ( + "Ruijie live 数据中未发现清洗后的端口名," + "请检查 vendor='ruijie' 解析逻辑是否生效" + ) diff --git a/tests/test_sqlite_vendor_column.py b/tests/test_sqlite_vendor_column.py new file mode 100644 index 0000000..e0503d1 --- /dev/null +++ b/tests/test_sqlite_vendor_column.py @@ -0,0 +1,160 @@ +import sqlite3 +from pathlib import Path + +import pytest + +from exporter.config import DeviceConfig +from exporter.sqlite_store import PasswordEncryptor, SQLiteDeviceStore + + +@pytest.fixture +def encryptor() -> PasswordEncryptor: + # 生成一个有效 Fernet key + from cryptography.fernet import Fernet + + key = Fernet.generate_key().decode() + return PasswordEncryptor(key) + + +def test_init_db_creates_vendor_column_on_fresh_db(tmp_path: Path, encryptor: PasswordEncryptor): + db_path = tmp_path / "test_vendor.db" + store = SQLiteDeviceStore(str(db_path), encryptor) + store.init_db() + + conn = sqlite3.connect(str(db_path)) + cols = [row[1] for row in conn.execute("PRAGMA table_info(devices)").fetchall()] + conn.close() + + assert "vendor" in cols + + +def test_init_db_alter_table_vendor_preserves_existing_rows(tmp_path: Path, encryptor: PasswordEncryptor): + db_path = tmp_path / "legacy.db" + conn = sqlite3.connect(str(db_path)) + # 创建旧版本 devices 表(无 vendor 列) + conn.execute( + """ + CREATE TABLE devices ( + name TEXT PRIMARY KEY, + host TEXT NOT NULL, + port INTEGER NOT NULL, + username TEXT NOT NULL, + password_cipher BLOB NOT NULL, + enabled INTEGER NOT NULL, + scrape_interval_seconds INTEGER, + supports_xpath INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + """ + ) + conn.execute( + "INSERT INTO devices (name, host, port, username, password_cipher, enabled, " + "scrape_interval_seconds, supports_xpath, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ("old-dev", "h", 830, "u", b"cipher", 1, None, 0, 1, 1), + ) + conn.commit() + conn.close() + + store = SQLiteDeviceStore(str(db_path), encryptor) + store.init_db() + + conn2 = sqlite3.connect(str(db_path)) + row = conn2.execute("SELECT name, vendor FROM devices WHERE name = 'old-dev'").fetchone() + conn2.close() + + assert row is not None + assert row[0] == "old-dev" + # 旧数据 vendor 应为空 + assert row[1] is None + + +def test_save_and_load_device_persists_vendor(tmp_path: Path, encryptor: PasswordEncryptor): + db_path = tmp_path / "vendor_persist.db" + store = SQLiteDeviceStore(str(db_path), encryptor) + store.init_db() + + dev = DeviceConfig( + name="dev-vendor", + host="h", + port=830, + username="u", + password="p", + enabled=True, + vendor="ruijie", + source="runtime", + ) + store.save_device(dev) + loaded = store.load_runtime_devices() + + assert len(loaded) == 1 + assert loaded[0].name == "dev-vendor" + assert loaded[0].vendor == "ruijie" + + +def test_save_and_load_device_vendor_none_roundtrip(tmp_path: Path, encryptor: PasswordEncryptor): + db_path = tmp_path / "vendor_none.db" + store = SQLiteDeviceStore(str(db_path), encryptor) + store.init_db() + + dev = DeviceConfig( + name="dev-no-vendor", + host="h", + port=830, + username="u", + password="p", + enabled=True, + vendor=None, + source="runtime", + ) + store.save_device(dev) + loaded = store.load_runtime_devices() + + assert len(loaded) == 1 + assert loaded[0].name == "dev-no-vendor" + assert loaded[0].vendor is None + + +def test_init_db_alter_table_silently_ignores_duplicate_column_error(tmp_path: Path, encryptor: PasswordEncryptor): + db_path = tmp_path / "dup_col.db" + store = SQLiteDeviceStore(str(db_path), encryptor) + # 第一次初始化,创建带 vendor 列的表 + store.init_db() + # 第二次调用,不应抛异常 + store.init_db() + + +def test_init_db_alter_table_raises_on_non_duplicate_errors(tmp_path: Path, encryptor: PasswordEncryptor, monkeypatch): + db_path = tmp_path / "locked.db" + + real_connect = sqlite3.connect + + class ConnWrapper: + def __init__(self, inner: sqlite3.Connection) -> None: + self._inner = inner + self._alter_attempted = False + + def execute(self, sql: str, *args, **kwargs): + # 在第一次尝试 ALTER TABLE 时注入错误 + if "ALTER TABLE devices ADD COLUMN vendor" in sql and not self._alter_attempted: + self._alter_attempted = True + raise sqlite3.OperationalError("database is locked") + return self._inner.execute(sql, *args, **kwargs) + + def commit(self) -> None: + return self._inner.commit() + + def close(self) -> None: + return self._inner.close() + + def wrapped_connect(*args, **kwargs): + conn = real_connect(*args, **kwargs) + return ConnWrapper(conn) + + monkeypatch.setattr(sqlite3, "connect", wrapped_connect) + + store = SQLiteDeviceStore(str(db_path), encryptor) + with pytest.raises(sqlite3.OperationalError): + store.init_db() +