From 6d3fefc7a63f0931406a6e443c1cd2328ef5f6df Mon Sep 17 00:00:00 2001 From: yuyr Date: Tue, 30 Dec 2025 17:29:50 +0800 Subject: [PATCH] =?UTF-8?q?v3.0=20=E5=BC=80=E5=8F=91=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E5=AE=8C=E6=88=90=EF=BC=8C=E6=BB=A1=E8=B6=B3webui=E5=92=8C?= =?UTF-8?q?=E6=9C=80=E5=B0=8F=E6=95=B0=E6=8D=AE=E7=AE=A1=E7=90=86=E9=9C=80?= =?UTF-8?q?=E6=B1=82=EF=BC=8C=20=E4=BB=BB=E5=8A=A1=E6=A8=A1=E7=89=88?= =?UTF-8?q?=E7=9B=AE=E5=89=8D=E5=81=8F=E7=AE=80=E5=8D=95=EF=BC=8C=E5=8F=AF?= =?UTF-8?q?=E4=BB=A5=E6=89=A9=E5=B1=95=E4=B8=80=E4=B8=8B=EF=BC=8C=E5=86=8D?= =?UTF-8?q?release=E5=BC=80=E6=94=BE=E4=BD=BF=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../image/Snipaste_2025-12-30_16-06-37.png | Bin 0 -> 77632 bytes .../image/Snipaste_2025-12-30_17-02-20.png | Bin 0 -> 53286 bytes specs/mvp/image/roadmap_v3.0.png | Bin 0 -> 66842 bytes specs/mvp/v3.0/README.md | 15 + specs/mvp/v3.0/v3.0_acceptance.md | 55 ++ specs/mvp/v3.0/v3.0_api.md | 109 +++ specs/mvp/v3.0/v3.0_design.md | 358 ++++++++++ specs/mvp/v3.0/v3.0_dev_plan.md | 232 +++++++ specs/mvp/v3.0/v3.0_progress.md | 154 +++++ specs/mvp/v3.0/v3.0_summary.md | 166 +++++ src/mvp/README.md | 8 + src/mvp/configs/dev.yaml | 21 + src/mvp/configs/dev_v30.yaml | 51 ++ src/mvp/docker-compose.yaml | 30 + src/mvp/py/argus/service/app.py | 241 ++++++- src/mvp/py/argus/service/config.py | 51 ++ src/mvp/py/argus/service/db.py | 126 +++- src/mvp/py/argus/service/janitor.py | 105 +++ src/mvp/py/argus/service/sftpgo.py | 221 ++++++ src/mvp/py/argus/service/ui.py | 629 ++++++++++++++++++ src/mvp/py/tests/test_app.py | 123 ++++ src/mvp/py/tests/test_janitor.py | 225 +++++++ src/mvp/py/tests/test_service_config.py | 15 + src/mvp/py/tests/test_sftpgo.py | 322 +++++++++ src/mvp/py/tests/test_ui.py | 78 +++ src/mvp/py/tests/test_users.py | 175 +++++ src/mvp/scripts/12_install_api_deps.sh | 2 +- src/mvp/scripts/60_start_api.sh | 7 +- src/mvp/scripts/run_all_v30_api.sh | 242 +++++++ src/mvp/scripts/run_e2e_v30_cases.sh | 19 + 30 files changed, 3764 insertions(+), 16 deletions(-) create mode 100644 specs/mvp/image/Snipaste_2025-12-30_16-06-37.png create mode 100644 specs/mvp/image/Snipaste_2025-12-30_17-02-20.png create mode 100644 specs/mvp/image/roadmap_v3.0.png create mode 100644 specs/mvp/v3.0/README.md create mode 100644 specs/mvp/v3.0/v3.0_acceptance.md create mode 100644 specs/mvp/v3.0/v3.0_api.md create mode 100644 specs/mvp/v3.0/v3.0_design.md create mode 100644 specs/mvp/v3.0/v3.0_dev_plan.md create mode 100644 specs/mvp/v3.0/v3.0_progress.md create mode 100644 specs/mvp/v3.0/v3.0_summary.md create mode 100644 src/mvp/configs/dev_v30.yaml create mode 100644 src/mvp/py/argus/service/janitor.py create mode 100644 src/mvp/py/argus/service/sftpgo.py create mode 100644 src/mvp/py/argus/service/ui.py create mode 100644 src/mvp/py/tests/test_janitor.py create mode 100644 src/mvp/py/tests/test_sftpgo.py create mode 100644 src/mvp/py/tests/test_ui.py create mode 100755 src/mvp/scripts/run_all_v30_api.sh create mode 100755 src/mvp/scripts/run_e2e_v30_cases.sh diff --git a/specs/mvp/image/Snipaste_2025-12-30_16-06-37.png b/specs/mvp/image/Snipaste_2025-12-30_16-06-37.png new file mode 100644 index 0000000000000000000000000000000000000000..fb62750ceb093cf70887e1608fd6d099a4bcd09f GIT binary patch literal 77632 zcmeAS@N?(olHy`uVBq!ia0y~yV0U9+VD95!Vqjp9R(U4Ez`(#+;1OBOz#vot!i@LQ zWumLc49>Qf4i3{E z9f)1iVbQfV>eaV(Yrk#VdMzw3`t|y)+e^Q=E$w^cu!cLNAY_`9K&tbMMuwRw&)>Y> zRaejABs6Klk(Po7Uu!-*m>Bc=_s&C}7SC(<{j8b#VnzfbgP`EbwJTq|6k-5_3u0TB zbAwq73=CVuC+b1jE<8%HP-cgO%PuHWz_6o)5yDnT5_ky;AO;2oha(D?SRtYf9ga(2 z#tAlih(kmfl-NeN!G|w(7TXwJTdnrRvbJE&*+sISA4J7hJpFa)`?UAQXZ?7f!N*|X z)Y;eh_Jt+4-0XsjD@0raT{4efTqd#X=P~u4+if?_3K6$Bc=cMyml94$$YyxAOY7e; zw*CF+;>uN38W(N9$-A0_?tFJ>UwO>j)a|m>Vfqk-3=2$U^Y$3j7P+an1%^I-w|cXa zV(r0tu2ZX<=k9>UBg2Bjz2au)ck3vt?ka8$t#)=z-Pe58ar0wci9e2iSM#Ro>)Z*A zdieg%3x>YVW6)?;-@$kOQm+5+JJ)tBUh|G8d%6GonzU1_i-Xqh`QN>(;h*g)r~?{2 zu0D+0Wt;tFLd)-U_G_1~hKbJ;Oq{r`BgSxFx&L&i7=x|yC#Aa*?(MT(p&|!PDt=PB z`~LD7c#?{G;tETaerct@uh(wBfBkZ3Nr-`jmi~H8tyL@5eUoatSj&9z$b%&lEniML zV=J$F`u$5Qh=C377gxL#*RRtNUwXHovdDk>CA(#rauxw`k&)T6+P7uQnl(#GURwID z>|sOia2J2@~u<-U3EPFZf>mfsM+ zXWH!DYTu`_--`7ApUHo|GXLqUJ%3$Ye{X-bF6PUh!{&VXWha=|KV5h7Z0+0duk(Ft z)Xd8-{`tP|WNlWEbvwjy41OMdyIqe6rM`b@b?dmv+1lTHVIeXni(Or=_x}BTT1z{7 zce0C8tf1h}thp*Fsn2=l7bRZq__nKa^0^bI&YhI9E%dstW}P>;C^4+eZ<_P}CAG{c zY0pzqpQog6dwfB2^Yk|{pZ_pl_m!DA^-1Tp>Zxtq2HZEH9QSJNB%X0r8Z#lWK*Gy9M z^z%KRezWfGFD`uh@GDzZ)*knKb6-vE)z`kRdw<}a6?05UG>Amhu;3K zZPRQvUi9zvtDac)SI_iy*M5AZl+>lUjOS!Y-mS;MQ=HRx&bhU8@#Fh{7KOM78oCCa zla(_styv!x|6*xB_w}6hysf=o;`*$Ye>?f^vTm;0tq)hiZ_CZII5lyi?cV)5cSHB< z7XO-X_;Qx+&BgLUGizULe)*WK+GR<_?cMXsGX?+3SA9I#DJS@U8*6#Y=gU9aPepu4 zKRL%P{om&6sg-K~KF!=cZ{M4j)6V_<*7`eb>lRsvzby(~=a#wW`ulfuZTj`&loe-`|Yju-h?`fpzOeybOqVV8w+tiN5i{d0b9D?9Tt z&0TqH&hBcT%huoAS(zw!Ql_T%@Um$4Z{iat{fT81{QY1fZ*7^e{y9IPiL3Uc@&+fl z-}&}u)}KYUW_5v3*PprG^XHekc`yF5zceqe?r>uEy}V1_iIsL$ z|NNiG@A@~jmF7*HZj%2!`u#lDpIc2ue~X*mv#z-|=g!}yqU~+Br*AJhw^ljID)rQx z?`C4MR%>!zPud?h`R0oTclAmamreO=EMEPBgzbUv%XYcN*pvlKi3|v+dT_uo)_hIa z+Wh-ZR_v11xBK;Hz1*DN{iboxFML!_o2RW*wtMT=*e)fp?rTq#%hIN;OPo>q@58=J z4);@jGha_@Idtq3Z|J=SLB83`PMj5A-Y4@yNYFK~hINA&8N*m zuKoNXu{`xr$BuXNxBotLE4IA%#H#+h`C@`Qvn%4eJ+5A}ls{%ycD=y={$}+~9Z|uZ zrq<3bOws$TOk&GU74Q6&dozl?x*hGSNm-D-nT(J*Vb+?erTK> zw^#GDp3=Wv4ILIXp33X0A8i&qyk4&M{H0^;%kEp1CoMZJ|KWP4@~w9-Y_qQ2pT9n) z_@aDS=l*q<;-43|+*VjcQ zZC(-H<9o%#)g>%<_0NpbpT}39-u~w6v0XWafnvU)E=Poui*M;J>QH*LAzN?x)uf9~ ziI?S#N)Nx8{YW6NOpas9)fb0Q54Ue${wWphn3!jwzkMJ7;U~-GW>0)N!QtL( zd)Zsss)nknAJ6>0e_vF<{o2<5f4i4Cr}Uk;W&Hoy#7<4k&b@B;{`@lA-=UoS>u~*= z`J&a~_wOkw{j`pbE7GmM$^JP$-r)b@uWxtG^gMrdpXO=3f4S{#-ky@bKJ$P2`{=~X ze+8FY&)NJ9?&1r#%lo!TOR!V!Yx8eo*OYT#CU$CWo*vfO@kH0d)5lZN>Tm0}9zIBr zZ;3aMShr$@sjr}5`{7q*+4s-<&f5I`L*C+N_TJmR$CMry5KZ;>C@}vrFLyy^R#w)Y zWuzcLd$gvxBMyjd5j#$L<>&M3byk7r)zHPpLgt<$~ze}6;>)rT1o%Off zk6k{Yptg~SVBl5f@1f!s{%>25@a*yI`j7RyUo)je?Fn@`GUcvo>G?hT|DUXvuP7}1 zsGiN2cmMtFcYET#`+D!MDY^4bDZXyv`rb*qj$G>BChF~6VqN%Zf{NhD({puqi#>TO zI&sZJd(q5{CA+il$_NV1th+rWr~AaO@b&YyD(wH~v^#Fczs}U7N_~OhcGpTe{_wxs z|MNmuR`s*?1)P9-T zZ)REce$sEg@O|$-R*StC67)Q|<5Bf>NEk9~(R;FX-I|r__&Pf7%jdkX%-$SnntOiN zS>LD&ZD#Lo=_oDwt(Cn>d+%Da^=}nDJ?9;9>0c$+S-Lh%*Vb0i>efZs!=*))CW4XM zPtTqyWLsKO*D8|gc=Wbe{sNiP{lPk5gH?OJd z`Fu;^+ZUJet#xLm(x%+EpOrSfx$CPMo~Dx8U+3}U>MyrlKWFnDT`-wVrPCwS;@d;Z z^X9w4)^v2Js{Xc`cwO3T+LPK#7VGYQzOu}Ab6dLC`!m%p^In~@o_WJO(q)y(!-YGG zrE>d(1t&gv^HPa7wqfR%QXV^Ze_ihO6&kyXz8tx$ zcWGzbuAs-fy0PJr5rVt2YLX0Ayw~NnTH`e(Ryg>Q&|K!rv0aNYRt5dfFMJ_?L_{#q z<&9bCs~>TX+mA%)b}d`UdUUI#=eE?>+QN6jTzm3AW!(v!cKMgf+Pg1fH!Zs(e`Wv8 zxa^BlT>Mw(w(UN5dFO4pSth6KD&E9?y6_^W`^3FHzfw;<)4%=eiK^$ko`;wDO)u%5 z{Jr92Ow@VF%geTJkehXP;)NfVPc6DW`^~xgk&mXIIUM=-*D5W+{v}K9YF$$O%WI>& zZl>9z7iamZy~1Olwfu`Mg68Mu)K1k~bvAeUs{<3i7>is|wKa=;lJI!(+iepor;{|+zODK9!}k;*RJ&3fLKOKmT% zwR}74>j$g6WiwW-a=AVAiADFKjzt<~t7kpgsIn`!FmLywj>K7u_;7LJb$`t)g% z)7=R>B;H>04)T|mc+CB3eQjMqa$W3}*u8&GZK=3k*irJ7e`h6skJP)D)!zl!_o7R6`z3$A1inf@N$$LJ03cS86{oSS6Z5DqjKOaB)$NTrYWhoz<_g8*?5IFm< z>grqNc96_=A$CpRyTd2f98TOEvE|o2?nVcX>+C%edy+n1b34xWcD}OGBFp61bC2V* zswEmc3pt4=-~KPF}S3BA@qK zbCbG?nmMmM)^;sAVzJg(|DEmYE}q$;!6HxJTz+(n&-25P8?T_gzKLI6;P-8L z*Ve;r%e2->MDIB*XLhFh{53V6?r@RLvWQ2veaF^n?7H~*^{01I^X3}f< zx6>NsbGi9*_G&5l?N&YbxWF}9)HNr}^x)f$;+tFCj%UmgdS95b3GD|P37W1#Js>|!_=-KgXC41kN z<{ckqn!18oj?-C z_;vN)Rb7kZu6_v*bP1l?mUbX(aeu#za?-_+)n!kDzPd-5TwS!%J$|#bZgqLE=U?~U z<&U~N{zX^*Y`0qzZ~S&c{oVijPp@*ZnJgEzt3LSMtUGs1qr30zGd!(0Y5$W>dy}0N zp8pO8^4Cw>Z)jeAcG;ia&FWPyPabTtG&f(erTSmNYv=dxxjHH~t*=f>bq$OR42;Y? zdP;p+YPHLs>U;H1<}EMV`r0gR-~adC{9P(OHYPE7@6IjyX`Xshbx-=o$(#FY{|Wl9 zzbz>+@rm}c>7t@*XBux*ehwXnIG%K6O?CcJvGtSY-P6|9b-aAVL19a}f9EvIcjo?g zKJ0eS+xPa2#{FZW`L(eZFSnXK{+o7g&vJXs&s%FlR;2yB|NL*VZ}_rZ$Fsj>Kbf&V zCwpp#$=oh&rB6S(Uw`j9G12rg-|f#=QYXKwu8j_ju#t?sX&HHQ?^~gDr>g({JDs0* z%f?Y_`oHg&ce2~o7H`^QY@_t({7SWJu6kFkHW=y%-xu&3f1Z^&LOYj;+W zS#fL6gMFIH>+2WZjTI@gt-bl=_w)R9Lfy;r{ld!p!iu(>IGFqS{r`h!<+uLtR#f`* zdj6TXJ?08ZeHqo?1fIQuczXeN<^IZtz2f$_H?4CJaeeeS+0T0I`3DDIN=oiE@fE&0 zRbYLm$H8B^r{kk9Y(4nmd&!5*{Bbu|Y=0k7`+2o|P29ayXYGrIveEK+r}v!xwZx_T z<`3EMfROTAyxmnB8m^`K?aB(-crQym?iG5k>FE7!r91ZtueaE} zJ@$|4>U)vX?!R30%YRQ%$t34K+dGDH3Lk#SIa&R8a_RlYQWhVtUYnEgz8svQ8zx5| z4KKF4WAo=!^syaMxmRjSAN5@8%Q48>SM~F6>CNf)%KYvWuHT@#ulIjN#nERD?tYRs z-#%Kqm7Khl=dz2T4>oLQdv1Hu!S&BhoA&$9zM#sPVg6xl#D~>CQ)O*5H?DrS;&89{ z89j+F)h|CnLW+T*Wd5sH4IT?t?OOgiBevAFUG2p+?vT*3voEIg%?k*5cmBo2$X%|Y z|Kwz#b)ds`uX!csN&*{Nw^zy5#vHLZO5d;5RR%n)q{Iu{&1y|}Z(HF)ylw>M|zrMoW^(_X|Qal_u! zG}48OySx0|jnuLw`s?&pJb$}VPmjN)bn#+V<_z^|?hq3!4wmlt*woQdnq0p!zkcO< z=XA}kqA-ip3G2E}e0zH@KTO`hcbXMMEkgs}i-O0nu|x;|muGbDzIRSegAMAITr2D< z5uJFj@Ll!fnB=#KfGcqy+1#_ z_H$|A%|Z!T2kVVCQeeLw;9U1w`R+LiqKWp3PmoK^N@-Ktr;uf6+V zK5J!mS7cmVU}WUgt*VR}>d-Oe2H_ZS^SF-A&Y3m;RJxDesz{!e^`%_PUH;Df$Gcv= znRvTL&3jAh;f1Q*_UqTKeS7N=*ggh^2PzkiPF#0dHhKGP(WRaZ(n~dz+`5muXU=lX z6?&O9>s8Tl_?Yy8i@Pi?>FR3U-dMZ*^xLdE6^=K>0Au&fsOWUkJ$NcD; zmFqNh7aoNBeL=VK-2#h0cgtVrm_x95Z0`aWLakk;}?UZ7_jDss@~#B*B-?x#WL8o89)ZY z7npuMdGg{xy@`4aau+TdO#c88p5t@tzRmX+3DZ5q4}2^;Q!|YLDt^amm(#4wS-k85 zFPSXrIEuk8V2HXBHM?~&zrrQff;n3RuWy_CWYTAFS=%t#zx!pbGtZ@{ZP~NbCh9dj zUbr=4+tIfvJHRe_u;jW9L*}%H+YB?Odx$%1&#s^6eF!o!;3xR@WP$Yd3%L$UxEEA! zHC~##r7Zt?L@tgP>)A$6}!7l;*D92*AngpyJd{e z=bk_QcBdNHJ_nbKjW?Yh$L6x^=3NQ$^W)`O!yE8+;uhx6oRqq7gy%p=FD$r1ACOgqR`~^mJ17dr*Z4H?fi0y z^+NKk$5&TQeryKF02{i(yxUbZ|-Z?*$*1wspYK%i^68mu0}bs>Yr?#JdV*azTfh9$ z)n(HJ)x7O#_El2c?WKne7B70UR+kYnom9{jAfl>uDo-NKVvYRX?RHU$mskt-m{!+& zUiWQ3>^2V)vkFRGi!5Z!V$GxHEYb$~FrfQx(bA>zw!G0ii|cGnenCTACTVL_NT?~# z=A!|v9^wZIuj+g=nlNM=o9e${C zp4H0bvPt{lN-dwv_bxfPvz}>6ltD_@E$-PTJ=v9FXWw0DFq7|vg==VTnY({@VUprW zNYS6MJk8OoS~;paEwN7TLguVxkz3AfS!efC;29)Ql{h)CxOC4WeDef1#{1m&UCthg z*%S-%1w%u!l~rJ7{*u*3uHrxH{LbbtG7EEs*y^CZ#r%@3s8~7EuB)0~eXo?Y!Sd7v z#oosv)2t!N7{GI_3yvGaLOiyBSGP?a6dexMha-)kC5GSX{xA~j2M7Lyxh4=dmSu@8 zf!C;r>1^8KeSWHpguXTujHy-x?lFJ=hrr;&9yYs-V&U@M^}XFYS{GO zdrHrS6})!)d+cuE>yY#2{(nEtZF+2?1a>6@Lk<63>6f?MeI#FIRfgw$+xe1pzGz#& z$by@?eQ|r48S67o+Bsj3wp;6*SNU^xe7|>IrvJxN?&oLy{B*W{*5?x{+wWLh`FBpW z|Ag=Qwg29>{FwB#e{S*L?pOm0s0AfE-}RORU;e#im;Y22Z=agpDk+uizMa?q?MADml6!r{C1!(~gdE;6^xo2@W^&y?MZf3wg2c%b<4FOP?(Zp&9Mo~r!IXUF68 z>+`2QDPZ}dDjipyc<$3(-|zi1K0fF_mRj+KuYN(rY3cKJyNmL-i^JNvS4t1L?ov|9 zdUGK#J@4MfOy6>TlV_7wub0%Y{-f)@>u=g#v;U3Uzf4NkIcwg30!WA^&`Zx^Dn z#Q(gt|3AlYLtt|IN^_t3$Li1T{m(QHhnYKNsod`ga)S0c+Dp^5XZ(4xnm^{kaXF0` zL zak=<$)wVonAgFH?E}FKk!z0H@f8rv&omUQIT9^A|?^#&$^u60%lkIOOzuTRD%VBrO z+CPu^(`|M~yF7Wj{hn^*m-+MOTYfkcz3kMb^3*#ER0MDS{rR^1e{FNenwX!Nv0sbR zPrg!(y~-p3wPu!eVEW54&FU?CPs(qKJM#1<;hj4J@Y<3cp=H(#vIxzZU6VU^Y;B7 zur(G7cF*COn3o>X>guv;TiutJZ4RGnOQpAUcZk?jzm?=Zesn0aE}X)_s(sSEeYlv?NQ|9w7JW?T8wIsfA<-CbPT z{{7i&E-&-;+^m|*S3mu{eSEnW)DO#N1TUJNr@rU!!!{wo-yJK@$6kH@VVhm~p1-*V z&-rTqtDD?$=g+C_tk&f@@n0N{E!-=8|K90C8BpE3#5b2je*}5*^GD^!zheG=?{~ku zE%ER+hfgy$pL_LKY^C14ADcSP#O{21u=`l$xjnx(i>9ynvF^&e!e=Wk*1DwRzdP-7 z5mE{>T=3mxe#tf>PIGUvb!pbqgO~L5mNj$D+xsi(|NZ~}9)vO*yY-xwP*hTSYJKnD z>ATzaYbYuGH-G%?iTS^h%R71+AL~hLC@DRBmHYnB&tJPU5uvMb(eQ0o%;H7KR>ePW zXa?t7s4o;&5394RzxQa?Ii8zOfBEcqbVjB92mhMLEt?*^xO-S{)~u(3AE&STm+FXzqMJADQFAL(D3V#YgaAO zP+FvMY9jwG^Gl+hGk$ztdDzM#@cN3F-}feG^OfW|JUHUIr23J|u7w-oBHw=f*>rG1 z-5+=M3p38HvYfjuqI}BLMPEyFLH#7b3rFQAP0(wreA8T56d<}W>(Pd;zrSr))|AhE zzxC=??Q%A*)uoe-&Yd>{53ES-g7PS5@_0xn|ee zS^N|ZChXX;=9cVZm8+}_40D%B2fY-(`{Je5h07+PNwTuurTihS`akYZ(sf;!r_D9- z?G};O%fGA1{twx{)#H1`RY5K54Ljdh^s+LnC>#9Eft?)9=IfLj+`WXx0-*7 zyX(yt2N)O_td^@e%;TPTc=Fa{YopMFYkP`5#cWCR4$Qc==TmFB%GVOb_L3SYD+ZS} zr?T>Ea!e}q3X(S6)cLsa;?%46=IOC8Ft{vf@Y~NSBX~vi!n@;#cfYRr5`Dl>_tuN* z^1Gd%EI`dHu8%*PE(U&^w~onS^`gaZ?BX|oSI7vSJkR58a`j*Pk?hs}U(7l!9Oqc@ ziz>x3ET53zu|0C;BJMdma>Bd~j=Yg#V7MS@)9GORw%%jS9myBxoC-c!6v}D2>|*fK z?YeYXlp#a*{z3+Z1($g~-#GqY%B6qpE{Uf391CLo=CpIc)@S*}s=a4lP>U?YK=xX& z#FsCb7#=)HoV6)G{$=|7wRiXZb;wa_i{;;clvXXzlY~>8=UH&!W z>e=T<{+yq>Q8RCC;^`A5w_BH6*8P0nwKM+Ow}eCs%Pr8`wvj#XODZ7F^;3`~rTPrlspv*zI4-QCl4ewx|uzhQ7DK*Of~pONcC z!(_JaB@aPmY2U-sbN~IG{cPt~yZf)I|IXfZ=7+Ln=iyZ<$1)@f#a62wP<5WOfBzoO zdC~fMUVAHZ&YZiX_Orn}LnP#d>(w*N&g@&vm-4<&^u2D{JKx74{lzZ1Yn>(je%57g z{(rf=^=oCGQ`76?_WKNsjaMeFznppcrtEG?UmhbcO>Uw+M zerd7tuNuew_8LkTFY?XqHNOptl{~>qXVa&JhRaTU-sQ3N{*Qvi<CiO_OJHzPu)!ue@&K+4~!gzP&K%Zj@*5$G7(!cC@zN zUitJ}=epU)T%Dz5^mXk|MTExy7;u|*MHPBwsMExExWx`mN$i8#^T2* z?fK$j)2?q%3ky}Xwyr(a&@)aTz2VRU0b{9YWjAK^>?PcDAOq4 z7x^zMFSdM&)K04p%!f>T_uo(7Y&~N&_m=tVULS0jzxASDSY_q!Z*Q$Nl*)FOzPhq8 zSADjp=e(Df)z8e`ts=qm{%-W7`SEk6UfumS`R(24Uw4YTYxX&$rCoZtoS&oh(9WHe z(c47M%-#Lu+im^sE-TmhTA0u$cM2E7l6B|8 zgMu`bR;}ujxw+pqzbJE7)w7w*+jBGxd^c~{aOXL<#oV`ZV_$z~*A>e>&~T-#QAg}; zm$uvdx?c@fuKf6=yG%2B+nc`+nnPV(pRQUpuPkc*X?=TZRn>J5l~`Gq{{Q!VdiD2d zuXfFw@L=DAriqK)`NKk8U9PRaZ(INGQ{m$xp2IQQa+24_y|aG5=g`Y7Q>TXR|NrlL z^fr+er-y0FudH7Aaqstg;nHSr1SdK+Us|`fOsN*s4lmG(-mU-R+V*?Z=Zd%2-Z){m z?d$KR|9|~{?unkXXpQdvIR?Hzb5_6a@7Dh8gIOV#+%{C;w^2wT(tHc^N-V9`{9-T-6>bh7bgGoUwVGV-^8Pf zdhdCypZMllT3^{+vy*qDCf=LybNl&Kac9@A{3Pidl-nOCdH>&TfrD-D4zk6v{R`1`6HLo8j2FAbtX8*M^w3~g#GMjx%ik_WT-R8T018T=7B5e1Fic z^-lTEV>>)ruiuDY`u|&V*Hi1ig??9WdKJ`Mx)Hw8{#WAFzDv`dd#UZ-;@xhs|G5BP z_oGePl7+WwpYMJvrn=we%?7RMe5L>HfBmRywsCK6)9bDCRr+40^S?}sD?G6XB)6~7 z`;lt9cI1c4NB&!#pDVEToeuX~E$;6{ncwHU{&#!Jj<(DH?_Vvvc`~g2#GPn4xmjs@ zGktn)-Q<(wTm7hLMc_6uzw@43_4X*gYzw@|w|di3jSy*r^QShP-KqF+8>gONq2-nO z|2iPaFkds#HZHA0k)GGo>mXJpJ(&q5{>FBZ>H)dGB+hL=5 zQd#Ni+%j>!n9}ESr)zg@sxF^+TekFZ?{i+ME730B-sx68o9X)c+S^$snHfH37XDwg z_uhl^b!9If9DJ!H#M36O8};@po8Io6E3M-57HR|q1$}+9nLB*lotj@SnT3SzCIc_Dl4_P1lYo}GrXGEd3dulw^`Je2oj&h7F*`_JYAMw4T2sefOha%|3- z)#fvo)oeDeIREF<`f_%=J5Q>!s{TYuo(&J&964pnjdeejRCaE0m9D-1fAgAS`FG3? zem%Z&qjOMhxlf5{_L~PqE4I%O7M5SDclX`?kMmQ1Ykf8R`$qrXjDo1HuirY?^_%@% z_Qb;Ykml^2NK&;`yBk0^(P_ zB9)XDtvY4N{AT@~DK|DOy_0RTdrMlhXY~3h?`OP_nw54te(CvH^c2# z11pnata;PHZ*E(zUcHyF!FX@>q)9gWe;nl(*N^-B^?GGxHy&i#MM+x_bEol29YUfmtP|HN?= zFmd~ZcD}P~&KJ8bS+eBQfyU;QD+?bV=U-VrZ{n0EJ0G?c^UJNtjOf+XI%Ss?682Ym zgPebfpPyUc#a-v_DljlG2so~Z-MHsMgUf4u{V#X^zyIWJzozEzv?XcP;cq^LSM<(?Zgosp3Zo_|cM>(39F>(<$?`L4ZuyR$oSZ>~~O;M032-%YkY z&nx}uT-5to?o-R>3a*t-+vNTG;s2>;b}Bwxba_tH_di^j;kkvYE!UpvD2c9JQvG$#rEj$>m4rZvW74vf zVXM`Y;_?^l*kNR8`EJJY4v)%8-`Ynni!5dP`u0Xkh^Fp;wW@fTpY8HRi_-4yx~mPdRj`IlgXZ>iKz- zy1H)dvf95+TF$@Zv0_qke@#?W)YH$b>^vU190jf^7Z%)WW|Q5usUu?Tv==Xw{|jf| z{gT4Mz`$T(=yH2X`Z{l!d1>AK*Ib_5X0KjvFRz?4S{$J?d%#=qt2@z}ohw)h#Z zZGN(XdkdP&y#qJIH>I9GSn%HB^nxW}ljj$2o>RYfm*@T4KQeOf{!7U0wbQsc>y4ez zhHw5GDr8~dsp`E- zIalnO;_msvzkBt+ual23?p^cxJ9~Zo&h%){A4}I~pFg$b>#KP>yVqR%k#KmTMwxr2 zg@l>xv}gIQJl>m+-k5qRDmnY(M2phk#0&C9wtP$Tn^J%FIaIAFJFas5Nsjyd3hy50 z-c4G(?=yJbq!va@-SljEd(Faa`;?m!KOfc4-k){l#&v0v@b{UkoA0+Yep8vgOta{> z%k?!M+xG>FxE4l<=3gj?m|A}QXPuE*McuZ?jof$3eT$yT^VDy=`~S?PS!Y{i|H(ZsFQ>KPWw{0wYyDstYFWJwvRk7yR*4#{-wX9>wvSZupbGIA=t=5Ts zKG(eF(vn1OF`f90NuMt*m0s>Yf5Gdv4i(j-X3=xcWL=HXTb_C9cK-T^eKnI;X?^^- zj*&SiIQa2&y}K96wl2GQQhgZ%1H%DN%?yvcx&?=_eI#B8Onhqn=eC#A!Q_&&x!s|` zayxbMVjUl}PQA)pW$KxEXjjLHhPZ3@eCvhpCx!|ohX_jAADAexcI~O}^Dp1uw_mB| zZKR=c)$O3Ww>G`~*JI(GxV7%-hZQX^!X7)dyS|kDzPCv6u!!@l{V5w(bop9;y7Xd3 zd12QHzioA;^<8Hdy;*<7^~fsC>vIH4&r0TQQ_pBUyC_99^7gz7RVIf81Or72x8CG` zl9BxD=JS6V54VV9-?<{?*e|VK< zSBGX$#M0}gf2VzBJNwCM@v;0r1%5A^gYVeONKO^ZKk2#k*^;RAIg)>8f7kxManG{9 z%&uHYu?#O%Tqg=D|H{2FA%E7hHr}7sWpCEM54_#BXHTtlnT&v7XlOg{>)Zb}zIyeC zTYR0+#MAoryA#9KtkU}Xd-=MEjXPJZ@^h*B`l;i@jzY`D?&g+f#{^fUbZ#sBVZ+Tz9jhej7 ztsOSj%7HGkO!Z#O3F-f4Z{M;1Y5dat(Gd^%XU{v_8T~&_OmL>9S=zCgy6fLhk;|5x zl5#UxE^gyteWmo&8|4>rxp(As*4kb=v-ZyF_nP*Xy552!D?ntUtf}+1#fRE5yHs3O zr>EYMa^EFcC^p^sqVGyxnUhwwex*g*9)I5BeELG_k!-VWA)_VBULLCb`SjreJ-^kH z1Ztnin_pcUX|nWEwn*q}^Les!=jPn4HK_T#ntlI`XW|p>W8`iNyu3DFE;Tqv%J=m- z&y7}Vyh11E@!r4w%J!ht{)^gfwaLDBo!^IVtMKW)|Kn@*)ctkqgIv~0Nq)UCbz^hz z8hcs!;vlzO%P;&^WVrCOjpw9~Z?Et~2L+S-d*Z69y6Z!t*DqO;_3zKqud(94zWbj% zc{1a_!`JTFwST`}Xewc^SpS5U668SCTC~oyT89rze;}bi`(w+m*4%i-_jfp zp1d=e160f|dnC1YhnxMk!nekjw<}LagtuP*V4C&7!1DIXEt>lEkb)JO zUH#$ir%6(=E_2MDzS-AzN$T&?_1Ukhb*_{r-v}41OOK7!pKfM!_u8k5wce%KhcD+= zpFZ4eR`q)OjAeUvZ(DaMyXR&4caymfR%#e6+pnB+Vo&JSgC7>2vcL7@c4(DKuFbR9 z1%IdId=w9Tz0vRMHqLc(t~l5JIre4q{qn}!Zv(nleNDCQ+jGRbFxy_HQseUel#b7T z4*Ff5(#t-}+&p5V@`n9JGU7}9tp8r{cAf4#chcjMsHw6`ANp_mAsglzx@3db`t!xE zD%Ll%Y;6Rq5C2PuTr2lFq~74qg$T=)*Sgy+bYtgQT>H`c{u}GsSw%+;(hu*wZ+39& z%k7!-?@YGQy8P{e>b$l4y`w*NPx+O3;{BSh&z`Zn99dQBc>A@mz~&Oh1)mo!dNV!F z>ec-Je_XEje)=@)`Ml^KkLBZM%)D6>$t-viK3pg&xcqY6N!4`IGu64z9iGp#{&UuR z`rePnE{STJsjBQaoLGB(-J)Gq^-fK%=M)#YEUEuLMbX)4|L?r#ll}J)w`h zblIUppN!8*NXWho5IOp|^VYt<2X5!DKXJlCOMiW4bywbF$M~AUlP4zry?j}C|DU4b z!osJS%fFsH#1!(PB!K$Bs7j^0FnYHM|-RG72?uh=__P|5MeAoKFhYp_me!W`1 ze%p&QYN{4@gXWx90uEg0ME*V++Hode@|6 zKkl6THs$IRYxP~>@A+)@Y)CqL@4$?@KL2eM_IF=-_iz65WR95Fo?YL1Ui`d&<)8X_ z^Pl;x=JRAXZ#39ZGJF4Y@73QV-|p`Y_TA$9@=nj)_2ul()~?$9zUF&Na>)xFvDnw2{)?G?}{B ziC^bE*nhq1z1{WFvyy>_tCr_#@$|mSF1EbV*0G;^@2qI^#0AL_v)(@5J!#(^(SnGn z#q5@M_)6nbE56Qq&=7Ym&M+!$Qt4Cu;A>(m!pT=Un`L$>p5FF*_nt|@j1%=3{?vba zXJfbO)xZBgc5c?6dD4tirA0MS@KB#lp`(C+c9)m-(M7J^-m%$Ne`T-D&HbIb|5|>x z_tk4lU0pXeXk3Y95pi^MILya3X@ck`b>o?T&+XlB&(=PnWkO4ibKH4$e!1`GY`@<- zKlhA}+I!35bCHs^R<*}^Hom_8J}7kSs#9N|OwM-eld&j$wW5)E^4YZJ+i$fsG~&0v zo4wfm^py8?FMrlF+E#zMeEIi_lH7`B)6`Q1tFM0fb~|1~EKEc!j6=~xD)UIkzt7M8 zdHI(txSW~4<6+R|n?J6Gw_jWHGl*m1)hsoCyZ5_Z>s`t+HJkmjOC~^$+hhbD zH5^>9`_-zG)@4sD=IYi;rk$Gd&}#0twcGdI*r=R-yicarP0Cy^`Qf3LHd`DYz}5m- z&7I~U&cML%+-!E_W%ID=J2&Tj-&>o!)vWmTr&AW)&$bso%B%bO5lokwNCuYlqo(;7Q>Id<-fop(Uh`Rv=* z^D7=ET-G*Mzq@@?)>rd)SMTpIog^_e>&J7a;|cD@m#x)rZt)gZz5CPc>Ap(`7QerG z!*|yHzX~7U=6rvqeD7NP(Vfp{7>m60yycO#rtGM$XIky&>v9vWUb;0+uBd-caP-qK zxm{=O9bd*f-zn6!Zt9=w{&W8S`7`Aqt5=ruZ?ERh$0F6fn6FrMV(oo8d@nO?O}xobtLm0Wj==Zf_fLI0Bc0v% z=HKt{uivPhJ>+>!u=cC+uUGxn&gXNB{^nE}%QMVb%KyFkePrO@AA5Erp8Uo7{B?@} zW8~i_6TbhuYU

YJGR*##`-roBO>k_WNDsR8Rq~bdM+%Hj-2zgWX`sS58YVLj0_$9CSh~L`0MB~-(_xblXB!;VO zu~qh*v}x11+4l8WvxDw#_>i$}lFHE|GiKe5o#y=YtDU0DWwupXF?#)rRxH20J@VmR zC&i;#rdwAQ8(!d>`^AQpf#La{Prv%?pG}s2_3u+*^``~#v2x}A&)n$KQdU?!HP4mf z;fAbpbvDZ7=7!C0U;0R8NZbq8Z?Pz`*|GS+l!qe!Dnjq*r{C@mIidW2xrwo> zz24b^JCa>It8O=GyU!F}Gi%bVG^yoBGxg{z{!GtjpTCc`%xk#iId#Q~CE2#8 z`v1k=D==ueHsiwmAM>LpJ+6Aag2~pa+;x}P7&q;f? z{#v%g%Xv>1UI^juEIBF2upqfd#`42OclI~8rtZ$XyzkMYQwblA$gHp0n|GjL$!Y!h z200P4R6Jk4ti4}TTx@am?pAGmk)XU?nPMwJOC>HaAB{9w|5xSZys4qHi}tMj={3(y z=yKc4`LPo`=G{KMv*wilw9tJO&W}=+qpSlX=7v|6O@3{jKKJ>e=?X3k4Sb6)dI+oW z%rr>s5Kt1BU@KRD`6I91o`RN>Oa7m)H~ab5QPIWE&oeOa=!1ircY_T}pxOD5f29Qvs{I_4~2 z#s$9Pa~AGhdho-OOpEZ3PRtB*4<%ffyYBtl7Eb39tGAz?&ad2CxBKp+f*ZScm@zOg zFyu~}w?cWft98w^$G>%+pJ~2Pwx+)BU6x6x)H&Nda*Ph<62@wK>;ErV?0&k9$8+n= zGwJsjLHZaRI2@O7Gu&0Rvs1I%SDO44vNElv{oyW%JcB|OV*S_^*m8e{h7}-fU=tVu zo595#h_i?d6yG2gg9|TosW`)zxJg&;iYj=00Z&pl+;a`xy_<<)`Eg?r28J!OmmPP} zeW4ije}c!xZ0pSebF8b*IWL{96zl7BYR1mX>*rWk1UP*cVOS7u^kQq{f;)FZ87$U1 z1em?3J~dl~fk7a)Lb%Z_>i>it85=elb06Hf__<9;$=w3Kvj^7Pt~WcW*CMdk-EaNL zf4^VP7b$U;WT=wgeDlG#AH8kwDqb>Q;L7X2pJP^1F3P~5pf&T$A^-opCJC3eZ+m*k z(=fm8@yjz&1$_|~n#*>3TP&2WdKsm+&u-51wxj*~>uO@IhchlP@7f%_D*J9DL;jbB zc^U!}R%hQWEb}fGXJTMjG$~MW|=wM>ts^*mF4(X z&MC=UvikB%6;H2@e3KhZ=Wfib-);F!r}FD#+4$7@-P!NA@BUV|`{!fRe!V@X#BSEy zpS6D5`@dN?-&<{DVz6zCTkh+%VQQ$AXKCX+HGv7IwdQ(qn(ghgKgz)1V12X4>GT{& z0jJf1+sx|h{~eVUa{H27x;^fTo${|&@p~4s_sZXP)i0g2#Pi+kYxNH{NJQrx-8d}cw{C(Ts+sE|Zv+`Xx80L+Hn`h=$u73Z z7jq0|RiAQ@V6ci_b2q2oP<&cg`1M_X8E5XC(eQ5RszdUiKxtii^_xWTn!U?wUhpz7 zESeam{K)6a)wgeXq6E{kig(PHS{+>{j?@QIO0@&9K#X zpRQV4Zh7^se!b0u>C@9^vSkt!DyK_JLuHGvDSFrK*Ep=f z^fUW^(e3={;arL?x1Ri-7g?0OEcu!4{(0NJJoJ?=(%4`6Y2TFCFde&NKHIBlO_W+inp+qn2&cg%mo^bR|Ry)#uhMRtE%bnsZ~F+d9RT0H=kYK26%E$^2MoGLz%}>%sRK)%U3x$8rSz zKHIJSDMr!dZP-DcD-opZokDVm;P_s)6-`17Ec4Vt*PXG z&hKw5VZ#`H@>=TYuy8js-`)Qoa$i2Oac%$JP@DXRkF@oKqW@L=-lXirq4+Ci`-@}T zzm7`IvR~%my}#_^SLI1O(;OCTt2q7IKTq#ZvCnajG-qQODp`rcLMOvKFCH7p(F!}!0{J{IKQ)Tr-&i`9Hf!+R2 zX>r>Jxr(G`ds5e3-ctGEaBR2Hk%gtFoi2;_+E^Yfd)jvV`QMt%3iabn;o*O@PG2)4Dr9+FAM6=-S<0&34Xd@9Jf>)@D_Q+`O7T?Saa4;`B{m&_x63;J2ijL?vD#L#QuIYX@1(R zl5@`4^Xq?nW0(K;dY7|aqICV?Nz29BXW##J=dbZEj{29|rq@;e-n07s-@~_(_MbC+ z{9iZn0^@XEaCyt{V9wQ-%=$BQ!$L!C-(CJXDfi6d!pP(|Y|R(nf1dMpXKerd&&v;L z*V(R(k2$ud{h!VIh4#O-yUX({-)~Ky%~u$(=Ubn)+->C+f!p`3J{`E4mIQyqU0h_qBU-6+(IeGow$20EMaeLEmX};|$v)!$}Y16J9ZXAlgcw=;01X5>T+f(p~z3puBCU67ml?_2r2AAcX;?u6}%nG?6%eOFh9 z_e`VonpGO7;>r!S?^$|o*Q}N)Yb+lJyIwrU|4nY~yOtzYcV*Zri@0|MYutrxQnKhriXz z-Lx@fxqwrLZGVh?<pKR@lCvUZzlx`b#_cHYwZDEq}LYCa!4-nZq#y93Ws`Saq`S_HP8 z^q%(j@v0K{Yg@UG&JvFb1+6InHM8PY6d%8|HKyk861KL)@z3>X%Z|{dTL3 zqgqzv`krq2vE*6wwtL@>|J(bsDc-L%Md!!&Hp``zs-}k|-h23OCOQ)BfgcJ!3 z=W^%X_P!om{%XgDuG68HYfjI)v?;jlW~kSxuJgaXtXuj$?*H+rzvnt}L}wlQFj4a5 z$}&ZEuTA%>7hH9|_U!-HgX-5rf0usylDS!2XO8XG`JWHluRU%5IImi6=i7BlXMbis zW+yp0J>E1ws?Ayc#QiN_?%kFwo9l^r^q+9(p5T{d*Pb^)%l1$pugIs~`2T zo|b?$77x7iyf$OA+=B(G+Z;d3&9T0s)*{elUViwoe9C;i%{ezNP5u0EQvdhzgk3W& z|1|OJ7I50}XDlGa(n$v0PB zmsII>3l*t9ApLIR?Wt42PGewjFnM*!!`vrOq1j-IBA!bYk~roVS%N~ zE`}=E{D2*Q?#8L9#XS{@tr!6%A?<6^st=o#x&4(%b)mZTD`_7&HUJ z1(u0=4RI<~sV&K4|Uns;Lz zw>*>s_rMk`zgSw%$RNO)wfEF~yA?lQG;BY&ddbnu{GBKE6%_Tj>X(*7QpJMn6W16P z-i>8$n78O&=;eK~p%^KDr|+rPw>MvZro8WV-1-$C=UhrGb*g{7zy6I!ul09(3Gcp_ z@4#(5h8I)k_-&6{KT*Y#L(wxRrLj?*;lY-W&^7;#)n3+q+WRR+@rb$WzxVqCzU4-4 zh~@%2lwq&fL_G!*{Usf$uV2^g+jUAdV0P|=c-8pN=O54R|FLU_0XXC?%ocpfP;ac}@1g_tr zw4#*#%KY`~s7vFv@dcOW$&TB?++#J}swpuB6??H+AGi2t^SYs}+ zdnp4$Mp#~I!M)F^mbvr4_yiu6wtn?KF7W2Hkn4}bugQMcv;TT%>Y1$mcCKmPPl^R7vfpL|j#uD84R2lKvHr;O%bzk75lgM%_-WbKpvrbkVS zcK3g{7k8dj@@u#LmePb!aL_RDmIf}-_MBvTOm6ywHP>HC=Nd`*Xs%>#F50kd*PYn+ zA(Fj94(30%>CA{N3EP__8v3Vv`)_yQ}Gr3Ods*m;?C8D%kZ zB9&WC=zY5Xb^fW6_J8{%H}=;?%nldbQ?lZx|Mg?fLr?VYi=G@VI>+Y1kK;Q(t}IlK z`;fi&RQ&a;bu|a1IaWUBH~5zn_U1&0b9*?XdbjcnciCk%SI;uF;bKD8!rr*${$B6I z#pj2VtOF6?~aaJUGPh!J|OmEX>DAn!TO+Y zKSbxR*|+<8@YAqq_b26C^lml%^i_ZA^Dwr@XQbwa{rb`Hyjyu+c%Ij;f&y>-i{Rj2 z@cc!Whqz_-$e%ZI+69~{nCldK(tdwn-Ctdw^X%ivY@%lix^S_PgJ8o&3be!2drY>+gAOGWdmv+Jixm#F{# zw(GBYJX^NyDdYK9Pbz-Z|12ctyOGuT)m?3^`!^p)Zrb;5Cu?89?4sp=_Fq@inUxe> ze&P3JbFF=vU)sa>pZWav$J0-HRU4U?Hg+=i>gSa`3uCJ{F?{xa-~5CNp8sz~{=abh z>i^$?Z&%&^9q~o>{$@z4sAPxVd^z9s*QNc1XPKQ&FnYPAmpwgWxuSR8{I>#!cK-GM ze9TwQHucksmD~4}pV03)yuSLu^5rHaxm7iHUVQfZTYgmdey;ES!kAqGPGU|S4};(P zzn}4l;em>)sHxo2Gm>3@R)3%WM!)7k#exN1=R7XOo8D6LJhWhPUD3j`i>B|>`!IdC z(?hd9iMMM$pP8iMw)91^@2ttI|MEzBR=I|zx?cG?CHH6D?7-ru8Q!hhAwkOh-o|Mv zueeq{J@qTVJk|;vHyN|_UM$u243e&{D-Chqm~C{qRlxPOTy&*EO>_3|X6^0qpI(Gi zAA51sNhkMi;JRIZn@kjsybFE(hC6-v^8Vd*9Tjgp*h4uKs*XgP?4K)aU%KMuUfX&5 zYlB|%l{`6cKdkx7G2Pltm;CE&ADoxJ_V4fIT^~Meyfs06MHJ7Zxavn=3lsL7%e(fY zX_btXv*aX!Tkq}mtp2Vgx^BYh^>=<0RW9B<{aPE_k@BxCGr`VXAX{}u{zGF!eBik< zozuc`|4+Y-ztv)_zyGAz{yVQ8RxOXU)ePnM-}v^tUEUl6>)MK2N6phZ@8`ds!Nusp zIoIvaSN6_g_vjXZa^KCrQ@`rVUoX3PQ`gONxuBw}%cI-L=O)}%L!4``#3THd`<-SOxmOT&msvq-&GV{QDLe=WO2o&VOEZ%5$0R zhLVpdvp>AAf4*_*@|+E^CI7>3zt68Yz3}Pc{HiF9jX&Ba%Q|&9&;Nf>wWXr&^*dI{ z(?kYYg2g%$`mutzsjrI`e@ZfvFZLlUn~e;Ke^88YM$Aa zg?xL%cKsIm-BCns|L@}bdmO9s8k5gy zct`V>K1{e=%q`0q_I7ssZu#2vYU_xSEEG5Z{JVsDnPGh{5T`?%}we$VWx-^$@fY>QlDQm!tTT=#eHj`(wZ z+U2c2RhsvTK0P(kzB}Xla>nJ>3+^uY`QzS{HurrwJ1l=+|C?pgWxm2{jZ||Vn`0(Un^c|pYj%Y zedKih&xZ!t&*!S=-g~TFZ+cnr&(Z9<%G=4eyKirJ?XNGHEp}-3_QJD`U2Nvd{q|;M z)gQib^LzJH|FV*;;J9UAxG>4Dzrc;2ytL`pU*6C%7 zUZJP?dc)-9zI8vA#-A;eczUY0e%DKW>06VYcWv3Nf6|2O;jORt+2yMLUEwm`w@dx9 z#kWbfk4)AtjabRBD}GaKwwcGhhFc}Udt8%E=hbZ8B&D*~Pw4E{EwfFRZS#o)Hvt(g zsLi#rowZ0~tEp($s+REPgASR2`!>g!f7*3lf7VTv^P#KCYrf>gy3hT6Eqz|(j6@M9 zj^D2P|9x2fw%Y&S?*~UEb>C<2E`BAU=^;` zJa*bxca~b^8c+SZhG*rjKVZ!cS@m$4=C-uxd$xDyWxSmeDF;ql47<`Eu=d{$4Geqa z;rF%j?$oEpr?jlG2=Uxhr{tKRCZsJ+SQqgxz|L$A! z(23)EBkx(B8NZ)Q+uN<~Z^pAfVdLAI_GUA)c^$SGy;$hHFO3{9m21SeglzpXA zeEe;my1Gm{a`=At?y@hjBLuV<@x3ORX95{Z1V>t4#LRb0MJ%Bl-9=e<5({rl_o{U0=9!ypYD1_qa< ze)8VmMZ8Mi&APw-M%W|!nfFc|-)6tR#I#bcVcmkax32FgX7~=NS#5o<^X@6&`hDTx zVz#KJb+hiQ?_T{hPlLgs_}tG%@tC@Yyz_QSDxIlH%&vS<2=?Cv%U$&kT7yqp&iVcH z;X6yyhPX3H3YT)FyBF@(1}k7#{z!W(LzUoKm-&~?Hb}OHy?O`PPIq8sM+xH>y&jbd zUS^98Uc-AFF6O*@7#QvzUn9;o_XCTIf{N3bvlUuv-6XagN?K44n(bm>uoao8*Kn^x z5aMcwDM=3?ZES{N)bo3>Yc~VK3%N})Tx?GRJOV|QKAk4j>$dvpi?3F5{cf9Xg{1O1 ze4q}RduZtT>-wh`hHVQMaSfc35OD3~mbA@*la~1D@`Gbx0V~fQh6Uw;p;sS&+-i3| z*JGNqT)%(lRlfC*{?US|;K+zuZ!cnX*n?Y_qg7=6_4|!q!I~MiaDpsxndbcV_59lp z*%=w;vQ7_%tpxxn$&goyW!URspz(dhV-TN#VT&k8%afe8m=`@LLN0+;YF%%Ul|eKx zxbVi7i!(4-tbLUg?&s&}(^Gk*<6qR;XFc1Q85-m~LS0>5-yUpsZEY20W>ZN`dP-JOIAb#PAL-?+qvb#$Ly?E@iiY8J1G3PJpY`hU!SPcj+lGf@7>x| zp5G{tbgXBiaaxbd{$r8i74<4yY|6tJHNyP zlZt|_n>jCzaWOJDoVJnszbRFkPfjN&G?ZgwvirH)`Sq4nUlvH`@41p<^yBZrDj(W1ONJ9<7oW;f5BFn8|nlj=8} zIy!~h|NS^_tom}w+W9q;_GVs=Q*_zQ-5qmlk*l$=P|nt^?^bE0D=5g@$IYE1Bh96m zzi(&d^>sGAZbq|v|Ngc<_i5wB7bR~sBp5DC2K8P2#kn-)YrmAbxq0niXJ=sO5L-B5 zQ`XgI%uPAMU%Ar${T`FqXJ!5gy|}pe>B^Oy zytmD5q6{?BQ@-@}Z#R9lf{B44>hQ}iPoBwL&M>k6b@5B#W1|*<$jHONL65@M|5Gym zKI8gp^O-$it52pFX>@6|1c+RH`l)5Q^rk3Zw!@wK@1|IW1O&td22P(fYsRcu7YV`*va=3Zs{z9ma;G%`pB~TESIug2Om=-c;_~}I z|(fZIdiMjT)*Ar?=P;6=J%UxwJMW2l5cI?-=o`d*_Ge_c>Fjy zZ(n6tRaLy=kzZeL&aatNd(m~~(W6sYS)V#_?2R+8|97;VPc%LKdE0&#t0b5Iv-9`q z*t9t>UhFx^q^9ClulczRH_ybDon&B`lWZebpHlhiO5nu!7jgLyj*8p!)LYj*I~E#=Oy#iE zni5ksZ!TW$Co9U=&CFQ&`J1-%L_=iN$peXTiqyIfDF zp?*Zc$=dI+#uWu#zP^8NZN1IZIJv$w=XROLq$QTcYDbSBXKDV_B|5vPxIeb;XUmy0 zjFVJqf9IZjcv$t}H2L~JzCSXq$4oY@|953y?PGp9ovP1g&n}#B$yJ<(;lR#AS*EJ0 zN42^>U5k$2^X=BLLx;E``L8EuOA1ia$R-c*i<@fvfLN-Uz;q8)ik>{mA$<-X_D6Jt0h-MCm(h`)hA_I z@bJ(}Ny*yTwyyvG_0|2AIo^N$*^@aH*(F~xB0W7*@9!&oIN!HgQF;@Qk+JX?#54!rhX~XBImD=2Ud+m;3VNa&~c% zSD)i;iU_I7R9 z`L>f8U}P# z_EeYuTNC+s*SAx<+HdCU`1vd{HFfLEnan(FcNaL;_Q`18-M)UoiWNzBcJ%zJT7O@D zOO)^BWxbw0TVm^e{%mGn9Ezb$cZ{#+ZEwjhw6Z;K^Eody^=^-3a`e|`MurkgrC5dzrb#NX#lB&Ifv$P)Hu~G0 z%(IAZ7dVt`NSzBkDy}iBZX@h{0NKnnb1=poltXQFZiIri&+DDI*-|ee@w=p?1 zzP>i){k_FcpKh(JJU+i7>1-40^eqB9RW|=jv-f@Z$+S7GceYXL4uM0@Kg-+4ExzdC z-al>sr&H<=)0Qs1>eO-Q(5H1fr{yiboEaP>l$5-donI_8eD}V;+kSj_cZiza3Vj^?-+f&wcdyYN-eErIm-+vEoF>$Y|n*TxX<;&VhlOK0= zYu_$>xJv8L;qMJ>)40hhk_EPPG|r9m$2J^`oxRh=T$%Z_gMbD{m&<}=Kp;& z>&K57ud=RR_WytV?YFzd=kHwpn##sdVhxG_KC{_BXJv1irWgDD=kwocJ{GC7p53{V z>^*&5@y4A_3R&4wbKiyYTN5;XviC>eEoF#Jb(X`SD#s9_0}(1;odjJ zEbD%Co%`vc7ro|ozn{%kekk~VZP;YL<(zE(IlaAG?-iYXlX}`r&N^&wZS}ctZ!M*I zO%e}HXysnMJyU4s4vW_-0-McpZ|zP#{&0rGwy%$3a1d9x#CoAS!$ivUWyAi;Te36eEfrK| zizzy};_K`3+K)#YPxQv+C!C$7|N3=p@bbQ0pH6v-bsL(0zw!Be{e^wOM~)wt=&hQ4 z_Rr_}@zOs($uclx%>VP}@%-9t=7tyl?R`I2{@<>iqe*iti|;%*s0yeU_Ic6Sc_ti z*0eiY(o|KC-pYCQ>ea6gCdy76(@*a%diwnIwA_rdJkksd3l=6AY^kq5xP1P*dpkdG zSjDB-vS>y1w}+s%h8FkB?1Pty;F!l8xW*Vus4})6tPHdva_mHoSN`-9B<_ z)@CpEAhx^{Kv?W#rlYK4D&>n-11>$iM){{0`znB`}mZ7X=Ov-bD) zhfl6N{+NHeEP!L;ynEMk%)Y#p&fh2@y>I!g?duop+O_ZQ^?CE|g>y`tTdN{;B`y8= zzu))e*GGTvU8Ip-zw64CBfsD8FFVrl@B4lGej9OyD!ZzeOfOu&RK*(HIJai=#lmy{ z6y7FplvuydYW2e-M;?0G7ZvxfjoSM2QMbFCZPd2%_kkIy!4stXwH?vckXOO)LY$g(^wOTK7Jg8FT0EPCB}*{NBxFmoxwVTX0`~ ziptABpWAP}o#qytFm>wHec$hW3-Flu^up@(>-z85)&6=Bx%pUp-OrypcOL)t&i2=j z$N9^asfC4Fc6V#{x*gt)#`lZ)tQ-n^Zz{A)W|&9RO;+P=cB8` za{auz{(d^$92%;w9J~7Jl*z%5KK|(L);``Zzo)+5F)(oH%^VrKJ^O#?3G3^uxv``0 z`@6e8yT#+LITd6YyioI(EBlf$bJeQF?)+Tet8-JXt@&tasmjXw)O*^VxznC$=&f6` zVuiQI2H&|>UK|rAO){vd_@BSea#PmT=kaxw4(qRriiW-@ndPux?tO{f9}WrbE`Kg6 zGR6MikJ-#@DwQ_>_Uw7=)M1!>$yCa>eU6@`<-2p%>vQ5O85;E5^kW(JcK9vNz3h=S zw|v58!;*|ws?&QW?eos%WxLB?R}dH!^k!r7)%<-se;#N&w0hmFoXuP7&$MU;x1FDF ze>KHO#x7=8b$MOv-0CA8|LlLBOg;P0kePuY3bI?^(#_545&dG!s_ZbJhTmne;+DhYFvEa%ge>0t~WQO{=c;~`uCU4-4B`sC&sezcbLLEHX$gKe-?CWk>+9(fT$-YyrRjfuT|6<7Sy(9Pknm#D+X{akd-(dU zSyPgh{yZ$qDmYj;fB)a=hx@KZt$p_Ewf;<#%o*VFb<37lzwhr~k$?Y3*?mQ^?!u3c zZYsNdS--mY{k^&MKMGc!OqpR{|LRERVXMa_3=MLAn~s^t?csD-ANqb(=Ka2`AG2G0 z(U#QR4qSEoarSo|rKq*Xk_AGBMtK{k8mx^_CBeck||lt=@Q3$M^o^vL7!Q819~aS@QbptO71Y&r2s+m0}qL z_bOiEoqzrH*LL=W*}`-EvdyHc)1U{So-_^>rQzAyXjv~U8bZJN3DmItF? z+3u}@iK|wb+0;!AeOR=!RbY}z&NgF{Fp;hM?kTri$XGH%qI;3X*ROGkEpCfDI}Obv zb5|B>EZK24PO&9y^~R{#>*f`upDH@>_#>xciPhSpNm;><-B$O$yXUq#H|tKu){Fjo zPOf_M`Dd#DTl2XTE9d0>_AkD(w+Of`zL#P0<1gcWdyN(YDPFh5`%Y$g_e$H=&06z| znK2U7IN)WAUF@DYTgp7@Yemf_86l?#9kscBw==f-_DZkab@yI?hoDo$+O)M{ms7q@ zQTI6A`}+0Xu0|*H}5;H{d4ugtm~gI|ILWn$1Hs>xi{{6MC#Pgu=9JbcE|#a(* zPfYoBh6T%4lwaMFfBeJlZ_D7r_DkFMNI?ebzi9X^M_Pt^-~enye)wc9a1MYJ9T(U( z>8xJ0YE{@*;sOg_zhh!x$PIoPcITgP^QLT*prD>(D<<#St^zjx zLMmvm(W*t{Vfk{i12U zFXrdFxctk$?;3tRxNTY}cD8r&+Squ=6vc}q@B*yR(7kc{b=sqD#s!MF1~xfHF26kM ztlP@d)rVKD`f+pBthCQ}HZ-iU`@GWl;Hrz+($|+=?pPv`yURJ<^g<_Sza2wEF(YW^ z!!GeEgv!7A(GeS9H8kOaKfXnzpOXRyY}O}U-{nuJe#wsm|k6$ zjuriSw%j(XRbw4*?&kPjHd;xVzP86;9`dD{M%>+H^aC3>E{t%Yv%bPa1 z?fYzhRhV7s(OGr-b(b@jB-WNVPySk)b4#j!({>XBJ>T_-YNjAJIOs3X=+bEqx#(lA z*}LT8>&2#0z1MazE|6_<+_|{#a_Yo&>I<$_MBQF%p|Cpsnc4kL3*ETy2YhzBbufOk za_52U#d#33OvCHt#;&GAmlo9A(m0T1$}el9pm*)w)YeiC(2T^1n>;%&NFIObb=35e z{=HTEzAl<^C1S1eu?u&f&I!w_yZBI<_3`n(7nA4Q*x1X+;2=_1U9B+tyJ&r4RPutB14hW2#{G>zPFel-%*Qryh_uB?# zY>$-w`+{+SZ%F9bv)WpgVfTuRx8L4f{{NxibKWwQW2L@{vaKHC4*H8UWQC8GT`Al( zV^P=A_-p*H7t3GuUw{4Eg}zk^FS{%E=V_M6?${?OZH@A%j6+xF@4noi~S z`(L+*t=^d!+RImc%xl}*8~>l!hwZZ6_hHr5q+S2lu|`gIbK|@5N$}OHSO5PVdiAMm zR-37c`qBSi`^#0;woEx=_UYrXxBB}&^{ra9zpfyVd*VCE)v;UC9xu}`GrhI?W-=S| zC+XGpmIW^|4omyG9p<&4Q+sw!x2aUGz@xqW`)+(Wx^ByrCIA24^%s8R|58%D-N}3U z{{Kg}?d`4m!f+wXZ~E>vdsdn1Ix8++@gpK8r^ip+*D%xMDi?!=_a>!(|5m=9WjIaI zbI(7}B-ZiQU+usCS*5@K*7P?W-AmRbx^wsM{qU*&@uQ@=ub-uj*_J+&)%|-qc)fn~ zM^O4;=k@3`xn2Ka#mNi_D+^zaz~I+ak9JP=oBsQG`M-i+M<3h9-M9b0x9qa`?sL*s z3Gcq^2|2A;wJYoG^4n$a=2soOVV%D-jN{|^`t^7Blzv~eb^g6QAG+E+3lATjw{g;> zWYPaWzDxCneZJkEXIFoxGXDLqZ!4dN|9yNv``2>S@7|tSX9bxUBd0k>3cI(h=DM6= zvhmxo+Q#UBqBW1sZk?ZHDrI@4!icl~db*L+A;#B|tEW0E&bNQy(ewALeqLAqW>>zX z?u~QaU72`n)v9+l=YPL-sz0vwv9o*9n=9PM%rw>)UB9PntT*#Q!SO|pKH2^*j;`-z zD6lH^3%?w`|MJI*nz;A_V#2=lYT4)RurL(xX|9=e|K8Kg@9*ULw=dds>plODS9hbg z)op(Kx^|New_-}qPN7#PUE1_@ ze%{^k2ODZ;3HBX&erw0e)u&e7ns%Cdd(8ir*Ct&u3e8RXQ_a9|VRGf3eTNop+MPG| z`w#hdIVMvM?qmMBwe;)t`ub}VdQ5uVj8&B1{(66Y)!Nnf%N6V|sR?krv!8Ka-&FIa zpnl7W6>CC8Qt#c#+`P>H%F8Wfv%j*3`)`i&X)oeB^tOL`c>b@X>l1pmSm*D&mbdv< z?zP)_vu015wdG}Z?D5m9{mX9{hi&?|d9LZ!b7+$>H4kKZhUd zvojsM79!1aVqI0gvZ0>o(Tf}AiM2Ic$T0a>xa~+wgnQDR9&fcf_1Br^#{IrI?M>Uq zu2rj~&EE1{7x?&d`tF!@-}VQyHgK%p{xdUemTJ4x!GzVzRNq@J(yU`+DCy8I{`+WC z*}L5}SAW;nzIr-eZ{Gf@x9Xu+w>ot^JbbDC^Xd8jUVaqkd-{Z1(Z}uZdbvHN5Bb;g zY(IZif7knk$BX*r{5Y(sX!q^^zvqqKdh;aWzE@Z_Wip8K_ZGrwsfoW%A2it z`0~mB^Y?0xyN5pgFK=JGJT>@R&;q_tix+Oex+^@CfVIKTc)8ui6`@*b+5qS*+E|hwS;LQuW_rPu$XN6%hBA{Cz~Ze#4B} z1~XV07`CjRk@0!6|D8lL$y29RJMsBS*_S-o`M1fv?bX+X6O$qSQD1NzQzs;npcPqjO6uY&Q7^O0Bu}aE+$!>Qf%xmsVW; z*Ky>(jY*taRMM8CDO)XV@^0EsZ#pS)b*uNUf6mAGL`A;7y?gZjnmvoEK5vV)-IZP` zZM%Vw;nn3OigsN>mC8*LOSv7TW0%0!0;JD94YulbAGBGrmExWkl?6M2|d4GA>YCoI^*nWHStJg8zM{le@KT~JZgBP5u&l-N1 z^X69h^wjqcm0j2C$LOtG+O6LI;k`}Kn_Id%W<*O%gArgcBE zG9#Zp>JLjTKRcu9;EG$`dwpfBf4u$KZ}a&@uG-{3H?CN*X5a6H)0dyGs`$00 zJ%9J}o3-ryrkf-bUAA6*xTer^(vnH%M2r)^B&-eF{A~8y#_y$-Kij+frtwK%vQ9b2 z%aFl2{q)}Sc*m_*!=C5PzI*c?tC{dbJ%%}@d%7NF-&CKl{cg$EBZbe?mZ<(OJohzo zrRU$O^z(bZZMrh$$m!+t%oE>ic=YJ9tGZWXU9d)^D4ZXQR%)dW>;y-pg$|`+n+m`r0A75o&w=KCo zKYrDkO?ICS^zN-LjqZ=QCFE8uc`}mo*b?UO5JCpu@;qI3DY@6g7Xcw_<+m=1A%8m6*u1@V=9kOah zM(~F%oDWxTwVso=eff`&%JXm5?0z0&n|Eb_@X^$M+t2fxS&vKWxN#(wU(t!&bu6p2 z=fq3#rS_ArrY%!XEkAqa?#BD}$6If|johneu4tE5;dlR72{%LUmg@HlcjoKF?MqrK z@M!n@@8@chKXy%h-6|lx{n^>tQ$)n)&od~C$nskBf5p)ptMVHS z@Aut*ecz^V)02pYC+FRqyFKT(-G$~QK|hrr@3=94ZU3F^<=?^L4iGZT-C& zhaNQ6I!Q4wG{m#J22Rt7{rqi`zIv~G)zwsCro{ns{jOg;^ytf$B^ll?HpMEpfA#kF zwJ2Ee;We+d+`r?}*os=wbnAGdvbe|zo9uU9m8M?ZV?-$t!^uhiBkj>3N}a}=4Y z_kFp=9>4D9wZ+kCbIUa?V+#+@QE&dv^Xh)`rd~ z0bBlG*vOg8wp>kpi`Pwt2h;Da{p=ci?902Gp&S>Nr(ZuQ?9TV*zBh+qibH<(*4i5r z>fXjC7=yZ@b()*p!&Zi9U7b1Mdd7w2%T(rBL_R9`_v5RW=cK0p|2XG`oO}KCXU)E~ zJAXzOe{SC0+i7;)F8uzzyZ`^*iJw>d^6b?3+&?)_tG22<<$3x2&E)UwpOP3DK4>q{ zIQ8kgxz4|euLp`ZrJvtpQqgvE(xt9N!prsdX862K-EEb=wCtRhta;XjE8K4X-=6%* zEmk}2uCB4J>cJcn4#iWaS2s)lHWz-Je5gD+UNviL)cVU&Yx$<%7M@Xj?di9OwS3(- zCGL36IIh$Zurj3ayN6V7)t?uR(fjV!Zg^1f!{d5hxn9&BBSX8iUt2nRJ7-A8uT{&? z`MluYP0o*o?%dsT{le#de&y!+vW@rM?KEb`w3tbBRWf}urFHh0&Prow->H*UN*-G6Sx?=lsoORNlkf^@_t-&Woo zlfF+eB6q92zSg-j9;TAKY|U}YFJHZ~V^3WE1MdFiUZ9IY_vgC(JKmpWstgPr0bPq~cBb8qe*brUc)uV2 z-QwC4Yna&i<<}SP{QY;!wP^x}UY1zSF+F%OCCjU3{TsiX*54x6_HCKca&B(Wm#Vk> zn%(txCM>aM2o*`ae{gZtUNcq8b^AaiyP5Q*%R=REH8m__1*Jbfetto9x%MgTRp(#c z))i=Id+8WBC2RHJ=bw+d%w{soP`-a&WUh{F*`FzQ1C3vQTi*2D{_CCk$&(+=tNrt> z<;M(X*5mW5>Mm|LxHtX$t~G{NuhmI~YE4yX<&ImdyKHv4pYIP%vGx8nF<(v;pO5_Y z@)vJ(S>C;EtHaBdzsZw~%kQ36!_csqrT8TiL!D+b`__cW+{~cRn<)zurdt|M@i_W!pdSChUr`_Im zzq8IhlbUY*BbgUPq|-@mVEyfymymtVVh<2&8fo|C+C`(er2xaqI$ z*XvZ<|IgZd_@$I>O+)>f>>qM10{MHso_w==-p#GwkAL^r^ycms)A+?<*Uc|FI&3*= zQg`OTXZ0s6|D-Rh-z|{ju>alSw(r+pmpXOSviD7$v+l;u-y6<8%kkNI|E-MyC`#rZ z`(w-c^y$^Pn}c4)iHij*}n5CR^ zBfDiPH$#Vl@Nr&g)1(WFj%v&JU7s=_r^G)OK=rhZt>&uVM|M#oD_+8zjWA|ePrIUYVoqe`+{+eA0 zpJsGEZfl+MI;HUC9`k$W|K@$aTYgT&Y0tidlOGoahsRi*%ve)>HHU#gAkr-N_SZ|< z@;_?q_Qvgg@K&_(@7_(;u_l!+pRUcW6Q>~uRkoTmAc&QwRBg)rJeWd zwtqkQzdHL>>*C8nLf6*ZpSOQ`!ifX({=F=`n|FTG`F(;;KD$$LJlJN<5~~008{T4* zZ(S}wuj=88z~}QG-#31`CvoMcU{b=`1g`s?*H>_dENh6Cy#5l3N*8Q`FCCTd)gA!-_kyNGipk=zWrIh z|LX_g^((Hvnw=JxXd)F?QyRFSU30Qq<=*c$fA9RS{_ya5t3cM)U%MoO-|sD6<|h2> z_v@z5?o;Rd`@8z{{n>?w|2lPO?0q*)y59Krv6s1D0vH(%=&#wZ;>hEPtpc|{e`A0D zz2Zr?TEB*lRcKI@;3l`oB&RpR^>hFJntghE?)}}>_XQPOE((+$-YOnzSQ@k7=8TEV zd+hGN=ANz}vBila(NOyRuH0WUCNkGqWy;=dTkirIiI@BFIRC%x|HZ!1<+*nztq)q+ z#`~`Jxo$YC+hemE&j0t_{a&goyzdp&2W z*2>wR_eRh4n{B+JJYn;}ti`&^_TMote%G{g_V%~m%wJrz{XFmX`je+T@{Zkf&bac* z^KtREy4~qdc0NCHeB(CVhA?62w0$A@JaLf1IzxqJ@~&hzrtqUiv^1O`%Pw_effFop(inuRA!#dHTib=VVK^Z?<@29 zc1q>l%1GY)Rr|e<;j3GDFFB{5j?LXVv!~7K_?c6iVj@$GrM`UaT@fm>bknI}DG1@wP=`}m8)AzDg$F2=? zkM1eelX~{~=c-k2ojM-8%$9z$&GBDkMsM4SH2;8v&DMAG{I?tx*;8@rv}G<1T6rmW@~-mJ ztM;}e*Ouf;{|gJhz4ey7xG4XcIV*!!N-X7mptC5&lG=5sE^FIBOV?Dp+jEY+)KsCegjx7JiGy={Su&8|$EB5>Y)Dg(ou*c!Y0CxcUO z-Zc__9og@_IIvxwQOh$ZX3f%-cg;>DT-a{#*JL)&qH8|8)w{g!=ACcYcQ^V{aV~Gz z>dTqZF&}vttok(e=Dm2ZZ))hZO`o)uuG4T>etDUSx=H4>yM4_b;s+vKR$u+G|M9g? zby4>`AN#&cu1KGK_xcLW1}U|bN2b{M=yNkL)Y+Z?rQ_m2>E7I5ig7#dnm=rP z^hbDJ*vqx`yS}}y4tnhC1G&(vL&bMi#Y6E+j%sla{Hqw!#hV@PPs$X&lC|>fbG~kK zPyKjV`Rt6=@HY|7Up`&^_+%$&h?&7f@82r+g0jGsA#TYbAASk-K9`>#{d|(t{wuBVm?`XV%sw zSF>h`vuvLxX}K(M?$`1?@%ysoU%q|B^xl<87W=n9yqf)JxBACT{2)UdQj+yz8PezQ z7Qg(*WpU@!!=Os{h0)*4_9V@6TPrnZ>Faqb3}4+!EC8LP#lWz(b!Q2)xx_kF2Ek)1 zg1`D-|K@*vTRG_3AO?m7vIf)jKt_U&edmFlJH;RyyIUvx-a;-8__0+NE^e(}bn6`} z3;ay07cWd^mBop!xyy}ECBS&gFyyU8=Jg9GX?ZQbocZ>f)cWh%Ee10eUCAn4(9rNABiJLcYZ1%$`0U__wPlJe zUaC6N*ra~fbe(-B;N&ssRF2uN8oSwNHCraAoXj!1a*OBsjn`jqzV%aViPD|vv(>!* zTgs_T*B`DBbjmQvT5Pz=v)9?odEUJVXVpykZu0yTU%yrJ`js6m0#x*~Z(VOnhvGDrqFIBl~&ldDL*VSd^?z)o~$ss7Z^!iH^Mf;_`Su&em z7F;>8DV!tG#4Xe06~y2zx97&K_XkxzSF%=Kdv;!L!iU~mv*qWX3p>p?%jP*L$E^MQ zat_5aX^T}>X63eJ%hb584%uq{wo9o+K&;zy>93nlU+(`SegDVv>18{*w|n-^u2|YG zle<;hw&wAN>198!JR=X?t{9_E*flvZFJ+ z>cqvc-IorVuX}%qkK^;}-RHCo|9ri)ME=tR`E3XMJ?8FjmioDRq2c=HU;kcisb^}Q zzj4BKTeF)flh4NAPjg^j*?#ug-JEi*$(GypW;0v}ys<{~?C!;02Ac~8=xZ?J*Z310N|8^`{ zbk64bd0p-GYu5Iedq$PM@QCeInRMpWx=yLdeU5v;e*7Z^y5IV>M%SZ~T`4zjpZI1s z|NQgMl{TB)7Mn?YS!Fi=LBiq0>+aTX9^QUq_3~!;o0k*UtgSuqY*M|(WaFf(e~n9| z#Eyc4Ug6Nk7-cj7&wI_1B-5?CLqTB1}iqra#=WPf{<(GGr!N;d0}z&@*6W=Y|~j23JSUI3Y)L{ z`pmb^{}!dS_GVxBiSP9xb)Peet9q<1uE{<+Z}qA*_ut)^J4L{JL;d=&H)0G7bI!i$ zoAth2)=28h^Dl3g1!6`dAqR{3bd1D0(sp1kbxdcEME?H_A?QJMN@N6Y=@X?>Qjtv)&Zu{-~+;%}jW zf%bKWa<^N@&)A*f{VwnSvqj(cOH91{e%|f%W;1yXhPIa6PEd{BclYn1JKx{-N&R1T zQlj+y+v`0N*YqzW{*YU>Do20+zr>R-yB>gsHXtJ@1t)Vl^snX2{{Kw9Ytf&l`(`F8 z-DO)HyQ$>tuHH(;@2fWZ_ea-#KfM0io8O8R=~Lp$?_Leo+h5V6;_1{Os%^gR_KfK> z=Y6@Ny#3hmzd0#$-hBP{eC~O(yp#Mtm$Nr4Tea<$|I{g_%a&e$t!-0hWOi)LMVBBS zDbKA;4%|O$)(Rip7G|I8|E+WS^(cM$gfo6w^RNH!@_N0=KYYG~DtwHm%p&`lZp5~{6bM^KXY!Pq@ zoT7UATHeXKwq;KaSep9^JDrK%@MiYe`}&dF%C6<>bNO{O@6O#YY0>26&-Eg-RxWM- zZ<76DLEP=n-}e8wHEAdJw6s*U^d+jM%h4J@mG_1?T zb*k#0M!)~3Z>X5|=0v08)2Yv%%3I~%nef0`Oiusi&ff)(<&_z(EMIji>i5<4c6{!= zn~&uhx(E4anciVx_@G_1Cgz{T`HP4A)|mZnJ9|I(#-F)Y)7IC91Ws8bygY2}-93q) z%SESNZT!pEzIoB6s6dhFr)zg@x%04qjc;enyR|!iYVW^Q@Zm$?&TBUA|1PhM+P(9| z5lvUlxmWjXSyOa&*ILD+Etb>w|D9DD?R{D+`jZL+Lqpi}f@?C`Hl@GMhTD9;F=xU` zp0c-=L6NDm3!nbHdDlqQ^VGX(mXA+;)?S^n`k)REqqp|U z&zkTUtABquEv0&`ZYAw{wdtc;@|Ate?tkBQd!M?Vd*<}JX_n8IWnL5u-g4CBcbm8I z<;mA_&uHsuu3vwfO*`^yX`buSughL9=R5y$^~DwK&%f+_*5&zB^y=z;=W?GUZ@eX= zH+^^X^e-(xW*n$5+$dJhaa&^3o7(q>L**jpnU&_8SO}V9I$QT=W4hR`yYr5Pye>Q) zHe1*#T(dAz6YhT3gy#M#dTi2~owU^ImzuWh?T&nlkT-EuuzaKd% z9(f*L_xb1a`+r`(lTJVXt9Oyc`??Q5|L@&$;r>E_MbX#KJfB~;ApiY4CyvNz&-MR& zn0o!**MDEw4;&3XX7}-gyiVF=fkO`q>^`1(ecCSe?bn}wp8q5CYMXP}A=FM@{5B(4F*4ViQihT7q-Xf=M6B-t0ns?=ZLye5Qc8dU~mv=Zk=yB0|+X554YnRrSUq5i}q4V#$KQDLfW$(MT<4#~zY;*t8EqhMA zThX~~hKTFNn{(Kj_pRi%%(=0t`0=$rlHq=*&Uo-yhQz*|kea{s>cfvG>|8T9M73{T zwq=c$vaaXiu;0o;|36=-kj(77C^q@(vkyB`|Hp8tC5K$sd-c`puuZ zUY)&N_VvZQr@3DaSTU^de|ym3p|ttZ%Ju^qu?!ZkMZ&Asnx36wo%H?QVShVodpo^n z|M&j-aV)g^-T%jH!k_I;-u%~MhSRjC^>+IcuD@&MziV$@|N0Vdysp%|%m4SkuiIps zzh209OIHCr=YwLXrU`Tbmad`F~{jU+a&Cu*9Z)0@j?YA|*tga^+etTW4)~#bvbttQt$3}hk zx4Wq~tABW0e9b-GJL~16lgfXr`(>Dckj3RduY)y{&-#8 z)iHlAu6nahGFV?Cw0hc5VF=+vv__Fq1IiRAa6|Mf%e zcbnbsA8d3JQ*K$Y&1#ORp68^HOEZ(ydgRtz&AL{z*>=L>Cr{RG-p_# z8l(4pUM=g@u;{tlbIa15WlI7;2Z&w!sMWQ8=X?jf=#94?uBrSmZ}zcXxmi;r?B8DU zydGQJ`F;7xR#U07#{KsUgx_19I^kDmYYHq}(``?=WPtpaX~74^)bmIl52 zx@*aiip3WdS|qG?yg!w?{(Sw@i|b;IO0C{5`FNO}_s!<_2L%7;-xOB&wvqd@ER3Cj zL4m>3#WAGv&WnW`4aIuhjDLT>Y`(TK{n(f8R)OQ)ezhB`F0)QPey8&F){ljTCp|y% z->WYEo)?*H-l>g9FSFV@-b4~a~zebOjh{r!zh zjfp|V_464$LQX9Hf&uZ{6SXF)O!b=6sc)LKHE#Xae!)B+{;8p6ibpm+UcSs%w)*(l z@Ul}rH+QK12TjgDt3J}TsAfY>Zf51x_h~U1tF-0ASB4l~T=pPS_)vIuxy9W)|Bov_ z&AS;{etu`wofVGiU8m)$pV_l>_nAlZ2poF1B63qn@6}fl6EEv~)vK*O>*Igw-K?3r zllRy6ac{r9xhgi#spiawIs1BFiZMuKe!u5jd_Q7O&Cw{uBjxws&;5Puk*M}{Zbh@a zv$KC6d*p8ye;YJ$A8*RaU^4CSLz{vZ8D2$)8j7pf%KD`2Ui{ztwJ;%g{q?2GRYm_4 z{3&pJ*ywD(CTh28&3vt?T9%so7Hz6J{OrA-*z~-c-)jH8W9Iz0@!#Uw>T>^%{@O1m z!}s6Z^7m4=8jsu!b-i!LQz!QSDcG}QiO=dlcE5*Tf0bC>eW=`eH7t6P-M()-+juRX z-~Aqbr}DkM-p+zQtk3)9uIvx@=~-H4%)oGAqGs2ol$W!%Pp`4e{BvQo-fRJh)>kFF z`ozSRFIRPqcAEZH%5qJ7Ro0Q;g0{wE}jHaz!UU;E+dtX$h&sn;r}Pr58>ex}l>;{1Ytwrc(-IPWdK zoT=U?`{~e~@7tffn^|`9hBK?{Z+_X?25ALZMNX@y@G{ihylWM2lIrt*UTmvCZB^M^ zo5Cjt{_SlMaAy0mE$ntX@5}3;D4D3oupm>#Gidesnv>4!ufN`6|Idtbo`kvA`qLi% zo<1GC_oF}D+1<&x@$T-lnLX)klfS&Z`}h9&zfW&YzZdiK>ap`->%zT*&P}L0Z&z8L z<+bR|*Q(lmPv@_V{de=cUD(?xQ^l(K=Fc(zcPD-Rp8roP-|PI$cv>xAvo37)@;Hlk z6WgEtNPh;(A@dh$oVLHe;m6~v%;!(ev;BPOfn=Jumv)!moPA#w8E&2a_SKxJw=A_w z@2qeP)|dFZUH!R!#Fxmj?Ps=3VcC39p+!S~Jzw6N`#wGWocD8Ur>?k~wbw6ut5r;% zf5N%Q>9>=&TIr-E7pN!7bqhEx4Pq6F>vglO`?5gy{yV#f0-Ks8+1stfb>n7A$Q#$* zI&#eAOz-+IVUuZ<0sr5${Ft%0^{C(S)#oZp7cVL~%=Z1GzrdQSS#D3hNZTxMf4lWA z+t>5wJiKGJ8|Hs%nUi1sM$~DJdD)69Us7Zk4t4aI#TFg3`2Y6CjTfhv`>m<976Q%3 zOL?yilV0>iu0JODG2fdt+iuC6lluR*Nxtv=-2FSUpZ7ff|1)8_-%F&5RsfB*mU-t4r0-;8?Q9v4{T@BZB>tbYDXoOwyX{-gPJ+NVE#6jtTF_v^*s zrSW#ZA04Xy`#7#QYVEWo)&DCFzlq$Rneta_>Z_*D?(qfxGNzx7{c!lBczo=ZKbyDC z-E`?+!R=DtIz0vkhuOg*PfwfPzE}0Wes5Jt)r)zrozp}uDt=V-p1%8j`POYM0)ER+ zKU~AvrxqOis5-U$)ZLBevr3mNRVjHlWzqHQ@3J;kCs~#MGOk#p@taTX>g%nVCkwxp zO79ced~?FQX*;t09Z$aOx*@T-KK(z&!HHH_LIq*ms zS-aGB`Aw6vt~>Q^#mv&*!3TfOuX?xtq-WuIySi$pnllTV9{bzw&$#j6-sbe!YcsO4 z?R&MRdQCdSy8fcr<+H}i)&7WdUst!&t9r8Yxz68`ziqnK=J~g;YVQs%+s?Co`%G^; zh69nUJ4+ZDdY4{*eeUJ#+D$*coC=lxQ=Jm|@5|@)wsJT3zbx5xC-3`|&u%|I$0#0o zx3~S!_sp%gf+q>0q4#sB!W$v67*s~FXrufMLlzvJ)J>-Sc!+4Sn` zF1x+E?pD6udQt51$3?-q)nN<_4$%Q5OkAgJ(A8aS6%w%I`fHuE#R7|p!?Lo9oZ|i+ zT-<8E@9&NJ4>!KO|6YE|=j}Diy`a?rivm{{vwbbIh+TdC>96zJr|+HLf9p-bE;oMp z2lB6__OUA-k)*|p8ueV@ zu9~EI)`WvA>i?eg_nW6R+r)19H;!zE47p~-RUdn|O7(_GCmxPdUK7LM@O#&rNuBb# zzh6zQd><11wzjG)_V3qgz0qYXbw7_Di_j6f%zk>`n`@@4)61Uw+r|I>_DJ+#`FHb( z&3Uhj*Y7F)*CNRL`}?Ug9zUOK-U@2VEt?rGXB}r#{;&Gz@>7>CPx|xLUu^oh>c@ZY zyf3?7``kzI$ZPItGWu4fN0(0Sace#(@b&g;{mOSs&HoqN4-Sv7Z1h|HH{s>Lwf`qB7mG10ogo7 zDJ_%UC!}~}MQ7poZljFSx-`WQsc5^NAA~M7F9fd z``q33<#85OS9U)C@!iKVKO!ruCd#X+J|eIADrmh^c-gh%^VezXX-ZGL46637t^0p2 z$1KJuHKq8!;rSD%eB>^y|9wpN=ggO$&A-yO?{Q}MU@Xb2HFeduMN>*IWtsllnWV%4 zs+c&f(wFZ4bSM7bm+jBv;y1W(Of+6{-(K$LVdwh#i}fbP%18t&6sR3deo_FM4f_s(t+R za*M#yr&IrZ6Q5(h>dBY)@pJ#*>ydsZ?&R{abo-qjvu~RFiG#+=Bd^D%ocA@4DWCbjd-B%vrI*iWuMPYCi2wiho2R}yb!2qE%;&#X^>B(|?vFo= z3=L^oI$9s=4Ku3m{d@3NuD|Bu6Tw#3``$FbXe<&BFD_^Yp7eEY*x4n-fe!_4)6 z-tK>1za{hay58tdp;}ir9NhbU&(l>0GVZWdzqr5scl^8AmiY%SeYcO(ZMl$TD(){= z__%cX59XNJcYi*;l{5bFmvrK5x7GkIi;h z`n}@crje2-&stP-^hVic)||VR|GmfN`y9zH=WXI>FC{fC9d-D>(8NK zVcwHMo(I-5&ps=dYslJXCF8gIuw<^`@BRD{>FrJ(H*REF5>$YBK539Qd)+3_IB1H+%HA2s_XFF&j)ckYCP3-S|xWtvI4tesirFEV}C-Fb|)ySFcSeoJai*z(I} zJ03|gl*j~D-Mw(IR(tiD)Tg0dv4IVK%Y8llGA^yzEz1s?SLg^^&8hR`>LKh??71dK zBp4VL@On)8(jL98Yf4`9yM%)uw_G`LE>e@%oV8)|Y!%Osg@zvs_aEwCJfni~faLyP zo#HJ5eD1wE)zO!l-4>fk-Tvadr)YQnA?~9;)os>ZivC~n_WJiF$7bKkTL77d*1f)@ zQ@l=5HsulPza{JiWrh-8bZ445uAO$P?%jrp$)K6_jcs8SNBc}Y^y6jyAsdbzo;+F) znvtLCm1^R~x?cZc#lMd`-)>rPtz}-|=XJg2ucJ${HpiL1zx9LDR=H>%Wa9Y2@t9o< z=dAvPgiqTm*Z=10FR2&<-$^Q4qb_Bcp5<b+&v#SE2| z%uiRyYQ#cjlo%QeR6H-eEZL-^-L**Llzx>SXo`KM)s-JH`}x8lj$>eGxyYD&ZQGSb zHXg{ai{Q-}x@?v-iZUzZ<9A60rKJTJV(HZ&$7? z8BrqwrW@H4O@SG zXH4Go)1W&LORM&7yOy@&n4-P?R$%$hL&5@@Er#3C!@rgn>g)M68< zQi#E~-ukV-uHIrWlP7CyNcQJ+agQIzKxO@BPQ^26%_^Q|vkyNHZV^b^95^XttJz^w zzI}^ASKXRj<|^pK(S3AD(BC(ozQq6Q&HwY++xpJY+e?o5?z!aLx9!$cxqXE{ysiHn zy*+7q>j&}t$x`MYz4$F}WrbJ$IQCHg?FaFE?>MnT@q2u;?i|s!eUh;{_T@wA>*bm3 zEkD1;KA$S_uPXBrf8`1O=m+Xc&fRzH{d4ugjO(9&{mVRL-+1tR#EI8(vvW3`e6~K{ zctP`(!)dScwppVt1iScu-QI7AgDu!r zI2?hmUK!%)A8CQi;G1qws5D{dFi0(%fA{NS9=l0Pyo|i7dRNtYgLk(-n11bDXVz|U z0f!~r3=A6e4Q6gjxF6`Ko{sBx|5jCNH($TT+jG*DEYHTdCW!sKZ`wqMjD|p%Vt~#r3aiieI*Nc4BKg@RfReck!u2RX)mfy7V ziTtT2JrSkN^<^t*FW}syL|67uk3A)ML+$OpL*Zs)*QLTekQMH-95i*>+JYh-_Ol|zc=$s z$3b4Re|Gi%UvBD3Z2o=pNW`pKHwK0Ru}#)l`|g*X$ha{7^7h$owa;q9w_e?s{QTfv z_PP`AW>vnsV0Te0_=N0f?N#zWe&4xgYjwZ;InRF~j!kdw?tSkO_wS&_-n=Ux{>^;p zspzfzqh_BotE+XHoPZOL?3LBk*L!Ydt6zDy?V#5mUOpMEH#6r>HQup(<+ba34t-Im z-n;voNJw=3wpmSa%cX4=wEKl0zs32raJQ{>nOw=vyS3G&zCInCmyK$+x^s8;i;1=0 zS$fVcJAB?8iR){(XXgj+FPD$ibp1H57JXjit)*nt z+Fdi5OSjkTF8<%BY+Wv!;j`rTyGiG-t+~DH!&Y_BC0Gv1nq8az{{E|fE;cAK_4V!D zr<3dVc_#lby#71w^LPETzZV>P`Yqq>W$E@ayK+7qNj%7zbMfS5(W%!Q|5pDfm(#NN zcc%OO9GlQ*_P@SO(m&oWSMvPL>P3s{{ensq~V(Q1&2S_hJ@aXtNVL+(q++upgq;L zzkj^t{P=LvO@a6|H_iT^^%rsddAip4u*)vqu-z#yXO&8Sc6t|@K3{J8pLgx?Z*P8g z{%}rX@4H>Am&eq7l<=I?BA^|X7qi_^-`Mu$O830p{@*uGSloR5_q_G|+&h!x!GRl^ zyLG;nX10&_`s?8pe^PRKA{TcqnPD<1i?u=Q)6XdNuH3bCx8}VO)?dFh&hEgBxmV6y z|8IZ4`tqFF>pMHmw&x`>FfcfAEWiBqQMR{P?%k<#B&0oJ|JGTQefrvZIyw1l{l4G7 z@?tM>*I9f#FMj{u`$to4H*elm;>B%pd++;pjlJvKzxybP$`?FG3E3SXr^H+c0wv=~GOCx8Vl+5((eR=ThwA7o{ zO3UZ(*RuDVl(fkCI8%FI$)_*ur?0J2_59X$&gNOcHJ6vA{~ER)%t~Fq{n%4KpN^lm z|C%2;I`8M4dr4;3%3nDoy9SoM(~J1>@6`5tf8&#!&fJ*D+}mUwdpi^lmh0HW zZ7Tl$@R+?D!wUb~OSe{Nl~q~K||^tNZucGpQ|3N6}nYFFCbYrM1fZ@%?Lv0bXToPnWWT7gA&pXj_@ zf1hd3tIY8`5yH0no6O2Jo6hd|^yf|9%t+Iq6}|5{{v-DGnOV@q#VOg7BA-?;eSx1*|0UzG|61ndtN>*RSU`PhIoNYWt!~RhPd8w?F@KHRHGmQecvBy_3QZ0wlaGXkaut2>oog+H;lRO z=J_YD`nn@^_1TFt1ZK~hCK2O5LsLP;v#Wcn%8}zLYDeao=bf4hFs{QviC|kq@ zillzs6;sd4_}ctw*M=H zKX%Nhw=I0X!_e|z`A@t0`){oO$A!gdwiwLh@wct|-uk@%8-F$D&T5}6KX0nPpKnkW zQ7kXXz|fInc=1{K{2hmLjHXNtZ1eW)feV zI_BMsT)+3j@&{~o^A~A!{aP*=)h{|vOMAVPc~*jgU0;8umYlEBq} z1v~#&X6)(NawMbsWL9y-olW(6tdk8_Wl#U|x4lnROXJn;-QD{mD_y@n%iUd7a{iN9 zRe~(T75Bg?YNmN!pZOXCcQF)P(_Hs0DmyCiUB{+Rr>f;@o`rbc+&^ze`Quft@;X&^ zR=55bR z*+1X@xX-KnVmA5Yg&A3YH#>xcgx&vp;q>KnTbtTzhqvGT{AOSCajD22fkn4o6p$0mG?@Au`q``52o^Y6#xP$!P`-qlY^BA2iE()hP}rRvJ;>1X)w*VUb@pWnS> zsv9GNgLJS+>dUULl|R{+$J%W3UVi1kzde7NKW?0ME^?*fTk-lmRkup{?|ywgU2FB4 zew%{i_w(j9_p`CR{UzBO=+yB*lj+rKGauDSOM*I$KQ47!d+qhse};#8UoPBqO0C7B z?9Y`gE0f>v_<722rl$1vC-$Ie`m#3=sjcW*Y7OD`=w1^xD?(@KL6)=!oiPh-GWXxWKF%Nt=kwSt6cQU_2v1L ztutSG-i$RaE`Rg=eBr~g>ag{veLMOk=Uv`zc$djR`R%>xjCa!A)7M96xBPhbdoO6( zH|c4W;*n!(D~s<({5knN`}XbwX-SV*861qCzPFUfHSCozQ`oRP|NT43mzu4|yJh>N z{?7+(-2zSjO7TeEP`9*vSm3zua%8fhH2b&rf8R|yzi#hG|8SqVCHGn$##*PlT$5VN zz_8%0CXMH2c{POYGB7~a0*F#!p7zJhJ2BMS#QSl&F~fqHDgA4LR)(xHj{jjG^<|}I zr@G%Xi#dMvOQbI;Xn8HanzhGne!bLg_ILX(CYjakc(eL>T;=PX;rF_>&3O0sFzqkyYay8A7|6nsIwh&>YCZVtk}El_JV&m zB{Mr;n?3WLHvzPn&#B|{F}|r4@*E37UEOEv?M#^Szbq%`%hO=>Yx`t7K_g#hQ#Q`H zBX{$u)o0cNS*!n61Qw>btvdPR@%BGi<^@)-w2;-*wH;ocwJhmhYG6_w0$WU-Xw>?egn? z)cjoa)%o??;%6whw0N2x!-BcT`sC#JTL1rjcc~(D$JEuQ`Q_Hg80JiP zecHoZfa93-jAcLHJrbQ7YASrJcgyYEuV3%AuHWgx3Q*I#_T zeobwC0WSkX!mMYLmS)ed__cLmYHj_Oxzdw9#auSIJ}qDS%z4eX!6Ns6-kao~zvr*` z`Talq{*+H$_3GDK>+iSUn)$bRyZoB^?N#WE?b~W@zuViM#Jer$_UD(obZfny>HX)T%}Ewujh50x^>?C zO-2sSmb5MNYxjL!wv%7guJ@^Q`L!!1U$oy|eIjz#Y^Gsq&*jwmPx}8YDt1(TJHxrs zcn#x%+Btsiucf1P#a3@^_pN@Lc$k%;L_YoO42#rDGt$NPJmha#<}0_m_W8Z^dG)0Z z|0n(5^KVjx^y;ghe(j%jM5^Y~&0pg2Kg(|(*e5>M-q|I@xAy7Q^6feQ554D~WBRyf zsrI!q|6gmX)^@c``TBlSFcSmA-K(2IU&~iNay@MDrtI~f=l43^%ST=NeyCXes`-zy zH@ECF9Okd_DgU$1Q`mja$M;Lu@7b5~`P$vh%X-(Y{l4$_!B<_UZgroZeP^{sxQmhP zyVvXH{M}utf9L;&KibdE2uyU%uaV$sTR+EGS!}b}&D>uTJ5KayF4vFT_U&wW*cDR~ zKN;&klTBw&TN`6v^rOM}IDft`f7hZp_P=}l?QVbRzHevzylLg$?oaprf4Kaq*ZTNe zJC!gwbD6$9zs~o&Jo(hUTs?Kc-maqOb$`2-Jb(L{*ZNtELFp;UkB2^s8#S)+%*hiJgU~#k=kQ=qMkq{8%~p_V1%MtF|mD_;SM0 zJABG?5%C_$(6T?WOb$J_pN5F2mIj_ui(ES`V(kpM77uZT3x0ajKbP;{^Zj7F?Z;2q z?{#XP>IzC4SKU}&|8K6{Uhe*Fg;sUH4&Q!myH(*@f=A^3`2Xi^H?qy+`MzFm&u@17 z+Ar7p|NNZ(|M&l-@BcnE?)DN&JIKJmaOBP1c)`vWN}pcd((U}MEco|OvHIJ)^*a`= zGRy4U^k>hT`Tt*BQ~&?z)+&GV+#7qmU1I!>_g;^$I66o2W7=i4{ywQ=ll}Lb{l97M za%A$uU7u%%`_H!db(%eQ(d{qFf`Z#`SH9o=|MCBSUp{S``g;00jc}3FM>i77YaXxq zk#k3^TEaBxLhbiEAAbCC)!*}JUPr{*Y2D}MmV6G^?_=H3yYIm5?(nbLZ@^rQA=Ij=eRz)`jQ{N8O_{1c7)FR+RE&bBQ+bjsh% z&+hS-J9ExFelNg~v}v=Ek+Jcs*L&Ay-dP>qcsI@ZqVp?_jdP+3(^Ja7dN7oT_ujkp zO?JkYW8pQ%{|bNK_`lLzNl@81@zmkYYd0hediXTXEnSu6=Jx6NV?TlV8+#u7=d0gh zd!^`d*zCBp`qzC*)7RR1``dh-KE3Yqi_88tW&dv)pYM6k(|U;ap4;}*lIyomIAU^k zSJnECl=RA=@K+yxGuzcGoPEf^!0>YKms{-J_aZ)B_`Ch9_S>md>({wU@3Hf}Y-GDm zO`O-5XYsy+>PnqYv!mZ^bKR)5MQ{1yyR2MNwT%T9lU^38>&+|s5b*VT{kI>k&wcm3 zJ9YEz>usL8eP%L!71Do?7h5qD%sKS1;I%Zn^TSVHqig>;%rfg*B=TKL!eoO}M6&_&|Mapi=Qo$+EV*re{?b01 z>+ktVT+0#yK)jd>x^F) zM(RZUl3cKA)w7i9Teot%=lK0DTsB`aZ~2mtTebx&v}F2vg&AEE{_v5_Pm;Wey-o~ z8<*Q7b@nK8fJRAQgyd@NDQGH*de|CKB&5GbL8-G<-0J=2!weT>E#`iEtT}r@X4doT ze5XrS@87Rxe)}fl`WrX>zJ9;{WSjDNQNi}RX34i&Iv@Khp9fz8_JF1He2aIphxmb& zuAsdD6L;)$d42TRbF-aGj&+)4cRc?1y4d*5@vj#o%btFGU7XJQ@?B{Y$Q=w8rduVR z9lK?4nd^Y2`m|TCs!W6~E)HCLl=X5*=v9-eSzEX0bG*~I?pxshCjI|4ZqxU9d@i?l z9GkuDl;z<$^?S_90~qyxgH0-N*L}8gBU{&^0B`XFfyw?kgFZBO99Y z_EjU;=!VBr>x%Lwe;0ZQ9*1XOa1hUMowr)$t)cJarx(FW7#K>*V>iY8eY>FFunwq*7&whY&R>{Mf5Sit`B zP{{UV`CU6TcV((QKeqfG+~OCKA)#+4g58@Ii-pyntKEC?=;bM>GcI_o zT6HVOEcSXZ_qj3)*FX{1m#cp6^fb)-Q-9b1)RF|N`=SyX7yiA)Yt6=lS<4n_Z2X_> zTDmq&yA?chz`(%ppe6gP$lW70<{#r?ue%<3Sg<2T53*H^fq}tdW1sr@Ubn?vi!5aL zcHh-gf?xc7;b_om=Hs7Z1Pgb@n9bJRHXkzf&A`Cmw;;^#qVFET^TocGODtqQ+JwX1 zvLM@Z=JR9MoHyUh@mp@Z(QqPc?{&$xBNFDNyYh6-hlXB#TBIuL0Si(Fb+6)3@k?8J zmhURt9UD4XIR@^y7a=QyOD6v_=Q*6WeVN}yQMiPKW0bwC`8k808;xbV_nrhzR)B+o z;R5TdIg6zCKP38uSy#AQ=Po(Aa`&=Kt*%2lkiCx#3@?@m72o?Zb3J^v+Ckm> zY;Lqq(PM;Zn^i?G`Sz5Ex_TBbc-q3p4RI`kpX1v3rL#*o;8C?8J;PVvj7}`W@(FEh z@o(JHmRU>n{;F{lyl8UvZCT6Vi3tWeN@laKKHR{{w9vyvTyXj2ysc4t;_Bb1UVU8H z*>U}~xS(sGja<0OB6cQ4!HrS6e#>noc-jvO3Km-An9aWWoZD@2pkU!{*;&huvI|U~ z`O;5nM&_)}zsqyFuD=!*bQSDh-g>Mp(ah|Zt4rwA9-irm61}~Qf`Or?%7(nztagSfedV3iU z8@Q+_A6~gCD|zvK10TVYCkj@sngnv}Bhj@QD`)S!kslz}A061Y(dfW4 zD{f!e(HmZMX|HVA(&hPc{$28`|0s9+T=(eQ2|LHf3>O}oRAt{TPbgk*-8o;}z}NVzn@ZB0 z9pTR4WYVziQjE@Qss4_RM2Tn9leXK+`4`&;SAfer1_lNzf9}ZzqN=sbh*a&ybC{$3 z@Y%cG@ML?pW0A&2R?b;22$9xB8mm^p%OZ#E%QU(cX>62kOKfpks0O-{9c(#+!}HGP z+dlpm&Oh?=Z=)K&`Xz83!NAb)4s=hVflQ}M(0e7g;JPKNjz8J5k&CA-@yFkPYEtFb zRzQqlcn~r@Dnaq(y(3+_76xbtcTT(9q_C3C_;Wdw3Cr&AcU?JBxBS@BXNB zwJch{YE`3y!n{{eR^?m3W->4|q#aF)xok3d*=x3D$HN9j#>NN>A8U3c%}RSMeLU|7 zU!aI;q1F0TtCWPeT3@|-1uxYZ=4x#`kK?}&Q04Q7ge)d<;afCgL!}0;Le>RuySE;x9;RTjV>YWC97*X55WqE z3#>^Cb89%aF8RbgX~HYDcPcQmE=+#0D{oes4X3SRpHJsjnfxXx?yTD-`AOSm&-x4x zr5BPshcA@wEtb=sCe?m8vHPfyc5OkE%CcP_Z+}eUE_xFad)Z{=QmADM*k7EnF`u~Z zbz9<`9sef3(mIl_KJ`NH+i?F3_>mT}OT9nc;j4ThChqB-HDSuDQyVXr!b7g4Ja)$O zvU3cR((W(wL>5-BGTb6;+aRX?%Ij8$`NlbVrx!Ci@CUy&u34xoS`!kgc_QHLv}yHP z;PAV!`0E#s@V;2KwU(<^sa<>9tKTWMUlCL-T`+dxt*oki+?;>y{aV{+&2o9OKdnAp zywCLV<*sr_yTPG+`jl5*hf40=;8lucc)=*ubnN=Pbya(9CR9z=&$;*6D|egs-&>OJ z&dGyUH8EuDz2T}ZIP%W8x!jr;YpFDRe{G#*G131o$|P-V`Fl%& zi(jAJ+Hv>X!RB9S>8WMU4hU`4yxP09|F1vDT%k^r-&Xgo)$Ph!xuxuO`0D!aPiI9h zOA|@Y{3*F@ot)ohzVMLsk=rv1BKJEod+n~$VPIHr`p2JxrLVW|p7ZUp8_$*u_xkr) zAAVh{*_V6yn*J%aDRT_#DwZ9eTaw_Ldw8$?T=SfQ{nhVw=zS|+zs}t}=ib(BHM0-r z%`SSjV$!3gCFj>kwQa0@{?BxNzUah{$!&a67t~ANwp_`JmhoJ3@Y>tz_9;{SB&^TO z{bab_#P-F-7r%O~rS}|Pvn*lz&0~FXIh##4mN^w4dU#^i`R%QT9tt|MKY5#}!u>o+LX8qe1c*q36oJHwadRE)<#*YziM-O_*`sHVEFxl*Js0*|9e^V$1La8zqjxIeyT3N>$8NL z;lRq1CTIPx=T?^7JGAT9F~P&X>_0TGkDlXRw6}lS`sz0y#hVuv+XmOs!9A zf7_io$!W)4UVJfs{{QEfM4x)bS21L)W?TQhz_m0x%5Cwnd$sJFHyi0p)9d!UJ?j-$ zt2o1)AfCm^i*EG2TK@TE+n>6P-#1-cx2dUY-#_=o7gfE(C(U2 z>f^I>Z~SRJoF{Fw!o?-)^`8B|A9M4@-H+WKZ=8KAqF&+0k3Zqlbz-ec3hsOB&CpU> zmZ?`$`myV(^fG_Dzu(r!>nZi0um8IE>1(llyM8SDzr(;#)9=l;qZ8lQM_A_nQwj~P z|Fn1Zv3|*4FK_Ag+ifhk_s4aqYHMwk_?Bhu1rgW3zF5MkoV>UzFw|6gGw=S|xAqY~ zAFb{-4ZXVftSTKe>~)y}mGw}H+v7rGg|c>ktr_xB~2#;#wrj=Np?;)=k~*zoA<3A55P zMAofY_3<}zUHyZ{Pv@`IzAu#jKKD!Qo%D#Tpy6XCqr@l{Lvu4%0 z>`xo7<=^@C?$OiV{4#m3Z%<`lSkV57y{Y<}+`Q`3qWty+)t8<(Udz7|8h*dx?$qZ~ zcy$6px8@|AEDfI*9DaXC-Q#r?vkNRXC0<@bi1DZ zvSa85gd~^4q4aZ?0{x|ND*P<9G9_*B)I}`{CoPBNazY&Vt5}dllauPR-tXuk7D4 zE{0B~#%0rIW=@jpbyHd!HSLd!_u4;~Wzt?U9neg_**9U%?Rm`R`F|fh%m24x-@-r_ zz1`pTtIsX`CEWin<*54n`p@Us*YADR^`tRR{PSIDYtAFsE-~Q*T*YA%O#rcNj|2lvA{m=K(kLMR#mh5=7`v2Fz zrt|ar?pD7I*ZcEv@BJ5t{0}lPG@P3_b>_#}^Xs(_zg@F#rT?z%Z)ZMEJ})uT!uU#_ zf1k+eY^k#MwkFxPB9>p)Q*u7#HNWCXbtf;+VH4lr-Jo`TE_Qobx9VnIBo7m$82|_4@RjTf3|!xUAo7 z6Fa|j)v|lf`O~WIEXy=AW1Hhw9W7q;qvGgu@9=5y@z$4qoaK+JdpkEe@879gsrqYUzg?O-eeUP$ z`~N22JXQLUz3S`N@5_B9_y36ZxBvaWE;Q=a@1w5&V&8h!?E{?bRl=FJ|`moUJx)nd~Zepx`HpgDI^w9Ol z%g61$FaIt7@22_Py!320>D5eu(bo&E^VipZ^|jymYuWso&+qQ%+}pN%YO~-(w)OS` zJS$hNTH(%aoc$_d_0>=R+~*&g+`aYb?eFqyW?AG#1fAt&u#j6AQ1STLT;6-f)bh=L zF~!;*P&*>#;C;W%L!80SGVrp=t*?AbjwYQve|p=^HM5pId!G6{CH+|anK{MRp3JZL zzwO`OUthF!#U@&cpR0L)*53ZhW@~QlqPJzY!}aX9`_Et0JG=bHUq->7N#_}tUp6eg z6fynu%OXqp9~ZYz+xz>@7fpBVm1k$!6~0@0{oBjfif1i><#) z{aK>(FV%a}^S4MpF0yn{+34a>e0P~-=PIo(nZMb07G;+0HZ^nKVQPEjs(IImdA05O z^Ns&TZ;!dTeM!d4u1}z_&$e^Vnf0uDz23T2EB~K=tyf?^wO_ky($trJIt`CKo@sQx%5U@Md9L;Mtril3AAdG;i|>8^^_RM@#LErxXS2M! zxKFW7SrfNeHSDpHOy8dEDW!3F*JcX4ZCMu0kg=3^_O|oYSqHQ8b;Kr~zm_}e`;{j1 zd%pr)rmc&ye!r6Se?@U&IRA#om(8(uN4OYXFdS~<^Y~Nj5q$NY>(8m7TUUoom$!-e z`f-#0cli|>?`oeE*9#w?`)#}Uy&aFO{}+E`H9l{j{%S^`^X5%9XTMH+m{<0cmw};x z%gDF*;iB*JqknvRGxPEKT_s_E&fj%V0G&nt=pO6Lc~9cy)cj>4?`Y@0{;Xl*|6lKvzAsiXp*tB`dE)t&AT<6oz*#JuOlxXw>5g-I(4;qT;0bnZ>_)Q z-`tmeZOg{JT%WB{ZZHT0n{Bw?`rKFAETy4?N8aecoy}_V?Xp&M^hn#Dx&2_aMP5oc z{|45xe+v{Zu`-nG>789(^Spiky`9uLw_NYhOnx@U{_FAmKQ8}C{~u|#VM%#lT-#|)B~w%0&fetN zKX*pH+?|`JGHcn*+_3f6<+(8w7Vn2k{v(mpLDGA{4hyS?p9jRBKOOJ(_PAK7>~ znYC$c*y$yl%E$ZU=Iq*bdq!YtZPj!A$X(y=L<-wQZuWFJH_t9Ex7vu0Au}aym%d%S z+nchovS+Eq=e+jbJ$tlHNu~B2Xc_y3gH8)SB!8Z_dc}&Lhx6ZCKYw#_)~c|ut1$S?0dK|5l~{ z`?r2!^Y4Ej-mO;+6}_=7Io?1-?&s5}XeA+zmOqUNNhUW}Ym{e1-oKh{R=xM%J=e;= zzr=37y>|9?S$xgEClL%;vz|T6-Tk&KJ226&`0g@!Pq((sJIeq4>b2I^%HF)$C<#Po zXG!}>8yd(pEPHv--z4%(bZqK97pbAg-b+p+Af0JADROJ~SELmQoIqhwbw>ZOvgNrU^C`d>d=3Llv z^07&%=)_uft*m`9+pVN}>vnv(Y;W_iCU)=jyzRX5Rv-FquRH%`$M?7Pca|<@V_>*D zD|43jv~{(QUS4mT_Vw?@@;r}I#lDwgqW7&euwTB#Kd|O`wYS~gkKf{L{y(z*IMX-u z>et)l@3!C0oyRl112p%&+8{so=eE1m{70p7Zf<*ixitFMv@cRO*6-Mvce($_qD#El@B4YKs9wS3>zRuicdmO`EiiHUyr0L{mfP*AyS(nkvUTp$*XnG~ zh$?R7dt_w$Zsz4<^Nh2d{@i~tvDqc%%L&Keb26#hZ@Z*?zGf>rb?f~68y9a#WSq^g z|6TLiw^-h*M(VWryEQ8_UxeRUeNuB*u2J$SiRGvNJ&FHU_@UtIo0-Bv=@HBhzmH7+ zl*haJXwueiGcwn2OxS*$N1Wk;?2WhEn)b}`51an~RsHkxvkX5j`n=5i`m?#;r~mzV z({*}mP1w)2+|To(1UOidzh}+bwyx&$9(&yzKacLaoxk(lg@>!$|NmHXUVMgOsZ&-s z0|SHM>9Do+Z#ug$2A5S*>?*H-DxL9w-t=$^$|MJ`G-rrGt=cRMf)560KPptm`$HPT$e??Ug z+pX0a5tw@Xz9Pes9kJ)G zB(a{|7^S;0%Fvbrw7;0w#P|2P?fHL_&Np8F|MlQC+1<;1<*uj9et)<0^TESY>nk4p zw72=V=l0t@Z=bCe*ZWnz=iv4H9hY8z)vb6L|G(z7v*5`uJL>=Zy{%`FAN71L8w10O z%x~}3ces=Y{w#Sr?a1GW9d!?PPX6}JJ|-}9_7=Cuy0~xW>q}lwuCL6!x_q`-&OKEn zz2%ROz1H88bgrjjbLM1scizV*m&eck|3+Hr(aD0lKhLgS?ko5AX|vfy(AtNN+i&-L zzy1GXeSKBs%~M~!!>4F>85O?SV7;#}w8kv2+qOr_@WsCByo#ECi^St9{ax0Copych zx9jI>@#9Q)+~Oa+J-YhW)Nessztz0EmbTeX!dPeD%|{u=w(oww+nIRpkL&61+zwL- zn-x#q?Yutin`X}O#p)X~ZoQc)99Flf@RQ{7r_WO4>f0FvCeNKZapugx`1iTKkK^`o zy)nDpDHn7+=#J#gSX(xRyRM73Z>wcj+xt=Ue$U;G|MRPqg$#|1t@B;i96Q#KSmIEq z7aD48G|67Ryy?f`XRQCre=L5xknc@#)y%(Zt-E5jJ&vk6bo)iI)f+MIUEA5D_v|aM zm^63l#T4V%>#tu)oBEzT_i0OVYX$>D!;a~3zY{J>dxM4%>Xz;edVlq6M)URMnNiD* zUwx7<+m?96{D+Im#+uyjyW2FvTCY7V*|OWgak0JtpSzLmyCX+0^U1CH_wT&i{ug^r zUXQihU3=GT<-Xk=Xo~p4X2hZG7_l<2EG*28+11L>ZfyShEc(`)7XLp(CdImnR@Jw0M(k?%Qpy ze#`$_Tt3gUJvd0~t(--OD4)AqzeI_R?eeU`EoQT~9u+;$u;6#Wjoin_`&O*}9(Mi8 zY+qMKv)Nl$7_9dVE#J)#8ew-v{~kNf%8t2-)>i($E0XlQZqrlMlML)#aVHiQ0d zPvF^elyWH3&&la4_QGQb+Zbs-X0G&bG&@z+rbkl2hVvdcn;dAyIjmQFhXJ;nMHcD<jFyv`=Bj;u!OTwj1aFjn==iXSbz5 z?qjL^Sz6C|ivNAQb;pr)&l~WD)eD;gvCe{r`(T{~zjCSfe!?y>hA#$svyE2Wuv&HF z)>UT6Y*B1O6nv5*89rb6;@z&4ouBed8!X^cNOxnWtFMZ+Ll&8P^wQDQO{G5>5R;@| z7-A(>mCZ+*{{6zSYE_x%maL5tAzP)NuDFZXsB01uy7gwxu647QWzKrWS*rt{%ntip zoV@F91307@7!-Q*BsP~V%QH!ofQn3zgU^wCkw8pLBb@Hg?+BmKvPDd&406SZFJCb- zI6TkH_;55YC2iN@i!08HiwOE&zLV!}A;Zab7&K)n%NMO6(B)DhSZMV&Z1vICUuQ4V zRFdoWcG(lRzeA(z(Z?N1g0=!2ogG&}srO)lg-l;Z$Kk}yH`mz38%Xtbb=-b?!9>@t zVaFMt@=FJNF8jQ(PYvD^H@`!ptHMUt#EfV2#*6w&t5&6??BF?^k;Bu`v1*m1HS~Fj>p}4 zsdvxJH1F}$c_sPnu`gdrFW)aokJK?`1cp&f8b`%iH?z(P|rg z?w9(gTFlEck1zfC@$QqUGyYX&UN*11WFGy;ddbQ8j%)v11l@A{Y@f-?`3H`q>%1)O z_cgnj^7+Z_p4V!R6E`QD@1FDM+=+Lu3>jYNN$uVG*4p4(boHyWH*SCaUy+{JanU!9 z7m_D;O>JYFe|42smy%M~q9geM@QK|j=)?#E1H%hL#8QIe;6Y?i25FE3^&3De1_mM& zA1*pGO>ftUbCB@|MQcm ze9e!Ghgi8+uU>tktZ}jrxcZ&7?B)}=_b1MLX*bUhT)HvLnIa`s6SjJlMpuVMSJ4+Y z1&KWOa=ZEY54rXKzIqrR9vUk2hDjYXp9gYsL*AmD9^2=hUcB?}yV?I=A4@%Lx99)z z@76L$AZIWzFkFzlCHeB$lc!Jb{(NgKre9b6sKwYi%K@aBfq|jnnTq?aHqAE8z{fB4 z-{NE2V z_y1G9Jo%>OWAL6*28M><36Hp2uKxIymiDEfdFd*@mHZwSN=iway`}|!+I)7APppXU zwNnPJxoMtT)<$o?SAO5$?nlDMPI=3QzT=-VAa>q$DL%bevoRs!$erM&dUqGByR|IO zUSVn+r}U!PN=jm`Ijck}maN(CBa`GKo%!XgxyzJ@n8hZdZ|c5$yrjK;$0?IuKO1WX zSy+l#FgwHS(%n7kn=*EOJAZx(^VXnP{lHie!BA0G7ZF2WLmop{6W>ppmRdyX$>qlx zTHacJK}0Zk$;q3$&fEQdbGN*H`AloZl6Y8|d)H<2#CL1n&$iZHy3a8rRA*L)NuYtb z*x6TY5^X7|ClA!@teaMNb;^p{ddqHpiMc!f&i~EFpP#j!n9j1`G3bCXP{6W)(#vIG< zi!$EJY(9Bu7cVdGWHsNlD=h9vshSpvU@6H(T#9p`l>8LB#y=v9mKMO9^ELm-p zvq)o+#-de=W-QWLv})1HMXMGa`>wr6W6??_r7k7E-62;`bbC)_7mL^?AChF5Kk3Yw zGr!;c-glzY_Y@>kGR#rhrE*?WF!nm{V(GOq%ep$2$@-RVZK)6Ij@Y8SJ^5;BXsC&= z%aN>!1rhh6mHGE4S%xgv@LjqqJihkp0>@_Szdev>Wth{lOXd9APwjq7qe23ooR+w* zB-XuRm4wo<>D-G~e#{9MetY_uQqolypU8cY|9-SxlMwLKJe0lt_WOO$*9pg5WP%uT z;N<$#&R6y19pyXWiGxa&rG^xoOi(r8Y&^pGI3e z+`gN$i_N~77wqz?=F~anQ@ix16w!=#7 zubxazy|rEc>eeY!@2mOGoigP!6T>aVtP__{OUu`6uFHRGDF3D4_LTV8d7szLl#9y* z$DxI2M2KsM?=jv<`E%rDZ(a{Dy%@4lW6=sFrDck{G|g69rYCKwpZN32?OW-$`=9Jw z)*+#!v`UF_zs}7iF=n2US8ki|1PV9Z?G#qCl0BE8aY1!CJA-b~&wsS_ zin!VP%B5zvm)5=IDSyrnx%H#Os9VEmPe|Tw1*^1%#I74_kB5a{IHD$Gsv-2PSo=r-OkKY+r_V$&3t~WrSvm@w{+3nRiWPVR%Qn-RdAM`kY9Lv z#u0ECec+^n?un`1>Qc$%i1;+0a|i{1MLU+PU&V>kd>Zt&teyRzWkqc-6#Jcm16zT8lZ4KjcJSa#v` z>pw1SkE}P?^fN$3!f^6G)3!6QSARXyJ!o2exa#zCNLt%vT^g|Pj6`n6svA1GCR-z2 zf>WPmBnK>vFbNC|Oj0cuy%=K1doy13_bL%VPcIv;?oYmxZzeMAYK@q;`t`*!nbof^ z>Uoy_-*@Y}MAk#czlonWe&0K_*LVJO%}wj|b|r;tT$=Y8QWD?oNG++~tZ_Qz>a=i! zV3EL3%`-;s-_5tngfKY<`U(b%2nM=@E>YB&TIzM5$vD$?OPp(~`X>>F1-wj+0VcQp z-CT8Xg&;>ujGj9?pXOeVD|g}~Gks6nZvFMPe*Me~=chfk`PInXy^2S36OY~&vC1t` z`vdC~Y?d8awF!EHzes{a+dB8N%U-ds=*rr<&E)9T-J5uH4RfYFsoZ)tQ1qgX(U$&+ zFRwW{23}ESU$tsQSBGm>t&);wa8dE+BI~Lf3>V~f$>rYvbLWbm)!KFI>~pUDik|J* zURr#!FJN-{)F%=9jID!3U(QZGc^=vdm~)2butmX!qVMyV1!MEq_1=CfW3wW^EG#XZ zZ;f*V*Jc-&Gt06bsCBt+Z|g|%a@>}A(IwDjqO$u@tyfxIEsMK4X0&-YT?-5d&{!1U zC+;x4+hta*Y<{+V?ADN|-EUW4-T%AjtZuMz*XAvMc%sAkpLZmHS0KOT``xlp*<@|I z?NSDY2S+~sY<_($zufZe@?MDy&mXD_55A86{BxUc^nMNLfTCkR9?q02ztm{DmG}F< zl6xBt{(ZOev6X9}i_7)J(q`|YqW7Im-0o{x9O7BnSo9`-e_LYC{hjq^pGUi<{F$+^ zne9tp&_5f$x7&_RJR+5IcVD?j#0ZJiD{qi-0;YHE$;G%~vUoM~I z3I4ZdO`*rcuUqWGR$tRqdsVf!m*4J>OX2%N>T-WOPd|P0>LCL|!@BfY&z8U5W@D9~ z7+d?eBk`~NkAv5B&nfS`=YIOS?YAG@2Lmj&z1(nnU*YAY&*zyM8aCdo->_-hIqS45 z4{n@$YyI8!&x_CO{Ncyn)#g3CZM?j1=i_UuW%`ug7yPbibbqEBv$4uK=krbRf3}&2 zPV9aj_qa^{^K8$c?Yey_N7bd}W`1uvFW-~9E4FX<^Em6wM<*)UUskVJpa19YWc|>P zYifN9Hz+(e=RbG$wD0sYYCpgKUj6UO^!gdgD(~BxFX=3amU(-~^}T-NF0+>_)PDE0 zeBHnI%eiA`<5k|3+{lgJ`)to?y;;jvt?JnG@!BQOG`NVkP09b*M$LW=wwDS^nr`f$8@PACE1|tlO9Pe6#+ZuV?Q6n>v5GY3-+%oQGFOMeqCj zM)I)`|DE0IAARPvj;iltxG?7zTmGxn@x_1sB`8RIOZl?Ie)83|zeOjCt^dXnB4|1+Nhs1 z_rHmFAT3=IBFJe>4+l5`Tp6$vlRC!;w zVb`{EzrU_OYgv4VqubTG_)tgo`Wdmow?DjNUmabkch9X}LqzVzehYnb?SJ~}w}rVA zqn)oWuCIT2w_{zrU3%4}*wxqb{=HhuZ};KD<%b1JOc(dIl;$tCx7XjjqrC1>#ic0? z0imYqw&WaOS)?Jqrx|7PtvSs)!t#pj|qH#xM^$L`;Y&YaWNv_Y>C;#SI4@vYBt2xzN%fj$V*8u(8Wd2SFpRP zB-cbRP_%Q_q=yq~`ZYBf3w?7h*Kc8F*mY!+tfJ-izwPTIw!YhK|0~z^>BRlTYTKi_*j=f1zO z_4~Q^e~Vs!Q?Abt6?^jUoA~{?CC5M;Cj1y9XG-q>`E$F`opY~d{aUg+?YxZr&z^~$ z#?EiQJ@MGMYgyv=d(NAe^_E(_RWe$#=1ZshzUem(82DT^c`W+p`y1Ps*kJqQQ!hXM zurOEu-1AXcZ12a1^Y*q^i$68Jnfq%Zi}OR@=9cF~!h`EyUu0Wv zFLaUja!%ove-0Z~?b_%6o~e7;QKd;YK3}M+t$Lnbb?2U2y~>*7S(-ay`xyUrZ_l~E zx%T-#!Nb3f&$@cWOecP8-G>F2GFGc!V{|AEN*7<6e`ngWyG>6W_U_s4x4WpwL~81i zQ;)kmG?f1T3t7ERG*soA@-Y#?&mN_BZ=5{#?A59+kyP*Zchu%J-1@ak|8pXP!*%N` z0$|L;de|F+*pUGx7x z{P_I*tjcG$_xD|VTclUwZ>fi2VC{e!p?)ryIV} zWq;2f_J4D~e#egQ6Ze~}%6|3k-@n6GO~3m8`?oy3M}F()?!Djde7`M!=l=;~f7>hT zx9m`!&&0rRK{rw-s%`1ZPrv+@+ZuZEv@5@_+i>e~VXsV`N%x(dzZ6ozgt=Raeuq~x(q|0lC!XsG&p!%uzD>poS?T3b3{ z#j0I-2T#AB#{0tf`5(>KL9Zm{nAX1eVdy-UZ}!==wd+>q7jD_8C9t)WxBTyi@73RA z#U@UA)U^9qoOMpY{cAjKE>e5`IT$QiwQ7-EFKe2w?C$FK3%}d{D*17tZljcV_UY~K z?ITM6hM$X!-nVwDEwe+n)4~sZvA?~3iKJB-1?7L&eLMHzyNRcFt+1QF=VRxt#QsH@ zLZ*FwdwTDFV0+@QcE+!XM%X^8Z}F+H3d!oh^IzYpH+V z9?kyy`|Hn>-}9wS7HkO*{AZrOv+UKB!v8hdCzt8&c1byTe0BMoTfck%@A~!0J5Bt_ z^K&z-{+-+Tf98#!mqfF-<=+3>T%Bi`eM~ZMd-0VMiuU`yZ#(ts)>G~4vi~EQ85$9=eE=Ov+nKBU;pFZ)~C0NHrxL7s#e_^|KU{i^!l&u<^LXR+*S4d@ArE) zIUf6uRBugIH{Thqpl1#{|U$m@P>8I!kw> zl=#DfCwo;_e=gem$kU3Uq=UG{BXf0DDYd^&xi@>-PVraDzh^2X&2nG%sLLZcpmUZx z*E6?X<(F$Kb23|R+{%#Tle9h&aP^6YxeVX_4GD?M=khZ=IKs)&^z`+1G0|tecDWuk z&)1sQKD{=3pOVt-ZEx1BSTWDAv}XOHKR06ecIUoYvQfTn&#waqyDqy;SsS@s^YY_) zCWR$`Zd_c>tEqFQJAYkFY~JSET3V~`S3Q4xV_w%OspW_FrQKcjdS6ju`W!_Dh8N#_ zF30S6@%OpdKHKVNKUV9_6`gM{!qHOp-ZtdUZUy61t=CUFmAk8-=HA`+y}AGTpC|S6 zc76V~TKM`)FMZ$bXM3OgIR8|8`q^EtF3nt)d93UCyy|O5;%$H3d3D|_|IVkQKMb8O ze~;W(_`F)a&Z_#@k3eO%ZKXE8TGA1l9-QZYadCy~WtS&&B8895wR^HrJ+9{W-_U0E zX(p8(uBmT(xBpivJU=6_`TSf{#lrrxX|~zFcDO&=yP@dovD5nV_SHWBr+fSRnOSDr z!Vl@UGZg$e#rAFYhdr5x+js;fK6GZAQ+@5pWp`fDiG_>XE2V3MkN3WL#kg&x$m7ie|FcePn)BJ4Y${AnbxxNOtRb5r%%(~-mBIS;4qC0zy5H8 zuZ*=#n8J;$tv<61ir&09xH5ZpuVm=8<>mh-EzLeZ*Yx3J)7dUh-n@Jq`iPl9R?GNu z?Z=4Qi?zh$s9;pB=CAofUnTyT~7t2fJeE20~Z=bO-YLk)iyt+r? z%X>2xvB)tnSR9?X?B}=NtLOdvy}ym`-S5M@_fHE6O}O1xdoZBl$@J{pP7eivPwY*d z7gN?Ry0b8~HhlffH(!5Mi$DGPIyz;0w0ZvR+K)dcCo`_doHcv<+ikbky0%)LEuCGb zA}M+9(Y>Fq&EKtAvF7Z#Qyp*DtXQ*lWv)f;kpmSBvzD=6Hj&;_ci3Q)(cS<1_x|qd zUv=ugecijc(@v^Pp64T_sIoA8Y57vkgoB%uc4&Ot_G;DdtEIX2*LSVU77yI0sIfqV zk(F`j_qZi3ahJGPPR{)P@86EZ9#0Ra1{LSH^X>gMYUS_d{N8C^De=tqd0n45+uAF# zk8=E$K9{kd85tB+cD#9g*qwi`qT}7&TK4?B)SS4`k)3~j&DZ+i>XPj0^Rm~kExomB zdf+6L$aODm_nBTO`rdLeF+J(yW=`IX-(KDg(@gwilMqej;{qZIZ3rQoVF8 zhO={Yyq(m!J%y|yr%dfyd^71IU*#wLUDuz#xF5IR{&Y2w@}Ebd^SNW^eiKqm@$^{5 z!6ULaYW3P=(do-SiNuD6rq|lfwh?F0<5ioyG5PnCFFkKuy-!OkuUPf#(rfwe5*4T4 z-RF06YdLn@E%)ZX-wzi*?Y-(JF7kI*(PCXK8$-n~j*DxHt(PCRto~<|E1Db{I{n`^ zJ8Sjx)^+#i#dddlUte?oS+4Q+{|6@O@0(fu;zTE#tv>_9g)b{s?MgcPt$+WF>uNiX z?w6Y%pm{ztwDkA7DlXew%q$utKPV@Z9Dw$%H!y=H~E6;*;56Iwz~haK6>=qu8`fO&l|1fYPLQ~z4P_= z!##TrwNylJ4K6u<^1!~>v;UTzjQ=-d-c0M1fbXk7XKmD-N!!2cea?l~+V|{V34fWS z(xRoZbI;ADIF9YX6*5l0V|==+T6LCi@s^!_{`^&oN{gTVB&DM#TL%fiUxS6^@YYmk3Zl(|Yped`Bf$@eoWKHr~T z^*7&tpVh~0hH0BKzwKGNyG*{~(M9VhwIf`LzrMb_wlewk&En}Rx~w+be>!{HySObW zzDGli*uL%GQ+{4g-f!N#sdI1dKcBj@a=Ch~nX>)<4Z*)fCRTi##lYZTyrJyzyK?(^ zOEtPqrp)+smg)UBvy-~(ru;IytiNT(o8y&;CzpuP!drLoJPujOXqQCiMr0?vH zu5-M2>FMmvn?AeqzEAGv5_O7T)=kcr__V)2&F25BDTa&LxfGx2L_~%?+mrakK{st} z*^OD+f=)bdZl@l-_10cDvVPZw9q!x9W^a2NyF0PGy{vPuUViv=K^0Fa!zIs`t*ws# z`96H{`d8wzqTgPh(%oHWUlTd~T2yt+^2?^>549AJd|k{Ia>^k$a3TLa(`A=RZ|{75 z;l=7-U*FygylwR7l$=>~MoGvFGu`divaGk}UsbcL`}RjvGIHnM-Q_=O|6jZxmdDFs z&@Xjw`9AwI_D7Dpmc5&>CGGFJ9Wy%C&E4V8eSkIjqq+B_kkY_YS&}?eYr}i^FP@$> z;oqKRGpD_Mbxx;w*(oO>r>QMEEn0ss<~s^Goj4?>llSiZHrCZoqqQ`&w5mS$Za-1X z_#h~$o9q9Bwe_}t4_RMM*O~L{ZOvRA{=J*dfA+Uupt?Dn@72$Dv;Wupd&)oW_xInR zGm!s0SuC6{U%$3YJ-Hy@!R__`@7vF-ef@jq{u=x5)5AIo*YEjo;`INSZ)9x@4t$zF zeSXE$v;ND@|H*u{{d23pTA{~wb?<+^v%9@fj)5UYL)|ZRXH1^rk!f##Yv+~kyc5p< zy7o~>T9{wmQqxn14mbV(yS`ej-ZKC3w7u2eWCfjm%>J9V|LZHm#q3q(ukSD4>>j9A zZVE?5$Nh^dCN85k9=Ibg<<=SBmYk4OA9~BEh;@N zedSb!Ny+w$^QJ5$Ee27%s4|vqr3CWMy?Ny?HaO+d#5+>-+0dJg)CuvMQ@GAbMid$BAZ> z!?otGSiQDu*=6qN6+1R=U!JXfbb)Z_a&`Y%UtVrLZ>Q~9+I5)Gg!QQF&#%>qOZoTh zxu3>8Dn=H#>2( zAC8O&$uXL_rjhsiYeoi!n(N;`PdgvE;nCIS@4Y*=ht^(u`%Oe_>WdPq>;?5(yW2Fo zf&wCxT2|cHx9wVD_R`XYU(J7C-Rz z!}bjujwEQrx7^uXv`odb%5%#4>N2&RYkjUty*%~wuVjl>h~k+vL3yQPUrM+41(p_> zR_2wJeS5urzvDda1)djQKH(O6`RbLa;-b1gy+Nl9{Z+u?V>BsNcfB&vN zzg5+fi*sG(B-gc$Yfq;17_?s7Z^6KDVU9-EqAxfs47;`FmR&X~n&TJ#{F~=<d5Q473$gjste7znpO1t z!`jQf-Ea!stFvmGez?ffsng@NH8pcC1T!|=lHjR&E4Tf#{PRETD|bJ9d*-s)G!5|= z#zv7shi{u+0579rQ#k$U{=2;w!`p1@SGN1R-99AzXmQqkQHELOk<*-=o1C2W4)!lz za=&cH^*2-bmw3y$?uc;;JQb~-5fV7>$*D~S5-pBmnisS7mw8R{HJsgYQh_IQnv1Yg z&6kVn=WW01#FjA5FFt7=C*U+?+O(Z_=Y5uRTz~!NZ}S)J6_u)|9&9%*Te?&4M8@1C zp40#HxVM_zaY+WxKc2g9X&Se~W0uEbR!!~ukuhq0DJQG_J=!llJtXw-3rD1S%e7ud z!By@vz9?OYS8>&T`{?Qs4%GuXC7V7NbM4%9q3q)$)yK!~x}9b{Aa2_35gvN=(j}w* z<@4T(mss7re}DZgu^jQMpO{mV)zas0o1cHZBVn`6^L-r$*Vca8Cj@eVL$TYE-^ZJq zl9u0AwpmrM%VlNosT1PMyO#*HKM86PIHbVc?a9j*lB;;={t78xCw0!feiG-4&VQU| zTkYPzZ{3O&3wEvB!!T>tGl7>`$G6^={W@dy>g%u1qy-;-zH`=lv*~w?b+_LL{QrM9 z+l(dt$J=|~o@;{m{J!&&-^ZD&X9kD1PJ2~llWwK^XQm3LC&%1%Hs@`lXKhS6+V%T= zy@s{*Y#Z?pfoD}NUEOh4Zu;rBA2qh>%}_hN$xmIH=T{duuX&gA&3El-Pjv65+h6xQ zW);bkoBi*@v%2_m`KK+kZOo zIO_Y|?h-x*y=`ftFMq8IT3)4beDB+q%Wqfy{lC3x?iJm8Gep*XH^1~i1{7Wo4tJF7 z<=67Re(9>>-nC2CocZ`^^?Ki5ACx^OfohYa&*olplX&JP@$|-m&!l0v`Cp$qVF)%O`Y`kiGNmqC4`MK8L@0QzJR2Y2q$&URL1CUuzGTZ5ZTmFutcdA0Al9GicAdz-xR%Dr=&mTW1xdu!|dwGo!t$G-2}X*o${#vJf$ zCj-NSB;`wAC;ibq=;Yzl(JB1?#jUNy6%{TK5d|3$JIJ_(+Q66Td0c=oe8JuaN!U^ih6lz_w=pmrNQW+yU|?u~%@{H; zFw93RnzMr~7-3+jL9CzqfmnJpYA`8be8Ieok>P^HoxJU(2*R~Blv&NK+8Z}*XE-?J74SWKx558@$ew2tl=ogJ zUYO?TIjLjYt6!=!kA3CXv9J5*A)YGF$vTV&dt;d!mMz+$vA<4#jmh<^zt?^{@O$ek z=IP!WqjaAKf8Lq(tTIn^33#<@|753Vp()6*45bsp9?1R5yLR+kajO7#`R(?7v!{j~xP5h& zz)PkKHxGl=k7j|@WtG0)vS+X4x;D#e_js=Fz5Ge)S%h7ehq#0BB_46-)uPu~K*|C( z@3elyYhb^9QS0uv#pNb11Z%k`>NTXb{Me+m4t&9I#kwYTkvd8e>C_0f-s%)d&esjSpHAIBu(dUHqW z`#-MPmyC*A34@vppZK99cIv zNbk*wo0iL;>qjkK@#=eH z{){bBcfDPGywjewk2c@>s;{TFc1Pitj(sQBK6(}V^XRmRGtH|HcAeh6<>l4AwaM2( zOO9f!`ee@gowkX6RN$ayW6zC{yk}bl~&8pp2ClY%0v^E$2=4%`L%kmr}R)p^Rad6|^cNMP>zrU7$D|zFLzh{@7S+i%m z{j7~CKbJk9x94t>p|#Apt4*eOgDb7u7!rfa_#-b$MVWGR#o{muUKVO z{&3ZtJx#a&8SQc2edNZA4lnDJlxKQ>b0=%H?7#WsuEhfX>BVceDT<0sT@$fa*W`%T z5>TBJqtV`%nwlcYp}3~;UvH%Q?g?AVqkqqEof#|mOzonI!iiV&C&o^gHgUa{S91GZ zafXR{3?X4*&*p4S2z@Un`|I1AAM?wXS8tcMoV73S@w>pozyExl_HdF)>FYV4-ka~A z_x^z->+7x;3vcZ5 ztv1W~SNo>d`S@LvkZGx*q3P#-ew{sYzfR=7pT7c`^}bzf6-ajDmoct-&?;N}?a$Qs z`t2DF+stR{Y<;;?$`* zk(NId4>lfN_d-xiDx{RXdgkG$W%dQnXDn>qef_rvD4*9vy_$Gt%^W917q6s{ij2kI ztUAoL8CPEWJImfqKjF1+-ND{Subm$*F!ZZ`dT?>gmo-eTq9=80t*@$r5)hBu-{kN2 zWyG?4ZA&&i`gXUve~0b7)4R7_tI~{M*1fxXTjY<^yL#5_S#spYtodu>Rp;K?8neIh z{;IFlU#jjtsGF;8`}_8*xvQ_e-}vKqX~ykEb&uyyUNfg=^Zb?7+qZ4f;Z{`f+?DsF zp`{`^;KKZW3pf1!emK7FcX3{v<8waG?SE5UTq=Iv&s%)XH1>)}=_A1rA2zvoNB?I~jU`^;lQ)c==U zJe^-x`Xgf2@x_1s&iwxS+p874K|xX55-!e)56>&z6(f5~t#5Z`gind7`THGn-Yt3* zwE1R=!IgjP;h|S+S03O0x9((TaHLrM{?~4NJCY8hR_%TF;o_G6`~K%vrL7Bd-hGX; zzW#-n{p5|0vb($9`YqqBw{l~dq?>++or&MFDsR`juEyHk8T*-?Iv%SVr=R;-dVZg>TXW)x1*g~V-L-%I z{J`CL>-T&uElSF{eU0;e-Tp1n)2IAak6XX$)vHqL^fQrRK~dLUfBIHiRkkK##&)*l zr$yzZE1%~sj^6eoxENe%nBJ(@P*z{>!7*`?gs5cP<-PNG75DyFXzsmCUii_eWA7Ke z78Mm#@eF!3@ywYwOrfE{LW`w#FZz`&WIUg7f#=27C5a2K{`qTNsC8N@qqC3ebDn2L zqPE_~^ygw%e&zgaVPU^hdz+(s|NLq9!fLXv{<1xN{mqsizwg%G?s%}E?T56aq+F7I z@tNoytCno>x&7#Q`kT9pL1A~V_M5E5w{J^kE6e;z!4{>sYQ)sr)C`tSeoan)Aq7J)*;iLS0oXK#Od+3m)b-1xF{FV|XsyZx3= z@yJ2t<$Q9MS2wwC5m>Zji;nsF18=`^D9ZSSmz{gL^1g1|j*8sX(+pLYe$xyVKNGnj zNIK=~uDSaA{{8$YJX3S+qoU7T|3BaRBCKxQ-SzKEdYExZ!S`*NrmThfzVu?n2D6j8*Ya+BJ+fI|#(LsH+ud1j`An*MtEW9ldn)qIeoA4V?#EdIX)l=^Vk^%b z@&8(}?Vgh}(|Z~7np0bjdWYv2zbM_id%LL9n)`e5Uv}mGOS?Sxaa()aoZ|B9zh2(o zeRx-J8b!FTU;yetGl%pZdw$-`uD_d-bnP z-uXAI+J~LiMycLo6Z~rZnxVV$(2do6vbIwvM;Cl=xfpf1`Dgd_cem@`d~DB?yt99{ zfRpvFE0W#u60jVS@Y)4 z`g9#Hzne5%0k-K-R#}q5+u1?~eYB6`+q)VH^|C^hh z$ST}v>peaH-^1fi^=(WIwT!mBI>*YhXdiO5R z+@EpQx%~cw`dt~f*2D`v%GI|mo^E>KQCao9nI^@DpV?mTeIqX^_vz=BRp{>ix>OT}XUHu-hG2le}|ym5(IH|@FF)*c@I+y&(w`?QVjyb7Fh`1q#cPhEe` z9{VvP>z;{eE$GyaU6)>#e69Od)6yRKen;ty{WrgS-gH)ayX=h}Zx5V5ZT5S%Z}}ao zHP5DA*3(&h@xu@2?R&m{;#RP8VqoBN^K@|xIqb|9J8$ab{~yJR-~YP(`hV^J+ncz$ zPb}Ti)!)S<=yYP!oyVKX_}W*mxS`i_V@v7$qmQm{4|mTG@49Dem6GyI)^eJh#Dn`w zlRvH8@@CDPKYRWhy0fQg_VJj78mAs6XSyC;%FZ{>qhouhb!HUD#nsi-YcD%xY`7{W zI%{(Et}R=1Y>mxrm6u09zZP{)!}HRCeX*}MuDN-0ishW!q z81{YP=KVL<$G<-pH81*E!Mgi<)D`bdn;|fFcKC$Z;R!}klZBIm`RD5BZCoyARjFm0 z{$jzqYYyE4PV2AR{@fnDy!Mxq)1J2>Ro^}e&%F0qr{%>hO;sbCiqqfz-s3zZdrR%u z@oDBOtm>4E&DS5OZmQawcVxmmwctvoyp1#d{_)8+e|Kx2_SKgqM_tGySGf1GwxY?(j*W8X&S#Y$4Uj_y(MTs!%3wEA)TMfavmn`r#>HLLcu zoPG}eeSfd5-YT0CIce1wmH?5ZYtAGZNiu8c2wzvyJN5Qs#`Y)WFK5`)Uz{~L^Jdv@ z=k?dWzq^=t{9Q9Bj#^mLQ=WZ$d2{2XE8*+C40kPBv1(OTytZGesnpk~Pt}eUcjVd+ zi%Pu6w+f!B;#s(H;_PkjN-ea$TU~fv8f&Fiy}y@(f8WDdjrn_w-#&QwDNiVF={KX$ zP-pFy>xm1)x{tBKwdaazxe4BQCX{LORt~yxw1Ag z%A52ejp8OF&5KXo+sCv8?SBNIInJyyfHuUboPzhZY671~#n`i=3wF+11-u@!_4JzuotX_n42L zk~t?8r+s?U!3EbRg}p!4<(cZVN7uxn;P<(Z>Y2)#hH8rUu9>NVLf>#(LI0wXy!j#h zP1=3ypYHi8u_S7fjx>*zYhY;L-t_&x63W&zN;#iGmzqS z3_CqB`DOa+6|a}h^?Uu~hq|0xbm2+U$Z5@MrmYICeEHRCu8wy4rD(;L+m8z^V(xWr zzq+bo;lDr9idCig_HQpgH#Yjb?VZJk%acljm+4)2`6PSYtMJn+L4Clp^He&`Jk1nc z{NgVDbC&E{WY$@>%Qq;@FY(nxpTLDH*Yz*@$i#3Z)GH|FIiIKf-%WM@7HvK5pXT>7 z?O|iHI@|a6tL}QlecmkYvPSpK!rkjCf4s|1%H{p*xX3;5-fWxK2i5Oel|Aaey0@$A zE#LCXkKTvB4mmYt$&WjGn&zfodt9{J_O{=Lnm#~btJ zM!hcGr6)Vtc=M-cf45kfSt;4+eVXId+rz=hyAhP#jkW!6Z>qh1@Q#*X*502#C8LkH z%G%DW`w~(4x#iHJNpq|DTH7MDOU1;Ta&GSX|Euu-pS*8IZ=*v)>=+FVxlyjpA}~ zZ)IiswLWhD<=sg)=GD4?@)zHleRtEfNpt+J7q71=yMB26(>amd`o`M&UoV`z@uFi@ zaLH<0*|)8U3mp>|IyyQsF27VP!#7>UbK}K_j$x;}OmFXf_~{O-wtUT_wTIm=A3EIR z)WmF_dwcoXcC{9ZBAve>Rb@_l&gPhf+6Gi)OgMT!|L-%~dkeR{_tM=uU42jG&(>`7 zcjr#)cXjf~*M{CtpEpAwWoyXpN1*Z9hOA0)H*ecXGp7dMxbf7)b>>ahsCm+&f=@12 zu1lDv$8cqC-1_Pl>kQ@nW`2J9WqxSrS7XWJ_Y6{gh}ypT^rlrvCS~Ua=4%t-^lapY zqQlSbyYszXt2<}u^_RcCyx5WW`Mp^E{%a>cI(4*E%szTw-`INp@zmUGUR7;QJ}!yc z(ETUl&-{MwJ=uDjw@qw7&6f(dv%&7!&z`6E@bG)umK41M*Zo=7trCw+h|4y6QmwB_|4_+$CsWqum5-UkYMv`wUVo!MglopU#i z|Mxz8vHO?Oop(NquhrDj+nbfKe)HEgA10}ITF<@y@B7)7==EKlT)#74{$lOE7H7NV z`rq<6d1>K`tE<~p_vik4a9Mtxv}MBacg^?zZ7|U^G_%q=lVip!dFAiyncesQR$NlC*V`Ml1^>>k-n-w@awqG1 zzgd#v!aonhUT0_faen6hMJsk}$$i}tbzYW%fuSMXD`|>~XH7t5^r{x-FBmLq& z9$Xyh)bZlPf-uhKX2mNwl`gS1Z1(c(nr-#-W>+*5^WXV(IeYHD(g>tz}y(9zBeHy<@}e1$D8l0+aX8_j=EUtfhN6F8$wX2TH?{ZyTf96G_Q%7WRee~|a?E*@A zYL8YP&s%*(`A5I-mDlffKi*~3Ui|Y_?*6s6q+~_QUi%(iR=0h{p0*`B*xyMR|Lm^p z|NGf?ZCm#GclBQ{+$&uv500>+(;ku2E*blai%k`J85R-{q1Do{wRKAmfBu^&J%*D) z7cQ)R&5*IFtNYXI?}xmyU8?q9m8;kvR{NkxGc5bCuXU+acGk~ZIg-7{Zk4ZEu;a+P z0t;>3@-X+Z-G}3zZD5PiE4%mhTg4t@hvLs%|33b4e;cL#=kdhC=f=wpKQxjk+4RT# z{GvxfyH7v8_KW-apQz=RLt~QGpYGg!ciy2zLYwEWy!DOM^m3Hk>yjedIuoakM?$+V zzg)HLgw(1ak);bZ97(uv{o{gL>2g=&ws`5zeq9LO-_;_p^XJinVfAz0HT_*2RkfjT z*P@m3u^Xbl-;rCoV1tRyk>g444jmF|cD%OC*>lp9+5qg3h5#eVVCrT;(6?9BS!wkw`}@kN2CXnvO7 zxb@t;E1w6kO;TZ#`Ct>{i_%X^ zOCmvYBGX0I<{!8w`&+*DRcvmzif3(gnT*+v>c?W>?PCX6LDM2TuBdG9`u*kZ@^5df zg90MfOfL28+!#1f@!r0@pw#vu-oN?zyC|I!I;l^)}X)gu_q`8yy zs~WX$GUkONj^<7m&An=hI>3EZ|Kt@PzE|2EC5$G@Lfdmg4w`~G4h&avV)`4>iUQ&t zmvAqTRNS|wD;rcRGpxAzHTKM6t69>YIu={aTL1pi0{1VvF1r|J3GR)%e?!&8GDaBW zCWp0llJd9CrDe=W-nv;5#5OqoDG;PiTK4k?hDhFu+;m-K5*HZeYX)%PkR?pH>N zk3fESwo)Whlk{#j24C00z`DP2D^_J~MXWkleYMEW^m_9mjlAu<8~Jasy!=`f`K;L% zWZnVG8M_bL)dqi@<=pqh>HPe|4ZkL<2>TT5fTF_yCYZqj?A$z8V=OuamjuOTS z)5#v;kIQ!Noob?e>psJ?=U^{hI5JoF(o@hxNtX}UGYmhJ@^(Te%E%bo2f1x@S`2N5 p(1gizlGJYS%nSp={QymmpZ_=dD(atI@+KG*UY@RgF6*2UngCS_6YT&1 literal 0 HcmV?d00001 diff --git a/specs/mvp/image/Snipaste_2025-12-30_17-02-20.png b/specs/mvp/image/Snipaste_2025-12-30_17-02-20.png new file mode 100644 index 0000000000000000000000000000000000000000..4005c93cb419c4eeee22cddb8032ad0cfe021e87 GIT binary patch literal 53286 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 z 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 n5+wUT8Prw+v1(XoZP4>gTe~DWM4fl|!K! literal 0 HcmV?d00001 diff --git a/specs/mvp/v3.0/README.md b/specs/mvp/v3.0/README.md new file mode 100644 index 0000000..b338179 --- /dev/null +++ b/specs/mvp/v3.0/README.md @@ -0,0 +1,15 @@ +# MVP v3.0(Design)— WebUI + 用户数据上传/下载(SFTPGo)→ 首个可发布版本 + +本目录基于: +- `specs/mvp/mvp_roadmap_v2.md`(总体路线图) +- `specs/mvp/image/roadmap_v3.0.png`(v3.0 迭代图) +- 当前已落地的 v2.5(User Mgmt + Stateless Ray Node Pool) + +目标是在 v2.5 的基础上补齐 **用户数据闭环**(上传→训练可见→产物下载)以及最小可用的 **WebUI**,形成“可发布”的 v3.0 版本。 + +文档: +- `specs/mvp/v3.0/v3.0_design.md`:总体架构与关键机制(WebUI、SFTPGo、数据/权限模型、任务流)。 +- `specs/mvp/v3.0/v3.0_api.md`:v3.0 API 扩展设计(UI、数据、SFTPGo 管理、权限约束)。 +- `specs/mvp/v3.0/v3.0_acceptance.md`:部署/升级/验收流程与可验证标准(含故障注入与回归清单)。 +- `specs/mvp/v3.0/v3.0_dev_plan.md`:TDD 驱动的工程化开发计划(里程碑拆分、测试分层、E2E 验收)。 +- `specs/mvp/v3.0/v3.0_progress.md`:实施进展记录(每个里程碑完成后追加记录)。 diff --git a/specs/mvp/v3.0/v3.0_acceptance.md b/specs/mvp/v3.0/v3.0_acceptance.md new file mode 100644 index 0000000..fe710e5 --- /dev/null +++ b/specs/mvp/v3.0/v3.0_acceptance.md @@ -0,0 +1,55 @@ +# MVP v3.0 — 部署与验收流程(草案) + +## 0) 环境前提 +- Ray 集群:延续 v2.5 的 head + stateless worker(自动 join) +- 共享存储:容器内挂载 `/private`(dev/prod 对齐) +- API server:宿主机代码挂载到 head 容器,在 head 容器内启动 +- 新增:SFTPGo 服务(建议容器化部署) + +## 1) 部署步骤(高层) + +1) 部署/升级 Ray 节点镜像(沿用 v2.5 的 `argus/argus-ray-node:v2.5` 或更高版本) +2) 启动 Ray 集群(compose 或平台创建容器) +3) 启动/配置 SFTPGo(挂载 `/private`) +4) 启动 API server(head 容器内) +5) 启动 WebUI(由 API server 托管) + +## 2) 验收用例(必须通过) + +### A. 用户与凭据 +1) admin 创建用户 `alice`,签发 API token +2) 系统联动在 SFTPGo 创建 `alice`(home=/private/users/alice) +3) `alice` 使用 token 登录 WebUI(或调用 `/api/v2/me` 成功) + +### B. 上传数据闭环(核心) +1) `alice` 通过 SFTP 上传数据集到 `/private/users/alice/datasets/...` +2) `alice` 通过 WebUI/API 提交任务,TaskSpec 引用该路径 +3) Ray worker 读取该数据,任务 RUNNING 并最终 SUCCEEDED + +### C. 下载产物闭环 +1) 训练完成后,产物落到 `/private/users/alice/jobs//...` +2) `alice` 通过 SFTP 下载 checkpoints/logs 成功 +3) (新增)`alice` 将需要长期保留的权重从 `jobs//...` 移动到 `models/`,确认移动后可长期存在 + +### C2. Jobs 回收站与自动清理(3 天移入回收站,7 天后永久删除) +1) 将 `jobs_trash_after_days`/`jobs_purge_after_days` 配置为较小值(例如分钟级,用于验证) +2) 训练完成进入 terminal 状态 +3) 等待 API server 内置 janitor 扫描周期后,确认对应 `jobs/` 被移动到 `trash/jobs/` +4) 在回收站窗口内,把某个文件从 `trash/jobs/` 移动到 `models/`,确认移动成功 +5) 等待超过 `jobs_purge_after_days` 后,确认 `trash/jobs/` 被永久删除 +6) 确认已移动到 `models/` 的文件不被删除 + +### D. 安全隔离(必须) +1) `bob` 不能通过 API 查询 `alice` 的 task(404) +2) `bob` 不能提交引用 `/private/users/alice/...` 的 TaskSpec(400/403) +3) `bob` 通过 SFTP 无法访问 `/private/users/alice/...`(chroot 生效) + +## 3) 故障注入(推荐通过) +1) kill worker watchdog 或 raylet → worker 自动恢复并重新加入集群 +2) 重启 head 容器 → head 重新写 `head.json`,worker 自动重连 +3) SFTPGo 重启 → 不影响 Ray 集群;用户可重新连接上传/下载 + +## 4) 回归清单(与 v2.5 一致) +- 任务队列、重试(INSUFFICIENT_RESOURCES → PENDING_RESOURCES → retry) +- PPO/GRPO/SFT 三种 workload 均可跑通 +- head 不跑训练(driver 强制落 worker) diff --git a/specs/mvp/v3.0/v3.0_api.md b/specs/mvp/v3.0/v3.0_api.md new file mode 100644 index 0000000..67f113b --- /dev/null +++ b/specs/mvp/v3.0/v3.0_api.md @@ -0,0 +1,109 @@ +# MVP v3.0 — API 扩展设计(基于 v2.5) + +v3.0 的原则是:**尽量复用 v2.5 API**,只增量增加 “数据闭环” 与 “WebUI 支持” 所需的最小接口。 + +## 1) 认证与权限 + +沿用 v2.5: +- Header:`Authorization: Bearer ` +- admin token:来自 `MVP_INTERNAL_TOKEN` +- 普通用户 token:由 admin 颁发并持久化在 SQLite + +权限规则: +- 非 admin:只能访问自己的 task、自己的数据空间(`/private/users//...`)。 +- 跨用户访问返回 404(不泄露存在性)。 + +## 2) 用户与 SFTPGo 联动(管理员接口) + +### 2.1 创建用户(复用 v2.5) +`POST /api/v2/users` +- v3.0 行为:成功后,**可选**联动创建 SFTPGo 用户 + - v3.0 默认启用联动:创建 SFTPGo 用户 + 生成一次性密码(password 认证) + - v3.0 仅保留该方案(方案 A):不做外部认证/SSO 集成(留到更后续版本) + - `data.sftpgo.admin_api_base` 推荐形如:`http://argus-sftpgo:8080/api/v2`(包含 `/api/v2` 前缀) + +### 2.2 下发 token(复用 v2.5) +`POST /api/v2/users/{user_id}/tokens` + +### 2.3 禁用用户(复用 v2.5) +`POST /api/v2/users/{user_id}:disable` +- v3.0 行为:联动禁用 SFTPGo 用户(可选) + +### 2.4 SFTP 凭据管理(新增,管理员或用户自助) +(具体由你确认 v3.0 需要“用户自助”还是“管理员操作”) + +#### 重置 SFTP 密码(管理员) +`POST /api/v2/users/{user_id}/sftp:reset_password` +- 返回:一次性密码(只返回一次,服务端不保存明文) +> v3.0 先只做 password 方案;SSH public key 作为后续版本可选增强(不在 v3.0 范围)。 + +## 3) 用户自助信息(新增) + +### 3.1 获取当前用户信息 +`GET /api/v2/me` +- 返回示例: +```json +{ + "user_id": "alice", + "is_admin": false, + "paths": { + "home": "/private/users/alice", + "datasets": "/private/users/alice/datasets", + "models": "/private/users/alice/models", + "code": "/private/users/alice/code", + "jobs": "/private/users/alice/jobs", + "trash_jobs": "/private/users/alice/trash/jobs" + }, + "retention": { + "jobs_trash_after_days": 3, + "jobs_purge_after_days": 7 + }, + "sftp": { + "host": "h1.example.internal", + "port": 2022, + "username": "alice" + } +} +``` + +## 3.2 Jobs Retention 提示(新增) +为了支撑 WebUI 展示与用户预期管理,可在 `/api/v2/me` 或单独接口返回: +- `jobs_trash_after_days`:默认 3 +- `jobs_purge_after_days`:默认 7 +- `jobs_root`:`/private/users//jobs` +- `trash_jobs_root`:`/private/users//trash/jobs` +- `recommendations`:提示用户把需要长期保存的产物移动到 `models/` 或 `datasets/` + +## 4) 数据浏览/下载(可选,v3.0 最小化) + +说明:上传/下载主通道仍是 SFTP。 +WebUI 如果要提供“快速浏览/查看”,可实现只读接口(避免实现大文件上传/断点等复杂逻辑)。 + +### 4.1 列目录 +`GET /api/v2/files?path=/private/users/alice` +- 权限:path 必须在 `/private/common/` 或 `/private/users//` 下 +- 返回:文件列表(name/type/size/mtime) + +### 4.2 下载文件(小文件为主) +`GET /api/v2/files:download?path=/private/users/alice/jobs/.../logs/...` +- 返回:流式下载 +- 大文件仍建议走 SFTP + +## 5) TaskSpec 路径校验升级(v3.0 关键) + +v2.5:仅允许 `/private/common/...` +v3.0:允许 `/private/common/...` 与 `/private/users//...` + +应用字段(至少): +- `train_file` / `val_file` +- `code_path`:仍仅允许 `/private/common/...`(v3.0 不支持执行用户 code) +- 本地模型路径字段(如果引入):允许 `/private/users//models/...` + +## 6) WebUI 路由(新增) + +由 API server 托管: +- `GET /ui`:主页面 +- `GET /ui/login`:token 登录页 +- 静态资源:`/ui/static/...` + +WebUI 的所有操作均调用同源 API(不额外开 CORS)。 diff --git a/specs/mvp/v3.0/v3.0_design.md b/specs/mvp/v3.0/v3.0_design.md new file mode 100644 index 0000000..4edf84b --- /dev/null +++ b/specs/mvp/v3.0/v3.0_design.md @@ -0,0 +1,358 @@ +# MVP v3.0 详细设计方案(基于 v2.5) + +## 0. 结论摘要(v3.0 要交付什么) + +v3.0 = v2.5 + **WebUI** + **用户数据上传/下载(SFTPGo)**,形成第一个可对外发布的版本: +- 用户可以通过 **SFTP** 上传数据/模型/代码(至少数据),落到 GPFS(容器内 `/private`)并对 Ray worker 可见。 +- 用户可以通过 API/WebUI 提交训练任务,任务读取自己上传的数据。 +- 用户可以下载训练产物(checkpoints/logs 等),最小闭环跑通。 + +## 1. 范围与原则 + +### 1.1 继承 v2.5 的前提(不回退) +- **Stateless Ray Node Pool**:head 写 `head.json`,worker watchdog 自动 join/自愈。 +- **User Management**:token 鉴权、任务可见性隔离(跨用户 404 不泄漏)。 +- **作业产物隔离**:Ray job 目录落到 `/private/users//jobs//...`。 +- **API server 短期运行方式**:代码在宿主机,挂载到 head 容器,在 head 容器内启动(保持现状)。 + +### 1.2 v3.0 新增目标 +1) **Data Management(SFTPGo)** + - 提供用户上传/下载入口(SFTP 为主)。 + - 数据落到 GPFS(dev 环境 NFS/GPFS,生产环境 GPFS),训练 job 在 worker 容器内可直接读取。 +2) **WebUI** + - 用户可视化创建任务、查看队列/状态/日志、查看“数据路径约定”和自己的 SFTP 信息。 + - 目标是 “可用而非豪华”,支持核心工作流。 +3) **权限闭环** + - 用户只能使用自己目录下的数据(`/private/users//...`)或公共目录(`/private/common/...`)。 + - 防止用户提交任务读取其他用户的文件路径。 + +### 1.3 v3.0 明确不做(留给 v3.5) +- 不做 “自定义 reward function / 自定义 verl 代码 / 多版本 verl 共存”(路线图 v3.5)。 +- 不做复杂 Serving/训推一体(路线图 v3.5)。 +- 不做 IB 网络/拓扑优化(路线图 v3.5)。 +- 不做系统级可观测性平台(路线图 v4.0)。 + +## 2. 架构概览 + +参考 `roadmap_v3.0.png`,v3.0 的控制面与数据面: + +### 2.1 控制面(Control Plane) +- **API Server(FastAPI)** + - v2.5 的任务队列/调度/重试 + 用户管理能力继续复用 + - 新增:数据管理能力(与 SFTPGo 对接) + WebUI +- **WebUI** + - 通过 API 使用 token 登录 + - 提供任务/日志/数据入口(不直接运行训练) +- **Ray Head(状态节点)** + - 仍在 head 容器内(或单独节点) + - job server/dashbaord 提供 job submit/status/logs 能力 + +### 2.2 数据面(Data Plane) +- **GPFS(容器内挂载 `/private`)** + - 存放 common 与 users 两大根目录 +- **Ray Worker Node(无状态)** + - 自动连接 head,执行训练 + - 读取 `/private/users//...` 的数据 + +### 2.3 新增组件:SFTPGo(Data Management) +- 作为独立服务运行(容器化优先),后端存储使用 **filesystem**(GPFS 挂载路径)。 +- 用户的 home directory 指向 `/private/users/`(或其子目录)。 + +## 3. 存储与目录规范(v3.0 统一约定) + +### 3.1 目录层级 +统一以容器内 `/private` 作为根路径(dev/prod 对齐): +- `/private/common/`:公共资源 + - `hf/`:HF cache + - `datasets/`:公共数据集(可选) + - `code/`:公共代码(例如公共 verl repo snapshot) + - `db/`:SQLite(队列、用户、token) + - `logs/`:API/supervisor/watchdog 日志 +- `/private/users//`:用户空间(v3.0 重点) + - `datasets/`:用户上传的数据集(推荐) + - `models/`:用户保存/上传的本地模型(允许;也用于“把 job 产物移动到长期保存目录”) + - `code/`:用户上传的代码(v3.0 **不支持执行**;仅存放/下载) + - `jobs/`:训练任务产物(已在 v2.5 落地) + - `tmp/`:临时文件(可选) + +### 3.2 Jobs Retention(两段式:3 天移入回收站,7 天后永久删除) +v3.0 引入 **jobs 目录两段式保留策略**: +- 第 1 阶段(soft-delete):job 结束后 **3 天**,将该 job 目录从 `jobs/` **移动到用户回收目录**; +- 第 2 阶段(hard-delete):进入回收目录后再过 **7 天**,从回收目录 **永久删除**。 + +目录约定(建议): +- jobs 根目录:`/private/users//jobs//...` +- 回收目录:`/private/users//trash/jobs//...` + +计时规则: +- 以 job 进入 terminal 状态(SUCCEEDED/FAILED/CANCELED)的结束时间为起点; +- “3 天”用于从 `jobs/` 移入 `trash/jobs/`; +- “7 天”用于从 `trash/jobs/` 永久删除(即总共最多 10 天窗口)。 + +用户保留关键产物的方式(无需 keep 标记): +- 在 “3 天窗口”内把需要长期保存的文件从 `jobs//...` **移动/复制**到 `models/`(例如权重)或 `datasets/`(例如评估输出数据); +- 即便已被移动到回收目录,用户仍可在 “7 天窗口”内从 `trash/jobs//...` 把需要的文件移到 `models/` / `datasets/`; +- janitor 只管理 `jobs/` 与 `trash/jobs/`,不会触碰 `models/` 与 `datasets/`。 + +这里的“清理程序”我们称为 **janitor**: +- 定义:一个后台清理执行器,按固定周期扫描“已结束且已过期”的 job 目录并删除 +- v3.0 目标:实现“3 天移入回收站 + 7 天后删除”这一条产品规则(不提供 keep/延长保留标记) + +实现建议(按你的偏好): +- **janitor 作为 API server 内置后台线程**运行: + - 优点:天然可访问 SQLite(任务状态、结束时间、user_id、ray_submission_id),并能把清理结果写回 events 表用于审计 + - 部署更简单:不额外引入 cronjob/独立服务 +- 删除/移动动作建议 **直接在 GPFS/NFS 文件系统上操作**(API server 运行在 head 容器,已挂载 `/private`): + - 第 1 阶段:`os.rename`(同文件系统原子移动)把 `jobs/` 移到 `trash/jobs/`; + - 若跨文件系统(理论上不应发生),则降级为 copy+delete; + - 移动前做严格路径前缀校验(必须在 `.../users//jobs/` 下)。 + - 第 2 阶段:对 `trash/jobs/` 执行递归删除(例如 `shutil.rmtree`),同样做路径前缀校验(必须在 `.../users//trash/jobs/` 下)。 + - 为什么不依赖 SFTPGo API:SFTPGo 只是用户访问协议层(SFTP/Web),目录物理就在同一份文件系统;文件系统直连更简单、也不依赖 SFTPGo 在线。 +- 如果你强烈希望“通过 SFTPGo API 删除”: + - 可以作为可选实现/补充(例如用于统一审计或未来接入配额/策略),但不建议作为唯一手段(SFTPGo 停机不应阻塞清理)。 + +### 3.3 用户在 SFTPGo 内移动/整理文件(确认点) +支持用户在 SFTPGo 中进行“移动/重命名/整理”(例如把权重从 `jobs/` 移动到 `models/`): +- 前提:SFTPGo 用户权限允许对其 home 目录进行 `rename/mkdir/remove` 等操作(v3.0 默认可写)。 +- 行为:用户可以把 `jobs/` 下某些文件移动到 `models/` 或 `datasets/`,用于长期保存权重/评估产物等。 +- 与 retention 的关系:只要文件被移动出 `jobs/`,就不会被 jobs 清理逻辑删除。 + +### 3.4 路径权限规则(API 侧校验) +v2.5 约束是 “只允许 `/private/common/...`”。 +v3.0 需要升级为: +- 允许: + - `/private/common/...` + - `/private/users//...` +- 禁止: + - 任何其他绝对路径(例如 `/private/users/other/...`、`/etc/...`) + +并把该规则应用到 TaskSpec 的相关字段(至少): +- `train_file` / `val_file` +- `code_path`:仍仅允许 `/private/common/...`(v3.0 不支持执行用户 code) +- 本地模型路径字段:允许 `/private/users//models/...`(确认:v3.0 允许) + +## 4. SFTPGo 方案设计(Data Management) + +### 4.1 运行形态 +推荐用容器运行 SFTPGo(与 Ray/API 解耦),挂载同一份 `/private`: +- `sftpgo` 容器挂载 `../../shared:/private` +- 对外暴露: + - SFTP 端口(建议 2022) + - WebAdmin/API 端口(建议 8081,仅内网或管理员访问) + +#### 4.1.1 镜像来源(现成 Docker 镜像) +SFTPGo 有现成可用的 Docker 镜像(无需自建): +- v3.0 推荐优先使用官方/上游发布的 `sftpgo` 镜像作为运行基座 +- 我们在 v3.0 里不需要定制 SFTPGo 代码,只需要: + - 正确挂载 GPFS/NFS(容器内 `/private`) + - 配置管理员账号(用于 API server 联动创建/禁用用户、重置密码) + - 配置每用户 home/chroot + +> 注意:具体镜像名/tag 在不同环境可能有差异(官方/镜像仓库策略会变动)。落地时建议在 `argus@h1` 上 `docker search sftpgo` 或由你们内部镜像仓库提供固定版本;v3.0 设计只要求“使用现成镜像”,不强依赖某个 tag。 + +#### 4.1.2 docker-compose 服务草案(示意) +下面给出一个**示意**(最终以实际镜像名/tag 与你们端口规划为准): + +```yaml +services: + sftpgo: + image: sftpgo/sftpgo:latest # 示例:使用现成镜像 + container_name: argus-sftpgo + ports: + - "2022:2022" # SFTP + - "8081:8080" # WebAdmin/API(建议仅内网/管理员) + volumes: + - ../../shared:/private + - ../../shared/common/sftpgo:/var/lib/sftpgo # 持久化 SFTPGo 元数据(可选/建议) + environment: + # 管理员账号/密码(示意,具体变量名以镜像文档为准) + SFTPGO_ADMIN_USERNAME: "admin" + SFTPGO_ADMIN_PASSWORD: "${SFTPGO_ADMIN_PASSWORD}" +``` + +与 v3.0 的配合点: +- API server 使用 `data.sftpgo.admin_api_base` + admin 凭据联动创建用户 +- 用户 home/chroot 统一指向 `/private/users/` + +### 4.2 用户隔离 +每个用户在 SFTPGo 中的 home dir 绑定到: +- `/private/users/`(chroot),用户只能读写自己的目录。 + +### 4.3 用户创建与凭据管理(两种实现,建议先做 A) + +**方案 A(v3.0 推荐):API Server 负责“联动创建 SFTPGo 用户”** +- 在 v2.5 的 `POST /api/v2/users` 成功后: + - API server 调用 SFTPGo 管理 API 创建同名用户 + - 设置 home dir = `/private/users/` + - 设置权限(默认可写;是否只读可配置) +- 认证方式: + - v3.0 最小可用:用户名+密码(确认:v3.0 先 password;API 生成一次性密码,用户首次登录后要求改密) + - 或:SSH public key(WebUI 允许上传 public key,API 写入 SFTPGo) + +**方案 B(更强但复杂):SFTPGo 外部认证** +- SFTPGo 把认证委托给 API server(token/SSO),SFTP 也走内部 token。 +- 复杂度高,建议 v3.0 不做,放到 v3.5 或更后。 + +### 4.4 用户上传/下载体验 +用户通过 SFTP 上传: +- `datasets/...`(训练数据) +- `models/...`(本地模型,可选) +下载: +- `jobs//...`(checkpoints/logs) + +WebUI/文档提供 “路径如何写进 TaskSpec” 的指引。 + +## 5. WebUI 方案设计(最小可用) + +### 5.1 目标页面 +v3.0 WebUI 采用“**多子页面 + 侧边导航栏**”而不是把所有功能挤到单页: +- 原因:信息密度更可控,后续可扩展(v3.5+)且不会把一个页面做成“巨型表单/巨型列表”。 +- 实现仍保持轻量:服务端渲染(或静态 HTML + 少量 JS),不引入复杂前端工程。 + +信息架构(IA)建议如下: +1) **登录页**(`/ui/login`) + - 用户粘贴 token(管理员发放),浏览器保存(localStorage/sessionStorage) + - 提供“退出登录/清空 token” +2) **任务列表页**(`/ui/tasks`) + - 默认列表:最近 N 条任务(按 created_at 倒序) + - 支持过滤:workload、state(QUEUED/RUNNING/SUCCEEDED/FAILED/CANCELED)、时间范围 + - 支持快捷操作:进入详情、取消任务 +3) **新建任务页**(`/ui/tasks/new`) + - 两种模式(二选一,均可实现): + - **YAML 直接提交**:上传/粘贴 TaskSpec YAML(最省开发) + - **表单生成 YAML**:选择 workload,填写核心字段(train/val/model/nnodes/gpus),生成 YAML 预览后提交 + - 提交后跳转到任务详情页 +4) **任务详情页**(`/ui/tasks/{task_id}`) + - 顶部:task_id、workload、state、created_at、updated_at、error_summary + - Attempt 卡片:latest attempt_no、ray_submission_id、ray_status、start/end + - 操作区:取消任务(若非 terminal)、刷新状态、复制路径/ID + - 链接到日志页与产物提示(SFTP 路径) +5) **任务日志页**(`/ui/tasks/{task_id}/logs`) + - 默认 tail=2000,可选 200/1000/5000 + - 提供“自动刷新(每 3~5 秒)”开关(简单轮询即可) +6) **数据页**(`/ui/data`) + - 显示 SFTP 连接信息(host/port/username) + - 显示用户目录约定: + - home:`/private/users/` + - datasets:`/private/users//datasets` + - models:`/private/users//models` + - jobs:`/private/users//jobs` + - trash/jobs:`/private/users//trash/jobs` + - 明确 retention:jobs 结束后 3 天移入回收站,回收站 7 天后删除;重要文件请移到 `models/` 或 `datasets/` +7) **(仅管理员可见)用户管理页**(`/ui/admin/users`,可选但很有价值) + - 创建用户、禁用用户、签发 token、重置 SFTP 密码(方案 A) + +### 5.2 页面组织与导航(建议) +侧边栏导航(普通用户): +- Tasks(列表) +- New Task(新建) +- Data(SFTP/目录说明) + +管理员侧边栏额外增加: +- Admin / Users + +### 5.3 大致示意图(wireframe) + +下面是一个粗略示意(非最终 UI,仅表达信息结构与布局): + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ Argus MVP v3.0 [user: alice] │ +├───────────────┬──────────────────────────────────────────────────────┤ +│ Side Nav │ /ui/tasks │ +│ │ │ +│ • Tasks │ [Filter] workload=all state=all [Search task_id] │ +│ • New Task │ │ +│ • Data │ Task List │ +│ • Admin(*) │ ┌────────────────────────────────────────────────┐ │ +│ │ │ task_id workload state ... │ │ +│ │ │ mvp2-alice-ppo-... ppo RUNNING ... │ │ +│ │ │ mvp2-alice-sft-... sft SUCCEEDED... │ │ +│ │ └────────────────────────────────────────────────┘ │ +│ │ [View] [Cancel] │ +└───────────────┴──────────────────────────────────────────────────────┘ +``` + +任务详情页(示意): +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ /ui/tasks/{task_id} │ +├──────────────────────────────────────────────────────────────────────┤ +│ task_id: mvp2-alice-ppo-... state: RUNNING workload: ppo │ +│ created_at: ... updated_at: ... │ +│ error_summary: (empty) │ +│ │ +│ latest_attempt: a01 ray_submission_id: ...--a01 ray_status: RUNNING │ +│ [Open Logs] [Cancel Task] [Refresh] │ +│ │ +│ Artifacts (SFTP paths): │ +│ jobs/: /private/users/alice/jobs// │ +│ trash/: /private/users/alice/trash/jobs// │ +│ tip: move important files to /private/users/alice/models/ │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +### 5.2 技术取舍(建议:不引入 Node 构建) +为了降低部署复杂度,建议 v3.0 WebUI 以 “服务端渲染 + 少量 JS/HTMX” 或 “纯静态 HTML+fetch” 实现: +- 由 API server 提供静态资源(FastAPI StaticFiles) +- 页面调用同源 API,避免跨域与复杂前端构建链 + +## 6. API 扩展设计(概览) + +v3.0 可以保持 `/api/v2/...` 不变,增量加: +- SFTPGo 集成管理端点(管理员): + - 创建/禁用用户时联动 SFTPGo + - 重置 SFTP 密码 / 更新 SSH key +- 用户数据端点(可选,最小化): + - `/api/v2/me`:返回 user_id、SFTP 信息(host/port/home) + - `/api/v2/files`:仅用于浏览/下载(上传仍走 SFTP) + +详细见 `specs/mvp/v3.0/v3.0_api.md`。 + +## 7. 配置与部署(v3.0 新增项) + +在 `configs/dev.yaml` 基础上扩展一组 `data` 配置(示意): +```yaml +data: + shared_root: "/private" # 通常与 ray.shared_root 一致 + user_root: "/private/users" # 用户空间根目录 + allow_common_prefix: "/private/common/" + allow_user_prefix_template: "/private/users/{user_id}/" + + sftpgo: + enabled: true + host: "127.0.0.1" + sftp_port: 2022 + admin_api_base: "http://127.0.0.1:8081/api/v2" + admin_user: "admin" + admin_password_env: "SFTPGO_ADMIN_PASSWORD" # 仅 head 容器内可读 + + retention: + jobs_trash_after_days: 3 + jobs_purge_after_days: 7 + trash_root_template: "/private/users/{user_id}/trash/jobs" + janitor_interval_s: 3600 # 每小时扫一次(可配置) +``` + +## 8. 风险点与对策 + +1) **路径逃逸/越权读取** + - 必须在 API 提交任务时校验路径前缀 + - SFTPGo 必须 chroot 到用户 home +2) **大文件上传稳定性** + - 优先用 SFTP(断点续传/可靠性更好) +3) **用户 token 与 SFTP 凭据的生命周期** + - token 走 v2.5 SQLite + - SFTP 凭据建议独立(密码/SSH key),并提供 reset 流程 +4) **GPFS/NFS 权限** + - 确保 `/private/users/` 目录权限可被 SFTPGo 写入且 worker 可读 + +## 9. 已确认结论(来自你的反馈) +1) 允许用户上传并在训练时使用自定义数据集:允许(`/private/users//datasets/...`)。 +2) 允许用户上传并在训练时使用本地模型路径:允许(`/private/users//models/...`)。 +3) v3.0 不允许执行用户自定义代码(不注入 `PYTHONPATH` 作为可执行 code path)。 +4) SFTPGo 认证方式:v3.0 先 password。 +5) WebUI:按“简单最小必要功能”做(token 粘贴登录优先)。 + +## 10. 待确认问题(需要你给结论) +(已确认)jobs 清理执行主体:v3.0 采用 **API server 内置 janitor 后台线程**。 diff --git a/specs/mvp/v3.0/v3.0_dev_plan.md b/specs/mvp/v3.0/v3.0_dev_plan.md new file mode 100644 index 0000000..9a14b4b --- /dev/null +++ b/specs/mvp/v3.0/v3.0_dev_plan.md @@ -0,0 +1,232 @@ +# MVP v3.0 开发计划(TDD 驱动) + +本文是 v3.0 的**工程化开发计划**,强调“先写测试,再写实现”(TDD),并将每个里程碑拆成**可独立验收**的小闭环。 + +输入依据: +- 路线图:`specs/mvp/mvp_roadmap_v2.md` +- v3.0 设计:`specs/mvp/v3.0/v3.0_design.md` +- v3.0 API:`specs/mvp/v3.0/v3.0_api.md` +- v3.0 验收:`specs/mvp/v3.0/v3.0_acceptance.md` +- 现状基线:v2.5(Task queue + User mgmt + Stateless ray pool + 单镜像节点守护) + +v3.0 已确认约束: +- 允许用户数据集路径:`/private/users//datasets/...` +- 允许用户本地模型路径:`/private/users//models/...` +- **不允许执行用户自定义代码**(不注入 user code 到 PYTHONPATH;`code_path` 仍只允许 `/private/common/...`) +- SFTPGo 先用 **password** 方案(方案 A:API 联动创建/管理 SFTPGo 用户) +- jobs retention:**3 天移入回收站(trash/jobs),再 7 天永久删除**;不提供 keep/延长保留标记 +- janitor:**API server 内置后台线程**;删除/移动采用**文件系统直接操作**(不依赖 SFTPGo API) + +--- + +## 0. TDD 规范(所有功能都遵循) + +### 0.1 测试分层 + +1) **单元测试(fast)** +- 纯 Python 逻辑:路径策略、SFTPGo client、retention 计算、文件移动/删除策略(用临时目录)。 +- 不依赖真实 Ray、不依赖 docker、不依赖网络。 + +2) **组件测试(中等)** +- FastAPI 路由(含 WebUI 路由):`fastapi.testclient.TestClient` +- mock/stub SFTPGo client 与 ray client + +3) **端到端(慢)** +- 在 `argus@h1` 通过 docker compose + scripts: + - Ray 集群自动起来(head+2 worker) + - SFTPGo 服务可用 + - 上传数据 → 提交训练 → 下载产物 → jobs 回收站/清理 + +### 0.2 代码与测试约定 +- 测试目录:`src/mvp/py/tests/` +- 新功能必须先补齐测试用例,并让其在未实现时失败(红) +- 最小实现让测试变绿(绿) +- 再做重构(重构) +- 覆盖率:继续沿用当前阈值(>= 90%) + +--- + +## 1. 里程碑拆分(v3.0 = 5 个可验证闭环) + +### M1:TaskSpec 路径策略升级(允许 user datasets/models;code_path 仍仅 common) + +**目标** +- API submit 时的路径校验从 v2.5 的 “仅 `/private/common/`” 升级为: + - `train_file` / `val_file`:允许 `/private/common/...` 与 `/private/users//...` + - 本地模型路径:允许 `/private/users//models/...`(不改变 YAML 结构,见实现建议) + - `code_path`:仍仅允许 `/private/common/...` +- 阻止越权路径(`/private/users/other/...`)与非 `/private/...` 路径。 + +**实现建议(不扩展 TaskSpec)** +- `model_id` 字段保持不变: + - 若 `model_id` 以 `/private/` 开头 → 视作本地模型路径 + - 否则视作 HuggingFace repo id(如 `Qwen/...`) + +**TDD 用例(先写测试)** +- 单测: + - `test_paths_allow_common_and_own_user_prefix()` + - `test_paths_reject_other_user_prefix()` + - `test_model_id_local_path_allowed_only_under_users_models()` + - `test_code_path_still_common_only()` +- API 测试: + - `test_submit_accepts_user_datasets_paths()` + - `test_submit_rejects_cross_user_paths_404_or_400()`(按约定返回 400/403) + +**验收点** +- `v3.0_acceptance.md` 的 D 类安全隔离用例可由 API 测试覆盖。 + +--- + +### M2:SFTPGo 集成(方案 A:用户联动创建 + password) + +**目标** +- 引入 `data management (SFTPGo)`: + - admin 创建用户时联动创建 SFTPGo 用户(home=/private/users/,chroot) + - password 模式:生成一次性密码(reset/create)并返回给 admin(明文只返回一次) +- 提供用户自助信息: + - `GET /api/v2/me` 返回 SFTP 连接信息、目录约定、retention 提示。 + +**实现建议** +- 新增 `SFTPGoAdminClient`(同步调用): + - 通过 `urllib` 或 `httpx`(建议 `urllib`,减少依赖;禁止 hard-code requests 使用) + - 支持:create user / disable user / reset password(最小集合) +- API server 启动时校验配置(enabled 时必须具备 admin 密码 env)。 +- 同步创建用户目录结构(文件系统): + - `/private/users//{datasets,models,code,jobs,trash/jobs}`(最小必需) + +**TDD 用例(先写测试)** +- 单测: + - `test_sftpgo_client_builds_correct_requests()`(不发真实网络;mock urlopen) + - `test_user_dirs_created_on_user_create()`(tmp dir 断言目录存在) +- API 测试: + - `test_create_user_calls_sftpgo_client()`(stub client,断言调用参数) + - `test_me_returns_sftp_info_and_paths()`(含 trash/jobs 与 TTL 字段) + +**验收点** +- `v3.0_acceptance.md` 的 A 类(用户/凭据)与 B 类(上传闭环前置)覆盖。 + +--- + +### M3:WebUI(最小可用,多页面 + 侧边栏) + +**目标** +- WebUI 由 API server 托管(同源,无额外 CORS): + - `/ui/login`:token 粘贴登录(localStorage) + - `/ui/tasks`:任务列表 + 过滤(最小) + - `/ui/tasks/new`:YAML 提交(优先)+(可选)表单生成 YAML + - `/ui/tasks/{task_id}`:详情页 + - `/ui/tasks/{task_id}/logs`:日志 tail + 可选自动刷新 + - `/ui/data`:SFTP 信息 + 目录/retention 提示 + - (可选)`/ui/admin/users`:管理员用户管理(若时间允许,强烈建议) + +**实现建议** +- 先不引入 Node 构建: + - HTML 模板可用最简单的字符串拼接或 Jinja2(若引入 jinja2,则补齐依赖与测试) + - 页面通过 fetch 调用 `/api/v2/...`,并复用 token header + +**TDD 用例(先写测试)** +- 组件测试(TestClient): + - `test_ui_routes_render_200()` + - `test_ui_contains_sidebar_links()`(简单断言文本包含导航链接) + - `test_ui_tasks_detail_shows_ids()`(包含 task_id、state、ray_submission_id) + +**验收点** +- WebUI 能完成:登录→创建任务→查看任务→查看日志→看到 data 页提示。 + +--- + +### M4:Jobs Retention janitor(3 天移入 trash,7 天后 purge) + +**目标** +- API server 内置 janitor 后台线程: + - 周期性扫描 DB 中 terminal tasks + - 到期后执行: + - move:`/private/users//jobs/` → `/private/users//trash/jobs/` + - purge:递归删除 `/private/users//trash/jobs/` + - 全程严格 path 校验,禁止越界删除 + - 清理操作记录到 DB events(审计) + +**实现建议(数据与状态)** +- 需要稳定的时间锚点与幂等: + - 使用 attempts.end_time 作为 job 结束时间(latest attempt) + - 在 tasks 表新增字段(或新表)记录: + - `trashed_at`(首次成功 move 时间) + - `purged_at`(成功删除时间) + - `trash_path`(可选) + - 幂等:重复运行不会报错(目录不存在视为已处理) + +**TDD 用例(先写测试)** +- 单测(用 tmpdir 构造 jobs/trash 目录): + - `test_janitor_moves_job_to_trash_after_threshold()` + - `test_janitor_purges_trash_after_threshold()` + - `test_janitor_never_touches_models_or_datasets()` + - `test_janitor_path_escape_rejected()`(恶意 path 不可删) +- API/组件测试: + - `test_me_includes_retention_fields()`(jobs_trash_after_days/jobs_purge_after_days) + +**验收点** +- `v3.0_acceptance.md` 的 C2 用例可按“把阈值调小到分钟级”完成验证。 + +--- + +### M5:端到端(h1)— SFTP 上传→训练→产物下载→回收站/清理 + +**目标** +- 在 `argus@h1` 落一个一键脚本(或手册)跑通: + 1) `docker compose up -d` 拉起 Ray(head+2 worker)+ SFTPGo + 2) admin 创建用户 alice(联动创建 SFTPGo 用户 + password) + 3) alice 通过 SFTP 上传: + - 数据集到 `/private/users/alice/datasets/...` + - (可选)本地模型到 `/private/users/alice/models/...` + 4) alice 通过 API/WebUI 提交任务引用上述路径 + 5) 任务成功后: + - 从 `jobs/` 下载 logs/checkpoints + - 把权重移动到 `models/`,验证不会被清理 + 6) 把 retention 配置调小,验证 jobs→trash→purge + +**交付建议** +- 新增脚本(命名示例): + - `scripts/run_all_v30_api.sh` + - `scripts/run_e2e_v30_cases.sh` +- 新增 `docker-compose.yaml` 中的 `sftpgo` service(或 `docker-compose.v30.yaml` 叠加文件) + +**验收点** +- `v3.0_acceptance.md` 全部 MUST 用例通过。 + +--- + +## 2. 风险与测试关注点 + +1) **权限与路径逃逸** +- path policy 必须覆盖:train/val/model_id(local)/output dirs(jobs/trash) +- 所有删除/移动必须做 prefix 校验 + +2) **并发与竞态** +- janitor 只处理 terminal tasks,避免清理正在写入的目录 +- move 使用同文件系统 `os.replace`(原子) + +3) **SFTPGo 可用性** +- SFTPGo 不在线不应影响训练与 API 核心功能(除了用户创建联动) +- janitor 不依赖 SFTPGo(文件系统直连) + +--- + +## 3. 交付清单(代码/配置/脚本/文档) + +### 3.1 代码 +- Path policy(v3.0) +- SFTPGoAdminClient + user create/disable/reset password 联动 +- `/api/v2/me` 扩展(SFTP/目录/retention) +- WebUI 路由与静态资源 +- janitor(trash+purge)后台线程 + DB 记录 + +### 3.2 配置 +- `configs/dev.yaml` 增加 `data.sftpgo`、`data.retention` 段(详见设计文档) + +### 3.3 scripts / compose +- compose 增加 `sftpgo`(或新增 overlay compose 文件) +- v3.0 e2e 脚本(上传/下载/清理验证) + +### 3.4 文档 +- 更新 `specs/mvp/v3.0/*` 与 `src/mvp/README.md`(运行方式、路径约定、SFTP 操作、retention 解释) + diff --git a/specs/mvp/v3.0/v3.0_progress.md b/specs/mvp/v3.0/v3.0_progress.md new file mode 100644 index 0000000..49ebfe6 --- /dev/null +++ b/specs/mvp/v3.0/v3.0_progress.md @@ -0,0 +1,154 @@ +# MVP v3.0 进展记录(milestone log) + +本文档用于记录 v3.0 按 `specs/mvp/v3.0/v3.0_dev_plan.md` 实施过程中的里程碑完成情况。 +约定:每完成一个里程碑,追加一条记录,包含**日期**、**完成内容**、**涉及文件**、**验证方式/结果**、**待办/风险**。 + +--- + +## M1:Path policy + tests(已完成) + +- 日期:2025-12-30 +- 范围:按 v3.0 路径策略升级 API submit 的路径校验(不扩展 TaskSpec YAML 结构)。 +- 完成内容: + - `code_path`:仍只允许 `/private/common/...`(v3.0 不执行 user code)。 + - `train_file`/`val_file`:允许 `/private/common/datasets/...` 或 `/private/users//datasets/...`。 + - `model_id`:若以 `/private/` 开头则视为本地路径,仅允许: + - `/private/common/models/...` 或 + - `/private/users//models/...` + 否则仍按 HuggingFace repo id(如 `Qwen/...`)处理。 + - 拒绝跨用户路径(例如 `bob` 提交 `/private/users/alice/datasets/...`)。 + - 拒绝本地模型路径不在 `models/`(例如指向 `jobs/`)。 +- 涉及文件: + - `src/mvp/py/argus/service/app.py` + - `src/mvp/py/tests/test_users.py` +- 验证方式与结果: + - 本地单测:`.venv/bin/python -m pytest -q` + - 结果:全部通过(`54 passed`),覆盖率阈值保持 `>= 90%`。 +- 待办/风险: + - `model_id=/private/...` 的“本地模型路径语义”需要在用户文档/WebUI 中明确提示(避免误用)。 + - 后续 M2/M3 需要把该路径策略同步到 UI 表单/提示文本(避免用户填错路径)。 + +--- + +## M2:SFTPGo 集成(方案 A:用户联动创建 + password)(已完成) + +- 日期:2025-12-30 +- 范围:SFTPGo(Data Management)最小集成 + 用户自助信息 `/api/v2/me` + 用户目录结构落盘。 +- 完成内容: + - 新增 `data` 配置段: + - `data.user_root`:用户数据根目录(默认 `/private/users`) + - `data.sftpgo`:SFTPGo 可选联动(enabled/host/sftp_port/admin_api_base/admin_user/admin_password_env) + - `data.retention`:jobs 过期策略配置(3 天移入 trash,7 天 purge;janitor 在 M4 实现) + - 新增 `SFTPGoAdminClient`(`urllib` 实现,不使用 `requests`): + - `create_user` / `disable_user` / `reset_password`(最小集合) + - API server 增强: + - `POST /api/v2/users`:创建 DB user + 同步创建目录结构(`datasets/models/code/jobs/trash/jobs`) + - 当 `data.sftpgo.enabled=true` 时,创建用户会联动调用 SFTPGo admin API,并返回一次性密码(明文仅返回一次,服务端不保存) + - `POST /api/v2/users/{user_id}:disable`:禁用用户(SFTPGo 禁用 best-effort) + - `POST /api/v2/users/{user_id}/sftp:reset_password`:管理员重置一次性密码(SFTPGo enabled 才允许) + - `GET /api/v2/me`:返回当前用户的目录约定、retention 提示,以及(可选)SFTP 连接信息 + - 同步更新 `src/mvp/configs/dev.yaml`:补齐 v3.0 相关 `data.*` 配置(默认关闭 sftpgo)。 +- 涉及文件: + - `src/mvp/py/argus/service/config.py` + - `src/mvp/py/argus/service/sftpgo.py` + - `src/mvp/py/argus/service/app.py` + - `src/mvp/py/tests/test_sftpgo.py` + - `src/mvp/py/tests/test_users.py` + - `src/mvp/py/tests/test_app.py` + - `src/mvp/py/tests/test_service_config.py` + - `src/mvp/configs/dev.yaml` + - `specs/mvp/v3.0/v3.0_api.md` +- 验证方式与结果: + - 本地单测:`.venv/bin/python -m pytest -q` + - 结果:全部通过(`62 passed`),覆盖率 `90.11%`(阈值 `>= 90%`)。 +- 待办/风险: + - M2 仅做了“API 侧联动 + 单测”,未在真实 SFTPGo 容器上端到端验证(按计划在 M5 完成)。 + - 目录创建依赖文件系统权限:生产部署时需确保 API/head 容器对 `/private/users` 可写。 + +--- + +## M3:WebUI(最小可用,多页面 + 侧边栏)(已完成) + +- 日期:2025-12-30 +- 范围:API server 托管最小 WebUI(同源,不引入 Node 构建),用于登录/提交/查看任务与日志、查看 data 信息。 +- 完成内容: + - 新增 UI 路由(HTML+少量 JS): + - `/ui`(重定向到 tasks) + - `/ui/login`:token 粘贴并写入浏览器 localStorage(key=`mvp_token`) + - `/ui/tasks`:任务队列列表(调用 `/api/v2/queue`) + - `/ui/tasks/new`:提交 TaskSpec YAML(POST `/api/v2/tasks`) + - `/ui/tasks/{task_id}`:任务详情(GET `/api/v2/tasks/{task_id}`,支持 cancel) + - `/ui/tasks/{task_id}/logs`:日志查看(GET `/api/v2/tasks/{task_id}/logs`,可选自动刷新) + - `/ui/data`:展示 `/api/v2/me` 返回的路径/SFTP/retention 信息 + - 统一侧边栏导航:Tasks / New Task / Data / Login。 + - UI 不做服务端 session:所有 API 调用均由浏览器带 `Authorization: Bearer `(localStorage 注入)。 +- 涉及文件: + - `src/mvp/py/argus/service/ui.py` + - `src/mvp/py/argus/service/app.py` + - `src/mvp/py/tests/test_ui.py` +- 验证方式与结果: + - 本地单测:`.venv/bin/python -m pytest -q` + - 结果:全部通过(`65 passed`),覆盖率 `90.53%`(阈值 `>= 90%`)。 +- 待办/风险: + - WebUI 当前为“骨架+API 驱动”,不做复杂交互与大文件下载;上传/下载仍以 SFTP 为主(按设计)。 + - Starlette TestClient 的 `allow_redirects` 有弃用告警(不影响功能,可在后续清理)。 + +--- + +## M4:Jobs Retention janitor(3 天移入 trash,7 天后 purge)(已完成) + +- 日期:2025-12-30 +- 范围:API server 内置后台线程,对“已结束 attempt”的 job 目录执行保留策略(文件系统直连,不依赖 SFTPGo)。 +- 完成内容: + - 新增 `JobsJanitor`: + - 以 `attempts.end_time` 为基准计算 TTL(从 job 结束开始算) + - `>= 3 天 && < 7 天`:把目录从 `.../jobs/` 移动到 `.../trash/jobs/` + - `>= 7 天`:确保目录进入 trash 后删除(`shutil.rmtree`) + - 对缺失目录、异常移动/删除为 best-effort(不影响服务主流程) + - DB 增强:新增查询 `list_ended_attempts_before()`,用于 janitor 扫描候选 attempt。 + - API server 启动时启动 janitor 线程(可通过 `data.retention.janitor_interval_s` 控制;<=0 视为关闭)。 +- 涉及文件: + - `src/mvp/py/argus/service/janitor.py` + - `src/mvp/py/argus/service/db.py` + - `src/mvp/py/argus/service/app.py` + - `src/mvp/py/tests/test_janitor.py` +- 验证方式与结果: + - 本地单测:`.venv/bin/python -m pytest -q` + - 结果:全部通过(`75 passed`),覆盖率 `90.72%`(阈值 `>= 90%`)。 +- 待办/风险: + - M4 只做“逻辑 + 单测”,实际 `/private/users/...` 的权限与在 `argus@h1` 的行为验证放到 M5(端到端)。 + +--- + +## M5:端到端(h1)— SFTPGo compose + v3.0 E2E 脚本(已完成:交付脚本/配置) + +- 日期:2025-12-30 +- 范围:补齐 h1 端到端所需的 compose/service、配置与一键脚本(实际运行/验收由你在 `argus@h1` 执行)。 +- 完成内容: + - SFTPGo 集成到 `docker compose`: + - 新增 `argus-sftpgo` service(SFTP 2022;Admin API/UI 8080→host 8081,避免与 MVP API 8080 冲突) + - 同挂载 `../../shared:/private`,并持久化元数据到 `../../shared/common/sftpgo` + - SFTPGoAdminClient 实装(对齐 upstream OpenAPI): + - `GET /api/v2/token`(BasicAuth)获取 admin token + - `POST /api/v2/users` 创建用户(含 `permissions: {"/":["*"]}`) + - `PUT /api/v2/users/{username}` 禁用/重置密码 + - 新增 v3.0 dev 配置:`configs/dev_v30.yaml`(启用 `data.sftpgo` 并配置 `admin_api_base=http://argus-sftpgo:8080/api/v2`) + - 新增 v3.0 一键脚本: + - `scripts/run_all_v30_api.sh`:起 Ray+SFTPGo、启动 API、创建用户并提交 PPO/GRPO/SFT(引用 user dataset 路径) + - `scripts/run_e2e_v30_cases.sh`:最小 E2E runner(HP-1) + - API 启动脚本增强:`scripts/60_start_api.sh` 支持透传 `SFTPGO_ADMIN_PASSWORD` 到 head 容器内的 API 进程。 +- 涉及文件: + - `src/mvp/docker-compose.yaml` + - `src/mvp/configs/dev_v30.yaml` + - `src/mvp/scripts/run_all_v30_api.sh` + - `src/mvp/scripts/run_e2e_v30_cases.sh` + - `src/mvp/scripts/60_start_api.sh` + - `src/mvp/py/argus/service/sftpgo.py` + - `src/mvp/py/tests/test_sftpgo.py` + - `src/mvp/README.md` + - `specs/mvp/v3.0/v3.0_api.md` +- 验证方式与结果: + - 本地单测:`.venv/bin/python -m pytest -q` + - 结果:全部通过(`75 passed`),覆盖率 `90.35%`(阈值 `>= 90%`)。 +- 待办/风险: + - 需要你在 `argus@h1` 实跑 `scripts/run_all_v30_api.sh` 完成真正的 SFTP 上传/下载与 retention 验收(按 `v3.0_acceptance.md`)。 diff --git a/specs/mvp/v3.0/v3.0_summary.md b/specs/mvp/v3.0/v3.0_summary.md new file mode 100644 index 0000000..c952025 --- /dev/null +++ b/specs/mvp/v3.0/v3.0_summary.md @@ -0,0 +1,166 @@ +# MVP v3.0 迭代总结(Ray + SFTPGo + API + WebUI) + +本文总结 v3.0 迭代最终落地的功能、架构、运行方式、验收点与已知限制,便于后续评审、交接与继续迭代。 + +相关更详细文档: +- `specs/mvp/v3.0/v3.0_design.md` +- `specs/mvp/v3.0/v3.0_api.md` +- `specs/mvp/v3.0/v3.0_dev_plan.md` +- `specs/mvp/v3.0/v3.0_acceptance.md` +- `specs/mvp/v3.0/v3.0_progress.md` + +--- + +## 1. 目标与范围 + +v3.0 作为“第一版可发布”的最小闭环,主要新增: +- **WebUI**:最小可用的人机界面(登录、任务提交与查看、数据入口、管理员入口)。 +- **用户管理**:基于内部 token 的用户体系(admin 与普通用户),支持创建用户与签发 token。 +- **数据管理入口(SFTPGo)**:用户通过 SFTP/WebClient 上传下载自己的数据;同时暴露只读的共享数据/缓存目录(common)用于复用。 +- **保持训练闭环**:仍通过 Ray Job 提交到集群执行(PPO/GRPO/SFT 三类 workload 都验证)。 + +明确不做(本迭代保持最小): +- 不支持用户自定义训练代码(TaskSpec 的 `code_path` 固定走 common 下的 verl snapshot 策略)。 +- 不做复杂资源排队优化/多集群/多租隔离策略(目前隔离粒度主要在用户 jobs 目录层)。 + +--- + +## 2. 系统架构(最终形态) + +核心组件: +- **Ray 集群(容器)** + - `argus-ray-head`:head 节点(无 GPU/不跑训练),提供 Ray Dashboard 与 Job Server。 + - `argus-ray-worker-0/1`:worker 节点(有 GPU),承载训练任务。 + - worker 以 “stateless + watchdog 自动连接 head” 的方式加入集群。 +- **API Server(运行在 head 容器内)** + - 读取 YAML 配置(dev/prod),维护任务队列(sqlite),并周期性调度将任务提交到 Ray。 + - 同时承载 WebUI(`/ui`)。 +- **SFTPGo(容器)** + - 提供 SFTP(端口 `2022`)与 Web Client/Admin(端口 `8081` 映射到容器 8080)。 + - 用户 home 为 `/private/users/`,默认可读写。 + - 额外提供 `/common/*` 共享只读入口(见第 4 节)。 +- **共享存储(NFS/GPFS 等挂载到容器内 `/private`)** + - `/private/common`:共享缓存(hf、datasets、models、db、logs 等)。 + - `/private/users/`:用户隔离目录(jobs/datasets/models/code/trash 等)。 + +--- + +## 3. 任务与调度(Task / Ray Job) + +### 3.1 Task(平台概念) +- 用户向 API 提交 TaskSpec(YAML),平台分配 `task_id`(可读、包含用户名)。 +- `task_id` 对应内部状态机与重试逻辑;底层每次提交 Ray Job 会产生 attempt 与 `ray_submission_id`。 + +### 3.2 Ray Job(Ray 概念) +- 真正执行训练的 driver 通过 Ray Job 运行在集群 worker 上(避免 head 承载训练)。 +- head 节点通过 `--num-cpus=0` / 自定义资源等策略避免调度到 head。 + +### 3.3 VERL 资源预检查的处理 +- VERL 在创建资源池时会做 fail-fast 资源预检查(如“可用 GPU 不足”直接报错退出)。 +- v3.0 延续 v2.x 的策略:服务端识别失败原因并按策略重试/回退(具体见 scheduler 实现与 v2.5/3.0 文档)。 + +--- + +## 4. 数据管理(SFTPGo)与 common 只读目录 + +### 4.1 用户目录(读写) +- 用户通过 SFTP/WebClient 访问自己的 home:`/private/users/` +- 目录结构(至少):`datasets/ models/ code/ jobs/ trash/ common/` + +### 4.2 common 只读(方案 A:Virtual Folder) +本迭代采用 SFTPGo 的 Virtual Folder + 路径权限覆盖,实现用户可读共享目录但不可写。 + +最终对外暴露为: +- `/common/datasets`(只读) + - **mapped_path 指向真实目录 `/private/datasets`**(避免 `/private/common/datasets` 中大量 symlink 导致的 WebClient “权限不足/越界”问题) +- `/common/hf`(只读) + - mapped_path 指向 `/private/hf` + +备注: +- `/private/common/datasets` 内部存在 symlink(如 `gsm8k -> /private/datasets/gsm8k`),如果虚拟目录映射到 symlink 根目录,SFTPGo 会把 symlink 跳转视为“逃逸 root”,导致点击进入时报权限不足;因此选择直接映射到真实目录根。 + +--- + +## 5. WebUI(最小可用) + +入口: +- `/ui/login`:粘贴 token(存 browser `localStorage`) +- `/ui/tasks`:任务列表(Running/Pending/Completed),Completed 支持分页 +- `/ui/tasks/new`:提交任务(PPO/GRPO/SFT 三套样例可一键填充) +- `/ui/data`:展示当前用户名、支持重置 SFTPGo 密码并复制;提供跳转到 SFTPGo WebClient;提示 FileZilla 等客户端用法 +- `/ui/admin`:管理员入口(创建用户、签发 token、用户列表) +- 导航栏提供 Ray Dashboard 快捷跳转(当前 IP 的 `:8265`) + +关于 admin 页面权限: +- admin 页面本身可访问,但其数据请求必须携带 admin token;否则会在页面内显示 401/403/错误信息(满足“需要先提供 admin token 才能看到内容”)。 + +--- + +## 6. API(v3.0 新增/强化点) + +核心接口(节选): +- 认证: + - Bearer token:`MVP_INTERNAL_TOKEN`(admin)或用户 token(由 admin 签发) +- 用户管理(admin): + - `POST /api/v2/users` 创建用户(并初始化用户目录) + - `GET /api/v2/users` 获取用户列表(包含最新 token、创建/更新时间等) + - `POST /api/v2/users/{user_id}/tokens` 签发用户 token +- 任务: + - `POST /api/v2/tasks` 提交 TaskSpec(YAML) + - `GET /api/v2/tasks` 任务列表(支持 states/limit/offset,用于 Completed 分页) + - `GET /api/v2/tasks/{task_id}`、`POST /api/v2/tasks/{task_id}:cancel`、`GET /api/v2/tasks/{task_id}/logs` + - `GET /api/v2/queue`(运行中/待调度概览) +- 数据/SFTP: + - `GET /api/v2/me` 返回用户路径信息、SFTP 连接信息,并 best-effort 对齐 SFTPGo 用户配置 + - `POST /api/v2/me/sftp:reset_password` 用户自助重置 SFTPGo 密码(一次性返回明文) + +安全取舍说明(当前为内网/开发优先): +- 为了 Admin WebUI “可查看并复制 token”,数据库持久化存储了 `token_plain`(明文 token)。 + - 这在生产场景通常不建议;未来可改为只展示“重置/重新签发”而不回显明文,或只回显一次。 + +--- + +## 7. 持久化与清理 + +- 任务队列:sqlite(WAL 模式) +- SFTPGo:自带 sqlite db(容器挂载持久化目录) +- Jobs 目录清理策略(服务端 janitor): + - job 结束后 3 天移动到回收目录(trash) + - 回收目录再保留 7 天后删除 + +--- + +## 8. 运行方式与脚本 + +开发/验收脚本: +- `src/mvp/scripts/run_all_v30_api.sh`:端到端拉起(Ray + SFTPGo + API),并通过 API 提交 PPO/GRPO/SFT,等待完成并验收 +- 其他脚本用于启动/停止 API、准备数据与模型、探测服务就绪等(详见 scripts 目录与 README) + +典型端到端(示例参数): +- `MVP_INTERNAL_TOKEN=my-dev-token` +- `SFTPGO_ADMIN_PASSWORD=my-dev-sftpgo-admin` +- 支持 `RESET_DB/RESET_SFTPGO` 用于测试环境重置 + +--- + +## 9. 验证结果(已跑通) + +在 `argus@h1` 环境中已完成端到端验证: +- Ray 集群可用(head + 2 worker) +- API server + WebUI 可用 +- SFTPGo(admin + 普通用户)可用 +- 通过 API 连续提交 PPO/GRPO/SFT 三种任务均能完成(SUCCEEDED) +- 用户可以登录 SFTPGo WebClient/SFTP,访问自己的目录,并访问 `/common/datasets`、`/common/hf` 的只读内容 + +同时本地单测通过: +- pytest 全绿 +- 覆盖率阈值 >= 90% + +--- + +## 10. 已知限制 & 后续可改进 + +- WebUI 当前为最小版,交互与权限提示仍偏“工程化”而非产品化(后续可增强错误提示、搜索筛选、任务详情聚合等)。 +- token 明文持久化仅适合内网/开发场景;生产建议改为一次性展示或支持撤销/轮换策略。 +- SFTPGo 虚拟目录目前保留了历史遗留映射(例如 `/common/models` 可能残留),后续可在升级脚本中做一次性清理与迁移。 + diff --git a/src/mvp/README.md b/src/mvp/README.md index 9758140..4fd720e 100644 --- a/src/mvp/README.md +++ b/src/mvp/README.md @@ -16,3 +16,11 @@ - CLI 提交流程:`scripts/run_all_cli.sh` - API 提交流程:`scripts/run_all_api.sh` - v2.5(Stateless worker + user 隔离 jobs)E2E:`scripts/run_all_v25_api.sh` +- v3.0(WebUI + SFTPGo + user datasets/models + jobs retention)E2E:`scripts/run_all_v30_api.sh` + +v3.0 访问入口(dev/h1): +- WebUI:`http://127.0.0.1:8080/ui` +- Ray Dashboard:`http://127.0.0.1:8265` +- SFTPGo: + - SFTP:`127.0.0.1:2022` + - Admin API/UI:`http://127.0.0.1:8081`(容器内 8080,host 映射到 8081 避免与 API server 冲突) diff --git a/src/mvp/configs/dev.yaml b/src/mvp/configs/dev.yaml index d117f65..7d0f98e 100644 --- a/src/mvp/configs/dev.yaml +++ b/src/mvp/configs/dev.yaml @@ -32,3 +32,24 @@ service: retry_interval_s: 60 max_running_tasks: 1 +# v3.0: user data management (filesystem + SFTPGo) +data: + # All user writable data is placed under this root: + # /private/users//{datasets,models,code,jobs,trash/jobs} + user_root: "/private/users" + + # SFTPGo is optional in dev; when enabled, admin endpoints will call SFTPGo admin API. + # Admin password is provided by env var `data.sftpgo.admin_password_env`. + sftpgo: + enabled: false + host: "" # shown to users via GET /api/v2/me + sftp_port: 2022 + admin_api_base: "" # e.g. http://argus-sftpgo:8080 + admin_user: "admin" + admin_password_env: "SFTPGO_ADMIN_PASSWORD" + + # Jobs retention policy (v3.0 janitor): move to trash after 3d, purge after 7d. + retention: + jobs_trash_after_days: 3 + jobs_purge_after_days: 7 + janitor_interval_s: 3600 diff --git a/src/mvp/configs/dev_v30.yaml b/src/mvp/configs/dev_v30.yaml new file mode 100644 index 0000000..3c2649d --- /dev/null +++ b/src/mvp/configs/dev_v30.yaml @@ -0,0 +1,51 @@ +ray: + # Ray Job server address (head 容器内视角) + address: "http://127.0.0.1:8265" + + # 共享根路径(容器内统一 /private,对齐生产) + shared_root: "/private" + + # 强制 driver 落 worker(head 不跑训练) + entrypoint_num_cpus: 1 + entrypoint_resources: + worker_node: 1 + + # 所有 job 通用 runtime env + runtime_env: + env_vars: + HF_ENDPOINT: "https://hf-mirror.com" + PYTHONUNBUFFERED: "1" + + # v3.0 先不支持 user code 执行 + user_code_path: "/private/user/code" + +service: + api: + host: "0.0.0.0" + port: 8080 + auth: + token_env: "MVP_INTERNAL_TOKEN" + sqlite: + db_path: "/private/common/db/mvp.sqlite3" + scheduler: + tick_s: 5 + retry_interval_s: 60 + max_running_tasks: 1 + +data: + user_root: "/private/users" + sftpgo: + enabled: true + # Returned to users by GET /api/v2/me. For h1 E2E, usually connect to the host IP. + host: "127.0.0.1" + sftp_port: 2022 + # Admin API base should include /api/v2 (SFTPGo OpenAPI server base). + # From head container, access SFTPGo by service name on the compose network. + admin_api_base: "http://argus-sftpgo:8080/api/v2" + admin_user: "admin" + admin_password_env: "SFTPGO_ADMIN_PASSWORD" + retention: + jobs_trash_after_days: 3 + jobs_purge_after_days: 7 + janitor_interval_s: 3600 + diff --git a/src/mvp/docker-compose.yaml b/src/mvp/docker-compose.yaml index a74c1a3..6ed9194 100644 --- a/src/mvp/docker-compose.yaml +++ b/src/mvp/docker-compose.yaml @@ -38,6 +38,36 @@ services: HF_ENDPOINT: "https://hf-mirror.com" PYTHONUNBUFFERED: "1" + # v3.0: Data management service (SFTPGo). + # - SFTP: 2022 + # - Admin API/UI: 8080 (mapped to host 8081 to avoid collision with MVP API server on 8080) + # + # NOTE: This is for dev / h1 E2E. In prod you may use an internal mirror image/tag and different ports. + sftpgo: + image: drakkan/sftpgo:latest + container_name: argus-sftpgo + user: "0:0" + ports: + - "2022:2022" + - "8081:8080" + volumes: + - ../../shared:/private + - ../../shared/common/sftpgo:/var/lib/sftpgo + networks: + - argus-ray-net + environment: + # Create a default admin on first start (used by API server to manage users). + # Override on host as needed: + # export SFTPGO_ADMIN_PASSWORD=... + SFTPGO_DATA_PROVIDER__CREATE_DEFAULT_ADMIN: "true" + # Persist the sqlite DB under the mounted metadata dir; otherwise it defaults to a relative path. + SFTPGO_DATA_PROVIDER__NAME: "/var/lib/sftpgo/sftpgo.db" + SFTPGO_DEFAULT_ADMIN_USERNAME: "admin" + SFTPGO_DEFAULT_ADMIN_PASSWORD: "${SFTPGO_ADMIN_PASSWORD:-my-dev-sftpgo-admin}" + # Explicitly pin default ports via env-var schema (double-underscore for nesting). + SFTPGO_HTTPD__BINDINGS__0__PORT: "8080" + SFTPGO_SFTPD__BINDINGS__0__PORT: "2022" + ray_worker_0: image: argus/argus-ray-node:v2.5 container_name: argus-ray-worker-0 diff --git a/src/mvp/py/argus/service/app.py b/src/mvp/py/argus/service/app.py index 0d7c2db..b09ee5c 100644 --- a/src/mvp/py/argus/service/app.py +++ b/src/mvp/py/argus/service/app.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import secrets import threading from typing import Any @@ -12,7 +13,10 @@ from argus.ray.models import JobSpec, RayConfig from .config import V2Config from .db import Db +from .janitor import JobsJanitor from .scheduler import Scheduler +from .sftpgo import SFTPGoAdminClient, SFTPGoError +from .ui import register_ui_routes def _utc_now_iso() -> str: @@ -38,11 +42,48 @@ def create_app(config_path: str) -> FastAPI: db.init() scheduler = Scheduler(db=db, ray_cfg=ray_cfg, v2_cfg=v2_cfg) + janitor = JobsJanitor( + db=db, + user_root=v2_cfg.data.user_root, + trash_after_days=v2_cfg.data.retention.jobs_trash_after_days, + purge_after_days=v2_cfg.data.retention.jobs_purge_after_days, + interval_s=v2_cfg.data.retention.janitor_interval_s, + ) stop_flag = threading.Event() tool = scheduler.tool app = FastAPI(title="mvp-v2", version="2.0") + def _user_home(user_id: str) -> str: + base = v2_cfg.data.user_root.rstrip("/") + return f"{base}/{user_id}" + + def _ensure_user_dirs(user_id: str) -> None: + home = _user_home(user_id) + # Note: create a physical /common directory under each user's home so that + # SFTPGo WebClient shows a "common" entry at the root. The actual shared + # content is exposed via SFTPGo virtual folders under /common/{datasets,models}. + for rel in ("datasets", "models", "code", "jobs", "trash/jobs", "common"): + os.makedirs(f"{home}/{rel}", exist_ok=True) + + def _sftpgo_enabled() -> bool: + return bool(v2_cfg.data.sftpgo.enabled) + + def _sftpgo_client() -> SFTPGoAdminClient: + cfg = v2_cfg.data.sftpgo + if not cfg.admin_api_base: + raise HTTPException(status_code=500, detail="sftpgo enabled but data.sftpgo.admin_api_base is empty") + pw = os.environ.get(cfg.admin_password_env, "") + if not pw: + raise HTTPException(status_code=500, detail=f"missing env: {cfg.admin_password_env}") + shared_root = ray_cfg.shared_root.rstrip("/") + return SFTPGoAdminClient( + admin_api_base=str(cfg.admin_api_base), + admin_user=str(cfg.admin_user), + admin_password=pw, + common_root=f"{shared_root}/common", + ) + def _auth(req: Request) -> dict[str, Any]: token_env = v2_cfg.auth.token_env admin_token = os.environ.get(token_env, "") @@ -74,6 +115,9 @@ def create_app(config_path: str) -> FastAPI: def _startup() -> None: t = threading.Thread(target=scheduler.run_forever, args=(stop_flag,), daemon=True) t.start() + if int(janitor.interval_s) > 0: + tj = threading.Thread(target=janitor.run_forever, args=(stop_flag,), daemon=True) + tj.start() @app.on_event("shutdown") def _shutdown() -> None: @@ -93,7 +137,42 @@ def create_app(config_path: str) -> FastAPI: row = db.create_user(user_id=user_id, display_name=str(display_name) if display_name is not None else None) except Exception as e: raise HTTPException(status_code=409, detail=f"user create failed: {e!r}") - return {"user_id": row.get("user_id", user_id), "state": row.get("state", "ACTIVE")} + + # v3.0: create user dir structure (datasets/models/code/jobs/trash). + try: + _ensure_user_dirs(user_id) + except Exception as e: + raise HTTPException(status_code=500, detail=f"failed to create user dirs: {e!r}") + + out: dict[str, Any] = {"user_id": row.get("user_id", user_id), "state": row.get("state", "ACTIVE")} + + # v3.0: scheme A (password) SFTPGo integration. + if _sftpgo_enabled(): + pw = secrets.token_urlsafe(12) + try: + _sftpgo_client().create_user(username=user_id, password=pw, home_dir=_user_home(user_id)) + except SFTPGoError as e: + # Make create user idempotent for retries: + # If the user already exists in SFTPGo, reset password and enable it. + if "http error: 409" in str(e): + try: + _sftpgo_client().reset_password(username=user_id, new_password=pw, home_dir=_user_home(user_id)) + _sftpgo_client().enable_user(username=user_id, home_dir=_user_home(user_id)) + except Exception as e2: + raise HTTPException(status_code=502, detail=f"sftpgo upsert user failed: {e2!r}") + else: + raise HTTPException(status_code=502, detail=f"sftpgo create user failed: {e!r}") + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=502, detail=f"sftpgo create user failed: {e!r}") + out["sftp"] = { + "username": user_id, + "password": pw, # one-time return to admin; do not persist plaintext + "host": v2_cfg.data.sftpgo.host, + "port": int(v2_cfg.data.sftpgo.sftp_port), + } + return out @app.post("/api/v2/users/{user_id}/tokens") async def issue_token(user_id: str, req: Request) -> dict[str, Any]: @@ -104,6 +183,12 @@ def create_app(config_path: str) -> FastAPI: token = db.issue_token(user_id=user_id) return {"user_id": user_id, "token": token} + @app.get("/api/v2/users") + async def list_users(req: Request, limit: int = 200) -> dict[str, Any]: + _require_admin(req) + lim = max(1, min(int(limit), 1000)) + return {"users": db.list_users(limit=lim), "limit": lim} + @app.post("/api/v2/users/{user_id}:disable") async def disable_user(user_id: str, req: Request) -> dict[str, Any]: _require_admin(req) @@ -111,8 +196,122 @@ def create_app(config_path: str) -> FastAPI: if not u: raise HTTPException(status_code=404, detail="user not found") db.disable_user(user_id=user_id) + if _sftpgo_enabled(): + try: + _sftpgo_client().disable_user(username=user_id, home_dir=_user_home(user_id)) + except Exception: + # Best-effort: DB state is source of truth for API auth; SFTPGo sync can lag. + pass return {"user_id": user_id, "state": "DISABLED"} + @app.post("/api/v2/users/{user_id}/sftp:reset_password") + async def reset_sftp_password(user_id: str, req: Request) -> dict[str, Any]: + _require_admin(req) + u = db.get_user(user_id) + if not u: + raise HTTPException(status_code=404, detail="user not found") + if not _sftpgo_enabled(): + raise HTTPException(status_code=400, detail="sftpgo is not enabled") + pw = secrets.token_urlsafe(12) + try: + _sftpgo_client().reset_password(username=user_id, new_password=pw, home_dir=_user_home(user_id)) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=502, detail=f"sftpgo reset password failed: {e!r}") + return {"user_id": user_id, "password": pw} + + @app.post("/api/v2/me/sftp:reset_password") + async def reset_my_sftp_password(req: Request) -> dict[str, Any]: + """ + v3.0 WebUI: allow a user to rotate their own SFTPGo password and copy it. + Note: SFTPGo does not allow reading the current password, so this endpoint returns a new one-time password. + """ + subject = _auth(req) + user_id = str(subject["user_id"]) + u = db.get_user(user_id) + if not u: + raise HTTPException(status_code=404, detail="user not found") + if not _sftpgo_enabled(): + raise HTTPException(status_code=400, detail="sftpgo is not enabled") + pw = secrets.token_urlsafe(12) + try: + _sftpgo_client().reset_password(username=user_id, new_password=pw, home_dir=_user_home(user_id)) + _sftpgo_client().enable_user(username=user_id, home_dir=_user_home(user_id)) + except Exception as e: + raise HTTPException(status_code=502, detail=f"sftpgo reset password failed: {e!r}") + return {"user_id": user_id, "password": pw} + + @app.get("/api/v2/me") + async def me(req: Request) -> dict[str, Any]: + subject = _auth(req) + user_id = str(subject["user_id"]) + try: + _ensure_user_dirs(user_id) + except Exception: + # Best-effort: user may still be able to use API even if FS init fails. + pass + # Best-effort: reconcile SFTPGo user to include /common read-only mounts. + # This makes the virtual folders visible without requiring a password reset. + if _sftpgo_enabled() and not subject.get("is_admin"): + try: + _sftpgo_client().enable_user(username=user_id, home_dir=_user_home(user_id)) + except Exception: + pass + home = _user_home(user_id) + out: dict[str, Any] = { + "user_id": user_id, + "is_admin": bool(subject.get("is_admin")), + "paths": { + "home": home, + "datasets": f"{home}/datasets", + "models": f"{home}/models", + "code": f"{home}/code", + "jobs": f"{home}/jobs", + "trash_jobs": f"{home}/trash/jobs", + }, + "retention": { + "jobs_trash_after_days": int(v2_cfg.data.retention.jobs_trash_after_days), + "jobs_purge_after_days": int(v2_cfg.data.retention.jobs_purge_after_days), + }, + } + if _sftpgo_enabled(): + out["sftp"] = { + "host": v2_cfg.data.sftpgo.host, + "port": int(v2_cfg.data.sftpgo.sftp_port), + "username": user_id, + } + return out + + @app.get("/api/v2/tasks") + async def list_tasks(req: Request, limit: int = 200, offset: int = 0, states: str | None = None) -> dict[str, Any]: + subject = _auth(req) + lim = max(1, min(int(limit), 1000)) + off = max(0, int(offset)) + state_list: list[str] | None = None + if states: + raw = [s.strip() for s in str(states).split(",") if s.strip()] + # Keep it strict to avoid typos silently returning empty results. + allowed = { + "QUEUED", + "PENDING_RESOURCES", + "SUBMITTING", + "SUBMITTED", + "RUNNING", + "SUCCEEDED", + "FAILED", + "CANCELED", + } + for s in raw: + if s not in allowed: + raise HTTPException(status_code=400, detail=f"invalid state: {s}") + state_list = raw or None + if subject.get("is_admin"): + tasks = db.list_tasks(user_id=None, states=state_list, limit=lim, offset=off) + else: + tasks = db.list_tasks(user_id=str(subject["user_id"]), states=state_list, limit=lim, offset=off) + return {"tasks": tasks, "limit": lim, "offset": off, "has_more": bool(len(tasks) == lim)} + @app.post("/api/v2/tasks") async def submit_task(req: Request) -> dict[str, Any]: subject = _auth(req) @@ -126,13 +325,40 @@ def create_app(config_path: str) -> FastAPI: except Exception as e: raise HTTPException(status_code=400, detail=f"invalid jobspec: {e!r}") - # v2.5 constraint: training inputs must come from /private/common (dev/prod统一)。 - common_prefix = ray_cfg.shared_root.rstrip("/") + "/common/" - for k, v in (("code_path", spec.code_path), ("train_file", spec.train_file), ("val_file", spec.val_file)): + # v3.0 path policy: + # - code_path: only allow /private/common/... + # - train/val: allow /private/common/datasets/... OR /private/users//datasets/... + # - model_id: if it looks like a local path (/private/...), allow only models dirs: + # /private/common/models/... OR /private/users//models/... + root = ray_cfg.shared_root.rstrip("/") + common_prefix = f"{root}/common/" + user_prefix = f"{root}/users/{str(subject['user_id']).strip()}/" + + common_datasets_prefix = f"{common_prefix}datasets/" + user_datasets_prefix = f"{user_prefix}datasets/" + common_models_prefix = f"{common_prefix}models/" + user_models_prefix = f"{user_prefix}models/" + + if not str(spec.code_path).startswith(common_prefix): + raise HTTPException(status_code=400, detail=f"code_path must start with {common_prefix}") + + for k, v in (("train_file", spec.train_file), ("val_file", spec.val_file)): if v is None: continue - if not str(v).startswith(common_prefix): - raise HTTPException(status_code=400, detail=f"{k} must start with {common_prefix}") + sv = str(v) + if not (sv.startswith(common_datasets_prefix) or sv.startswith(user_datasets_prefix)): + raise HTTPException( + status_code=400, + detail=f"{k} must start with {common_datasets_prefix} or {user_datasets_prefix}", + ) + + model_id = str(spec.model_id) + if model_id.startswith(f"{root}/"): + if not (model_id.startswith(common_models_prefix) or model_id.startswith(user_models_prefix)): + raise HTTPException( + status_code=400, + detail=f"model_id local path must start with {common_models_prefix} or {user_models_prefix}", + ) task_id = new_task_id(spec.workload, user_id=str(subject["user_id"])) db.create_task_v25( @@ -257,4 +483,7 @@ def create_app(config_path: str) -> FastAPI: return db.list_queue() return db.list_queue(user_id=str(subject["user_id"])) + # v3.0: minimal WebUI (no server-side session; token stored in browser localStorage). + register_ui_routes(app) + return app diff --git a/src/mvp/py/argus/service/config.py b/src/mvp/py/argus/service/config.py index 7d3f5dd..3ece213 100644 --- a/src/mvp/py/argus/service/config.py +++ b/src/mvp/py/argus/service/config.py @@ -27,12 +27,37 @@ class V2SchedulerConfig: max_running_tasks: int = 1 +@dataclass(frozen=True) +class V2RetentionConfig: + jobs_trash_after_days: int = 3 + jobs_purge_after_days: int = 7 + janitor_interval_s: int = 3600 + + +@dataclass(frozen=True) +class V2SFTPGoConfig: + enabled: bool = False + host: str = "" + sftp_port: int = 2022 + admin_api_base: str = "" + admin_user: str = "admin" + admin_password_env: str = "SFTPGO_ADMIN_PASSWORD" + + +@dataclass(frozen=True) +class V2DataConfig: + user_root: str + sftpgo: V2SFTPGoConfig + retention: V2RetentionConfig + + @dataclass(frozen=True) class V2Config: api: V2ApiConfig auth: V2AuthConfig sqlite: V2SqliteConfig scheduler: V2SchedulerConfig + data: V2DataConfig @staticmethod def from_root_dict(root: dict[str, Any]) -> "V2Config": @@ -58,9 +83,19 @@ class V2Config: else: shared_root = str(root.get("shared_root") or "/private") + data = root.get("data") or {} + if not isinstance(data, dict): + raise ValueError("config.data must be a mapping") + sftpgo = data.get("sftpgo") or {} + retention = data.get("retention") or {} + if not isinstance(sftpgo, dict) or not isinstance(retention, dict): + raise ValueError("config.data.{sftpgo,retention} must be mappings") + default_db_path = f"{shared_root}/common/db/mvp.sqlite3" db_path = str(sqlite.get("db_path") or default_db_path) + user_root = str(data.get("user_root") or f"{shared_root}/users") + return V2Config( api=V2ApiConfig( host=str(api.get("host") or "0.0.0.0"), @@ -73,4 +108,20 @@ class V2Config: retry_interval_s=int(scheduler.get("retry_interval_s") or 60), max_running_tasks=int(scheduler.get("max_running_tasks") or 1), ), + data=V2DataConfig( + user_root=user_root, + sftpgo=V2SFTPGoConfig( + enabled=bool(sftpgo.get("enabled") or False), + host=str(sftpgo.get("host") or ""), + sftp_port=int(sftpgo.get("sftp_port") or 2022), + admin_api_base=str(sftpgo.get("admin_api_base") or ""), + admin_user=str(sftpgo.get("admin_user") or "admin"), + admin_password_env=str(sftpgo.get("admin_password_env") or "SFTPGO_ADMIN_PASSWORD"), + ), + retention=V2RetentionConfig( + jobs_trash_after_days=int(retention.get("jobs_trash_after_days") or 3), + jobs_purge_after_days=int(retention.get("jobs_purge_after_days") or 7), + janitor_interval_s=int(retention.get("janitor_interval_s") or 3600), + ), + ), ) diff --git a/src/mvp/py/argus/service/db.py b/src/mvp/py/argus/service/db.py index 0aa36d9..8ca314b 100644 --- a/src/mvp/py/argus/service/db.py +++ b/src/mvp/py/argus/service/db.py @@ -40,7 +40,8 @@ class Db: user_id TEXT PRIMARY KEY, display_name TEXT, state TEXT NOT NULL, - created_at TEXT NOT NULL + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL ) """ ) @@ -49,6 +50,7 @@ class Db: CREATE TABLE IF NOT EXISTS api_tokens ( token_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL, + token_plain TEXT NOT NULL, created_at TEXT NOT NULL, last_used_at TEXT, FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE @@ -78,6 +80,15 @@ class Db: conn.execute("ALTER TABLE tasks ADD COLUMN user_id TEXT") except sqlite3.OperationalError: pass + # Best-effort: add missing columns (forward compatibility). + try: + conn.execute("ALTER TABLE users ADD COLUMN updated_at TEXT") + except sqlite3.OperationalError: + pass + try: + conn.execute("ALTER TABLE api_tokens ADD COLUMN token_plain TEXT") + except sqlite3.OperationalError: + pass conn.execute( """ CREATE TABLE IF NOT EXISTS attempts ( @@ -168,10 +179,10 @@ class Db: with self.tx() as conn: conn.execute( """ - INSERT INTO users (user_id, display_name, state, created_at) - VALUES (?, ?, 'ACTIVE', ?) + INSERT INTO users (user_id, display_name, state, created_at, updated_at) + VALUES (?, ?, 'ACTIVE', ?, ?) """, - (user_id, display_name, now), + (user_id, display_name, now, now), ) conn.execute( "INSERT INTO events (task_id, ts, event_type, payload_json) VALUES (NULL, ?, 'USER_CREATED', ?)", @@ -183,7 +194,7 @@ class Db: def disable_user(self, *, user_id: str) -> None: now = _utc_now_iso() with self.tx() as conn: - conn.execute("UPDATE users SET state = 'DISABLED' WHERE user_id = ?", (user_id,)) + conn.execute("UPDATE users SET state = 'DISABLED', updated_at = ? WHERE user_id = ?", (now, user_id)) conn.execute( "INSERT INTO events (task_id, ts, event_type, payload_json) VALUES (NULL, ?, 'USER_DISABLED', ?)", (now, user_id), @@ -195,15 +206,18 @@ class Db: return dict(row) if row else None def issue_token(self, *, user_id: str) -> str: - # Returns plaintext token once; stores hash only. + # Returns plaintext token once. + # Note: For v3.0 WebUI admin convenience we persist the plaintext token so admins + # can re-copy it later. This is acceptable only for internal/dev deployments. now = _utc_now_iso() token = f"mvp_u_{user_id}_{secrets.token_urlsafe(18)}" token_hash = self._hash_token(token) with self.tx() as conn: conn.execute( - "INSERT INTO api_tokens (token_hash, user_id, created_at) VALUES (?, ?, ?)", - (token_hash, user_id, now), + "INSERT INTO api_tokens (token_hash, user_id, token_plain, created_at) VALUES (?, ?, ?, ?)", + (token_hash, user_id, token, now), ) + conn.execute("UPDATE users SET updated_at = ? WHERE user_id = ?", (now, user_id)) conn.execute( "INSERT INTO events (task_id, ts, event_type, payload_json) VALUES (NULL, ?, 'TOKEN_ISSUED', ?)", (now, user_id), @@ -226,8 +240,48 @@ class Db: return None now = _utc_now_iso() conn.execute("UPDATE api_tokens SET last_used_at = ? WHERE token_hash = ?", (now, token_hash)) + conn.execute("UPDATE users SET updated_at = ? WHERE user_id = ?", (now, str(row["user_id"]))) return {"user_id": row["user_id"], "state": row["state"]} + def list_users(self, limit: int = 200) -> list[dict[str, Any]]: + with self._connect() as conn: + rows = conn.execute( + """ + SELECT + u.user_id, + u.display_name, + u.state, + u.created_at, + u.updated_at, + ( + SELECT t.token_plain + FROM api_tokens t + WHERE t.user_id = u.user_id + ORDER BY t.created_at DESC + LIMIT 1 + ) AS token, + ( + SELECT t.created_at + FROM api_tokens t + WHERE t.user_id = u.user_id + ORDER BY t.created_at DESC + LIMIT 1 + ) AS token_created_at, + ( + SELECT t.last_used_at + FROM api_tokens t + WHERE t.user_id = u.user_id + ORDER BY t.created_at DESC + LIMIT 1 + ) AS token_last_used_at + FROM users u + ORDER BY u.created_at DESC + LIMIT ? + """, + (int(limit),), + ).fetchall() + return [dict(r) for r in rows] + def get_task(self, task_id: str) -> dict[str, Any] | None: with self._connect() as conn: row = conn.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,)).fetchone() @@ -269,6 +323,40 @@ class Db: running = conn.execute(running_sql, tuple(params)).fetchall() return {"pending": [dict(r) for r in pending], "running": [dict(r) for r in running]} + def list_tasks( + self, + *, + user_id: str | None = None, + states: list[str] | None = None, + limit: int = 200, + offset: int = 0, + ) -> list[dict[str, Any]]: + """ + Returns recent tasks (including terminal tasks). + """ + with self._connect() as conn: + params: list[Any] = [] + where_clauses: list[str] = [] + if user_id is not None: + where_clauses.append("user_id = ?") + params.append(user_id) + if states: + placeholders = ",".join(["?"] * len(states)) + where_clauses.append(f"state IN ({placeholders})") + params.extend(states) + where_sql = f" WHERE {' AND '.join(where_clauses)}" if where_clauses else "" + sql = ( + "SELECT task_id, user_id, workload, state, nnodes, n_gpus_per_node, latest_attempt_no, created_at, updated_at, error_summary " + "FROM tasks" + f"{where_sql} " + "ORDER BY created_at DESC " + "LIMIT ? OFFSET ?" + ) + params.append(int(limit)) + params.append(max(0, int(offset))) + rows = conn.execute(sql, tuple(params)).fetchall() + return [dict(r) for r in rows] + def count_running(self) -> int: with self._connect() as conn: row = conn.execute( @@ -383,3 +471,25 @@ class Db: "INSERT INTO events (task_id, ts, event_type, payload_json) VALUES (?, ?, 'ATTEMPT_UPDATE', ?)", (task_id, now, None), ) + + def list_ended_attempts_before(self, *, end_time_le: str, limit: int = 2000) -> list[dict[str, Any]]: + """ + Used by the jobs janitor: + - Only considers tasks with a non-null user_id (v2.5+). + - Returns attempts that have end_time <= end_time_le. + """ + with self._connect() as conn: + rows = conn.execute( + """ + SELECT t.task_id, t.user_id, a.ray_submission_id, a.end_time + FROM attempts a + JOIN tasks t ON t.task_id = a.task_id + WHERE t.user_id IS NOT NULL + AND a.end_time IS NOT NULL + AND a.end_time <= ? + ORDER BY a.end_time ASC + LIMIT ? + """, + (str(end_time_le), int(limit)), + ).fetchall() + return [dict(r) for r in rows] diff --git a/src/mvp/py/argus/service/janitor.py b/src/mvp/py/argus/service/janitor.py new file mode 100644 index 0000000..61a5e59 --- /dev/null +++ b/src/mvp/py/argus/service/janitor.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import os +import shutil +import time +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone + +from .db import Db + + +def _parse_iso_z(ts: str) -> datetime: + # Stored as "YYYY-MM-DDTHH:MM:SSZ" (naive UTC). Parse into aware UTC. + return datetime.fromisoformat(ts.replace("Z", "+00:00")).astimezone(timezone.utc) + + +def _iso_z(dt: datetime) -> str: + return dt.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +@dataclass +class JobsJanitor: + db: Db + user_root: str + trash_after_days: int = 3 + purge_after_days: int = 7 + interval_s: int = 3600 + + def __post_init__(self) -> None: + if int(self.trash_after_days) < 0 or int(self.purge_after_days) < 0: + raise ValueError("retention days must be non-negative") + if int(self.purge_after_days) and int(self.purge_after_days) < int(self.trash_after_days): + raise ValueError("purge_after_days must be >= trash_after_days") + + def _job_dir(self, *, user_id: str, ray_submission_id: str) -> str: + base = self.user_root.rstrip("/") + return f"{base}/{user_id}/jobs/{ray_submission_id}" + + def _trash_dir(self, *, user_id: str, ray_submission_id: str) -> str: + base = self.user_root.rstrip("/") + return f"{base}/{user_id}/trash/jobs/{ray_submission_id}" + + def tick_once(self, *, now: datetime | None = None, limit: int = 2000) -> None: + if int(self.trash_after_days) <= 0 and int(self.purge_after_days) <= 0: + return + + now_dt = now or datetime.now(timezone.utc) + move_cutoff = now_dt - timedelta(days=int(self.trash_after_days)) + move_cutoff_iso = _iso_z(move_cutoff) + + rows = self.db.list_ended_attempts_before(end_time_le=move_cutoff_iso, limit=int(limit)) + for r in rows: + user_id = str(r.get("user_id") or "").strip() + sid = str(r.get("ray_submission_id") or "").strip() + end_time = str(r.get("end_time") or "").strip() + if not user_id or not sid or not end_time: + continue + + try: + ended_at = _parse_iso_z(end_time) + except Exception: + continue + + age_days = (now_dt - ended_at).total_seconds() / 86400.0 + src = self._job_dir(user_id=user_id, ray_submission_id=sid) + dst = self._trash_dir(user_id=user_id, ray_submission_id=sid) + dst_parent = os.path.dirname(dst) + + # Between trash and purge: ensure in trash. + if age_days >= float(self.trash_after_days) and age_days < float(self.purge_after_days or 10**9): + if os.path.exists(src) and not os.path.exists(dst): + os.makedirs(dst_parent, exist_ok=True) + try: + shutil.move(src, dst) + except Exception: + pass + continue + + # Purge: move to trash (if still in jobs) then delete from trash. + if int(self.purge_after_days) > 0 and age_days >= float(self.purge_after_days): + if os.path.exists(src) and not os.path.exists(dst): + os.makedirs(dst_parent, exist_ok=True) + try: + shutil.move(src, dst) + except Exception: + pass + if os.path.exists(dst): + try: + shutil.rmtree(dst) + except Exception: + pass + + def run_forever(self, stop_flag: object) -> None: + try: + is_set = stop_flag.is_set # type: ignore[attr-defined] + except Exception: + raise ValueError("stop_flag must be a threading.Event-like object with is_set()") + + while not is_set(): + try: + self.tick_once() + except Exception: + pass + time.sleep(max(1, int(self.interval_s))) + diff --git a/src/mvp/py/argus/service/sftpgo.py b/src/mvp/py/argus/service/sftpgo.py new file mode 100644 index 0000000..09b2681 --- /dev/null +++ b/src/mvp/py/argus/service/sftpgo.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +import base64 +import json +from dataclasses import dataclass +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + + +class SFTPGoError(RuntimeError): + pass + + +@dataclass(frozen=True) +class SFTPGoAdminClient: + """ + Minimal SFTPGo admin API client (v3.0). + + SFTPGo OpenAPI documents the admin token flow: + - GET /api/v2/token with HTTP BasicAuth -> returns {"access_token": "..."}. + - Use Authorization: Bearer for admin endpoints like POST /api/v2/users. + + See upstream OpenAPI for details: + https://raw.githubusercontent.com/drakkan/sftpgo/main/openapi/openapi.yaml + """ + + admin_api_base: str + admin_user: str + admin_password: str + common_root: str = "/private/common" + + def _url(self, path: str) -> str: + base = self.admin_api_base.rstrip("/") + p = path if path.startswith("/") else f"/{path}" + return f"{base}{p}" + + def _basic_auth_header(self) -> str: + raw = f"{self.admin_user}:{self.admin_password}".encode("utf-8") + return "Basic " + base64.b64encode(raw).decode("ascii") + + def _get_json(self, url: str, headers: dict[str, str]) -> dict: + req = Request(url, headers=headers, method="GET") + try: + with urlopen(req, timeout=10) as resp: + return json.loads(resp.read().decode("utf-8")) + except HTTPError as e: + raise SFTPGoError(f"sftpgo http error: {e.code} {e.reason}") from e + except URLError as e: + raise SFTPGoError(f"sftpgo connection error: {e!r}") from e + + def _post_json(self, url: str, payload: dict, headers: dict[str, str]) -> None: + data = json.dumps(payload).encode("utf-8") + req = Request(url, data=data, headers=headers, method="POST") + try: + with urlopen(req, timeout=10) as resp: + _ = resp.read() + except HTTPError as e: + raise SFTPGoError(f"sftpgo http error: {e.code} {e.reason}") from e + except URLError as e: + raise SFTPGoError(f"sftpgo connection error: {e!r}") from e + + def _put_json(self, url: str, payload: dict, headers: dict[str, str]) -> None: + data = json.dumps(payload).encode("utf-8") + req = Request(url, data=data, headers=headers, method="PUT") + try: + with urlopen(req, timeout=10) as resp: + _ = resp.read() + except HTTPError as e: + raise SFTPGoError(f"sftpgo http error: {e.code} {e.reason}") from e + except URLError as e: + raise SFTPGoError(f"sftpgo connection error: {e!r}") from e + + def _admin_token(self) -> str: + url = self._url("/token") + obj = self._get_json(url, headers={"Authorization": self._basic_auth_header()}) + tok = str(obj.get("access_token") or "").strip() + if not tok: + raise SFTPGoError("sftpgo token response missing access_token") + return tok + + def _auth_headers(self, tok: str) -> dict[str, str]: + return {"Authorization": f"Bearer {tok}", "Content-Type": "application/json"} + + def _ensure_folder(self, *, tok: str, name: str, mapped_path: str) -> None: + url = self._url("/folders") + try: + self._post_json(url, {"name": name, "mapped_path": mapped_path}, headers=self._auth_headers(tok)) + except SFTPGoError as e: + # Idempotent + self-healing: + # If it already exists, update mapped_path to the desired value. + if "409" in str(e): + self._put_json( + self._url(f"/folders/{name}"), + {"name": name, "mapped_path": mapped_path}, + headers=self._auth_headers(tok), + ) + return + raise + + def _ensure_common_folders(self, *, tok: str) -> None: + # Important: do NOT map datasets to /private/common/datasets because that path is + # a symlink farm (e.g. gsm8k -> /private/datasets/gsm8k) and SFTPGo WebClient + # will treat symlink traversal as escaping the virtual folder root, resulting + # in "permission denied". Map directly to the real datasets root. + common = self.common_root.rstrip("/") + if common.endswith("/common"): + shared_root = common[: -len("/common")] or "/private" + else: + shared_root = "/private" + + self._ensure_folder(tok=tok, name="common_datasets", mapped_path=f"{shared_root}/datasets") + # Expose HF cache read-only so users can inspect downloaded models/datasets. + self._ensure_folder(tok=tok, name="common_hf", mapped_path=f"{shared_root}/hf") + + def _get_user(self, *, tok: str, username: str) -> dict: + url = self._url(f"/users/{username}") + return self._get_json(url, headers={"Authorization": f"Bearer {tok}"}) + + def _put_user(self, *, tok: str, username: str, payload: dict) -> None: + url = self._url(f"/users/{username}") + self._put_json(url, payload, headers=self._auth_headers(tok)) + + def _apply_common_readonly(self, user_payload: dict) -> dict: + # Path-based permissions: make /common/* read-only while keeping home writeable. + perms = dict(user_payload.get("permissions") or {"/": ["*"]}) + # Ensure /common is visible as a directory and can be traversed. + perms["/common"] = ["list"] + perms["/common/datasets"] = ["list", "download"] + perms["/common/hf"] = ["list", "download"] + user_payload["permissions"] = perms + + desired_vf = [ + {"name": "common_datasets", "virtual_path": "/common/datasets"}, + {"name": "common_hf", "virtual_path": "/common/hf"}, + ] + existing = user_payload.get("virtual_folders") or [] + if not isinstance(existing, list): + existing = [] + seen = {(vf.get("name"), vf.get("virtual_path")) for vf in existing if isinstance(vf, dict)} + merged = list(existing) + for vf in desired_vf: + key = (vf["name"], vf["virtual_path"]) + if key not in seen: + merged.append(vf) + user_payload["virtual_folders"] = merged + return user_payload + + def create_user(self, *, username: str, password: str, home_dir: str) -> None: + tok = self._admin_token() + self._ensure_common_folders(tok=tok) + url = self._url("/users") + payload: dict = { + "status": 1, + "username": username, + "password": password, + "home_dir": home_dir, + "permissions": { + "/": ["*"], + "/common": ["list"], + "/common/datasets": ["list", "download"], + "/common/hf": ["list", "download"], + }, + "virtual_folders": [ + {"name": "common_datasets", "virtual_path": "/common/datasets"}, + {"name": "common_hf", "virtual_path": "/common/hf"}, + ], + } + self._post_json( + url, + payload, + headers=self._auth_headers(tok), + ) + + def disable_user(self, *, username: str, home_dir: str) -> None: + tok = self._admin_token() + self._ensure_common_folders(tok=tok) + cur = self._get_user(tok=tok, username=username) + payload: dict = { + "username": username, + "status": 0, + "home_dir": home_dir, + "uid": cur.get("uid", 0), + "gid": cur.get("gid", 0), + "permissions": cur.get("permissions") or {"/": ["*"]}, + "virtual_folders": cur.get("virtual_folders") or [], + } + self._apply_common_readonly(payload) + self._put_user(tok=tok, username=username, payload=payload) + + def enable_user(self, *, username: str, home_dir: str) -> None: + tok = self._admin_token() + self._ensure_common_folders(tok=tok) + cur = self._get_user(tok=tok, username=username) + payload: dict = { + "username": username, + "status": 1, + "home_dir": home_dir, + "uid": cur.get("uid", 0), + "gid": cur.get("gid", 0), + "permissions": cur.get("permissions") or {"/": ["*"]}, + "virtual_folders": cur.get("virtual_folders") or [], + } + self._apply_common_readonly(payload) + self._put_user(tok=tok, username=username, payload=payload) + + def reset_password(self, *, username: str, new_password: str, home_dir: str) -> None: + tok = self._admin_token() + self._ensure_common_folders(tok=tok) + cur = self._get_user(tok=tok, username=username) + payload: dict = { + "username": username, + "status": 1, + "home_dir": home_dir, + "uid": cur.get("uid", 0), + "gid": cur.get("gid", 0), + "permissions": cur.get("permissions") or {"/": ["*"]}, + "virtual_folders": cur.get("virtual_folders") or [], + "password": new_password, + } + self._apply_common_readonly(payload) + self._put_user(tok=tok, username=username, payload=payload) diff --git a/src/mvp/py/argus/service/ui.py b/src/mvp/py/argus/service/ui.py new file mode 100644 index 0000000..27de826 --- /dev/null +++ b/src/mvp/py/argus/service/ui.py @@ -0,0 +1,629 @@ +from __future__ import annotations + +import html +import json + +from fastapi import FastAPI +from fastapi.responses import HTMLResponse, RedirectResponse + + +_BASE_CSS = """ +:root { --bg:#0b1020; --panel:#111a33; --muted:#95a3c6; --fg:#e8eeff; --accent:#7aa2ff; --danger:#ff6b6b; --ok:#3ddc97; } +* { box-sizing: border-box; } +body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; background:var(--bg); color:var(--fg); } +a { color:var(--accent); text-decoration:none; } +.layout { display:flex; min-height:100vh; } +.nav { width: 240px; padding:16px; background: linear-gradient(180deg, #0e1630, #0b1020); border-right: 1px solid rgba(255,255,255,0.06); } +.brand { font-weight: 700; letter-spacing: .2px; margin-bottom: 12px; } +.nav a { display:block; padding:10px 10px; border-radius:10px; color: var(--fg); opacity: .9; } +.nav a.active { background: rgba(122,162,255,0.14); border: 1px solid rgba(122,162,255,0.22); } +.nav a:hover { background: rgba(255,255,255,0.06); } +.main { flex:1; padding: 20px 24px; } +.card { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 14px; padding: 16px; } +.row { display:flex; gap: 12px; align-items:center; flex-wrap: wrap; } +.muted { color: var(--muted); } +.btn { border: 1px solid rgba(255,255,255,0.16); background: rgba(255,255,255,0.06); color: var(--fg); padding: 10px 12px; border-radius: 10px; cursor: pointer; } +.btn:hover { background: rgba(255,255,255,0.10); } +.btn.danger { border-color: rgba(255,107,107,0.35); background: rgba(255,107,107,0.10); } +.pill { display:inline-block; padding: 2px 10px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.16); font-size: 12px; } +.pill.ok { border-color: rgba(61,220,151,0.35); background: rgba(61,220,151,0.12); } +.pill.bad { border-color: rgba(255,107,107,0.35); background: rgba(255,107,107,0.12); } +textarea, input { width: 100%; color: var(--fg); background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12); border-radius: 12px; padding: 10px 12px; outline: none; } +button:disabled { opacity: .45; cursor: not-allowed; } +pre { white-space: pre-wrap; word-break: break-word; } +table { width:100%; border-collapse: collapse; } +th, td { padding: 10px 8px; border-bottom: 1px solid rgba(255,255,255,0.08); text-align:left; } +""".strip() + + +_BASE_JS = """ +function mvpTokenGet() { + return (localStorage.getItem("mvp_token") || "").trim(); +} +function mvpTokenSet(v) { + localStorage.setItem("mvp_token", (v || "").trim()); +} +function mvpSftpPasswordGet() { + return (localStorage.getItem("mvp_sftp_password") || "").trim(); +} +function mvpSftpPasswordSet(v) { + localStorage.setItem("mvp_sftp_password", (v || "").trim()); +} +async function apiFetch(path, opts) { + opts = opts || {}; + opts.headers = opts.headers || {}; + const tok = mvpTokenGet(); + if (tok) opts.headers["Authorization"] = "Bearer " + tok; + return fetch(path, opts); +} +async function apiJson(path, opts) { + const resp = await apiFetch(path, opts); + const text = await resp.text(); + if (!resp.ok) { + const err = new Error("HTTP " + resp.status); + err.status = resp.status; + err.body = text; + throw err; + } + return JSON.parse(text); +} +function fmtJson(obj) { + try { return JSON.stringify(obj, null, 2); } catch (e) { return String(obj); } +} +function curOriginWithPort(port) { + const proto = window.location.protocol; + const host = window.location.hostname; + return proto + "//" + host + ":" + port; +} +async function copyText(v) { + if (!v) return false; + try { + await navigator.clipboard.writeText(v); + return true; + } catch (e) { + // Fallback for non-secure contexts (http) or older browsers. + try { + const ta = document.createElement("textarea"); + ta.value = v; + ta.style.position = "fixed"; + ta.style.opacity = "0"; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + const ok = document.execCommand("copy"); + document.body.removeChild(ta); + return ok; + } catch (e2) { + return false; + } + } +} +document.addEventListener("DOMContentLoaded", () => { + const el = document.getElementById("nav-ray-dashboard"); + if (el) el.href = curOriginWithPort(8265); +}); +""".strip() + + +def _nav(active: str) -> str: + links = [ + ("login", "/ui/login", "Login"), + ("tasks", "/ui/tasks", "Tasks"), + ("new", "/ui/tasks/new", "New Task"), + ("data", "/ui/data", "Data"), + ("admin", "/ui/admin", "Admin"), + ("ray", "#", "Ray Dashboard"), + ] + items = [] + for key, href, label in links: + cls = "active" if key == active else "" + extra = "" + if key == "ray": + extra = ' id="nav-ray-dashboard" target="_blank" rel="noopener"' + items.append(f'{html.escape(label)}') + return "\n".join(items) + + +def _page(title: str, active: str, body: str, script: str = "") -> str: + return f""" + + + + + {html.escape(title)} + + + +

+ +
+ {body} +
+
+ + + +""" + + +def register_ui_routes(app: FastAPI) -> None: + @app.get("/ui") + async def ui_root() -> RedirectResponse: + return RedirectResponse(url="/ui/tasks") + + @app.get("/ui/login") + async def ui_login() -> HTMLResponse: + body = """ +

Login

+
+
Paste your API token (without the Bearer prefix).
+
+ +
+
+ + + Go to Tasks +
+
+
+
+""".strip() + script = """ +const tokEl = document.getElementById("tok"); +const msg = document.getElementById("msg"); +tokEl.value = mvpTokenGet(); +document.getElementById("save").onclick = () => { mvpTokenSet(tokEl.value); msg.textContent = "Saved."; }; +document.getElementById("clear").onclick = () => { mvpTokenSet(""); tokEl.value = ""; msg.textContent = "Cleared."; }; +""".strip() + return HTMLResponse(content=_page("Login", "login", body, script)) + + @app.get("/ui/tasks") + async def ui_tasks() -> HTMLResponse: + body = """ +

Tasks

+
+
+ + New Task +
+
+
Loading...
+
+""".strip() + script = """ +const out = document.getElementById("out"); +async function refresh() { + out.textContent = "Loading..."; + try { + const q = await apiJson("/api/v2/queue"); + const completedLimit = 25; + const completedOffset = Number(localStorage.getItem("mvp_completed_offset") || "0") || 0; + const done = await apiJson("/api/v2/tasks?limit=" + completedLimit + "&offset=" + completedOffset + "&states=SUCCEEDED,FAILED,CANCELED"); + + function pill(state) { + const s = String(state || ""); + if (s === "SUCCEEDED") return `${s}`; + if (s === "FAILED") return `${s}`; + if (s === "CANCELED") return `${s}`; + if (s === "RUNNING") return `${s}`; + if (s === "QUEUED" || s === "PENDING_RESOURCES" || s === "SUBMITTING" || s === "SUBMITTED") return `${s}`; + return `${s}`; + } + function row(t) { + const id = t.task_id; + return ` + ${id} + ${t.workload} + ${pill(t.state)} + ${t.nnodes} x ${t.n_gpus_per_node} GPU + ${t.updated_at || ""} + `; + } + + const running = (q.running || []).map(row).join(""); + const pending = (q.pending || []).map(row).join(""); + const doneRows = (done.tasks || []).map(row).join(""); + const pageNo = Math.floor(completedOffset / completedLimit) + 1; + const prevDisabled = completedOffset <= 0; + const nextDisabled = !done.has_more; + out.innerHTML = ` +
Tip: configure token in Login.
+
+

Running

+ ${running || ""}
TaskWorkloadStateResourcesUpdated
(none)
+
+

Pending

+ ${pending || ""}
TaskWorkloadStateResourcesUpdated
(none)
+
+

Completed

+
+
Page ${pageNo}
+
+ + +
+
+ ${doneRows || ""}
TaskWorkloadStateResourcesUpdated
(none)
+ `; + + const prevBtn = document.getElementById("done-prev"); + const nextBtn = document.getElementById("done-next"); + if (prevBtn) prevBtn.onclick = () => { + const cur = Number(localStorage.getItem("mvp_completed_offset") || "0") || 0; + const next = Math.max(0, cur - completedLimit); + localStorage.setItem("mvp_completed_offset", String(next)); + refresh(); + }; + if (nextBtn) nextBtn.onclick = () => { + const cur = Number(localStorage.getItem("mvp_completed_offset") || "0") || 0; + const next = cur + completedLimit; + localStorage.setItem("mvp_completed_offset", String(next)); + refresh(); + }; + } catch (e) { + out.textContent = "Error: " + (e.status || "") + "\\n" + (e.body || String(e)); + } +} +document.getElementById("refresh").onclick = refresh; +refresh(); +""".strip() + return HTMLResponse(content=_page("Tasks", "tasks", body, script)) + + @app.get("/ui/tasks/new") + async def ui_new_task() -> HTMLResponse: + ppo = """# PPO TaskSpec (YAML) +workload: ppo +nnodes: 2 +n_gpus_per_node: 4 +code_path: /private/common/code/verl/verl_repo +train_file: /private/common/datasets/gsm8k/train.parquet +val_file: /private/common/datasets/gsm8k/test.parquet +model_id: Qwen/Qwen2.5-0.5B-Instruct +""".strip() + grpo = """# GRPO TaskSpec (YAML) +workload: grpo +nnodes: 2 +n_gpus_per_node: 4 +code_path: /private/common/code/verl/verl_repo +train_file: /private/common/datasets/gsm8k/train.parquet +val_file: /private/common/datasets/gsm8k/test.parquet +model_id: Qwen/Qwen2.5-0.5B-Instruct +""".strip() + sft = """# SFT TaskSpec (YAML) +workload: sft +nnodes: 1 +n_gpus_per_node: 1 +code_path: /private/common/code/verl/verl_repo +train_file: /private/common/datasets/gsm8k_sft/train.parquet +val_file: /private/common/datasets/gsm8k_sft/test.parquet +model_id: Qwen/Qwen2.5-0.5B-Instruct +""".strip() + body = f""" +

New Task

+
+
Paste TaskSpec YAML and submit to API server. Note: code_path is required (v3.0 does not execute user code; use the common snapshot).
+
+
+ + + +
+
+ +
+
+ + Back +
+
+

+
+""".strip() + tpl_ppo = json.dumps(ppo) + tpl_grpo = json.dumps(grpo) + tpl_sft = json.dumps(sft) + script = ( + """ +const msg = document.getElementById("msg"); +const yamlEl = document.getElementById("yaml"); +const TPL_PPO = __TPL_PPO__; +const TPL_GRPO = __TPL_GRPO__; +const TPL_SFT = __TPL_SFT__; +document.getElementById("tpl-ppo").onclick = () => { yamlEl.value = TPL_PPO; msg.textContent = ""; }; +document.getElementById("tpl-grpo").onclick = () => { yamlEl.value = TPL_GRPO; msg.textContent = ""; }; +document.getElementById("tpl-sft").onclick = () => { yamlEl.value = TPL_SFT; msg.textContent = ""; }; +document.getElementById("submit").onclick = async () => { + msg.textContent = "Submitting..."; + const body = yamlEl.value; + const resp = await apiFetch("/api/v2/tasks", { method: "POST", headers: {"Content-Type":"text/plain"}, body }); + const text = await resp.text(); + if (!resp.ok) { msg.textContent = "Error: " + resp.status + "\\n" + text; return; } + const obj = JSON.parse(text); + msg.textContent = "OK: " + fmtJson(obj); + if (obj.task_id) window.location.href = "/ui/tasks/" + obj.task_id; +}; +""".strip() + .replace("__TPL_PPO__", tpl_ppo) + .replace("__TPL_GRPO__", tpl_grpo) + .replace("__TPL_SFT__", tpl_sft) + ) + return HTMLResponse(content=_page("New Task", "new", body, script)) + + @app.get("/ui/tasks/{task_id}") + async def ui_task_detail(task_id: str) -> HTMLResponse: + safe_id = html.escape(task_id) + body = f""" +

Task: {safe_id}

+
+
+ Logs + + + Back +
+
+
Loading...
+
+""".strip() + script = f""" +document.getElementById("nav-ray-dashboard").href = curOriginWithPort(8265); +const out = document.getElementById("out"); +async function refresh() {{ + out.textContent = "Loading..."; + const resp = await apiFetch("/api/v2/tasks/{task_id}"); + const text = await resp.text(); + if (!resp.ok) {{ out.textContent = "Error: " + resp.status + "\\n" + text; return; }} + out.textContent = fmtJson(JSON.parse(text)); +}} +document.getElementById("refresh").onclick = refresh; +document.getElementById("cancel").onclick = async () => {{ + if (!confirm("Cancel this task?")) return; + const resp = await apiFetch("/api/v2/tasks/{task_id}:cancel", {{ method: "POST" }}); + const text = await resp.text(); + out.textContent = (resp.ok ? "Canceled.\\n" : "Error: " + resp.status + "\\n") + text; + setTimeout(refresh, 800); +}}; +refresh(); +""".strip() + return HTMLResponse(content=_page(f"Task {task_id}", "tasks", body, script)) + + @app.get("/ui/tasks/{task_id}/logs") + async def ui_task_logs(task_id: str) -> HTMLResponse: + safe_id = html.escape(task_id) + body = f""" +

Logs: {safe_id}

+
+
+ + + Back +
+
+
Loading...
+
+""".strip() + script = f""" +document.getElementById("nav-ray-dashboard").href = curOriginWithPort(8265); +const out = document.getElementById("out"); +let timer = null; +async function refresh() {{ + const resp = await apiFetch("/api/v2/tasks/{task_id}/logs?tail=4000"); + const text = await resp.text(); + out.textContent = resp.ok ? text : ("Error: " + resp.status + "\\n" + text); +}} +document.getElementById("refresh").onclick = refresh; +document.getElementById("auto").onchange = (e) => {{ + if (e.target.checked) {{ + timer = setInterval(refresh, 2000); + }} else {{ + if (timer) clearInterval(timer); + timer = null; + }} +}}; +refresh(); +""".strip() + return HTMLResponse(content=_page(f"Logs {task_id}", "tasks", body, script)) + + @app.get("/ui/data") + async def ui_data() -> HTMLResponse: + body = """ +

Data

+
+
User files live under your home directory. Keep long-term artifacts in models/ or datasets/.
+
+ +
+
+
Username
+
+
+ + +
+
+
+
SFTPGo password
+
+
+ + + +
+
+
+ +
+ + +
+
+ You can also use an SFTP client (e.g. FileZilla) with the same username/password. + Host: , Port: . +
+ +
+
Loading...
+
+""".strip() + script = """ +const out = document.getElementById("out"); +document.getElementById("nav-ray-dashboard").href = curOriginWithPort(8265); +const u = document.getElementById("u"); +const p = document.getElementById("p"); +const sftpWeb = document.getElementById("sftp-web"); +const sftpHost = document.getElementById("sftp-host"); +const sftpPort = document.getElementById("sftp-port"); +document.getElementById("copy-u").onclick = async () => { await copyText(u.value || ""); }; +document.getElementById("copy-p").onclick = async () => { await copyText(p.value || ""); }; + +async function refresh() { + const resp = await apiFetch("/api/v2/me"); + const text = await resp.text(); + if (!resp.ok) { out.textContent = "Error: " + resp.status + "\\n" + text; return; } + const obj = JSON.parse(text); + u.value = (obj.user_id || ""); + const cached = mvpSftpPasswordGet(); + if (cached) p.value = cached; + const host = curOriginWithPort(8081); + sftpWeb.href = host + "/web/client"; + sftpHost.textContent = (obj.sftp && obj.sftp.host) ? obj.sftp.host : window.location.hostname; + sftpPort.textContent = (obj.sftp && obj.sftp.port) ? String(obj.sftp.port) : "2022"; + out.textContent = fmtJson(obj); +} +document.getElementById("reset-p").onclick = async () => { + p.value = ""; + const resp = await apiFetch("/api/v2/me/sftp:reset_password", { method: "POST" }); + const text = await resp.text(); + if (!resp.ok) { out.textContent = "Error: " + resp.status + "\\n" + text; return; } + const obj = JSON.parse(text); + p.value = obj.password || ""; + mvpSftpPasswordSet(p.value); + out.textContent = "SFTPGo password rotated.\\n\\n" + fmtJson(obj); +}; +refresh(); +""".strip() + return HTMLResponse(content=_page("Data", "data", body, script)) + + @app.get("/ui/admin") + async def ui_admin() -> HTMLResponse: + body = """ +

Admin

+
+
+ This page requires the admin token (set it in Login). +
+
+ +

Create user

+
+ + + +
+
+

+
+  
+
+ +
+
+
Loading...
+
+""".strip() + script = """ +const out = document.getElementById("out"); +const createMsg = document.getElementById("create-msg"); +const userIdEl = document.getElementById("new-user-id"); +const displayNameEl = document.getElementById("new-display-name"); + +function esc(s) { + s = String(s || ""); + return s.replaceAll("&","&").replaceAll("<","<").replaceAll(">",">"); +} + +async function refresh() { + out.textContent = "Loading..."; + try { + const obj = await apiJson("/api/v2/users?limit=200"); + const users = (obj.users || []); + function row(u) { + const uid = u.user_id; + const tok = u.token || ""; + const tokShort = tok ? (tok.length > 18 ? (tok.slice(0, 18) + "…") : tok) : ""; + const created = u.created_at || ""; + const updated = u.updated_at || ""; + const tCreated = u.token_created_at || ""; + const tUsed = u.token_last_used_at || ""; + return ` + ${esc(uid)} + ${esc(created)} + ${esc(updated)} + +
+ ${esc(tokShort)} + + +
+
token_created_at: ${esc(tCreated)}; last_used_at: ${esc(tUsed)}
+ + `; + } + out.innerHTML = ` + + + ${users.map(row).join("") || ""} +
UserCreatedUpdatedToken
(none)
+ `; + for (const btn of out.querySelectorAll("button[data-copy]")) { + btn.onclick = async () => { await copyText(btn.getAttribute("data-copy") || ""); }; + } + for (const btn of out.querySelectorAll("button[data-issue]")) { + btn.onclick = async () => { + const uid = btn.getAttribute("data-issue"); + if (!uid) return; + try { + const r = await apiJson("/api/v2/users/" + encodeURIComponent(uid) + "/tokens", { method: "POST" }); + createMsg.textContent = "Issued token:\\n" + fmtJson(r); + await refresh(); + } catch (e) { + createMsg.textContent = "Error issuing token: " + (e.status || "") + "\\n" + (e.body || String(e)); + } + }; + } + } catch (e) { + out.textContent = "Error: " + (e.status || "") + "\\n" + (e.body || String(e)); + } +} + +document.getElementById("refresh").onclick = refresh; +document.getElementById("create-user").onclick = async () => { + createMsg.textContent = "Creating..."; + const user_id = (userIdEl.value || "").trim(); + const display_name = (displayNameEl.value || "").trim(); + if (!user_id) { createMsg.textContent = "user_id is required"; return; } + const payload = { user_id: user_id }; + if (display_name) payload.display_name = display_name; + try { + const r = await apiJson("/api/v2/users", { method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify(payload) }); + createMsg.textContent = "Created:\\n" + fmtJson(r); + userIdEl.value = ""; + displayNameEl.value = ""; + await refresh(); + } catch (e) { + createMsg.textContent = "Error: " + (e.status || "") + "\\n" + (e.body || String(e)); + } +}; + +refresh(); +""".strip() + return HTMLResponse(content=_page("Admin", "admin", body, script)) diff --git a/src/mvp/py/tests/test_app.py b/src/mvp/py/tests/test_app.py index 99e2243..b96bf77 100644 --- a/src/mvp/py/tests/test_app.py +++ b/src/mvp/py/tests/test_app.py @@ -16,6 +16,11 @@ def _write_config(tmp_path: Path) -> Path: "entrypoint_resources": {"worker_node": 1}, "runtime_env": {"env_vars": {}}, }, + "data": { + # Avoid touching real /private in tests. Keep ray.shared_root as /private + # so existing path validation tests remain unchanged. + "user_root": str(tmp_path / "users"), + }, "service": { "api": {"host": "127.0.0.1", "port": 0}, "auth": {"token_env": "MVP_INTERNAL_TOKEN"}, @@ -95,6 +100,17 @@ def test_task_submit_get_cancel_logs_queue(tmp_path: Path, monkeypatch): assert r3.status_code == 200 assert "pending" in r3.json() + r3b = c.get("/api/v2/tasks?limit=10", headers=headers) + assert r3b.status_code == 200 + assert any(t.get("task_id") == "tid1" for t in r3b.json().get("tasks", [])) + + r3c = c.get("/api/v2/tasks?limit=10&offset=0&states=QUEUED", headers=headers) + assert r3c.status_code == 200 + assert all(t.get("state") == "QUEUED" for t in r3c.json().get("tasks", [])) + + r3d = c.get("/api/v2/tasks?states=NOPE", headers=headers) + assert r3d.status_code == 400 + r4 = c.post("/api/v2/tasks/tid1:cancel", headers=headers) assert r4.status_code == 200 assert r4.json()["state"] == "CANCELED" @@ -118,6 +134,14 @@ def test_task_submit_get_cancel_logs_queue(tmp_path: Path, monkeypatch): db.create_attempt(task_id="tid2", attempt_no=1, ray_submission_id="sid2") db.set_task_state(task_id="tid2", state="RUNNING", latest_attempt_no=1) + r6 = c.get("/api/v2/tasks?limit=1&offset=0&states=RUNNING", headers=headers) + assert r6.status_code == 200 + assert any(t.get("task_id") == "tid2" for t in r6.json().get("tasks", [])) + + r7 = c.get("/api/v2/tasks?limit=1&offset=1&states=RUNNING", headers=headers) + assert r7.status_code == 200 + assert "has_more" in r7.json() + r5 = c.get("/api/v2/tasks/tid2/logs?tail=1", headers=headers) assert r5.status_code == 200 assert r5.text.strip() == "c" @@ -163,3 +187,102 @@ def test_submit_rejects_invalid_jobspec(tmp_path: Path, monkeypatch): with TestClient(app) as c: r = c.post("/api/v2/tasks", headers={"authorization": "Bearer token1"}, data="workload: nope\n") assert r.status_code == 400 + + +def test_me_sftp_reset_password_disabled_returns_400(tmp_path: Path, monkeypatch): + from argus.service import app as app_mod + + cfg_path = _write_config(tmp_path) + monkeypatch.setenv("MVP_INTERNAL_TOKEN", "token1") + + class _Scheduler: + def __init__(self, **kwargs): + self.tool = object() + + def run_forever(self, stop_flag): + return None + + monkeypatch.setattr(app_mod, "Scheduler", _Scheduler) + app = app_mod.create_app(str(cfg_path)) + + # seed user + token + from argus.service.config import V2Config + from argus.service.db import Db + + root = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + v2_cfg = V2Config.from_root_dict(root) + db = Db(v2_cfg.sqlite.db_path) + db.init() + db.create_user(user_id="u1", display_name=None) + token = db.issue_token(user_id="u1") + + with TestClient(app) as c: + r = c.post("/api/v2/me/sftp:reset_password", headers={"authorization": f"Bearer {token}"}) + assert r.status_code == 400 + + +def test_me_sftp_reset_password_enabled_returns_password(tmp_path: Path, monkeypatch): + from argus.service import app as app_mod + + cfg = yaml.safe_load(_write_config(tmp_path).read_text(encoding="utf-8")) + cfg["data"]["sftpgo"] = { + "enabled": True, + "host": "127.0.0.1", + "sftp_port": 2022, + "admin_api_base": "http://127.0.0.1:8081", + "admin_user": "admin", + "admin_password_env": "SFTPGO_ADMIN_PASSWORD", + } + cfg_path = tmp_path / "cfg_sftp.yaml" + cfg_path.write_text(yaml.safe_dump(cfg), encoding="utf-8") + + monkeypatch.setenv("MVP_INTERNAL_TOKEN", "token1") + monkeypatch.setenv("SFTPGO_ADMIN_PASSWORD", "pw1") + + class _FakeSFTPGo: + def __init__(self, **kwargs): + self.reset = [] + self.enabled = [] + + def reset_password(self, username: str, new_password: str, home_dir: str): + assert username + assert new_password + assert home_dir + self.reset.append((username, home_dir)) + + def enable_user(self, username: str, home_dir: str): + self.enabled.append((username, home_dir)) + + fake_client = _FakeSFTPGo() + + class _FakeSFTPGoFactory: + def __call__(self, **kwargs): + return fake_client + + class _Scheduler: + def __init__(self, **kwargs): + self.tool = object() + + def run_forever(self, stop_flag): + return None + + monkeypatch.setattr(app_mod, "Scheduler", _Scheduler) + monkeypatch.setattr(app_mod, "SFTPGoAdminClient", _FakeSFTPGoFactory()) + app = app_mod.create_app(str(cfg_path)) + + # seed user in DB + from argus.service.db import Db + from argus.service.config import V2Config + + v2_cfg = V2Config.from_root_dict(cfg) + db = Db(v2_cfg.sqlite.db_path) + db.init() + db.create_user(user_id="u1", display_name=None) + token = db.issue_token(user_id="u1") + + with TestClient(app) as c: + r = c.post("/api/v2/me/sftp:reset_password", headers={"authorization": f"Bearer {token}"}) + assert r.status_code == 200 + j = r.json() + assert j["user_id"] == "u1" + assert isinstance(j["password"], str) and len(j["password"]) >= 8 diff --git a/src/mvp/py/tests/test_janitor.py b/src/mvp/py/tests/test_janitor.py new file mode 100644 index 0000000..8528555 --- /dev/null +++ b/src/mvp/py/tests/test_janitor.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from argus.service.db import Db +from argus.service.janitor import JobsJanitor + + +def _iso_z(dt: datetime) -> str: + return dt.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _mk_job_dir(user_root: Path, user_id: str, sid: str) -> Path: + p = user_root / user_id / "jobs" / sid + p.mkdir(parents=True, exist_ok=True) + (p / "marker.txt").write_text("x", encoding="utf-8") + return p + + +def _mk_trash_dir(user_root: Path, user_id: str, sid: str) -> Path: + p = user_root / user_id / "trash" / "jobs" / sid + p.mkdir(parents=True, exist_ok=True) + (p / "marker.txt").write_text("x", encoding="utf-8") + return p + + +def test_janitor_moves_jobs_to_trash_after_3_days(tmp_path: Path) -> None: + db_path = tmp_path / "mvp.sqlite3" + user_root = tmp_path / "users" + db = Db(str(db_path)) + db.init() + + task_id = "t1" + user_id = "alice" + sid = "sid-a01" + db.create_task_v25(task_id=task_id, user_id=user_id, workload="sft", jobspec_yaml="{}", nnodes=1, n_gpus_per_node=1) + db.create_attempt(task_id=task_id, attempt_no=1, ray_submission_id=sid) + + now = datetime(2025, 1, 10, tzinfo=timezone.utc) + ended = now - timedelta(days=4) + db.update_attempt(task_id=task_id, attempt_no=1, end_time=_iso_z(ended), ray_status="SUCCEEDED") + + src = _mk_job_dir(user_root, user_id, sid) + jan = JobsJanitor(db=db, user_root=str(user_root), trash_after_days=3, purge_after_days=7, interval_s=1) + jan.tick_once(now=now) + + assert not src.exists() + dst = user_root / user_id / "trash" / "jobs" / sid + assert dst.exists() + assert (dst / "marker.txt").read_text(encoding="utf-8") == "x" + + +def test_janitor_purges_from_trash_after_7_days(tmp_path: Path) -> None: + db_path = tmp_path / "mvp.sqlite3" + user_root = tmp_path / "users" + db = Db(str(db_path)) + db.init() + + task_id = "t2" + user_id = "alice" + sid = "sid-a01" + db.create_task_v25(task_id=task_id, user_id=user_id, workload="ppo", jobspec_yaml="{}", nnodes=1, n_gpus_per_node=1) + db.create_attempt(task_id=task_id, attempt_no=1, ray_submission_id=sid) + + now = datetime(2025, 1, 10, tzinfo=timezone.utc) + ended = now - timedelta(days=8) + db.update_attempt(task_id=task_id, attempt_no=1, end_time=_iso_z(ended), ray_status="FAILED") + + _mk_trash_dir(user_root, user_id, sid) + jan = JobsJanitor(db=db, user_root=str(user_root), trash_after_days=3, purge_after_days=7, interval_s=1) + jan.tick_once(now=now) + + dst = user_root / user_id / "trash" / "jobs" / sid + assert not dst.exists() + + +def test_janitor_does_not_touch_recent_jobs(tmp_path: Path) -> None: + db_path = tmp_path / "mvp.sqlite3" + user_root = tmp_path / "users" + db = Db(str(db_path)) + db.init() + + task_id = "t3" + user_id = "alice" + sid = "sid-a01" + db.create_task_v25(task_id=task_id, user_id=user_id, workload="grpo", jobspec_yaml="{}", nnodes=1, n_gpus_per_node=1) + db.create_attempt(task_id=task_id, attempt_no=1, ray_submission_id=sid) + + now = datetime(2025, 1, 10, tzinfo=timezone.utc) + ended = now - timedelta(days=1) + db.update_attempt(task_id=task_id, attempt_no=1, end_time=_iso_z(ended), ray_status="FAILED") + + src = _mk_job_dir(user_root, user_id, sid) + jan = JobsJanitor(db=db, user_root=str(user_root), trash_after_days=3, purge_after_days=7, interval_s=1) + jan.tick_once(now=now) + + assert src.exists() + assert not (user_root / user_id / "trash" / "jobs" / sid).exists() + + +def test_janitor_skips_tasks_without_user_id(tmp_path: Path) -> None: + db_path = tmp_path / "mvp.sqlite3" + user_root = tmp_path / "users" + db = Db(str(db_path)) + db.init() + + task_id = "legacy" + sid = "sid-legacy" + db.create_task(task_id=task_id, workload="sft", jobspec_yaml="{}", nnodes=1, n_gpus_per_node=1) + db.create_attempt(task_id=task_id, attempt_no=1, ray_submission_id=sid) + + now = datetime(2025, 1, 10, tzinfo=timezone.utc) + ended = now - timedelta(days=10) + db.update_attempt(task_id=task_id, attempt_no=1, end_time=_iso_z(ended), ray_status="SUCCEEDED") + + # Even if someone created a matching directory under user_root, janitor shouldn't touch it because user_id is NULL. + src = _mk_job_dir(user_root, "alice", sid) + jan = JobsJanitor(db=db, user_root=str(user_root), trash_after_days=3, purge_after_days=7, interval_s=1) + jan.tick_once(now=now) + assert src.exists() + + +def test_janitor_validates_retention_days(tmp_path: Path) -> None: + db = Db(str(tmp_path / "mvp.sqlite3")) + db.init() + try: + JobsJanitor(db=db, user_root="/tmp", trash_after_days=-1, purge_after_days=7, interval_s=1) + raise AssertionError("expected ValueError") + except ValueError: + pass + try: + JobsJanitor(db=db, user_root="/tmp", trash_after_days=3, purge_after_days=1, interval_s=1) + raise AssertionError("expected ValueError") + except ValueError: + pass + + +def test_janitor_noop_when_disabled(tmp_path: Path) -> None: + db = Db(str(tmp_path / "mvp.sqlite3")) + db.init() + user_root = tmp_path / "users" + jan = JobsJanitor(db=db, user_root=str(user_root), trash_after_days=0, purge_after_days=0, interval_s=1) + jan.tick_once(now=datetime(2025, 1, 1, tzinfo=timezone.utc)) + + +def test_janitor_handles_invalid_end_time_and_missing_fields(tmp_path: Path) -> None: + db_path = tmp_path / "mvp.sqlite3" + user_root = tmp_path / "users" + db = Db(str(db_path)) + db.init() + + now = datetime(2025, 1, 10, tzinfo=timezone.utc) + cutoff_ended = now - timedelta(days=10) + + # Missing end_time (empty string) => should be skipped. + db.create_task_v25(task_id="t4", user_id="alice", workload="sft", jobspec_yaml="{}", nnodes=1, n_gpus_per_node=1) + db.create_attempt(task_id="t4", attempt_no=1, ray_submission_id="sid-empty") + db.update_attempt(task_id="t4", attempt_no=1, end_time="", ray_status="SUCCEEDED") + + # Invalid ISO but still lexicographically <= cutoff => should be skipped. + db.create_task_v25(task_id="t5", user_id="alice", workload="sft", jobspec_yaml="{}", nnodes=1, n_gpus_per_node=1) + db.create_attempt(task_id="t5", attempt_no=1, ray_submission_id="sid-bad") + db.update_attempt(task_id="t5", attempt_no=1, end_time="2025-01-01T00:00:00ZZ", ray_status="FAILED") + + _mk_job_dir(user_root, "alice", "sid-empty") + _mk_job_dir(user_root, "alice", "sid-bad") + jan = JobsJanitor(db=db, user_root=str(user_root), trash_after_days=3, purge_after_days=7, interval_s=1) + jan.tick_once(now=cutoff_ended + timedelta(days=10)) + + assert (user_root / "alice" / "jobs" / "sid-empty").exists() + assert (user_root / "alice" / "jobs" / "sid-bad").exists() + + +def test_janitor_purge_moves_from_jobs_then_deletes(tmp_path: Path, monkeypatch) -> None: + db_path = tmp_path / "mvp.sqlite3" + user_root = tmp_path / "users" + db = Db(str(db_path)) + db.init() + + task_id = "t6" + user_id = "alice" + sid = "sid-a01" + db.create_task_v25(task_id=task_id, user_id=user_id, workload="ppo", jobspec_yaml="{}", nnodes=1, n_gpus_per_node=1) + db.create_attempt(task_id=task_id, attempt_no=1, ray_submission_id=sid) + + now = datetime(2025, 1, 10, tzinfo=timezone.utc) + ended = now - timedelta(days=9) + db.update_attempt(task_id=task_id, attempt_no=1, end_time=_iso_z(ended), ray_status="SUCCEEDED") + + src = _mk_job_dir(user_root, user_id, sid) + jan = JobsJanitor(db=db, user_root=str(user_root), trash_after_days=3, purge_after_days=7, interval_s=1) + + jan.tick_once(now=now) + assert not src.exists() + assert not (user_root / user_id / "trash" / "jobs" / sid).exists() + + +def test_janitor_run_forever_requires_event_like(tmp_path: Path) -> None: + db = Db(str(tmp_path / "mvp.sqlite3")) + db.init() + jan = JobsJanitor(db=db, user_root=str(tmp_path / "users"), trash_after_days=3, purge_after_days=7, interval_s=1) + try: + jan.run_forever(object()) + raise AssertionError("expected ValueError") + except ValueError: + pass + + +def test_janitor_run_forever_survives_tick_exceptions(tmp_path: Path, monkeypatch) -> None: + db = Db(str(tmp_path / "mvp.sqlite3")) + db.init() + jan = JobsJanitor(db=db, user_root=str(tmp_path / "users"), trash_after_days=3, purge_after_days=7, interval_s=1) + + class Flag: + def __init__(self) -> None: + self.n = 0 + + def is_set(self) -> bool: + self.n += 1 + return self.n >= 2 + + monkeypatch.setattr(jan, "tick_once", lambda **_: (_ for _ in ()).throw(RuntimeError("boom"))) + monkeypatch.setattr("argus.service.janitor.time.sleep", lambda *_: None) + jan.run_forever(Flag()) diff --git a/src/mvp/py/tests/test_service_config.py b/src/mvp/py/tests/test_service_config.py index c6ceb0e..9fe7226 100644 --- a/src/mvp/py/tests/test_service_config.py +++ b/src/mvp/py/tests/test_service_config.py @@ -38,3 +38,18 @@ def test_v2_config_requires_mappings(): V2Config.from_root_dict({"service": ["nope"]}) with pytest.raises(ValueError, match="config\\.service\\.\\{api,auth,sqlite,scheduler\\} must be mappings"): V2Config.from_root_dict({"service": {"api": [1], "auth": {}, "sqlite": {}, "scheduler": {}}}) + + +def test_v2_config_requires_data_mappings(): + from argus.service.config import V2Config + + base = { + "ray": {"shared_root": "/private"}, + "service": {"api": {}, "auth": {}, "sqlite": {}, "scheduler": {}}, + } + + with pytest.raises(ValueError, match="config\\.data must be a mapping"): + V2Config.from_root_dict({**base, "data": ["nope"]}) + + with pytest.raises(ValueError, match="config\\.data\\.\\{sftpgo,retention\\} must be mappings"): + V2Config.from_root_dict({**base, "data": {"sftpgo": ["x"], "retention": {}}}) diff --git a/src/mvp/py/tests/test_sftpgo.py b/src/mvp/py/tests/test_sftpgo.py new file mode 100644 index 0000000..bc0440f --- /dev/null +++ b/src/mvp/py/tests/test_sftpgo.py @@ -0,0 +1,322 @@ +from __future__ import annotations + +from pathlib import Path + +import yaml +from fastapi.testclient import TestClient + + +def _write_config(tmp_path: Path, *, enabled: bool) -> Path: + cfg = { + "ray": { + "address": "http://127.0.0.1:8265", + "shared_root": "/private", + "entrypoint_resources": {"worker_node": 1}, + "runtime_env": {"env_vars": {}}, + }, + "data": { + "user_root": str(tmp_path / "users"), + "sftpgo": { + "enabled": bool(enabled), + "admin_api_base": "http://127.0.0.1:8081/api/v2", + "admin_user": "admin", + "admin_password_env": "SFTPGO_ADMIN_PASSWORD", + "host": "h1.internal", + "sftp_port": 2022, + }, + }, + "service": { + "api": {"host": "127.0.0.1", "port": 0}, + "auth": {"token_env": "MVP_INTERNAL_TOKEN"}, + "sqlite": {"db_path": str(tmp_path / "mvp.sqlite3")}, + "scheduler": {"tick_s": 1, "retry_interval_s": 1, "max_running_tasks": 1}, + }, + } + p = tmp_path / "cfg.yaml" + p.write_text(yaml.safe_dump(cfg), encoding="utf-8") + return p + + +def test_create_user_calls_sftpgo_when_enabled(tmp_path: Path, monkeypatch): + from argus.service import app as app_mod + + cfg_path = _write_config(tmp_path, enabled=True) + monkeypatch.setenv("MVP_INTERNAL_TOKEN", "adm1") + monkeypatch.setenv("SFTPGO_ADMIN_PASSWORD", "pw1") + + calls = {"create": [], "disable": [], "reset": []} + + class _Client: + def __init__(self, **kwargs): + pass + + def create_user(self, *, username: str, password: str, home_dir: str) -> None: + calls["create"].append((username, password, home_dir)) + + def enable_user(self, *, username: str, home_dir: str) -> None: + # Not used in this test, but required by app for idempotent upsert. + return None + + def disable_user(self, *, username: str, home_dir: str) -> None: + calls["disable"].append(username) + + def reset_password(self, *, username: str, new_password: str, home_dir: str) -> None: + calls["reset"].append((username, new_password)) + + monkeypatch.setattr(app_mod, "SFTPGoAdminClient", _Client) + + class _Scheduler: + def __init__(self, **kwargs): + self.tool = object() + + def run_forever(self, stop_flag): + return None + + monkeypatch.setattr(app_mod, "Scheduler", _Scheduler) + app = app_mod.create_app(str(cfg_path)) + + admin_headers = {"authorization": "Bearer adm1"} + with TestClient(app) as c: + r = c.post("/api/v2/users", headers=admin_headers, json={"user_id": "alice"}) + assert r.status_code == 200 + assert calls["create"] + username, password, home_dir = calls["create"][-1] + assert username == "alice" + assert password + assert home_dir.endswith("/users/alice") + + r2 = c.post("/api/v2/users/alice:disable", headers=admin_headers) + assert r2.status_code == 200 + assert calls["disable"] == ["alice"] + + r3 = c.post("/api/v2/users/alice/sftp:reset_password", headers=admin_headers) + assert r3.status_code == 200 + body = r3.json() + assert body["user_id"] == "alice" + assert body["password"] + assert calls["reset"] and calls["reset"][-1][0] == "alice" + + +def test_create_user_upserts_when_sftpgo_user_already_exists(tmp_path: Path, monkeypatch): + from argus.service import app as app_mod + from argus.service.sftpgo import SFTPGoError + + cfg_path = _write_config(tmp_path, enabled=True) + monkeypatch.setenv("MVP_INTERNAL_TOKEN", "adm1") + monkeypatch.setenv("SFTPGO_ADMIN_PASSWORD", "pw1") + + calls = {"create": 0, "reset": [], "enable": []} + + class _Client: + def __init__(self, **kwargs): + pass + + def create_user(self, *, username: str, password: str, home_dir: str) -> None: + calls["create"] += 1 + raise SFTPGoError("sftpgo http error: 409 Conflict") + + def reset_password(self, *, username: str, new_password: str, home_dir: str) -> None: + calls["reset"].append((username, new_password)) + + def enable_user(self, *, username: str, home_dir: str) -> None: + calls["enable"].append(username) + + def disable_user(self, *, username: str, home_dir: str) -> None: + return None + + monkeypatch.setattr(app_mod, "SFTPGoAdminClient", _Client) + + class _Scheduler: + def __init__(self, **kwargs): + self.tool = object() + + def run_forever(self, stop_flag): + return None + + monkeypatch.setattr(app_mod, "Scheduler", _Scheduler) + app = app_mod.create_app(str(cfg_path)) + + admin_headers = {"authorization": "Bearer adm1"} + with TestClient(app) as c: + r = c.post("/api/v2/users", headers=admin_headers, json={"user_id": "bob"}) + assert r.status_code == 200 + body = r.json() + assert body["user_id"] == "bob" + assert body.get("sftp", {}).get("password") + assert calls["create"] == 1 + assert calls["reset"] and calls["reset"][-1][0] == "bob" + assert calls["enable"] == ["bob"] + + +def test_sftpgo_enabled_requires_admin_password_env(tmp_path: Path, monkeypatch): + from argus.service import app as app_mod + + cfg_path = _write_config(tmp_path, enabled=True) + monkeypatch.setenv("MVP_INTERNAL_TOKEN", "adm1") + monkeypatch.delenv("SFTPGO_ADMIN_PASSWORD", raising=False) + + class _Scheduler: + def __init__(self, **kwargs): + self.tool = object() + + def run_forever(self, stop_flag): + return None + + monkeypatch.setattr(app_mod, "Scheduler", _Scheduler) + app = app_mod.create_app(str(cfg_path)) + + admin_headers = {"authorization": "Bearer adm1"} + with TestClient(app) as c: + r = c.post("/api/v2/users", headers=admin_headers, json={"user_id": "alice"}) + assert r.status_code == 500 + assert "SFTPGO_ADMIN_PASSWORD" in r.text + + +def test_sftpgo_admin_client_builds_requests(monkeypatch): + import json + from urllib.request import Request + + from argus.service.sftpgo import SFTPGoAdminClient + import argus.service.sftpgo as mod + + seen: list[Request] = [] + + class _Resp: + def __init__(self, body: bytes = b"ok"): + self._body = body + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self): + return self._body + + def fake_urlopen(req, timeout=0): + seen.append(req) + if req.full_url.endswith("/token"): + return _Resp(body=b'{"access_token":"t1","expires_at":0}') + # allow folder creates to be idempotent in tests + if req.full_url.endswith("/folders") and req.get_method() == "POST": + return _Resp(body=b'{"message":"created"}') + if req.full_url.endswith("/users/alice") and req.get_method() == "GET": + return _Resp( + body=b'{"username":"alice","status":1,"home_dir":"/private/users/alice","uid":0,"gid":0,"permissions":{"/":["*"]},"virtual_folders":[]}' + ) + return _Resp() + + monkeypatch.setattr(mod, "urlopen", fake_urlopen) + + c = SFTPGoAdminClient(admin_api_base="http://sftpgo.local/api/v2", admin_user="admin", admin_password="pw", common_root="/private/common") + c.create_user(username="alice", password="p1", home_dir="/private/users/alice") + c.disable_user(username="alice", home_dir="/private/users/alice") + c.enable_user(username="alice", home_dir="/private/users/alice") + c.reset_password(username="alice", new_password="p2", home_dir="/private/users/alice") + + # Each operation fetches a token, then issues a request. + # For create_user: token + ensure folders (2 POSTs) + create user + # For disable/enable/reset: token + ensure folders (2 POSTs) + GET user + PUT user + assert len(seen) == 1 + 2 + 1 + 3 * (1 + 2 + 1 + 1) + assert seen[0].full_url.endswith("/token") + assert seen[0].headers.get("Authorization", "").startswith("Basic ") + # create_user + assert seen[1].full_url.endswith("/folders") + assert seen[2].full_url.endswith("/folders") + assert seen[3].full_url.endswith("/users") + assert seen[3].headers.get("Authorization", "") == "Bearer t1" + created = json.loads(seen[3].data.decode("utf-8")) + assert created["username"] == "alice" + assert "/common" in created.get("permissions", {}) + assert "/common/datasets" in created.get("permissions", {}) + assert "/common/hf" in created.get("permissions", {}) + + # disable_user + assert seen[4].full_url.endswith("/token") + assert seen[5].full_url.endswith("/folders") + assert seen[6].full_url.endswith("/folders") + assert seen[7].full_url.endswith("/users/alice") + assert seen[7].get_method() == "GET" + assert seen[8].full_url.endswith("/users/alice") + assert seen[8].get_method() == "PUT" + + # enable_user + assert seen[9].full_url.endswith("/token") + assert seen[10].full_url.endswith("/folders") + assert seen[11].full_url.endswith("/folders") + assert seen[12].full_url.endswith("/users/alice") + assert seen[12].get_method() == "GET" + assert seen[13].full_url.endswith("/users/alice") + assert seen[13].get_method() == "PUT" + + # reset_password + assert seen[14].full_url.endswith("/token") + assert seen[15].full_url.endswith("/folders") + assert seen[16].full_url.endswith("/folders") + assert seen[17].full_url.endswith("/users/alice") + assert seen[17].get_method() == "GET" + assert seen[18].full_url.endswith("/users/alice") + assert seen[18].get_method() == "PUT" + + +def test_sftpgo_admin_client_http_error_raises(monkeypatch): + from urllib.error import HTTPError + + from argus.service.sftpgo import SFTPGoAdminClient, SFTPGoError + import argus.service.sftpgo as mod + + def fake_urlopen(req, timeout=0): + if req.full_url.endswith("/token"): + class _Resp: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self): + return b'{"access_token":"t1","expires_at":0}' + + return _Resp() + raise HTTPError(req.full_url, 500, "boom", hdrs=None, fp=None) + + monkeypatch.setattr(mod, "urlopen", fake_urlopen) + + c = SFTPGoAdminClient(admin_api_base="http://sftpgo.local/api/v2", admin_user="admin", admin_password="pw", common_root="/private/common") + try: + c.create_user(username="alice", password="p1", home_dir="/private/users/alice") + assert False, "expected SFTPGoError" + except SFTPGoError as e: + assert "http error" in str(e) + + +def test_sftpgo_admin_client_url_error_raises(monkeypatch): + from urllib.error import URLError + + from argus.service.sftpgo import SFTPGoAdminClient, SFTPGoError + import argus.service.sftpgo as mod + + def fake_urlopen(req, timeout=0): + if req.full_url.endswith("/token"): + class _Resp: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self): + return b'{"access_token":"t1","expires_at":0}' + + return _Resp() + raise URLError("no route") + + monkeypatch.setattr(mod, "urlopen", fake_urlopen) + + c = SFTPGoAdminClient(admin_api_base="http://sftpgo.local/api/v2", admin_user="admin", admin_password="pw") + try: + c.disable_user(username="alice", home_dir="/private/users/alice") + assert False, "expected SFTPGoError" + except SFTPGoError as e: + assert "connection error" in str(e) diff --git a/src/mvp/py/tests/test_ui.py b/src/mvp/py/tests/test_ui.py new file mode 100644 index 0000000..ab4ebd5 --- /dev/null +++ b/src/mvp/py/tests/test_ui.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi.testclient import TestClient + +from argus.service.app import create_app + + +def _write_config(tmp_path: Path) -> Path: + p = tmp_path / "cfg.yaml" + p.write_text( + """ +ray: + address: "http://127.0.0.1:8265" + shared_root: "/private" + entrypoint_num_cpus: 1 + entrypoint_resources: { worker_node: 1 } + runtime_env: { env_vars: { PYTHONUNBUFFERED: "1" } } +service: + api: { host: "127.0.0.1", port: 8080 } + auth: { token_env: "MVP_INTERNAL_TOKEN" } + sqlite: { db_path: "%(db)s" } +data: + user_root: "%(users)s" + sftpgo: { enabled: false } + retention: { jobs_trash_after_days: 3, jobs_purge_after_days: 7, janitor_interval_s: 3600 } +""" + % {"db": str(tmp_path / "mvp.sqlite3"), "users": str(tmp_path / "users")} + ) + return p + + +def test_ui_routes_render_200(tmp_path, monkeypatch): + cfg = _write_config(tmp_path) + monkeypatch.setenv("MVP_INTERNAL_TOKEN", "admin-token") + app = create_app(str(cfg)) + c = TestClient(app) + + for path in ( + "/ui", + "/ui/login", + "/ui/tasks", + "/ui/tasks/new", + "/ui/data", + "/ui/admin", + "/ui/tasks/any-task-id", + "/ui/tasks/any-task-id/logs", + ): + r = c.get(path, allow_redirects=True) + assert r.status_code == 200 + assert " Path: "entrypoint_resources": {"worker_node": 1}, "runtime_env": {"env_vars": {}}, }, + "data": { + # Avoid touching real /private in tests. Keep ray.shared_root as /private + # so existing path validation tests remain unchanged. + "user_root": str(tmp_path / "users"), + }, "service": { "api": {"host": "127.0.0.1", "port": 0}, "auth": {"token_env": "MVP_INTERNAL_TOKEN"}, @@ -59,6 +64,9 @@ def test_admin_create_user_issue_token_and_disabled_rejected(tmp_path: Path, mon admin_headers = {"authorization": "Bearer adm1"} with TestClient(app) as c: + # list users requires admin + assert c.get("/api/v2/users", headers={"authorization": "Bearer nope"}).status_code in (401, 403) + r1 = c.post("/api/v2/users", headers=admin_headers, json={"user_id": "alice", "display_name": "Alice"}) assert r1.status_code == 200 assert r1.json()["user_id"] == "alice" @@ -68,6 +76,11 @@ def test_admin_create_user_issue_token_and_disabled_rejected(tmp_path: Path, mon token = r2.json()["token"] assert token + r2b = c.get("/api/v2/users", headers=admin_headers) + assert r2b.status_code == 200 + users = r2b.json()["users"] + assert any(u.get("user_id") == "alice" for u in users) + # non-admin token can access regular endpoints r3 = c.get("/api/v2/queue", headers={"authorization": f"Bearer {token}"}) assert r3.status_code == 200 @@ -177,3 +190,165 @@ def test_submit_rejects_non_common_inputs(tmp_path: Path, monkeypatch): ) assert r.status_code == 400 assert "code_path must start with /private/common/" in r.text + + +def test_submit_accepts_user_dataset_paths_and_local_model_paths(tmp_path: Path, monkeypatch): + from argus.service import app as app_mod + + cfg_path = _write_config(tmp_path) + monkeypatch.setenv("MVP_INTERNAL_TOKEN", "adm1") + + class _Scheduler: + def __init__(self, **kwargs): + self.tool = object() + + def run_forever(self, stop_flag): + return None + + monkeypatch.setattr(app_mod, "Scheduler", _Scheduler) + app = app_mod.create_app(str(cfg_path)) + + admin_headers = {"authorization": "Bearer adm1"} + with TestClient(app) as c: + assert c.post("/api/v2/users", headers=admin_headers, json={"user_id": "alice"}).status_code == 200 + alice_tok = c.post("/api/v2/users/alice/tokens", headers=admin_headers).json()["token"] + alice_headers = {"authorization": f"Bearer {alice_tok}"} + + # User dataset paths are allowed. + r1 = c.post( + "/api/v2/tasks", + headers=alice_headers, + data=( + "workload: ppo\n" + "code_path: /private/common/code/verl\n" + "model_id: Qwen/Qwen2.5-0.5B-Instruct\n" + "train_file: /private/users/alice/datasets/t\n" + ), + ) + assert r1.status_code == 200 + + # Local model paths under user models/ are allowed (no TaskSpec schema change). + r2 = c.post( + "/api/v2/tasks", + headers=alice_headers, + data=( + "workload: ppo\n" + "code_path: /private/common/code/verl\n" + "model_id: /private/users/alice/models/m1\n" + "train_file: /private/common/datasets/t\n" + ), + ) + assert r2.status_code == 200 + + +def test_submit_rejects_cross_user_paths_and_bad_local_model_dirs(tmp_path: Path, monkeypatch): + from argus.service import app as app_mod + + cfg_path = _write_config(tmp_path) + monkeypatch.setenv("MVP_INTERNAL_TOKEN", "adm1") + + class _Scheduler: + def __init__(self, **kwargs): + self.tool = object() + + def run_forever(self, stop_flag): + return None + + monkeypatch.setattr(app_mod, "Scheduler", _Scheduler) + app = app_mod.create_app(str(cfg_path)) + + admin_headers = {"authorization": "Bearer adm1"} + with TestClient(app) as c: + assert c.post("/api/v2/users", headers=admin_headers, json={"user_id": "alice"}).status_code == 200 + assert c.post("/api/v2/users", headers=admin_headers, json={"user_id": "bob"}).status_code == 200 + alice_tok = c.post("/api/v2/users/alice/tokens", headers=admin_headers).json()["token"] + bob_tok = c.post("/api/v2/users/bob/tokens", headers=admin_headers).json()["token"] + bob_headers = {"authorization": f"Bearer {bob_tok}"} + + # Cross-user dataset path should be rejected. + r1 = c.post( + "/api/v2/tasks", + headers=bob_headers, + data=( + "workload: ppo\n" + "code_path: /private/common/code/verl\n" + "model_id: Qwen/Qwen2.5-0.5B-Instruct\n" + "train_file: /private/users/alice/datasets/t\n" + ), + ) + assert r1.status_code == 400 + assert "/private/users/bob/datasets/" in r1.text + + # Local model path must be under models/. + r2 = c.post( + "/api/v2/tasks", + headers=bob_headers, + data=( + "workload: ppo\n" + "code_path: /private/common/code/verl\n" + "model_id: /private/users/bob/jobs/j1/checkpoints\n" + "train_file: /private/common/datasets/t\n" + ), + ) + assert r2.status_code == 400 + assert "model_id local path must start with" in r2.text + + +def test_me_returns_paths_and_retention(tmp_path: Path, monkeypatch): + from argus.service import app as app_mod + + cfg_path = _write_config(tmp_path) + monkeypatch.setenv("MVP_INTERNAL_TOKEN", "adm1") + + class _Scheduler: + def __init__(self, **kwargs): + self.tool = object() + + def run_forever(self, stop_flag): + return None + + monkeypatch.setattr(app_mod, "Scheduler", _Scheduler) + app = app_mod.create_app(str(cfg_path)) + + admin_headers = {"authorization": "Bearer adm1"} + with TestClient(app) as c: + assert c.post("/api/v2/users", headers=admin_headers, json={"user_id": "alice"}).status_code == 200 + alice_tok = c.post("/api/v2/users/alice/tokens", headers=admin_headers).json()["token"] + + r = c.get("/api/v2/me", headers={"authorization": f"Bearer {alice_tok}"}) + assert r.status_code == 200 + obj = r.json() + assert obj["user_id"] == "alice" + assert obj["paths"]["home"].endswith("/users/alice") + assert obj["paths"]["jobs"].endswith("/users/alice/jobs") + assert obj["paths"]["trash_jobs"].endswith("/users/alice/trash/jobs") + assert obj["retention"]["jobs_trash_after_days"] == 3 + assert obj["retention"]["jobs_purge_after_days"] == 7 + + +def test_create_user_creates_user_dirs(tmp_path: Path, monkeypatch): + from argus.service import app as app_mod + + cfg_path = _write_config(tmp_path) + monkeypatch.setenv("MVP_INTERNAL_TOKEN", "adm1") + + class _Scheduler: + def __init__(self, **kwargs): + self.tool = object() + + def run_forever(self, stop_flag): + return None + + monkeypatch.setattr(app_mod, "Scheduler", _Scheduler) + app = app_mod.create_app(str(cfg_path)) + + admin_headers = {"authorization": "Bearer adm1"} + with TestClient(app) as c: + assert c.post("/api/v2/users", headers=admin_headers, json={"user_id": "alice"}).status_code == 200 + + base = tmp_path / "users" / "alice" + assert (base / "datasets").is_dir() + assert (base / "models").is_dir() + assert (base / "code").is_dir() + assert (base / "jobs").is_dir() + assert (base / "trash" / "jobs").is_dir() diff --git a/src/mvp/scripts/12_install_api_deps.sh b/src/mvp/scripts/12_install_api_deps.sh index 565db17..90a2074 100755 --- a/src/mvp/scripts/12_install_api_deps.sh +++ b/src/mvp/scripts/12_install_api_deps.sh @@ -9,4 +9,4 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/lib.sh" dexec "${HEAD_CONTAINER}" bash -lc "python3 -m pip install -U pip >/dev/null 2>&1 || true" -dexec "${HEAD_CONTAINER}" bash -lc "python3 -m pip install -r /workspace/mvp/py/requirements.txt" +dexec "${HEAD_CONTAINER}" bash -lc "python3 -c 'import fastapi,uvicorn,yaml' >/dev/null 2>&1 && echo 'api_deps_ok: skip' || (python3 -m pip install -r /workspace/mvp/py/requirements.txt || echo 'WARN: api deps install failed; continuing with preinstalled deps')" diff --git a/src/mvp/scripts/60_start_api.sh b/src/mvp/scripts/60_start_api.sh index 29cef85..ed2cbd1 100755 --- a/src/mvp/scripts/60_start_api.sh +++ b/src/mvp/scripts/60_start_api.sh @@ -22,7 +22,12 @@ if [[ -z "${MVP_INTERNAL_TOKEN:-}" ]]; then exit 1 fi -docker exec -d -e MVP_INTERNAL_TOKEN="${MVP_INTERNAL_TOKEN}" "${HEAD_CONTAINER}" bash -lc "nohup python3 /workspace/mvp/py/server.py --config '${CONFIG_IN_CONTAINER}' >>'${LOG_PATH}' 2>&1 & echo \$! >'${PID_PATH}'" +env_args=(-e "MVP_INTERNAL_TOKEN=${MVP_INTERNAL_TOKEN}") +if [[ -n "${SFTPGO_ADMIN_PASSWORD:-}" ]]; then + env_args+=(-e "SFTPGO_ADMIN_PASSWORD=${SFTPGO_ADMIN_PASSWORD}") +fi + +docker exec -d "${env_args[@]}" "${HEAD_CONTAINER}" bash -lc "nohup python3 /workspace/mvp/py/server.py --config '${CONFIG_IN_CONTAINER}' >>'${LOG_PATH}' 2>&1 & echo \$! >'${PID_PATH}'" echo "[host] started; pid stored in ${PID_PATH} (container path)" echo "[host] logs: ${LOG_PATH} (container path)" diff --git a/src/mvp/scripts/run_all_v30_api.sh b/src/mvp/scripts/run_all_v30_api.sh new file mode 100755 index 0000000..78b502b --- /dev/null +++ b/src/mvp/scripts/run_all_v30_api.sh @@ -0,0 +1,242 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +source "${SCRIPT_DIR}/lib.sh" + +# E2E v3.0: +# - Start Ray (head + stateless workers) + SFTPGo (compose) +# - Start API server with v3.0 config +# - Create user (API triggers SFTPGo user create) and return one-time SFTP password +# - (Optional) verify SFTP login +# - Submit PPO/GRPO/SFT referencing user dataset paths and wait + +API_ADDR="${API_ADDR:-http://127.0.0.1:8080}" +ADMIN_TOKEN="${MVP_INTERNAL_TOKEN:-}" +USER_ID="${USER_ID:-alice}" +RESET_DB="${RESET_DB:-1}" +RESET_SFTPGO="${RESET_SFTPGO:-0}" +EXPECTED_RAY_NODES="${EXPECTED_RAY_NODES:-3}" # head + 2 workers +CLUSTER_NAME="${CLUSTER_NAME:-argus-ray}" + +CONFIG_IN_CONTAINER="${CONFIG_IN_CONTAINER:-/workspace/mvp/configs/dev_v30.yaml}" +SFTPGO_ADMIN_PASSWORD="${SFTPGO_ADMIN_PASSWORD:-my-dev-sftpgo-admin}" +export SFTPGO_ADMIN_PASSWORD + +if [[ -z "${ADMIN_TOKEN}" ]]; then + echo "ERROR: MVP_INTERNAL_TOKEN must be set in host env (admin token)" >&2 + exit 1 +fi + +api_curl_admin() { + curl -sS -H "Authorization: Bearer ${ADMIN_TOKEN}" "$@" +} + +api_wait_ready() { + local tries="${1:-60}" + for i in $(seq 1 "${tries}"); do + if curl -sS -m 2 "${API_ADDR}/docs" >/dev/null 2>&1; then + echo "[host] api_ready: ${API_ADDR}" + return 0 + fi + echo "[host] waiting api... (${i}/${tries})" + sleep 2 + done + echo "ERROR: api not ready: ${API_ADDR}" >&2 + return 1 +} + +sftpgo_wait_ready() { + local tries="${1:-60}" + local url="${2:-http://127.0.0.1:8081/api/v2/token}" + for i in $(seq 1 "${tries}"); do + # SFTPGo admin endpoints require auth; readiness means "HTTP reachable and can issue token". + if curl -sS -m 2 -u "admin:${SFTPGO_ADMIN_PASSWORD}" "${url}" >/dev/null 2>&1; then + echo "[host] sftpgo_ready: ${url} (token ok)" + return 0 + fi + echo "[host] waiting sftpgo... (${i}/${tries})" + sleep 2 + done + echo "ERROR: sftpgo not ready: ${url}" >&2 + return 1 +} + +ray_wait_ready() { + local tries="${1:-60}" + for i in $(seq 1 "${tries}"); do + if curl -sS -m 2 "${RAY_DASHBOARD_ADDR}/api/version" >/dev/null 2>&1; then + echo "[host] ray_dashboard_ready: ${RAY_DASHBOARD_ADDR}" + return 0 + fi + echo "[host] waiting ray dashboard... (${i}/${tries})" + sleep 2 + done + echo "ERROR: ray dashboard not ready: ${RAY_DASHBOARD_ADDR}" >&2 + return 1 +} + +ray_wait_nodes() { + local want="${1:-3}" + local tries="${2:-60}" + for i in $(seq 1 "${tries}"); do + local out n + out="$(docker exec -i "${HEAD_CONTAINER}" python3 -c "import ray; ray.init(address='auto', ignore_reinit_error=True, log_to_driver=False, logging_level='ERROR'); print(sum(1 for n in ray.nodes() if n.get('Alive')))" 2>/dev/null || true)" + n="$(printf '%s\n' "${out}" | tail -n 1 | tr -cd '0-9' || true)" + if [[ "${n}" =~ ^[0-9]+$ ]]; then + echo "[host] ray_nodes_alive=${n} (want>=${want})" + if [[ "${n}" -ge "${want}" ]]; then + return 0 + fi + else + echo "[host] waiting ray nodes... (${i}/${tries})" + fi + sleep 2 + done + echo "ERROR: ray nodes not ready (want>=${want})" >&2 + docker exec -i "${HEAD_CONTAINER}" bash -lc "ray status || true" >&2 || true + return 1 +} + +submit_taskspec_inline() { + local token="$1" + local yaml_body="$2" + local resp + resp="$(curl -sS -H "Authorization: Bearer ${token}" -H "Content-Type: application/yaml" --data-binary "${yaml_body}" "${API_ADDR}/api/v2/tasks")" + echo "[host] submit_resp: ${resp}" >&2 + printf '%s' "${resp}" | python3 -c 'import sys,json; print(json.load(sys.stdin)["task_id"])' +} + +wait_task() { + local token="$1" + local task_id="$2" + while true; do + local body state + body="$(curl -sS -H "Authorization: Bearer ${token}" "${API_ADDR}/api/v2/tasks/${task_id}")" + state="$(printf '%s' "${body}" | python3 -c 'import sys,json; print(json.load(sys.stdin)["state"])')" + echo "[host] task ${task_id}: ${state}" + + if [[ "${state}" == "SUCCEEDED" ]]; then + return 0 + fi + if [[ "${state}" == "FAILED" || "${state}" == "CANCELED" ]]; then + echo "[host] terminal=${state}; tail logs (best-effort):" >&2 + curl -sS -H "Authorization: Bearer ${token}" "${API_ADDR}/api/v2/tasks/${task_id}/logs?tail=200" >&2 || true + return 1 + fi + sleep 10 + done +} + +echo "[host] ===== run_all_v30_api.sh begin =====" + +"${SCRIPT_DIR}/00_prereq_check.sh" +"${SCRIPT_DIR}/03_cleanup_v1_legacy.sh" +"${SCRIPT_DIR}/04_cleanup_v2_legacy.sh" + +echo "[host] bring down existing containers (best-effort)" +"${SCRIPT_DIR}/02_down.sh" || true + +if [[ "${RESET_SFTPGO}" == "1" ]]; then + echo "[host] reset sftpgo metadata dir (best-effort, via helper container)" + SFTPGO_META_DIR="${ROOT_DIR}/../../shared/common/sftpgo" + mkdir -p "${SFTPGO_META_DIR}" + docker run --rm --entrypoint sh -u 0:0 -v "${SFTPGO_META_DIR}:/mnt" drakkan/sftpgo:latest -lc "rm -rf /mnt/* || true" +fi + +echo "[host] (re)create containers (Ray + SFTPGo)" +"${SCRIPT_DIR}/01_up.sh" + +echo "[host] wait ray head ready" +ray_wait_ready 60 + +echo "[host] wait sftpgo ready" +sftpgo_wait_ready 60 "http://127.0.0.1:8081/api/v2/token" + +echo "[host] render v3.0 config with SFTPGo container IP (work around docker DNS issues)" +SFTPGO_IP="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' argus-sftpgo)" +RENDERED_CFG_HOST_PATH="/tmp/dev_v30_rendered.yaml" +sed -E "s#^(\\s*admin_api_base:) .*#\\1 \"http://${SFTPGO_IP}:8080/api/v2\"#g" "${ROOT_DIR}/configs/dev_v30.yaml" >"${RENDERED_CFG_HOST_PATH}" +docker cp "${RENDERED_CFG_HOST_PATH}" "${HEAD_CONTAINER}:/tmp/dev_v30_rendered.yaml" +CONFIG_IN_CONTAINER="/tmp/dev_v30_rendered.yaml" + +echo "[host] verify head discovery record (supervised in-container)" +HEAD_IP_FILE="${SHARED_ROOT}/ray/discovery/${CLUSTER_NAME}/head.json" +dexec "${HEAD_CONTAINER}" bash -lc "test -f '${HEAD_IP_FILE}' && python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))[\"job_server_url\"])' '${HEAD_IP_FILE}' || true" + +echo "[host] wait workers join" +ray_wait_nodes "${EXPECTED_RAY_NODES}" 120 + +echo "[host] prepare data/model (idempotent; reuse cache)" +"${SCRIPT_DIR}/30_prepare_data_and_model.sh" + +echo "[host] install api deps in head container" +"${SCRIPT_DIR}/12_install_api_deps.sh" + +echo "[host] stop api (best-effort)" +"${SCRIPT_DIR}/61_stop_api.sh" || true + +if [[ "${RESET_DB}" == "1" ]]; then + echo "[host] reset api sqlite db in container (best-effort)" + docker exec -i "${HEAD_CONTAINER}" bash -lc "rm -f /private/common/db/mvp.sqlite3 /private/common/db/mvp.sqlite3-wal /private/common/db/mvp.sqlite3-shm || true" +else + echo "[host] keep existing api sqlite db (RESET_DB=${RESET_DB})" +fi + +echo "[host] start api (admin token + sftpgo admin password via env)" +MVP_INTERNAL_TOKEN="${ADMIN_TOKEN}" CONFIG_IN_CONTAINER="${CONFIG_IN_CONTAINER}" SFTPGO_ADMIN_PASSWORD="${SFTPGO_ADMIN_PASSWORD}" "${SCRIPT_DIR}/60_start_api.sh" +api_wait_ready 60 + +echo "[host] create user (expect SFTP one-time password in response)" +create_resp="$(api_curl_admin -H "Content-Type: application/json" -d "{\"user_id\":\"${USER_ID}\"}" "${API_ADDR}/api/v2/users")" +echo "[host] create_user_resp: ${create_resp}" +USER_SFTP_PASSWORD="$(printf '%s' "${create_resp}" | python3 -c 'import sys,json; o=json.load(sys.stdin); print((o.get("sftp") or {}).get("password") or "")')" +if [[ -z "${USER_SFTP_PASSWORD}" ]]; then + echo "ERROR: expected sftp.password in create user response (is data.sftpgo.enabled=true?)" >&2 + exit 1 +fi + +echo "[host] issue user token" +USER_TOKEN="$(api_curl_admin -X POST "${API_ADDR}/api/v2/users/${USER_ID}/tokens" | python3 -c 'import sys,json; print(json.load(sys.stdin)["token"])')" +echo "[host] user_token_issued: user=${USER_ID}" + +echo "[host] (optional) verify SFTP login (best-effort)" +if command -v sshpass >/dev/null 2>&1 && command -v sftp >/dev/null 2>&1; then + tmp_batch="$(mktemp)" + cat >"${tmp_batch}" </dev/null 2>&1 || true + rm -f "${tmp_batch}" || true +else + echo "[host] skip: sshpass/sftp not found; you can test manually with: sftp -P 2022 ${USER_ID}@" +fi + +echo "[host] ensure user dataset paths exist (copy from common if needed; best-effort)" +echo "[host] copy dataset into user datasets path inside head container (avoid host permission issues)" +dexec "${HEAD_CONTAINER}" bash -lc "set -euo pipefail; \ + mkdir -p '/private/users/${USER_ID}/datasets/gsm8k' '/private/users/${USER_ID}/datasets/gsm8k_sft'; \ + (cp -f /private/common/datasets/gsm8k/train.parquet '/private/users/${USER_ID}/datasets/gsm8k/train.parquet' 2>/dev/null || cp -f /private/datasets/gsm8k/train.parquet '/private/users/${USER_ID}/datasets/gsm8k/train.parquet' 2>/dev/null || true); \ + (cp -f /private/common/datasets/gsm8k/test.parquet '/private/users/${USER_ID}/datasets/gsm8k/test.parquet' 2>/dev/null || cp -f /private/datasets/gsm8k/test.parquet '/private/users/${USER_ID}/datasets/gsm8k/test.parquet' 2>/dev/null || true); \ + (cp -f /private/common/datasets/gsm8k_sft/train.parquet '/private/users/${USER_ID}/datasets/gsm8k_sft/train.parquet' 2>/dev/null || cp -f /private/datasets/gsm8k_sft/train.parquet '/private/users/${USER_ID}/datasets/gsm8k_sft/train.parquet' 2>/dev/null || true); \ + (cp -f /private/common/datasets/gsm8k_sft/test.parquet '/private/users/${USER_ID}/datasets/gsm8k_sft/test.parquet' 2>/dev/null || cp -f /private/datasets/gsm8k_sft/test.parquet '/private/users/${USER_ID}/datasets/gsm8k_sft/test.parquet' 2>/dev/null || true)" + +echo "[host] submit PPO/GRPO/SFT via API using user dataset paths" +PPO_TASK_ID="$(submit_taskspec_inline "${USER_TOKEN}" $'workload: ppo\nnnodes: 2\nn_gpus_per_node: 4\ncode_path: /private/common/code/verl/verl_repo\ntrain_file: /private/users/'"${USER_ID}"$'/datasets/gsm8k/train.parquet\nval_file: /private/users/'"${USER_ID}"$'/datasets/gsm8k/test.parquet\nmodel_id: Qwen/Qwen2.5-0.5B-Instruct\ntotal_epochs: 1\ntotal_training_steps: 10\nsave_freq: 10\n')" +GRPO_TASK_ID="$(submit_taskspec_inline "${USER_TOKEN}" $'workload: grpo\nnnodes: 2\nn_gpus_per_node: 4\ncode_path: /private/common/code/verl/verl_repo\ntrain_file: /private/users/'"${USER_ID}"$'/datasets/gsm8k/train.parquet\nval_file: /private/users/'"${USER_ID}"$'/datasets/gsm8k/test.parquet\nmodel_id: Qwen/Qwen2.5-0.5B-Instruct\ntotal_epochs: 1\ntotal_training_steps: 10\nsave_freq: 10\n')" +SFT_TASK_ID="$(submit_taskspec_inline "${USER_TOKEN}" $'workload: sft\nnnodes: 1\nn_gpus_per_node: 1\ncode_path: /private/common/code/verl/verl_repo\ntrain_file: /private/users/'"${USER_ID}"$'/datasets/gsm8k_sft/train.parquet\nval_file: /private/users/'"${USER_ID}"$'/datasets/gsm8k_sft/test.parquet\nmodel_id: Qwen/Qwen2.5-0.5B-Instruct\ntotal_epochs: 1\ntotal_training_steps: 10\nsave_freq: 10\n')" + +echo "[host] submitted task ids:" +echo " ppo=${PPO_TASK_ID}" +echo " grpo=${GRPO_TASK_ID}" +echo " sft=${SFT_TASK_ID}" + +echo "[host] wait for tasks (in submission order)" +wait_task "${USER_TOKEN}" "${PPO_TASK_ID}" +wait_task "${USER_TOKEN}" "${GRPO_TASK_ID}" +wait_task "${USER_TOKEN}" "${SFT_TASK_ID}" + +echo "[host] ===== run_all_v30_api.sh done =====" diff --git a/src/mvp/scripts/run_e2e_v30_cases.sh b/src/mvp/scripts/run_e2e_v30_cases.sh new file mode 100755 index 0000000..dc56b19 --- /dev/null +++ b/src/mvp/scripts/run_e2e_v30_cases.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Minimal E2E runner for v3.0: +# - Happy path: Ray + SFTPGo + API + 3 workloads +# - Leaves retention validation as a manual follow-up (adjust thresholds). + +ADMIN_TOKEN="${MVP_INTERNAL_TOKEN:-my-dev-token}" +USER_ID="${USER_ID:-alice}" + +echo "[case] HP-1: run_all_v30_api.sh (Ray + SFTPGo + API + 3 workloads)" +MVP_INTERNAL_TOKEN="${ADMIN_TOKEN}" USER_ID="${USER_ID}" RESET_DB=1 RESET_SFTPGO=0 "${SCRIPT_DIR}/run_all_v30_api.sh" + +echo "[case] NOTE: retention validation (C2) is manual:" +echo " - set data.retention.jobs_trash_after_days / jobs_purge_after_days to small values in configs/dev_v30.yaml" +echo " - restart API server and wait for janitor" +