From 792f5284f631c7f6bcb669f7c5dff24a1fef75e8 Mon Sep 17 00:00:00 2001 From: Artem Kotik Date: Sun, 12 May 2024 23:50:01 +0800 Subject: [PATCH 1/2] Version 2.4.0 --- .github/workflows/commit.yaml | 2 +- docs/colliecting-diffs.md | 47 +++++++++--- docs/media/screenshots/compliance-patch.png | Bin 0 -> 26518 bytes netbox_config_diff/api/serializers.py | 1 + netbox_config_diff/compliance/base.py | 5 +- netbox_config_diff/compliance/utils.py | 17 +++++ netbox_config_diff/configurator/base.py | 5 +- .../migrations/0009_configcompliance_patch.py | 16 ++++ netbox_config_diff/models/data_models.py | 2 + netbox_config_diff/models/models.py | 3 + .../configcompliance/config.html | 3 +- .../configcompliance/missing_extra.html | 32 +------- .../configcompliance/patch.html | 11 +++ .../netbox_config_diff/inc/commands_card.html | 16 ++++ netbox_config_diff/views/base.py | 41 ++++++++++- netbox_config_diff/views/compliance.py | 69 ++++++++---------- requirements/base.txt | 1 + tests/test_compliance.py | 1 + 18 files changed, 185 insertions(+), 87 deletions(-) create mode 100644 docs/media/screenshots/compliance-patch.png create mode 100644 netbox_config_diff/migrations/0009_configcompliance_patch.py create mode 100644 netbox_config_diff/templates/netbox_config_diff/configcompliance/patch.html create mode 100644 netbox_config_diff/templates/netbox_config_diff/inc/commands_card.html diff --git a/.github/workflows/commit.yaml b/.github/workflows/commit.yaml index 23b3ca3..6a12548 100644 --- a/.github/workflows/commit.yaml +++ b/.github/workflows/commit.yaml @@ -31,7 +31,7 @@ jobs: strategy: max-parallel: 10 matrix: - netbox_version: ["v3.5.9", "v3.6.9", "v3.7.5"] + netbox_version: ["v3.5.9", "v3.6.9", "v3.7.8"] steps: - name: Checkout uses: actions/checkout@v3 diff --git a/docs/colliecting-diffs.md b/docs/colliecting-diffs.md index b5cd95f..565c0bf 100644 --- a/docs/colliecting-diffs.md +++ b/docs/colliecting-diffs.md @@ -63,6 +63,41 @@ After script is done you can find results in `Config Compliances` menu. Each dev Also result is storing rendered and actual configurations from devices. +Compliance finished with error + +![Screenshot of the compliance error](media/screenshots/compliance-error.png) + +Render diff between configurations + +![Screenshot of diff](media/screenshots/compliance-diff.png) + +No diff + +![Screenshot of the compliance ok](media/screenshots/compliance-ok.png) + +### Patch commands + +With [hier_config](https://github.com/netdevops/hier_config) library you are able to take a actual configuration of a network device, compare it to its rendered configuration, + and build the remediation steps necessary to bring a device into spec with its intended configuration. + +![Screenshot of the patch commands](media/screenshots/compliance-patch.png) + +Supported platforms: + +* Arista EOS (arista_eos) +* Cisco IOS-XE (cisco_iosxe) +* Cisco IOS-XR (cisco_iosxr) +* Cisco NX-OS (cisco_nxos) + +However, any NOS that utilizes a CLI syntax that is structured in a similar fasion to IOS should work mostly out of the box. + +NOS's that utilize a `set` based CLI syntax has been added as experimental functionality: + +* Juniper JunOS (juniper_junos) +* VyOS (vyos_vyos) + +### Missing/extra + With the help of [netutils](https://github.com/networktocode/netutils) library plugin stores missing and extra config lines. ![Screenshot of the missing/extra lines](media/screenshots/compliance-missing-extra.png) @@ -81,15 +116,3 @@ Supported platforms for missing/extra lines: * Nokia SROS (nokia_sros) * PaloAlto PanOS (paloalto_panos) * Ruckus FastIron (ruckus_fastiron) - -Compliance finished with error - -![Screenshot of the compliance error](media/screenshots/compliance-error.png) - -Render diff between configurations - -![Screenshot of diff](media/screenshots/compliance-diff.png) - -No diff - -![Screenshot of the compliance ok](media/screenshots/compliance-ok.png) diff --git a/docs/media/screenshots/compliance-patch.png b/docs/media/screenshots/compliance-patch.png new file mode 100644 index 0000000000000000000000000000000000000000..b70509e99f0cd94be2e5ffe2170e7ba04a2101c8 GIT binary patch literal 26518 zcmd?QWmH^E6D~SHaEIW8!68Vn;4-*tNN|VX?mjpqI0Q(L06{` z_xsj4zs|aMoqN~)aeMJ&=-suuyQ+3o_fu7$)K%rMF~~6h006dvy!1N&00{~JAUL3* zz$eHXRTz9kb+KGpKVJJhHhq;{S7V4ZOZjnj${SI_qVb|n(TAMAuvDBH zL)xA0@Ff1*N|q=`W#z$rpdm@xvFUM>)%N#4cOwBwHg$UJQUA6vviKym()&A|>?7IJ-ba9PUH30Sp+_eF)F{+TTVb-hp^K(g9 zHj26P6c*OEH2aGZZq*&Re<_a-2f!t{fm{5$*6qP#K*uH_VaL9ME{W4?3dFvL4mnsh z3tM31f2VA!a|&z%fQ(AqJouLNV5T(Qn;byh$C!Y}PycLS&|zgkHz;nKqI@i3=>Yyl z3SmsP!2Zr`k>7o|jC71zH))~Kp$SZbt}r{il2+c8jm|(Ri@g!dOMDid&B02S#+PcH+$P7{3##_s4RHb7m4zjA{3g1 z(%q02+?s!PWib#BYd_v+TjnV}VnuCgvqD&`G~G3+?$O;YGZ$oLKg&ok+yMYgD?VD{wU6jMoULU*U0`uIK$qd6O`l9 zwH|$74St}jZ4^){@9yltl;wEDgl_IDU#3R(;A4{(b_iF<5_WcG|FiSsSGr4Q3mjBc zw~1=q1~1?rqQvlRpLK1LbmuA^smSu?=p3f?XZ<(|97}WgW2@5}7y(T2&rJBp4b=c5 zI2n45@=(RBb>40Rbj^z6o5@^_50R3&-47w>YtHe+AHrvKjAM+-lah(pQcht(; zyrp_c#nz*(oj!?S>H~8IOUEjTuSdU*`Y`CAYMhqMr+Xeq&wV4ZrEvGjfg0p6OqmrJDe%*WfMTv36?j|{&{ zJVHVHzwfY^H#YFNClsOOFV}9n@|Z=8KNah_w1x+ZMUfJHTCg$_U*|D*$fUD<+5YAg zNJ0cxc&1S&Gd>I1I48dun|u(T&xVDVU0KS%(IQWHhIu79^cCu`x?TzXIVBHqjgfLB zcmFwce4%;3W=g)fyBvkB>WPGr1iqqT#w3Q|V_v^x(;z*Zm+xPVhwl?gwLkXIogGHo z=)7g1Y_Bi;GE?Moc0v1Cwn@qYoeV2RaWNL%jEv$UOc0U#)LZmsv+3u@ygcj=175VS z2@RJFkKvyttC{pMp6-{waj^GUqSbYD!jdlP`j@VNYf%A~R9|>#*A7fvpEc;FOs!!p zlFN<;wga5-|NUs5JwucLbIVvshmE-{sV86mf_lKU;uQ2ihT;&2a2Xg2 zYmm{&_!io2JI!^aEWUu+xT`)G;6vvZYM+sq;@ ziHi$?-s^tm%bR4Jah z7L6j7Yl#MV6tzRslVt{ld|nRJh;>JJBj>EfZ~YP!Fs+T}EqW~)nmN@Z!0APJZcK%# zKfVQ6Ta!3j!c6d3_6|@XvVwahKp!S7sI||WrXi`KaG!g#qwR$sNKLNmBpJ_)__1Ma zeQZ7fnpYK=4@t+KULpu1K!49-P>&_xx^|~B=WYE&3_onk%UWf^9|I6_|>bR>p7R&*IMQa z$qu)HpwkHv;PO4hZlcNrxwc_!U6mx+LHSz*&0xZT3CP)s&Jk{*-6Ns7{;CYY*dPaV$xN za4lkVOh}+$w`>?4G+aQrzE*5-tzuZI=*+4;H_1Qlw8z9xqU)*fUL;P*SdBECa|M=B z!XVK>30=b&-pXwwZ~De!@TH#v>FE-3l{V*zdznayi%c76=HI%H!`@f=kvIezEM_?{ zCxor&^+dwmn*-sWp~nR{*I?;V+~f^)y&SDgqhazupVLFTXD?S%Yy*pHHTciIpEpK@a0FgT=;9+l!VyMsAvdpmU#v4{n;HvU@w-VCLl z9Rv1;NXjwyi>LP7!g;=2dkfeN*Dx;mB7A~-y$h3o9NcdAC5Tb%@(5*S zdBys6w&Bazc|k&hT}-^397XYUStspKlhOGfu9*>aaw-vp+x~p&hgp)C8xFwe{GCp_ zbO9urLHIh)R+gN$bX%91!gSoq>|)sEK44WZE57utlu-Kk2WBJE$W8UVY6s@K&fCVw zQF*!RVTGs(QITD1+1cPa`6ZnQ0*Z0!bk$<|y<2LZCqA}pi>cn6XBTP5)#=OqZ187D zJPij{VzP5#27s3Da)VV2q^IK!x7v$8*QJ6ONK@<3=c`pxhZo}p0ugFsvKeDo6Y5^Y z7dUn%7!G5zqsOG*cSM+4Z3K6y_HF-({A4M*t@x&DJvO(oMaJfH3cb7e2O9)CP7T(v~HMeb@ z;>Py;NF%i8FZiAi@z|gl^E(b~&sa^OG_1_d0{t_$m1#RxLLdAV?|dYKc_H1A_=}kd zv+TGL(#QO@vSum4!D&=~*WU6+G_amE_gr`bkk+zP0-E}whdTNwWl+=tT--NV;V8My zV>W>c5L=TEq{FV7dTg+xj#rTyV}0&i#prFXMzNV%xVU5(I;&I`6D932|MYVi9{mud8@ zn%e7%p?~IM(tH>Ri?ugmuA7`13H~@K45X{)Z36oZ5l$WO-#^gvURLcbJf_m`MtUDw zI!Qu*%V(_6??V|xQ#H>@Z6-JhiHL<$ksJu%7nY{vrVXyn8;aV!0fgI^9u|v@WiU1Kv? zL2rrwbZF8l>j^(O)5|XT*3-hsXsOa#`Cf74uog!raC{1v?YM8DiXo$xJEv57>I;tg zNf3khYtAzoyV=27YADw{LJJ*fqw=xr#lzEY1c2?Az}LUtP$h=AQ-op-0@b*DtWS}P z?F&J~ugAmEJ6_$=Gb+Us`X@9GN|s=r$j1cgr5nbES79noeY44x3G!!y&QJF{sTc|1 z=8d-RMizZ_9y;KwUFJ5Hlg*V zQNpT>MG$XM&7&qI$MZTse|a50<^O^aU|n7X-+p0sY_xfRkv<>|#7gcoWY*vtYQ3)*MZyer>Z-Dy4uv1pt%trhrRWA!fX zk8^x<4Gen(5yk*(zoPdMPQMf{APp+E)0js&pc{+#UT(bBRSTAr0|-?3J-wBU)W4_V zd@GG0PI6j&W|-lur*}uaRlMFzKmZDnM_JLYR57yB{#jXoR`ySk*h#H(EExe>z?aV)2YNB6w5{j3 zL&>lEqTq5K_DF@1fVJ~`dW<2gDayGM&zq+0(&@(M&3QuMuDh4xm1HQQP?=jYCT-x$#BgERT= z4>*d~J13hSkHoIG%wY)+h~>#l=PF{dwY)cV|2Mh|2f4_#^Y{H`XuijZgSA7$X+Z?f z;8G)TaT1>}J09h5be%&(89GFP0uaW+Sy>Lv zoSMWUOhE?gClv!NE$u$RpPbjH8sJYSeWnn{;};2>$Vkl|xXhOjU=*gcB7Vm8iBPog zFQwDu(&`!^RJtq7f5&n89}!&=hA)K zT}}nS-!gIkYtvr(lW^M-4CIuEr2rMF0?wy-CWTv0{#SXg${c1d`tsBmOee=3{QE#N z(6^SVYmr6a3OW`CtZSkUYZEKPShktoR~F)UN+;BN5u?np0Cb9>=G?Kyu0HM~3@(@+WaW zq3j=Tj`6XC?VzGHCZc{^>5^ihus?JW7|rmTG=Vcg)({fD5-3s^tnVM3_XoY=9N`{tJOkO*43UAD9xdwe&7w@_p=74@=Sc_^#b| z-r0w**t@Y@jt4ds*1V1ki$eJXL6o}n1%~&38Azof7jCNue#xx0MC2G# zrG$-^OylKoz9uEA1KbOo&RZXjBOJ(2o}$Yfc9$`KuaGeOh>vmpVWGt7PfpjT!-7G- zLk=B_0y~svbc}3y@T+;%U0WWxW}%=KPrOPa->R$IBZbX4*;dzO-HN*>yPOJtsK1i0 zl`|;YBurubacYWt*v-@{aT!Ee!Iv$&>9HCjkyW)m>LrKA*e1u?+`jIm_KokU zwf+-1Cg$ZCW7{V0`dL2=x_C=ptBc?>?KNxs>W+|_^UB%w7dxj9?THPvwFhDe?8%bP z-UnX;kRRD&c_(uF&!nvG>4ow07!{3DXgE4X6bk|=6k(asgnU!B_sxs8p#aE$=IIX$ zVwBjGLgeSgOQ(7Wp5CA*@%;gXhnNj_srK?*(2wpeC3GBW>-ZHBlo2Dkt7!5nda}uU zcUL^n|2XJlitegpJDRp8kB{2n#^WH<|6#E~zzq2lU(A@SUfxDr(4s}EqDjgpj5Kurk(XJw1JJSRg5ohH zBb=_`Va-ddaN}f6V3akP{0-+=LUzZMSL{y!hRRfR&M%oWVI_z0i8bl-0VajGSL!u( z$VQ8fu7``Z8jnx{i^Hm?nc`z7r5u9}5)ukF|J$S2nF+PTXfF0IWS+lzS{}pW4d69U zO)~E3zWpb|37h!HgIE+=N}l7O$je=ncs~JTJMF;W&8w7kuMCaGz}Fb_!}xsN#t?^x z7QMPaPS!1fMD0X`)8xp`2(^$OJhW&3(ViXzLll-6b zBm;V}vJh}12!;Q4tuMZwHj(b*5{=x0RRy{QRD=#ztPu7^PEkxf)NK70)kt(c| z$~yKVZho%83&XaT{7`oLC(NDM&jYw{s!mzWXDIn$G0-Ipj9rYgDegm!8l*m^pT8xn zU47X3X>j37SOxuXs-tmm?-6mmMu##zH$-V*_kkVi=gn3ahWKHfr$1dyn-1y*l4eE$ zG=?^_j#`HUzqm2cg6Ds&j>r4cPHBmK>MrHEKlt1CiZ!88O2iXy3=@EskrAhByhA=6-J3IZ4f?aKAhh41JgUk=95jbUEC7 zbO3xo6}_oVr?NVq^J~#!%%^(D>z&ZtSi%@H^?1#FcOZ!y}dT1a;wbkp9xROE%2P(Xl1QC{3#SV(SA{O_b)W`M_4> zj2UcpHWXWHgo#g+TJ)>&NG*PHl1)N=-rI2*qwBo+WMv8Fu5()XYKJX2cv z(dKq*@|pV_h?k-eU&m#BPiS2IT(O3pL0CvK;{YtTN~iq@H)(O-2Mzc;(sZM z#RpFlqvErIUysMgM7ghZ(p#LI%eZvWyfiA7jTIC4LT4|{Obg{fc+Eq=x!6r@eD5*7 z@EHk%h!1QKmh{f04JiPRON<~s-4mO7xRvPZ<6L%?*A&$z5$ScFleK`ERUz$0IMdom zS^s=oKCd&|)Q2U!#I)$gyp#yQ>$j9uFjH>BTrcKz$YS{Gi{aA-DL?1iVZ|>8zdOZ& z(lMW<4|!_vL3;{mH(ocf%6-{TAqqI$tL|KcW^BkQVsMWA;qRvbLe7ttl?)4{&9k!zjl{Phj#GV8x#k1Lf}aYU#pR+q!`76#rNfT3Xa)cy#9E zLQG-@x4|HbsA2o+FZH9vWHc!^$Dc=@w9RL|hPOckscnXyC8wy8nzifJrwARa`>y{0 z_fgAP>raq8SS6mf=%CnAVJj777_>(+#~XH|)zMSk$+!koBM@t#uf=K{Gi-hHW2LlXm1>d)w8M-u@^ii28nfD zQK~uOgo^k=$=Rs3cB_SA1zKG&aZrS-<9ko|&?-WHviB7KLLdg!w5hty$2$gNxY6%< ze59aMP)fH%Hsc$SsEys4e`WKfm4_gjJpeZ4$o9~P&r?PPqIh%EUu}l0)y9S1HFZOw zYPKTFYq>a>ExL7A;=<|uE#=6IyXCI#gQahw2$;9N$8g-?A93%0e)9rqmP5*ms?SFn z5`6x+!aOkOi282M0Mv|pP(E;__t)jg8abk^3MCpT7dL0_)hx2=^Q!sx;cHF(g{Hp1H0jNQ*<4(5>7Okx$u)#zk8teSLhuzCcy#w=fZ9hmEj;7J}Usa9Ue#51yY2 z(GUj_aPhgm2e(ZI1M2W2qdAavL;p6DTwO~cE`2Pt`jKK+*+=&61*JFIztOt3p{_z` zlK28WUc8oP$MsA~$j`CF7c>Z8sXzqt90vU!0PDt>3UXPq!Z#+EnPJ~FVsDFoOhT)D zV+9PBqjs6)LfVI!%g{PUY?=dv33z@Y=R9_Q9P&cMTaL{EUd)@gyeDbsh_*3&-u4@5 z(^(3aIFSu;^~$UO*F93WgkYvgRxbG?c>hFI1@$(Z$kFua=k`@FJG);QWvG z=!$YFtFx(bGIaBm@(NkesW(_0S#9)bF55osnqJ4734i7CM@xSvt5>?(8Dee#p@+AwjZ)ItVIHEmfh{(Xo18!3-dlVAZ=PKM@IK71nm zM=t)A;R11&Jlkp|3P3%u3);Z$5SnL$tY#R&t{GmqXZfrS@icrpPQ4y}4~S`1PaCo$ z{&YC=EOCFvy&KTCw)7sku~_-OP;9(;#rW|pCk{{O=rpH$7wIpE>eB;}e&u0Q{_>Il zaHKg+IlAV1fZxad&4Xs*x6xJlA`M}!C<^szzG7qzjrik7cN(|*8|LdhGzuixn1%$G z!)kWD&9U9wTNK(GCMKEfm=R}p97pG-2O?e;u!HMF3uDxN)3dJ__WZUo7E>#IdJTln zs`$e%Gc>q^d>)Ai=fza4fVU`)KHmtw(SB$k7*1~YUe*GwHlue}WK21=^1D)DYH4CS7ce7#fF z{v^5eu)mUj&gAK(7wo^e07}$s?t6Ip+)*FjYkv>*5N==NF5er@Bn_i-kiMJ&D1*(h zLsT;EmqdWd!wm^Wf|=gRES`DpP;z^X&pQL>vuNMzvGn;~MEM9tod22|AQaHSDqXBZ z=`vfb-!ZO-5HUFuXgQ#9+U+|vJ5>NzLOjfTo4IqADz?%}^Bv4#8Ak^Z$vO>H_!A8} z_}2}lqGnWl-`&rfWS-B>o4%@4v&M^;q|6=8WL?wrygPa8oPbtO@wh4({bHbnniMgp{3f=()}9Ur?2jdJk)`8CDu7KRN#c1EI=MxPAn;vny8LSqE+kZTRbrKrpRDlY9saNahlmj_yASl@js3bn6t(1dHZ-gk#A zT+1zC!1RvroJY5*hTG+UfbWR>8s*d$`2Cj&+PtpCZiKIb#tj@{WFNcg$BH&>t8bv%1-lXp=7mZRSh7K&C{0}atm=h^vxNyOW}2zlj@Xd(%6ZFNi# z77jteT5|fR`xIoS6=J=ii{DG`WOq$9wKd~=Kkkl$>Z!p*m#epXeGD+WZ3RU|Mn7RZ&o)xF5?kd0g4qURo*w}TX$fX~?n?ID&^;s;l{w*5ey z4|SNm-A8%gx$HP0pjd*;hlC)79m&w)I2y!;CXN5*FYnhTzV}|D^jKXCEk@xjmonwy zxSFTv3ui+PUMEF1`_ZiN(7;^qv|d(VzvCa|^NsNQ;FfQZgXfqQ(1VD?Y2SXO0reQr zwhqLN?&@fJG6uKd!*4xBX|?^8?r{|7+h1b;!8}|kaQLoB(yvp&t>g!BLlFz@Xxsi2(@;? zMWfyfqQN1zZXez1OL{|S78tgDBAMg(c*iv)?rK(6ogXfKhkP*lXu9K%hj?c$P{aXg zevMt`ke9_hoz{C%@jmV5*^E`yaUkM}q6pkp6&$m~3S>HYgBJF&@anHS77k&9Ts`fM z@ZC(MBINzRTcH*l@IZ2<#hCo}Ts7DuptXT(XzXBG?rfvE_19*&@^?RXNufiR&`OSb z!rPTB^2pt-i1f3+MTxQq-!9!u^RE(h9%U)#9KP`3&SB#)%@itK5Q~CwejLSMIQ{xn zm0$!Y?BGJ3zueO$4IA69=F_(MQ0q!&>A3s@0_@KlE{V14_TXW>OsHw-!k>?mA69dU z{XrG9=fs9Y|3FAAdx#pe%N1mMXUjG5vL2pqVO=%U)b6J+L&+A`%tnQ}X1c&VBpzO< z$uFxG-P0ADr11o|6T!)q``Gfoy#SVD@lvM)bNXEiZ+HT1=!wVE4x>8-*uE179nE8B zk7s&f9pvZT!#!l*yf%f`3jVMMXPm++@KYZJn`lmT8_Z)*3AOj_X!0I!&t0hHb2I|K z3H6&+$!xO_p>%*6e^z!$$j5!Vt->0%e@+1_Oqn9ZApAs%=30uSs}+xbPQ}L@ar{?I zw9VRIM@gq1R&@U9TqgQoj<0<1$N#i*lmF#>iv<3+lNJ}A2lWq8)LHmL*>cCU0 zg5VimmIt`j54o>Et8JKNS_k`wm*n4+BcM>4gIh5+vrBvxz+VF$8cuuX` zB;eOue4Z#!3Z+Ge_01Z;1?T%X8%n0vSKQgF2L>mS=%zdTJmr|iBqPpL zb)=q5+2IIsZRtz_hm=x0!Hi^+5hnfM1h-*YAu+OWkS{<&blf#Q#&~zO=IDY`&aJiA zmHrFwmMJy3dF0sjrun`n3ylNuJ!@M+1?!h9y;eU7Oo5LNb>7+x`PpoR+1YNmC-WHa zDa7ZH9n#80t-RdVpYU`cUw#>NTw818OwkI>V%}-2>))(%If#5FQi8>>4C&)z@~`2Z zA*dRv1e~3mPIV+#ad_lwP9G6Th~G)D@8?-qZskKOiT}t#Qm+0Qq^^ywvhWb;K#xnoBE+S`@P?GWXVOJaxr_%)O5?8`eMn7KeDqkkZ>#SS zW6KC1a+}jgbtVj_lP^!(G>=h$;`~+@)r@vl5G&EvYey&sv8{*=iS16c^Bnr+ZCxGk z`ha6np00Z`q{Nb!EZoH%Ur#U%Q+&a4Gkw%XK<+alY26` zd-79u3QNyvD2{<~?~}7f;c>+BoS;o;=seZ5KY1D*#g?AEiZcK(=S9maAF-d(Nh0gR z|C+}=c0NcYWt3X93CQj%zn#+jXP+PL*qvbqXVzKZfdBA8i1}o%#O50M>c4JU<+Yj)wBw zJ`x-$k>O^d|JayuZUJ z8cBjXZ1FMaHZO}>N-2&+&$U%O3!u!q2Tb+F^X3@9WEbQv3JLlpTq~)vpvXb_itaI| z?(1A&_i2yUd*v1eBJU0~t=SR;W_$i$vrpoC(<6yA&GL53^W{d-H#E~uYWw%9SYk|$ z8;WF>h7;sB6{YjEkC~=G$`97~$MVLTzJK=xzKH>gsV^3lhaYh6fg9Z}Nmp?-m+j8e zo2dcl1^ad0ZD{HYq@dG8s)zR}bXyqruAkoba8U)0IV0KcUF^~(oBMX1Horit} zVF1=~jr)xA(q{Cb9(5>zqbcs_ zYm>bqv1?jQ7MaCPcMLQySDbx{8cq-)3?j$DrZm^$A*b*01RJ~rQu@bAr4D0u^zwctG=4Qt)Fd9;MfRTQjggFI{S_}Wu;N}j z5M&rZU6V@Q?JB#pBU`xdi1k6TG7ocXBrf0!7K?yRd9SOWvGD_olxwjwrRmni9lH1$ z@{kxg9nXu;p4Y#IfA5ig%l%{h1&`jyW3#t|2>i`Eo@!_DQ5Gv6OBsdtIEuD46o=2t@we@3~i&-o^ z;vA|*>=ax=veES=aB^#+rfx0?DCql0nM(nXAMXMxm+;11Nse9zU#ixKH1+(AD9m3G zGn&UeWTJdPG9z^O#fHk)ZFFSUre|M}=;&Hw?EqO?{%OemlRJ#1mifJ1F!RCi0_#sv z+WKvuil2Lz$Y=c*LB-RJGTq4RW2z*G1XeeGCu4#(utoENOabRCkD0bv7>rq~!}&dI z|IdeJaVpZRd)A)$A7-a)7^e<&oQv-`f;C1?* zXShIR`fOne?Ml=}GX-}pq~5%wviT&`;w#aM)*YwoXvx@g`1QiAh!3=J9^qEa=2b7Ir1ss)yaMzS1G zY&@Gc%*edR(U>@Ln>wx==tly!7#34nwF1LBu^owDq8FNtem6F)Er=hHeS(pt>L?Vn zAG6>QyWtuh9HKp|q+X^-smvYnU!urz4Q~31FEqL!LpJ)Zj=cxN@uW7$-x?V`6NC9c zr!it1d{LA^v7$bIOdc{FE7H6inMVhKmOR1}FVmGawTPbsHi^Ef(fke~%XJD^_r^nK zl42&i-{-=&91J0ms0hXHSy!@^^?3CBIA6PK6P~-D74g5KYr`?x*RkUOr&M;V()LJ|hzv^?HkC}cPp30@iJnv;zx%pK3o`W8#)1Bje9^qP& z<eK)-Ed#n;52jgWt7_*6e7O!f8 ziN023PA1*ZX(BD3Gjnb}JK@NwyARY}c)}(|UG<;tu15XLw#?65i^h?ZnT`1|N_Acm zJvU?i@dbN#LEe2vqQ9K~fap)5FKeTeWQ<3rf59paopK|47AEw{(90n9 z+_H)&Gk-X0vt^15AhQ0CAT9LD=0=al9#YF+moF(3=4FhXe7Dt8vyhystvv{E91{Je z`eDJ1cYX#9oqVxI`m*jmHT@nl^va=x*jP2=vsJu7Uu9WGUkw7lnQY1ERupRa_D#^5 zsOdL|iSfSt3<(T}<4Kt8;hh|LK>&Ln=XY_h*;#O8rRnGI46gCqk0?ERThYD>*+!5o z@Uu}gUV#L6SO~E-e6!082CZDsYw~&c061N>r(V-!dDNVI>4YFHHw1TF@{{T@f4Eun z=Dx{mZ)?Bl_lS(z4V-OIc`_`Mobor?)RF*AiVpd|KRk}TS2>pU>S6E8aN5Vu=;)G6 zV{zpV@C(?1lN8-7LwKJr>fEJgB+CuYKc#1^?w#NN7hT!6`+o;>d9p44WtfEuk77KK zgQMXw)*}8ZOmy=7eGxqT$$)22qkJqx-`jaYTx9bhr=&&#zA!3f&xbhKgMHBDt!XbjllJB9-{oVW1%-o76<+5Oc)Br1wX5Nd$`B`yN#BRqzi z$WXoH^_DNst4s?H@I<$Govd%iQY1WME(tWd^A*6>Rsm_i^Zoze@UVBK#p~?p2D0&t z=mXeVsv~d}>mDwj<(TYj;Qcf95@^kHf5ZqUoT~bxWvsmf-nn+bBIC|K zaeWS?;84KB701i%Ww4d!F-fk>ryYJbZ{U;=uO@g0ZRF6F>vEf4+CRmQx)xlgX{&E{ zp(jU2EB|@E7nsNX6!;vy{&To7o{sb!=|Tm!z}GNHFgEa?0?W;=|5H%se^>B%QxdN> zYht{|`}pwS>F#Npm0q8gh6Q>W5+8{pi?}1~G58MT-W{u=f$wJ@{m+pwc}+MjPgd`E0)K{$nK>j3L_(w3v}3oTNCgPn=yQgGgHVm*JX_Y(s@Z6}rs_b#sZ8f;F_evvl};gabH^T9_*k9EV* z(jwd=*0$}W^ef=;a$ozDontbPyVBDhuAWhu;lZGwLMbJDLSZtliW9ly5*{hpH-VgF zTvf@5F7ikFrFepU9UV?yB_1u@AdAk8=ajj&UrusY6@wDWi-9!_0a88q!WYPF=|qv7 z*dUL z)w90BTav5B-|Vo~Btm8)SRD8ZykaXTc8H(dD_V<*#usZdnbnr?$VhK0(#g_#r2`}M9$FP*H`SoC4L;9=Km3yP zUqAtWL2Q)b1#7YYR6T~ahL|(A<;!XcQ{%p;_S6zuO^4AxwjtzT3076m7&nYOS1E1- z0AHGz5~vv3AzHEPpdDJ6HA6A^TX=R$%=fprPHib6XiW;TizOPjT|%40^FMwW##Ji3 z9@P8xc``0ebix_s4p!D3+@YPf%@`E<$mt`Cvg+#tF9i&+%E6`W2Vrk@zwM1w(zaL- z`*>{3b+Bk@;RFhg#ts6{NL!TtmVWFJpT09_t#JXV&e&4pV#%zpe;E9VkMmT>dnng` zNcn}D;9GXNg#4LatG;?fddAd|ozy6e;XV0nL|piJtTLPLaz*q&uX_NvGwLGaEbh(9 zAXDPC9%GW+*Tacx+m}0Av?uChwvLGHaH$(72FtM@ZTzQB!vzA0-qU=%3miluLZg7un>l{S%YPqBxTKx4Kgam8RfO1ea1A6G9Cgk3eij2E zK)Q$nup6pAnqtse>(23}9w~^@lrM` z>YA~6nJ%h!g^gc2DoI3TITQ)ElauNGR7c|a3S7gC)r%!;88iP*dTYnxnxnkL;lD!z zl8zw-z#kOzfo~g%C*SKw zEPt%%@*0~m)wH|d!oI$fzFI%^Jq7)4~x%qJL^2+Kkf z_`i2A`pn}Cce$(8uB}IQbpJ3M?MBMHeIcDk&H#qz)YWMP1%rH&mK+8jF9~R&ZuHwv zwH;?xp-)xUKH$ICDlef=EQ$}lLCF);z45bIP7aM5CV#V2@Z);{$^1_Irf#0U)@U5F z$>Nqv@k#Y`V>ZWqY3>$CGv!HxHcHy1Jvr!~>*F_>=TV{QmNS`;gsZ;Bq_Z5?O#)#E zK@Et}d?%86F*Im%nMTn=6+o$#aw+DgimIp-xpFP^&;QK@ko>iXT;NzAwEKRuHO-<( zcRsV*l4}>&PmHjsqIVC7_BQ2ECfvqoEG_qH)J{Wz#&YiUjCkCFb_1=Sr+|N3-{M|?T(}FSBbC&(-9wqb6*BQt6+!qP7G>DD^4I=D z21o?mNrgU_Z%$=-42i48R3Mga#e+oC7Kc1|8Fg3vKLy_sT|+#BQQxcyqDnMAyT63; zqSLI3+wHZeTfWRKe-|E8L6_LH!$|-{nFvG?ywIXHHjM6`u9FWmT6T_%`?PmIrS2_& zOtT7D9HX}C!q_ZfO@zs?ulKo^cvirR9w+D@ZS;lAdUby6UFzkx8KKiTSXFvy^8k>i z8jeEV*%KtJ=E}}CwDaNMO^Vtc>a>D*(Av{ghqG@qsrhmUlfC?>=LHYy=PD{ps`rbb z9$K;@+;e?2^}D1~iU#St&vq?-^GDaywYf$qC&+JP)0lSa$h-&vkB^RUc%v_eo4b#> z^9A5;BRK&L&=_~(xL~YwkQS*vfkVwrbRr)T2sn*b>Dm-eMt-RvpW$Fldm_9>mJa~T z6t&p+8Gc~$T&D%x9V**<8)5AtbS|Odk29IR4Jzcg$<-Mdcbz^NQI#r26o25#6mSx2 zH9cpwk%U9;xJ=Y%WM?qVcU8MCT<@;6{{FI}4TqIvn262gB9d`nN`L^j{q)ot%Y8H) zh3N4Au>1)$*TdV?KS1ntT89UugJaS(FAqZF3*3jDACvh2uJj%0g}C6G@7;eeBy_UN7nDVKVbIBOu6&2~~`3CKyly%8Yi{YMjR(+?H#t6ztuX ze_lbAWQ>#f+OSfAl#79t5;*>VkChGhTib>_+vu8$4dC2znchA|@z7@Ndz4Q1qBxD~ z-TjGhh_IiY4j?^=MhMOSb#GF%@ScxWh+xXs5r&ssMG$iT;sI8wtnepcNn&QFhaOohyZ(F`K?TAt;pS00<@ zB9j7gu*1!g?@D95m2G%y$<+({Y3TC%CJg^J{XG4!`O1FGQQCeE4Xf;tnDnR8f!vox zTiw%T&0E{dO&YU}25++qh9{+uwv^mdyzVVU@Vh&sN#;&^g(YlN!uaF->yi6doFCK8JiLaH~H+mnj zURi?!;z&*SA!DM9+y2yE&YF_z{A;)EDubC6=-}F1i}@CH#zz7{A_vtWbbMXycmCwx zemZ__VBVLQ;GkcQJK+S-R862xNOiP_FliBOeEQJ0@OTBFx9k%~r+&!byN!4ujt(G= zTS$O*B8B2pu+kSi4gqTCc=rde&?xv{5Sa;u$3EfnFvuFr^}4C8mAD|O>%C_yzrj!p z0QP^HSz9NGpqpzz&bacq$2U!Et_>5}$TabBmZnBah7WWte0MIg4MCHX%p}E zOblo&Z= zcXpmXikW<9Xd}NO!$h&~RYdt;m^NhOB*h>l{Ez0oGpebrTRT_|Dgp-)se*+fL_j4J zX+fIO1f_?j0@6g3-h&0CgY+uBg(i>y(xTD?B=i=fh#)0E2qn}ccPHnZG42@O`@P@y zj{E1Xv48BWz4j_|uQ})Q%(eGQCE{+BSUqL+4@X{(OQU6nkFKTZVaNGY-ds{V1*$N= zdELwnbEDqU(}5MX)5F!{+eTk*MbR4MniUxoDPZQTxZ+&%_|LCLV9^j{n>>4o1UG1 zS8iao>}(HHl}L$= z)nAyBb}z&_FRS1D`_QywAZNXWg*(kAEt)ox9~BSOW!O>8?e6Ts1Cl7` zx$qk&^>P)1Bdy5iSi?b_`!K0UX_i$Tju?CW{? zdyqnQIid104)`n%tlWFF>lMp#>rc(k{IO(~nUUk44HJeHLL%>~UuHP|qF`G@Z|+T+ zhe|BliM`tCC3R3B@KxW{8*@UfbSWn&b@)^IA6Gh zJd|R0Pv*(5R+ho(x2%WZdgj=-U-CYv9#CecNx`*=g+9ELuKQzI73(3wBi=odZ?B~? zLOA?xOy)8cA(RSJBsn@Zl~)?|nj!*fakW`(|k=vj{VGHj^HP zV^${^1_Lfgr`7KT=DaV%4r_jl1&OUy3bk{ftU%$ofmw2uz-8Qi&b)ZXL2 z@Wo%T5;!t5FFL!p;7r$@_WfH{6hNRAV!YYA>+j_Z7e>t5Jyr@tyy~Ifpn+JgGVedg z2bJ@b2W!MiUJ8st$a1WM-@+EXf{H_pS|rF7;TEK<#qdU=7KY&sAFk8NaCIHNp!Rr| zGKFudc`yi{Ae9YTR+UotcoU~5H&e(PjZjT^#azwJBh3qi6nN1vG$4CW0OWQgY@egZftk z59P{_Y6tEB+hff(IbwQAzzGQSIqVVvCqOrcyZBZj{nmcd=6;i>rzf(xx!mqkC9rtN#d;Z3KMdb* zw-zmE8Fr!c)nO+lw(b(DkVtQ8rwjievEwQPv!2RdlkKi4xhs`EGCA!vjKw07k}mkj z^ZLj?<2nTbQ3d(pB+zA$9)muKjw+BBH>p2?WF85Om73VE{x?xkGyYc;!Q3F|_CU04 z@7IrMLoT_I-pVQ5`J`%+7uFk(djYN(Ak@3QlNV)W{N{4vzSIS3x0QzS=J3AhFkWW+ zoj3Uxl%t|g(nd3ikte_tPW8B)n3w(vn-<~iV~eJr7caG+40Fm6O{-L>5Gwhp1M+iK6iDYGJVH` z(JVLTg+-YeK8S63>Wail`D~rjBiWLBV|R3^JGOyl)!8wg_4VsEb_vxDiCS+*yu{02 zy?^d7WiYdH7gn|2jcYow6k++$G2aYEQTD3%eob=9V%pGHRT!MBJzVh=ukmb5Tfom+ z({ydY`gYhx&=$)4O4(#smSgQqG`>o_Fj()Nv=MZ$+gvXI?JZJbk!pW$0;f?>xX{FZ z%E@QB;t#B&xucn0jZy!)RUHJ_P-WD-5v?N%C+o#)b%nQN*4NE(k+ctNWUt`~ynl6> z?L`M0B^!7P^2D?s!SSd4de6ICzHrz*n`Uy1jlAy@UvF$r9p0yCyDI0=`rkma;1ip=a(39`9%oN;u`Rf)l!s&cbYPmY>; z>1i$R{rgoN+(e}#<>;oyN`HQhxY8OK_+Yn?Opkh$xXRprVfe+hY;N_Y7f_L1=Hi4f zZ2Bdv@}@4^FIpGs(--$6lJ~pc?E6~+hn$=oc6xfW=EvCPzJFBMpCAy=we;QJwVp3B*2VjM%Nlg9! zUBr_ZMrja6KNfNOeVkU!#>#DWHVQBc2iksjc{Z?(6|=<7oPx}&sfw|9c6bJM{OV?d z4c#hjN3xCCPwa9P5hXNwuA3X1^q4W8Vxl&y_u}XGyQ|^XY%}+Z{cP4#8|0IRpBevp zazpS*+M0`tIjHDTa!T^=I8BcUary(r2pnlSyD5t4EcIT?2X-cenc+?Es56IvDh}*T zdlrezNTV)0i5L`wn`7;ie)}#HJ|?j~%_(n^Z7#VRBRlU`ccrb`6qLAVV6dzO#tX^# zU7{L}NQVb6r^eSL(!>ri;&~&T-KwRX&y5#XY&$MYeIjr*R6isosZp zk+~*q!T~hyWuza?l*+V7lfovy%^iQe}dewi2wlMc@MKX zqCuy`Ky3Bcn%k=I@5iFzQzJ_0{LV}FJxzqcbI5=kZMbUqAMRIwyWimG&m8RbOIP^d ztl#r1%W(p~UH2T}vU9cE#0!iVb4Y!o3wb6W2^KQtjd3c77O9hDi-ULX_p=UkyX$3M z)hJM3^&56l8#eVBOe?W*(H1Uko>xSHlTSHK+ws*|Gj}zmT<8BensCv&&F9X?dS2wD z=0T6Z*OkWP(a-nlGH2m|8epS`mWvB_thVDGUOj}yKm0TtU}_vvLZm)>rQ?|-0}9Od z3YTl#@UH(wkBrz+M{QW-Nu-I)XinE41wFcU1h2c2v_+xyCEpS{8K9xJIt1xJ5R>=S z%vHwX{o==W=wSxt5$a8=u$7njC1eGbfVm9)Cf)Rdg_V#pR2=*1xR8qE=$)581(X-> zRnG@2>$Tp8Pe&LRlm$SN8^oQQRRTgxSNYvTWyW-NbSy@`t_RSZJw$bjpL`d12@{3b zW3kP(hMn!bCOK$Pyf;?x;{H_+d)JapwGIN05A$meeR4XpuY?Y zu{Qao%D??E+|MK( za}U+@K}nK5SEsnrVh=nI%dFL3)lrfJJ5@H>J%{9eKMSqRR!lur+IQ+a6Jsyi?%86p zwiN0pRah&|&=soLM^oPn(SHv+Kr(v&Cz|-6{tj@aX`er(uFz}>h$-fOh8`#(nmv$X zx5ji4fHR7rhop_mmoU+^YVij+Yj5$~&#lC3F71-Cyv7w@9%t+YIK|;;!mRt$zXY;$ zo(ebVVqLAYI6kDonDHovD(0ZA@?|g?zz9#TR6MwP3C$7Pb<;I!^fKs`lN zVoeOKC-I6STuyvu#G0jaY^)?3!qsxW`C4Ob90#sNjN1C=s*H$m%G@xksEJ0ei0Dwt z*e0rx%YJfrb1wywY=F?Z@5(*8IcUL%TpOAI43~7lNpQ)1Av*LLWZ~T7-oqz=hrh4P z73XuaDz8_E#w(^Wb({I#r(%hRo(i?c*Y&$h0PzIu4svlUR|HV7 zfSY`ck*R~2y@cMPnx}Y89ph+J$wD8WqpPqf!5_Pe{(EeYfOf06$gr3ZVGUp7mJd^T zx``GHh&rcTw>n=H?ry~?bfx)9i@{RmaADvl7_6OX83U^t@gpFnjHG@k#SOxuPD+|D z1PAd8L(K2FH)D3}JUxDdmG^h63Q%0}7hKkjb~=$J z_W!_#xO7h11YmL%6Cv9bsl=n6o|R$q@M}0oIiL2x5#7C~G2xrrSp@Aqea7)ixPedA z6~MPAa=lE_|B(>#dv)SGZITSR0k@AdgROMUOB+XimDm5azPQ=kNHGQMi)}f%ds-n7 zTrn9eNnZ0s8#lOBR_9vffOCf*s&?cFVVJf(dA5p>f(n2Y0&hR|xe`#!3A)>;yYrE# zUAbiBvNY>Aa64|eTlF0;+?3F4-TRE%Cdj}o8rX!6YtK|Q?+x94=2aE9L|kX-(t%0* znUcO61s_5$2+Bh65T8RA2UmH1bk*U)lO0)B#wGQ#@!o#(LDPuoidV~=wow4;@JzqS zBKJZQZVImoy3q5KyUn9DbkbRYrCdAt`*nK;N15~2koai8>u5=dAg!ywD|ef8={JbX zPTx4|b0ez{oYm)K zXLomUa?&^H5T=+_d6T8Z6##JbE9EyWctJsq)e~jY#Fo6#Q6m%*`Rx}0I3TjuL7@H< z7=i(uHt-j*Zt^2D@RQFg zl6iiFI}M*2UySNZ1S>qALdM{SglHXTymzGgK#;57d1|m*_3n=Nvvv=^6-0<~0sYV| zdOY}JfYW5I6AfhQ|fXIwNHlBR02t5^dx{vu0Rg}O&>VIRM%YMg8;>&{I2kF+nl4DMqNI^xWXi; zgolq+~QM+D;u#&>;sq^kg`>WgvfD7 znH|+uqldyy>MB(}LYx5d0qlvLFH zL0KE^whTr5g>xJ6Nm~90Ipe42mk~1L1r^Q~uS^_+{WSYP>=4n=QzcovR!*E(xtpkN zlX2gh)^@TCdl>pbu`3#E)we!S)Ln6M#Oz*1;|MoakGTAW#Aw0X4b~7y+>uz&CRhQ+ zgb-W=@GfCL1;8+Au{k@Wb~O2GX{a69{RyVlH*4NVwc2V_EP;)+ZHu6{rV|Z$ns6|S zC`-2n;2+ZN_%nq8>qRhrUl!@@W*M)A(SExlRBf>Tgw~&+T(^uppfiBd;#fR9Ck(gu zP2L?Fi=L@T3=na^V~;wmo@-jHb*pwFiy!S{k5o2@GlmHC-fdP!fDGC^#!_MpQo3S4 z*>#`kwKs}8;_F&^KkX9Ve13MTK5{xm;V(cJB!YQ4H&gK{OAR8+(Z3$KoAYVDs%YKs z@SUVhWEWq2ezdYj^A=2%>ueWPfRmMlak=YN0ARg$bev_fRu$jR++~y;Z9kPTb0g8=1qZd_pm;CL zISSyS-=UgDmSk3vwcbjmjX(1~8+i2+T%yT_S_YI+!VtwY>%VN&rYZFjK0 z{Jy#EU992OUm%>#+PPiQ@x(Aq$B;tN0i_$ZRms|4X2PA2>i&0YOdBf$Iz+PC^<)`Z zTPlO47I|;<&>G|_`U?<9L-GZp)+y3jx`-%B{OKpmZu^U-1^;)b@9Z$IbXPJu_@SoH^&UPC@xp< z6mIr1YC7RUS-$K-(+Q3kCtKNTmHh1hBUp99s2)$#^=~S|AE2ng0UBFC{D^3c9w`r% zygIa}oJtOL?;BqDlz(MpU4q*6S#5|?9ghY-p7H#B#*2h)JB4gEryJb{-N7(H*e;3eb|38`7Kh6+cVupSEp|5Ymse|4vE?E3pTTQ zru^H(zuoK3_dmX=9&ccAQ*1j1Gk7PMye`{wY_8#Po7R(SVmkjgL&1{y3@qv7P8WcP z)U)|L?E)w)BfO`$b4HSEbc%~pvy-wFZ`l4A5DEfzx4ih z`kVft`KF~ylmI|WGJ^j_Ff@vZ0R)1)63qG=PDkf07ad5Wf#8NCWrZeTP}JRaT9D&E zi_c-cTwYuRf#`2j|06(S4h>A`Ai!HjF#)@l10r=>$lxbOeFSnpCe!Jl?O9^HNvY|NKv4XAJ`4)e}qh_`cJm`5Ck-yl#a#OKmEpq4eRl>1cY zHt-5kt_{haY$!+!i$2iB0-o4A+Y)eBy|+e<%(r5$aILs1dZ{H;#}WG;s>Zlnu&SRw zw>6kCReS65)c*B~AD1g=yu|WkZ`NM2fj_wDz%KW(sE}iwv7I>gHG}t^J@M_@?scO1 za!x1=_t29G0`}$k!_vq~B+O#K$Y`OWGH&X&>s$7#&UboYEFQe;W%?JvZh$)B?)a2S zDh6CBU5uM{Q}m?~_)8m90$(3WmBwu1i<|*Eo?>ur0^0*ImeDnZDDH-ugZD;d`Aq;j zQ5>nJ0O2ztuK$C=jamP0fv*jjdnHc~C#jrXMMBeb>b^3?;b)mPBp+bfCCi>d4sbo* zyZ?>T;|0E^?Y1XWV>8Lhx5Ne6ma9ugVuYS(u4!aTqmK4fbi@Rh>zSlAog^<9;P34Z z05O%85=M=SY3AVu0m89bQ^_8-^#=?z@puPI!sp;YIm#aOX3aT_<%)^!N?$X=fT)4d z(KGFPg8=Rp(IJ;Lc5`|v%RU=HxO&-dV%L49!dF{lOr~N{7vkUuh~*)g3UjBMH}eP%H`#G8c}$dF)U!w}5FVX(=g%eMBh8 zQN`wslB5_=8k|=K@?=|TpRcH2FFxty<-B)!w1f`^Xj@#nXeO~zsKQW)u1{5KY;SC^V%M)gZkK6%r{y20 z?k80Uv}>g;Pu@I1=4=J99d4RF=i7ANY%JyVmj@j%cy@dLyF(#-VxKnysjIjRIXCq@V`3P6iU5e%AOZ2LXZwvj~^5&NPF20U^S|#a&@c3Z7}QKs4}< z-&yM?>hTI&_q7`TsGwi)>flOCAJALi<1*2slgclkRE>a|^VXNJh9@=AKtH|3p9SXHY=Bo!ei~{UiN< z5I_7^7@j(f{JlLLO z@ewQ*yL1Bxmc=8$1j@8i<#M`9%WaZ9U^sI~|zEAz*El1g_4$a4~Ua4JH65~_I`eMXW+PUh11Y3-~o;= z_>kvpTVN`RS;mT4V;D^I-%C#b0B!aro5K%=ynwlM`_P^a6vSUrP@u*x=C50cCWOGu z%z(jBmzV&?r?&%4LR+Hf1fD$CKREa>g>IuG-9-ew)v(zOw%SPqaH)(AZ_NDrySzdJ zLO+4Rhb5_d`1`vvq?5TQ6l#M(ZQ6$=R%nYJH2XcVVxcl=JO1c6DCi-*(UH~~`N~CGMFFVP&Amnd&JO3Tx1rCYfhPf^7m7#-AZpqvZN-tAS4CkSohO7g8yN{w PFKDRUSFKQf^!mR5U4O<6 literal 0 HcmV?d00001 diff --git a/netbox_config_diff/api/serializers.py b/netbox_config_diff/api/serializers.py index c636a28..9b3a12d 100644 --- a/netbox_config_diff/api/serializers.py +++ b/netbox_config_diff/api/serializers.py @@ -29,6 +29,7 @@ class Meta: "diff", "rendered_config", "actual_config", + "patch", "missing", "extra", "created", diff --git a/netbox_config_diff/compliance/base.py b/netbox_config_diff/compliance/base.py index fb9702a..48a945c 100644 --- a/netbox_config_diff/compliance/base.py +++ b/netbox_config_diff/compliance/base.py @@ -18,7 +18,7 @@ from netbox_config_diff.models import ConplianceDeviceDataClass from .secrets import SecretsMixin -from .utils import PLATFORM_MAPPING, CustomChoiceVar, exclude_lines, get_unified_diff +from .utils import PLATFORM_MAPPING, CustomChoiceVar, exclude_lines, get_remediation_commands, get_unified_diff class ConfigDiffBase(SecretsMixin): @@ -204,3 +204,6 @@ def get_diff(self, devices: list[ConplianceDeviceDataClass]) -> None: device.extra = diff_network_config( cleaned_config, device.rendered_config, PLATFORM_MAPPING[device.platform] ) + device.patch = get_remediation_commands( + device.name, device.platform, cleaned_config, device.rendered_config + ) diff --git a/netbox_config_diff/compliance/utils.py b/netbox_config_diff/compliance/utils.py index 1da8fc7..7451a71 100644 --- a/netbox_config_diff/compliance/utils.py +++ b/netbox_config_diff/compliance/utils.py @@ -3,6 +3,7 @@ from django.forms import ChoiceField from extras.scripts import ScriptVariable +from hier_config import Host PLATFORM_MAPPING = { "arista_eos": "arista_eos", @@ -19,6 +20,15 @@ "ruckus_fastiron": "ruckus_fastiron", } +REMEDIATION_MAPPING = { + "arista_eos": "eos", + "cisco_iosxe": "ios", + "cisco_iosxr": "iosxr", + "cisco_nxos": "nxos", + "juniper_junos": "junos", + "vyos_vyos": "vyos", +} + class CustomChoiceVar(ScriptVariable): form_field = ChoiceField @@ -43,3 +53,10 @@ def exclude_lines(text: str, regexs: list) -> str: for item in regexs: text = re.sub(item, "", text, flags=re.I | re.M) return text.strip() + + +def get_remediation_commands(name: str, platform: str, actual_config: str, rendered_config: str) -> str: + host = Host(hostname=name, os=REMEDIATION_MAPPING.get(platform, "ios")) + host.load_running_config(config_text=actual_config) + host.load_generated_config(config_text=rendered_config) + return host.remediation_config_filtered_text(include_tags={}, exclude_tags={}) diff --git a/netbox_config_diff/configurator/base.py b/netbox_config_diff/configurator/base.py index 38bc94b..21c81df 100644 --- a/netbox_config_diff/configurator/base.py +++ b/netbox_config_diff/configurator/base.py @@ -14,7 +14,7 @@ from utilities.utils import NetBoxFakeRequest from netbox_config_diff.compliance.secrets import SecretsMixin -from netbox_config_diff.compliance.utils import PLATFORM_MAPPING, get_unified_diff +from netbox_config_diff.compliance.utils import PLATFORM_MAPPING, get_remediation_commands, get_unified_diff from netbox_config_diff.configurator.exceptions import DeviceConfigurationError, DeviceValidationError from netbox_config_diff.configurator.utils import CustomLogger from netbox_config_diff.constants import ACCEPTABLE_DRIVERS @@ -137,6 +137,9 @@ async def _collect_one_diff(self, device: ConfiguratorDeviceDataClass) -> None: device.extra = diff_network_config( device.actual_config, device.rendered_config, PLATFORM_MAPPING[device.platform] ) + device.patch = get_remediation_commands( + device.name, device.platform, device.actual_config, device.rendered_config + ) self.logger.log_info(f"Got diff from {device.name}") except Exception: error = traceback.format_exc() diff --git a/netbox_config_diff/migrations/0009_configcompliance_patch.py b/netbox_config_diff/migrations/0009_configcompliance_patch.py new file mode 100644 index 0000000..5ad5620 --- /dev/null +++ b/netbox_config_diff/migrations/0009_configcompliance_patch.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("netbox_config_diff", "0008_alter_configcompliance_device"), + ] + + operations = [ + migrations.AddField( + model_name="configcompliance", + name="patch", + field=models.TextField(blank=True), + ), + ] diff --git a/netbox_config_diff/models/data_models.py b/netbox_config_diff/models/data_models.py index 1567bbc..99539a4 100644 --- a/netbox_config_diff/models/data_models.py +++ b/netbox_config_diff/models/data_models.py @@ -23,6 +23,7 @@ class BaseDeviceDataClass: diff: str = "" missing: str | None = None extra: str | None = None + patch: str | None = None error: str = "" config_error: str | None = None auth_strict_key: bool = False @@ -99,6 +100,7 @@ def to_db(self) -> dict: "actual_config": self.actual_config or "", "missing": self.missing or "", "extra": self.extra or "", + "patch": self.patch or "", } def send_to_db(self) -> None: diff --git a/netbox_config_diff/models/models.py b/netbox_config_diff/models/models.py index 10b0f73..8fd7493 100644 --- a/netbox_config_diff/models/models.py +++ b/netbox_config_diff/models/models.py @@ -49,6 +49,9 @@ class ConfigCompliance(AbsoluteURLMixin, ChangeLoggingMixin, models.Model): extra = models.TextField( blank=True, ) + patch = models.TextField( + blank=True, + ) objects = RestrictedQuerySet.as_manager() diff --git a/netbox_config_diff/templates/netbox_config_diff/configcompliance/config.html b/netbox_config_diff/templates/netbox_config_diff/configcompliance/config.html index ae2c864..874799a 100644 --- a/netbox_config_diff/templates/netbox_config_diff/configcompliance/config.html +++ b/netbox_config_diff/templates/netbox_config_diff/configcompliance/config.html @@ -8,6 +8,7 @@
+ {% copy_content config_field %} Download @@ -15,7 +16,7 @@
{{ header }}
{% if config %} -
{{ config }}
+
{{ config }}
{% else %}
No configuration
{% endif %} diff --git a/netbox_config_diff/templates/netbox_config_diff/configcompliance/missing_extra.html b/netbox_config_diff/templates/netbox_config_diff/configcompliance/missing_extra.html index de94dd9..5de7f9e 100644 --- a/netbox_config_diff/templates/netbox_config_diff/configcompliance/missing_extra.html +++ b/netbox_config_diff/templates/netbox_config_diff/configcompliance/missing_extra.html @@ -5,38 +5,10 @@ {% block content %}
-
-
- -
Missing
-
- {% if object.missing %} -
{{ object.missing }}
- {% else %} -
No lines
- {% endif %} -
+ {% include 'netbox_config_diff/inc/commands_card.html' with data=object.missing header='Missing' pre_id='missing' %}
-
-
- -
Extra
-
- {% if object.extra %} -
{{ object.extra }}
- {% else %} -
No lines
- {% endif %} -
+ {% include 'netbox_config_diff/inc/commands_card.html' with data=object.extra header='Extra' pre_id='extra' %}
{% endblock %} diff --git a/netbox_config_diff/templates/netbox_config_diff/configcompliance/patch.html b/netbox_config_diff/templates/netbox_config_diff/configcompliance/patch.html new file mode 100644 index 0000000..ac77fe7 --- /dev/null +++ b/netbox_config_diff/templates/netbox_config_diff/configcompliance/patch.html @@ -0,0 +1,11 @@ +{% extends "netbox_config_diff/configcompliance.html" %} + +{% block title %}{{ object }} - Patch commands{% endblock %} + +{% block content %} +
+
+ {% include 'netbox_config_diff/inc/commands_card.html' with data=object.patch header='Patch commands' pre_id='patch' %} +
+
+{% endblock %} diff --git a/netbox_config_diff/templates/netbox_config_diff/inc/commands_card.html b/netbox_config_diff/templates/netbox_config_diff/inc/commands_card.html new file mode 100644 index 0000000..dae39c8 --- /dev/null +++ b/netbox_config_diff/templates/netbox_config_diff/inc/commands_card.html @@ -0,0 +1,16 @@ +
+
+
+ {% copy_content pre_id %} + + Download + +
+
{{ header }}
+
+ {% if data %} +
{{ data }}
+ {% else %} +
No commands
+ {% endif %} +
diff --git a/netbox_config_diff/views/base.py b/netbox_config_diff/views/base.py index 43cc8de..b01a7b8 100644 --- a/netbox_config_diff/views/base.py +++ b/netbox_config_diff/views/base.py @@ -1,5 +1,7 @@ +from django.http import HttpResponse +from django.shortcuts import render from django.urls import reverse -from netbox.views.generic import ObjectDeleteView, ObjectEditView +from netbox.views.generic import ObjectDeleteView, ObjectEditView, ObjectView class BaseObjectDeleteView(ObjectDeleteView): @@ -11,3 +13,40 @@ class BaseObjectEditView(ObjectEditView): @property def default_return_url(self) -> str: return f"plugins:netbox_config_diff:{self.queryset.model._meta.model_name}_list" + + +class BaseExportView(ObjectView): + def export_parts(self, name, lines, suffix): + response = HttpResponse(lines, content_type="text") + filename = f"{name}_{suffix}.txt" + response["Content-Disposition"] = f'attachment; filename="{filename}"' + return response + + +class BaseConfigComplianceConfigView(BaseExportView): + config_field = None + template_header = None + + def get(self, request, **kwargs): + instance = self.get_object(**kwargs) + context = self.get_extra_context(request, instance) + + if request.GET.get("export"): + return self.export_parts(instance.device.name, context["config"], self.config_field) + + return render( + request, + self.get_template_name(), + { + "object": instance, + "tab": self.tab, + **context, + }, + ) + + def get_extra_context(self, request, instance): + return { + "header": self.template_header, + "config": getattr(instance, self.config_field), + "config_field": self.config_field, + } diff --git a/netbox_config_diff/views/compliance.py b/netbox_config_diff/views/compliance.py index 2a4d49c..a5b0d5b 100644 --- a/netbox_config_diff/views/compliance.py +++ b/netbox_config_diff/views/compliance.py @@ -1,5 +1,4 @@ from dcim.models import Device -from django.http import HttpResponse from django.shortcuts import redirect, render from django.utils.translation import gettext as _ from netbox.views import generic @@ -15,38 +14,7 @@ from netbox_config_diff.models import ConfigCompliance, PlatformSetting from netbox_config_diff.tables import ConfigComplianceTable, PlatformSettingTable -from .base import BaseObjectDeleteView, BaseObjectEditView - - -class BaseConfigComplianceConfigView(generic.ObjectView): - config_field = None - template_header = None - - def get(self, request, **kwargs): - instance = self.get_object(**kwargs) - context = self.get_extra_context(request, instance) - - if request.GET.get("export"): - response = HttpResponse(context["config"], content_type="text") - filename = f"{instance.device.name}_{self.config_field}.txt" - response["Content-Disposition"] = f'attachment; filename="{filename}"' - return response - - return render( - request, - self.get_template_name(), - { - "object": instance, - "tab": self.tab, - **context, - }, - ) - - def get_extra_context(self, request, instance): - return { - "header": self.template_header, - "config": getattr(instance, self.config_field), - } +from .base import BaseConfigComplianceConfigView, BaseExportView, BaseObjectDeleteView, BaseObjectEditView @register_model_view(ConfigCompliance) @@ -87,7 +55,7 @@ class ConfigComplianceActualConfigView(BaseConfigComplianceConfigView): @register_model_view(ConfigCompliance, "missing-extra") -class ConfigComplianceMissingExtraConfigView(generic.ObjectView): +class ConfigComplianceMissingExtraConfigView(BaseExportView): queryset = ConfigCompliance.objects.all() template_name = "netbox_config_diff/configcompliance/missing_extra.html" tab = ViewTab( @@ -95,12 +63,6 @@ class ConfigComplianceMissingExtraConfigView(generic.ObjectView): weight=520, ) - def export_parts(self, name, lines, suffix): - response = HttpResponse(lines, content_type="text") - filename = f"{name}_{suffix}.txt" - response["Content-Disposition"] = f'attachment; filename="{filename}"' - return response - def get(self, request, **kwargs): instance = self.get_object(**kwargs) context = self.get_extra_context(request, instance) @@ -122,6 +84,33 @@ def get(self, request, **kwargs): ) +@register_model_view(ConfigCompliance, "patch") +class ConfigCompliancePatchView(BaseExportView): + queryset = ConfigCompliance.objects.all() + template_name = "netbox_config_diff/configcompliance/patch.html" + tab = ViewTab( + label=_("Patch"), + weight=515, + ) + + def get(self, request, **kwargs): + instance = self.get_object(**kwargs) + context = self.get_extra_context(request, instance) + + if request.GET.get("export_patch"): + return self.export_parts(instance.device.name, instance.patch, "patch") + + return render( + request, + self.get_template_name(), + { + "object": instance, + "tab": self.tab, + **context, + }, + ) + + @register_model_view(Device, "config_compliance", "config-compliance") class ConfigComplianceDeviceView(generic.ObjectView): queryset = Device.objects.all() diff --git a/requirements/base.txt b/requirements/base.txt index 80304ec..770e18b 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,3 +1,4 @@ +hier-config==2.2.3 netutils==1.5.0 scrapli[asyncssh]==2023.07.30 scrapli-cfg==2023.07.30 diff --git a/tests/test_compliance.py b/tests/test_compliance.py index 04f2ff8..fdbc376 100644 --- a/tests/test_compliance.py +++ b/tests/test_compliance.py @@ -155,4 +155,5 @@ def test_devicedataclass_to_db( "actual_config": "", "missing": "", "extra": "", + "patch": "", } From ae9e761e43088f449984f1b1055474b4cbfd0ae0 Mon Sep 17 00:00:00 2001 From: Artem Kotik Date: Mon, 13 May 2024 00:08:57 +0800 Subject: [PATCH 2/2] Version 2.4.0 --- netbox_config_diff/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox_config_diff/__init__.py b/netbox_config_diff/__init__.py index b4d2835..ab3ab9f 100644 --- a/netbox_config_diff/__init__.py +++ b/netbox_config_diff/__init__.py @@ -2,7 +2,7 @@ __author__ = "Artem Kotik" __email__ = "miaow2@yandex.ru" -__version__ = "2.3.0" +__version__ = "2.4.0" class ConfigDiffConfig(PluginConfig):