From 991a0c8b56ed894c889e598860ba7f17ce23df0d Mon Sep 17 00:00:00 2001 From: Oleg Sukhoroslov Date: Sat, 10 Oct 2026 11:05:32 +0300 Subject: [PATCH] Add HW 5 --- homework/05-message-queue/.dockerignore | 5 + homework/05-message-queue/docker-compose.yml | 48 ++ .../05-message-queue/media/architecture.png | Bin 0 -> 97292 bytes .../05-message-queue/media/architecture.svg | 74 +++ homework/05-message-queue/readme.md | 152 +++++ .../solution/server/Dockerfile | 13 + .../solution/server/requirements.txt | 2 + .../solution/server/server.py | 51 ++ .../solution/worker/Dockerfile | 13 + .../solution/worker/requirements.txt | 1 + .../solution/worker/worker.py | 15 + homework/05-message-queue/tests/main.py | 107 ++++ .../tests/network-fault/Dockerfile | 4 + homework/05-message-queue/tests/rabbitmq.conf | 6 + .../05-message-queue/tests/requirements.txt | 4 + .../05-message-queue/tests/test_server.py | 583 ++++++++++++++++++ 16 files changed, 1078 insertions(+) create mode 100644 homework/05-message-queue/.dockerignore create mode 100644 homework/05-message-queue/docker-compose.yml create mode 100644 homework/05-message-queue/media/architecture.png create mode 100644 homework/05-message-queue/media/architecture.svg create mode 100644 homework/05-message-queue/readme.md create mode 100644 homework/05-message-queue/solution/server/Dockerfile create mode 100644 homework/05-message-queue/solution/server/requirements.txt create mode 100644 homework/05-message-queue/solution/server/server.py create mode 100644 homework/05-message-queue/solution/worker/Dockerfile create mode 100644 homework/05-message-queue/solution/worker/requirements.txt create mode 100644 homework/05-message-queue/solution/worker/worker.py create mode 100644 homework/05-message-queue/tests/main.py create mode 100644 homework/05-message-queue/tests/network-fault/Dockerfile create mode 100644 homework/05-message-queue/tests/rabbitmq.conf create mode 100644 homework/05-message-queue/tests/requirements.txt create mode 100644 homework/05-message-queue/tests/test_server.py diff --git a/homework/05-message-queue/.dockerignore b/homework/05-message-queue/.dockerignore new file mode 100644 index 0000000..d291891 --- /dev/null +++ b/homework/05-message-queue/.dockerignore @@ -0,0 +1,5 @@ +target +**/target +**/__pycache__ +**/.pytest_cache +*.pyc diff --git a/homework/05-message-queue/docker-compose.yml b/homework/05-message-queue/docker-compose.yml new file mode 100644 index 0000000..df61c30 --- /dev/null +++ b/homework/05-message-queue/docker-compose.yml @@ -0,0 +1,48 @@ +name: distsys-mq +services: + rabbitmq: + image: rabbitmq:4.3.6-management-alpine + volumes: + - ./tests/rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf + ports: + - 15672:15672 + - 5672:5672 + + server: + image: distsys-mq-server + build: + context: ./solution/server + volumes: + - data-volume:/data + ports: + - 5000:5000 + + server-fdv: + image: distsys-mq-server + build: + context: ./solution/server + volumes: + - fake-data-volume:/data + ports: + - 5000:5000 + + worker: + image: distsys-mq-worker + build: + context: ./solution/worker + volumes: + - data-volume:/data + deploy: + replicas: 2 + + network-fault: + image: distsys-mq-network-fault + build: + context: ./tests/network-fault + network_mode: service:server + cap_add: + - NET_ADMIN + +volumes: + data-volume: + fake-data-volume: diff --git a/homework/05-message-queue/media/architecture.png b/homework/05-message-queue/media/architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..7afa5be5ddc56203e87a58ced2b47af59bebe9fe GIT binary patch literal 97292 zcmeFZi96J7`v+{SQL=?(%a)WS3fYA$36+q&D9RefzP5>kC}bzQ!6f@IrbUeiBg(WF;f`gYmw;r4JPqml)+Y#Ne9f5*5{PDg&Jh*8>LUGV%VL7R@*D zEUztRJDRrSJUf~^8XIx7AAX#_>E^9)Gco#v^R1V%O0N%5vt8thI&=bm+&fNKL`Y6p z0?+p%%*SN?W!d-gQfmuH0TMe18!GmK^am3-jUpP(+{rd$fp&XjOUpWBb|N8~a1BAa{pwjyPmqPXbA*r0i z-!2j}BxQ0WHhE@=N)liEi1rl=(!Ucuw;C??W_B+z%6N~TjV(qSEpKk?+9z*e8{8*< z!#1X8F{`(tW8ZfEvY3-$?;JjfPQ9pWG4>E;v)vVEJIGdfcQMWo-ph)UYLh_j_nC5&Yoj+a6by>D0E9#%fNXopLlBjAM%;_f{% z&0}M$JP);T?5o+pwObF?C{8U28{C*4t3P4k6^M%?E=X9)3Jvc&#=k8=LBFwDTR3<} zuxFF~ECP|w_xiMDE=$ynsHlQ(WiO}<1~l{>bVFT(_~Ykx1QY%4C@QM{eQ}HfJSB;5 zgWh`G-`Wb-&v17AdO-DJj+Tz3h!btbeVaz>d)WJVuEB2>&!B5GVtJ2Kp8iW%$5{rx zF0-qYmn=uEddZ8PB^;7vVyk?fw)L~R<@wG?fcx5Xhp39!174xOJ@)>BxlMh`bFagv z6L7n>*A7dw8bpg~0ulQ1V^?0$tX8`=_AnHD>3>nSdB7uz^5|bd(@J*cD+;G!betdAg&pXk32`i!ODy$PMp?c~d*F2`5&o~}6~T1De9by~ZasX> zHH!uQv3fo#iW90#B;+PRiH}=H_Jk}ebBE7RLh4^CzOoQ)-)k#)39-|7TU}XCUrVPf z$4R~UWCCulZefHKm9MCHnevUltn!uX_r*-Vv{J<>j21OzNs3eNZHma+x<3OwGAMWc zC7zQMmne=Ak#$6I0}TiW37bRti4R|U$wP@zKMd9zyr{wLf5rG5n?$zz#u|tv{GL$i z1?MQ;sHoypw)@u_6$!ez8uhKrM<{RqOEyz+^-Au(S4BIkWApve;ZlOK_~zdA zfA4!%_Tx-6Va1bl^+{YpWQ(wlPH(IhD%?iHI(lLxfm859?EiUjG0 zf9)okKte&;blA0{wJF>7(~1H&4$O}>{MT+2Bkn>n&wv#wKoQ}Th5dVFk0auuqW)ll zf9(sH7#mwcL?<&R<<`F}NJ~>sM~7PZH#+#+RmmKKtDciz`eMB22y6ozXa&pWk9y{C z%hQei^T}t$3BT{#MGN06 z$yC!C_w&E4TZxKq$6dPQre6|Vfg>N54G~!73gB+v(v3@X3uu<*DCBJ4lFm|bC)pLC zpEmfG_5QeG5K}dRCnX)MZ8ov0ot^4$;Fhv;nyvR~;^HFsJ_{jVJ~*XOJ1-=7y+Ukn z%YfNXW2Z1-TP~n_lV3@4F)?cTzs1pB;<8o_i@3ph+RRnbZk4O?`P;>wS`Fgakg~H* z9jB95ca(2GAKRjjKWZ6(J=uMFN*@(Mm6BpKNnrsA0&u8J?uSy{5LH7%Y+_B7;E}t1+9Ev z7QevLuF0`t1c#AA{D`2*1=Cj$TccwW7!?=jBsd1&PLcbVcapE(dnp(_bg}f=JM{Ufv3^e-O04rbQ2Y4=5v7;E}EYJ`3eidw0lm*XpbC zwNicS@3Xln#E7uv;d}GG3UX>An>8rkop;m0=0E~l=a=Hz@tB;zBH@FCnNJYGaLdst zFDw5!k@jSGB1f2B4OZKTSfN`UpL)iqG;H+S)b32^FRMSm!`D)eVxanspXG$Y6++itEyJ&AhNVTGFj1M;9|^_Ia)uO zIe{)Fxoxzxw??(U`_t3(L9$21JuH`#D7};O5&T*!LZEEO!X2qnW~9C_rVKtFNNz1|9s$II30vi z>Y$2X;+%ro$opJJoN_W#hQEhh(x<0~TRZtx?hFYIRVOr`8dk3l)=qTPdM1^l!Bk&v zG+o{qIJ}G^dXilQSx;6(?2dayWT^fs$~1}XMNNfIA!!H34-k@2_axOw%)TTa&c5=j z9#6+G|3SUjZF58VdR|PqZZ_=RxT&KyF08zlm}U0LVpQD5^v}BfReAtfL)7#*n>ymK z#1uAZVwe7bm$AHx2I*r79pbVeAVxCnrKsxa=*O5z| zd{Px0D_R~SPbNlWWwIB$M*W4k(ZI8RGMu@@gZ0+0xkZ%iz0U zefRE>Nak>?mH+ekp}L{Ngwqz~3fpO2FYn^|$n=Va*|G3aU4hiQ9esTk5;-n%!{k`lhxzGn8)y zJ+H3Upg|n7w0nUKh+R}3g5c&J9f?7v@TzY(vn=v@EXsRUf{R#xEKtkiNT0IlxH>MpCqg6Shdzk`=K zVosKHFNxQ6u3wn4lFqBO)U=rORJ_;MH;>07Z(mGhB)FVIbt@BGTzvD;6;AwTl#@xC zk&O`K-ur~YuCe#Kp-*;Gw<_IPdM02c#D8bJ!`+WmA0d(Ogj2p>K-qkU+rZikHb z6%$ivnUAo8T~+D9vu?~7SmEZBw=0#$%6rSe$ovE=SJoD+t z=bgS>+NqRXS^s%*t5@XOZZ`v4rCVvmtr@GrihDDIg%w^iR{lcAwHr4eASKz*L*!}RS=lCF71?I9?LWsRs_}K0 z=@Q0`+T8FoE6HhoR}4^vOWldH}YaMO0Bx?lRaJP zP9|ojh6ZoEZq~J1<@T`!nfu7I>a|0R0h?Nfvk|9U>-ZB=N7C;My9x5h-HSeSv`Ui{ zw+3*Oq;F~PD}+T!a5BQbB=|KVg-$gn7dN!;piA(htSMry@df(gUFWy*H4>QFPCMCH zVBgoEO{-mnsUXNXy0tfaC84W>Vm;`awHpEG1sYL#h2DF?oHppoubqO_IX4cnW6YZ3 zm;x903i$mxXoapl%h7XIyd9ZmNmfSd z?((==rYVI%~a znPSrC^K?tCLl^6r2rm3k`8OJS*!1d|H!7XalGG#74I#T_LlCvKDaUpe{Ue{dzCCJG zH0qMQ`{R7YX%KIQXw^a`Mw%N7_U^t>5*9W;)Th=pON5F#Xp4nlAA-Gpm|yj0|HRcgKPMOvKkLgjE_Tk z(y8~y=3)Ia3eOhJA)$*|IO#4$?{By3`d6ggA+`hUj<3g_o)R8aZ^soH!XFq9ZBN7? z8md_8IWXC(^^T+v5(}TDsK(hDnqy9Q!ifKYeMJ}ws2)xpFsx<0#>!TyHQNA%(W(U+ zP>|or_{X1bf5jv*NY3n)Qy?z#FM`8AP%kqT%)l~y~M0P-%G}}Son66KR^=ML|jS29l)G)@rDwS26gQ+8jS>1*PhIMN`KuX%R;kk_2e)qTh7&ZFXcRNwt8Y!>jgmX5Grwo|snmW9g`cbk^xBnijj|mu!1{@iF7hx}mQ>&B>?Bx(e)@Z`|{5 zmj~)+U*D1>D7G1L!3XAVFl;bCpkre@{rG~3eY-FkX@aAPU5HKh&wlakn?D5O6z z2ydD@KsY*a!dQj_IBD?v`=92s)SBS2Op0ma>WgVB3-r(`>}4K)(i;rLH@ThA_9zb# z%H}ksB}(p%*Epo#I{v!(Zk}q;a`Nkkfb{tOv}?3=?FldQV#2DqoByzO&u>*eY2B2( zr}SBA`}#3fG4-$#_RPzC+B(nzgn5N(+8bMySoNqQN$(K=`oFny?=V40dc)3xr?PSF z7@=Q(ZMv#SGB3nV13q${H`Ak*p44Z!K({u17U`zZi3;~nMFbA4nAOsda&AqyWy5tp zWxK_v$&^EI&Nc99SEscHkBxER-Sdkcnu-TE+Fqi!C9_|WB3fZZ{auX)u!=%75#^mg z@k>n+b)f-SDKdT0$))xmPrT=bnGKA(M01V{J3KMR9o*?8xe~u@6Wi|{RMgjUkfYSE znr-h}eewcEZrVJz3IAB^qod=G{8jH)>RNnN$5|AVxQ5}H7P*zqd$*EvhZdmyAW|fw zuU@E)7kO5C4+jdbl1%G)X9XmA{WSM56F~%hxpw`0U-8{4bp5>^ry&T^I{QQIsNni7 zNS@<2>z}qx)TEuE-9o{~XGlKlW6wX*Lglp|4)|PKjPCPM4k1J+&}gCT!+VTG7F~>p z840U&seuqi!tp8Z$%d<{f{P8hS~!ZA$MxBVd~hFi_TVMo}P`sgrNP= z_!$pO)5}ez4U5^kjaBNK9j+;0Gc#TDP3Bz>7gE)+j~F%pRF>zhsqt-YX~h=itzb7R zPM?L~-1wnq;kN4b#SyzpyHY)lu=Q>Lym=N>SxTCokyeoI9f|ZQvw5FsGVHZkxa3Z3LwR#p?%7xL<}oQ^DcU zhBx?@`PeGoMk-en;P9VSY@LhTj3?=JG||@tFiEFgSbxUAU3X4aj(sR3!&fXF>qjl* z)2$bZ9EzUh>XJuRP~+yB77z`k6D?CdxJKu?>D7>xpK$<0gt)SMtp;svTlVb8WU1Jm zgW#5}Qs?v3&s;eI9187Vwcb>EZ|f}kMC%HBcq#aAtp=cpNlh2K13{95wvy>n4dD0DT8V?RuM z|1vgv&-iLl$J!0XSg|Y-VXmYZ8Uq*e_-*VEjYUsA8ylwb6$7*&jT(1K@-VN`2dMqJ zd|v1m0ImW%F)8szT}8TPjU1Sc!kb<2W4GL@Jpsz}DN3s2#o%RZ##cfd#f{GnTNue! z=oWEb)U+@iPoL)qz@l2INi*`U?sRLNYT>?vb;lv0__)kueCkxgdx%xiHIM#zDdVOfcRGoF%eEVP&VH41y=0tTOlNJLLW*v zSR_XGD?sX;t@-<&y1~-A9HZA?)=+zjZdXb+$-Sj}T^o ziXA&QO+z26FuXeI^5z8(O^Yzo z#wQyj{OIDYdw7X?QNeUru;1dTcM$^1zfz~wL0)UcNlVPcD$JmAAhxlXy)r9~6rsVr zw$H+hgtkUS_}8aNuGU%bwmOa0Zv(4SfcdggwIG))_Uw>1tis}_e0%F9YzzU$f9Kr; z!O4)qc^a=|(LQC@Vr64H__NC5)=GQ*6GZUrjMUTpLO`se;QgVZlE)T;hGO@Hgt@BP zS&GivhQrmr{rvLB`9SiiI~3dF9H=y3GnTZz=cE4GJ^z=5u?#>*9rE!9#buYm26PaTU}-oz*`zh?^Iie#s<% z9%(OtJ5D0a*2+O;Y7ozI`dvgt;X}nCJtNN1T-ej<8H_b=Lb+Z1LwaaIkcT<^2~+v1aR~n@E*1d>6(jlJ?QLf21H!@%LaG48^z5vU^;qm1 zaAN+1+m2O}RMD49P`O*66;*GB9qjLM@WuW-T;oW zrzzlEaQECb^v?J@UvlT1!Z8v?`|p|M1dy)w1zGhf2CIFe4!Y1)^l+o59dFcS@gkyP zJiKo)eLy*U*LNDnOmGdsS_;^Q-uQ4A^^tZhgXP|h&;xJYZU~;gru9(JOHC+8o9>Ix zv^h?OSR^xivSd27g+)iFMXPwB;4GK>cbucM%)mZxbyA{&c3Lkx9N;WzjoUMRIxWPh zCk&~eTeVoTNfOb`<-k-{GeD=NR-J9*ewgQdU0ul24dxa-0ZSOtwV!o;7lyFZq)=!> zSFJ;jzIV%Sghhn)vmtnQH><4irtlIte4Tt1ET2y{D6t%k$s5IJ(XG92+)YN?Bizg? zX`xdeXvkANp2s+?b@nD<^n*mN$suPOivxr!9}WbOt`TE)!t$CXyJPAeQ+6P{G}fay zh?HMMZgf{+e=8tobUeVedK!5qE9XhnX4br{eYn)dQXHQCq9z)!oJjx|RzHso#P)ac z(5VG&UmvQxR&E`-@;bZLsV2=Echae}6)K9Rpy`}$ks;n&M6GnQ0lR(#JK zB-DA@boX;CVmdx;=o10?)2M2Hfd*maYNsY{5uXS6%qRPG+4@K-|K6_}!_Qopq?u3w zIfsWEV=?1Q(=G~hHQ%VPT-3_X2G2Bsbh|$baGkFy)PFXt1QpQX@sI)76&vFIx-<^y z_jOQhe_O;|!|i&>t@`Y+-n?Us2H&)FLQq);d%Ni0RCHWrv>YxzK)69qjj-ZCnCJNw zqRm7em}z#RMw``w^A}j=jfmu?Ak$Q(;O3T#P2X@uw)ovz!A3Jp3wyxMP~x@nv3&}m zRraOWcr5bj7r-ispMiB!y%$}j@|DA3 z8yR#q)-2TzN*d53J-{5aV&xAvBaS?7xlKvn@5#4g?Vm~isD90E z*a|wALg~ie;ZK@(b(j=SQ7b3=m|Z`N@Hah}bjnEvdO4>2V}TV}Wv_Sf^VZP{J7sZe z1<$_1DRbG~ddHRq(oIdT43pURfPB1IyHjbTl5KT*yEXR_yWsK+61D3h+8i_wxcw6& zhURzo_ST{u2T2=K%VeVthn&LRQKV~KnO&86(=Vxh>P?5msX-r<_i{!LSzwn`GR*+# z)!+TGbTcfO5$tM7CD@jDaHr&;0;@6ziZLI)uJ%taG>CVNic)C!CKKh(xSr2xQ%_AP zyfVKvExj%qac=E_+r&lj>(Zq88~bhYlc z4w_gx8S-bfj6j`DE2-1MUi+c`D6VGf+D&>zNor+bhZ4E75?Xeuhkw=_2hm3O=h#7< z%!+sbiTyl90Nq|ySi%Al1WyhMt2n}N`mornaK~%k=$6yqWq#G3Zx#ldW)95eaDZMB z8<62UKJ~zhM8oZMm3TJA{a5&^Y_Ay>Wieb1!NX6N(=x`YVofpMCk0QxG8x}@Xf~SE8Q;aib;pmQX zBa0nM#16>(Ed)L?UD2`5hjlrgBO)x!j>#3}IoqQLHu56s?l?X0Y+2F?Nh|+FewFs8 z6hN?~z8R$dO8R@#^R^TFZ;%lNTKnaqo!4-S?T;+p9NGzuUP#&O_A+Q;24T$g|RKC|=iqg|i>&nGw)bMVEwg{Xk_ zWGoLb7D|MqqAVK1Ptmo(h?=~bsrnWoVXQJTKHc&s z`Y0!dOiBeVS?#HDYh-zF&7PUahc|$mnCAB?hc{NFVyMgZ5WsOj`-9v z25sBu8n3-FX?7t%J6SYfBDUni*f>XgNZq2wIo0fIq37RAhIOT(w9@KkrR@7%ax;kl z8yFVZX-jq`B_<15`|-U<&LpKbdzdx57M9z?9S}IITldO(Wc=Rdv|+;#pN5Ym;=CPq z-fGy!JPXiW5f^qoqifGEXWErZfYUl(gT}>=Hg9!X;g$mSz44+lg)q!U8Y_Me0r>pM?4z0_ob?xL7eb4u08G@$>yWjQPVS=g}{qxCV5B{?GzpNS98w7b1 zAbL4P<+x*^eCa=xzn5;RdCaKReGCk1T;J<}Ua!lHXm-IV)O8Kuv9gITURjBP`;S{B z47-t?+=F?tCdT`$2WM?wU@ym-`^!rqLHovZ$bVk(WBFZs0oOH6NC15PlsYh@Tc5qAXt+(Fu)S0Wq z)Rcy6~~t$y|^cU@RA=orf7F7uPuABt{tt`~D0@U_dlv zBv8D7!gfOV0T~Cks(Lc5+&kKPuRJzjchhjTq?3Gz4|dVdB3`?EX#NzU)Tgxj`+B;6 z0?R4vyJ5ej;u6sNEUNhkQ|!}CfhA1OBUq|civ9dSJ~&MXantIZzSib8C1 zw+5-DY|`iP{wrD!0I=f?$r3pMx>qjlD_ryBw1pz!;9JzBM|xcu&2}YL9D&QqEo(KV zv(f4N!E?7|impz%+-|la4&M!4zjdH$deN3{tqbLOLeDtEf6xTU0Nlk1F?ucAh!#qT z!v7Gnrc`oZ-JRy95AkN7U0`f;=4@d-wV}y9nMH-p2!2eTGQkNS6KC|6&oWeT$?E|P z;%dHm9=hC4Wt}fJ4%RQG7_^|&v*16c^y5A%Hhj)kC1-CUCdga-XXUhGQ=p>$u6aSl zSTiMVWaI){`iJB|!-}c2i3J7-^&Dv)UD-ahVh-#1a;~EE=KPFOu|$7|z^9~Hf50%g zC_}53+?@BsU73E#Jxu==eQbtHeR%m7ejF14 zSo_O+KFMtiNtqf{_qV^@X-A*&c$PBVyY82zjdc2A+RdOE;-ZohZrhkNPi%F|BuDpf zvtuZE{~uCl@&ah1IT6o;0M}k@kX48#wHL`xfk0wRE|G7Jt%ci^M_YF}{`kRD`>57T zGyYrEiNFi}?iP6{Q0F?uWzLbD>-T;#ewK5L=q7t`VXXZ(-tGw05HQ%KqcfilEYL#N zk;?;h8b?c$X^Ojxk>@nbU-rBJ-@j_G`6Tv)J9_|tpn77lXzp~v^1iGPpu6OZX&^e|08`Owy#JUp% z={h=AJ1QdY`k-4D5&Rr_I)}UEM0xPhxH5`o{|SQs&h#MdgKCfCr70!JGshF-Wo8Vt zu9bR_6F6e^%CR2byUSP8=GS1*YJ+e9lr>VA$eZE*B)e&d;QM& zp^<>ldB1%-gss1u&+y#*ks3+H-Xm36L)1Q2{vx$2=B}8g*n`%r>n>9d`pim)=Z`gK(+ zx*WuX`$^?K0I+mGd%*;O8eXZmyLStFsw1~gt$ZycU(Rm)w$j91F?`NeyU7c-keHS=Z609Wxg@7i zq?|P{IMEi&l6B;K4!T9$AZf;kxlSc${9%t5s4MtfEB5ZzBZG{}?drN;{yHC8f1#X3 zJ)rmcCl=NlbZaQ{QZc*??rnz9&(+0ro&~b)BfrSSt5<=Whs=c>2hjW<-5GI+5E{mx z_jz)=R34^hwDQXS>7H~bL+ndqWMpefjnxg^dsDd+&dh?3(%#q@nR&>6RFV`Pl=uM$ zP;}R_=D~E>Q1PIZ4}E=h}F4Pb*oANfH)P*~CDHRc_{ z8h0%hNu%z%ezjjmokn-rB!8{qYS%NHO^*{r`OStspbm=DsGSutRYiVoUrt-+Io}WE z_bv!x`IP4nBblRz7IW$8hU1zHn>~MYhIFj+GaLYj>p$1OC#RyS`A$$ENpdeX(YFuG zy4?1)iRqO$uuTX2E@0zPke!$CW0x>5xs&6yKX(Bn$@z7wH_BPwYIx4Zj|QO?R?`PvQ1c#dn%*j-4!U(+@L5!x(cdk$ zxD}#C{v7yKS=~$yTm9nlVj7r%?~#|{$Cln3l#hv^RB9_67+47Q8}xcrx79#-*v84; zEl`By#J1Hc#{;g*w6`(1^RODdp_|sV1iCfy1rwv9YWu2NPsfyct(Knz;t=b5Mj+VW z4ij85mpelv8$z463`3+w^T3pvVEDlM`{jO=%=`x|>1#e^V#ibo^1t-A4rZNVt^Bs- z;cD_VEF@!pF>-tuGb;0|fS0^kCtw27z&FcDN2T?;1Q(-4kWF34jml3!GJF<|Eu>Pk z)=)u#q^=K2r>U)BX2PZQK^}K_VS*yBEnJ|kFKx+bv1jw%s+fdW$BNQxI_UZXy7e@e z@8|gp6J&+@Y+XZ0{4?d8y18C^58OdHRc=bv0>a?$8b&*ig;3cwg?qsE_tq$S&yBg?nnnIA0atIwn4T>4#w zi@aj|Yny?&0v1cNtjjiUFM(>`Q|%)%$dpIHFS2%{2ImNRE-vXHH0YBB9$nUZs0eDm z!vZR-q+teC=`mUzMVhSnidJoQjTE)hx)4HM~;ze54y&&noE5n-)Q)O@GomCndsQp#> ztjRlVe%C|J&da~3HK#v$xwjog!fj!_@`l{qiq-xh^U|Qv3#t|`v)Q6WC8$<%+2F}4 zJp2{#=E->5r;{MECuXFLZ7k2xuJL7~LPUoA1e2e%WABYwWnw*Or43BK%S{@;cbunT zpP^y);fD7MpccPmg321zvnu&4qT+fkX|RL*E^Jm{KBFDfq$7=>{B2H2h>+e7Zvb(E zy6M6J!s6L14K=aU)5o@XvXXm~%X;F%n~GXiBB?b8K6rJsDXUpDMo!svnJXmEIXeTb zf~jdqCIFlWcA9#BpgU#eno=Vjgdnd!S4GVotw|Nnf_n%Jz$;dDsWnr@$Xj$$8MDGc zJV&Def&-tXz)OHz_6M}Vgc~474r^)3e30+*?Wu)(iVt|f134f$-(K>sUzWqxbezo$ zetHdc0@QurQ#3(XT zwQ0n;+Wsmn3=a!9srn+~FqqP%aFz%PqX$fz!hx$TI1>s|QONJzx>3||a>Cvc~+GWvhq58mHaszLL8%B<-Y)s$j>YEB<38 z`_3JtMulYfpC>^}y)<+pYy=MVNBZ+}($76cxn3=UKH zx&N0B@6!!`sdb+<1W$p*@4x?_|CMs4225K}R{viTpeTnjdJnwt57WKhc%y^^fE6gy z^!v~K_LKjX2j#E`2PJI$gRUvwyuYg7ZUW>{FiyAjx?Z$lkC2qnaO`AoynRY+aM#zF z`J&rf$&T-!F9n5@ul02mlPq!7i%oMXUbdZf2YbVk9mij8S_u4$Zvhen+zX(1q9}eq zjj$DVWS@K({-SKiHV?Ht27EmtJ}hqPfBG8 zSLiDRs6{9Pz23d{SOn5CpPpuFG0 zMLN*0`7gek++JsR=T@~9^b59oUWjahd*SMz-8S*_45L47?PmUU1B3R-bk52nurS@e zu2NE}nMUJ`bHJWK_`LJr9NyOUVDAd^%piCz<*gJJnRe~ng`l9w$9g&j*P)W(DGZyWviN)D>(LChErSMg8Br7{&4mq8f>h@;!Qt z5qCX)*`Mx9tbB>rx3W@};RCE$M1;qusMoZCNBB-D(%9dv0z;q<17^1Eee$-pLlhyW z(4t~rC0S<{-5;qYHmQd58(h~Zi%<{d6yoK_Mr>oBB*jewQh6$6?Kixj0sd)s?0o3L zyKT@Xv|d()yDPK2c-4E|P7~;e3W9t8v+k<*`1jCdobMNCtuE+33{~I)bsPI(uIk{~ z1)aOM%f?7(7g`rB?_)x2mDK5kdiu@G#tFV|e!=>yRpykQ!+tp@o|D37l7)N`k8;a7 zI3Z|OE|YhnGnqg5aC;4}f6Lx4wSn5%9O!~tuf&@@$_6BdFOqlEWTuhFOCH(?C5d-dp;cDGThH{0T!VLFG`x@t4;a37kqfsNj@4oA(eyEDsqYqX_v3h4bO3X5aZq zK`2HDVpLZwn&;iz&3B@Ow>dDmH|n{Uu5f>Z^|?#k8moZQ!LXh^^ThVI(M_*`-9`v+ zkqs@G32?19nT0MEhDWoIT*VzDX_=WFiVRemO4_zV+9D1$t~+fZRZ!Se`KLD%jD%|! zXh?zRBFUqHaZw8aeK$o(bbH4aYX3w~!nCrYaj<>!(W7E^jftY_O6d`YFSbpl#z2Ka zdTNoi8|~k|y*kyG{%gUa=1V+S1&CxPpP^8%K1b}%nLYLh?75R3|1}l%ATcqmQ<0WR zlkS4;YL^W&W&xr^q{(&{pW2$$r$$_V8zC^ZIjJ+^NjueDRmb=^if|3J>hFs^>?_3> z^K3#Ij4e|F!zWZd>CC6;9WNqxH!bx3#YcjTle$Y_j$V|9ho_b}k8!-R&>X&1U1(i( zsU+c8x2)I7+?JvHk?ksOc=ew2e12h}!QC5=RRcD!Q$G@DPp``z%m&l>{5h?=yVv@u zNifV^T(S0OW)Xj0ciyHG6;*K#Fn(!RZD#MqwRdOOft5d=JMaDZp7O(%SJ3g}!iaOS zN=6FG6+D4(A(eQ$sfAPjTIkWYq9fBgUzcZvuGSXnisd}wG%)zOg1uDZEfO&Lz!5iy zx)oE=K&G@Lr0cpuh{o3ArA!t7ng!6nITC2M#>q~Ighiz=)1st9{AxxXqX(;Z&P&@P zB{G9qq+X1%RnX|^->naj+DI_ZB((#;tfJ<$?kqJ(O#0l8H3-pqGbZ~>#`0xwXTpT@ zK#(0?+O9{fzb+1)kj-rEhYv&IBd*rQHHH#}a9%{drHQBeIpw5Lc({Zi#I`&z%YSf# zIk-z{S2ER;mMVhD_`2Iew;w(mU15kttrB|h@ws{vFP?y-`>V_>2`d|NIviwdhv{5Uzsgw8R5LDo1 z!w&G_eDjIJk=?`JqI>W|$NHLm7TN0J(y=MQ5V~an0Zurj?bv^v-!n7N1?ta`){ZNk6vDV<>i|zd@kMA2=aclkjj&&N1~BWijhrDEhod}%+{eQ!C1xteXxmUQT4Md(w*yky+uqqdri zht8cGDYsgM(z$55O-2U^UhMi@aUaPBv)%X zGZj@NU*mlX#fTRX(o>2#_Dd|^KUeG&d9YHmRlZ+PnT9tO+B7Y{Xe_1V+sfP22;%N- z$*IoKm!fLFJ{wL!5~Rb~cFKCf!BoWirXYUdD!dG~P}brWBDC&-v1>%QbzN*z7>BT* z-c!#X2=r9o)A-DaA-!@hkI$tw);@K%Z+l5(eIpA`@f<~uJ~jXJQu(k~gxBZWCrWF2 z!I8OJ3+gUV(D;I{YNLq)7}IN}zJj-p16%@zg1Qzs!iS;JySTk#p>xD`M$uB^$%y|66y0_7YyZVOLUg=xJZ?LtL$6a059ZVRD~xQZ&Efuop3ji7?;W}NK09E6oE zCg>P30u8T+u>%83pzw_+FHj4pCjOcaGOVga<~vPnSe;R1ng(q}u?z88Tmyk-Z#yaH z`syq_G;a6y4!d{R)mw?0)^k6WsN$NA%S=tZcXTVMLU(~1&`hq?;NY51EsxTz9KKtjC%cec+|dpAna0OKQ$HAjobxJ7(elO23ha6n=k~~1ZGmjj7D00=NC-J z1%qs7=9>T&Cm7`_I?C(T=~+Fr*&5`|bzebRz9O&p8%#wqF{RS^>ym@YVd5 z#*G)r8~C7l;3fT`?(>EGi()x=wJ+SGiaj6IK>Pt!ZQ*^HjQEGzzK_}gr!@CgqwDxb z^oLtOu3Q+R0IDL!#^lmi`tzteT}^wNKLtUU1rVOiZC4yo(P>$uyE+Vqy{>p-4d5IuHez8Q^vwyPeMokA`1ID015ReiaA@(Z2YvHR zsskV)+@e1@>OBkwT&E;+Jbwfmb2ohCH|=NLV+Et*dg$2-DFB3zQKmb_$t;8`K?cU{ z$!2=jCPB8B4qxWW-iDXxtbXwUjx6uY*HeJE=dgV;x}Be2*!H}^&<8u_S5px*B7p`S z*_IYjcU#XIifh`aJNZ+SkP%P6mpW=#S2hLhWarzhb{0ZxWkVO(aoE0z1;v02w{6Gx z%}iBzIh%(6*r{nuXimtcC4Z3*I4P@Vvhr;wP1&jQ$R|pXrUFqT?n0IX_I4BZxHk&| zxjvt+6I?w(Llq&_t0scq&0PSer6uekp+;Fsq@-nok`@ge$?D~3Fhtd!#$VKs2M$6| zK=+lm_NsVIkn#$zwtFCs22am0yNizexr!C@fj;*Z z+dBYzcvM}Ug_yyffDAIeAsZQ+S{PFq%p%YeYWsP)$=<^C3G8Mt*)9j5Mjipvaz_uv z92vW2X@8X(frcX&z?}HHuKHGKu18}h@@ZQZO;|F#d+{vS%!X`G*eN?0e(!1@DYDs`L>4vMYH-2YIhOeV$gT@}<%g?}I9Q=-GMl z?j6wK6+NZgKP3yA=5Uo?D}CukbsZ|W;7l`jvvU>B1=d8EhPabo-q zIZN9LDySTkwt<@YMCFp{y$+#%LfQj_?aU)urEv2`1HGabCKPH0Op9J~z!(KmA`zf-V z^mY;HjU4kEW4pQ!j`eEsWy?}e4iUh)*R~xJ7%9s!LV7UN&S0 zJ^_GDA8Dg-Ecwr3JpS2oKoNH7#H7;0nbzg1L)vbA$22arAKp2KD(PvvpMETM&>-sT z;54Z6``ux>#ZedisxbWimDInyiF=@@v?``TzVeJW(Ri)>o_K zntgh2`J&o<*W7FW(4`D?-fqa^2ge5dmAnCsVjIIc+f_eg4ci7oT*j-NrzUp=l?lB* zQPP`riwaljx}LP}USo$K&Cp?mAF}tM2B32(+!LjapqTzXX-*kN0zWVkZt`~{xwcwC zA;zogeKt7Y`K#9AyFC+;kr(eiz%4A3Sa{&&{OZ4mN)6!~pNSolxW;h0Tb$*%zz{!N zGx^1W7w}gT?&2uS2BgFf5IcbX z?I&CcMG3C|D&ABMn7myiF}PZ)>+HXvcHKCuVbZ%CdeNBGLgaI791;A#8~N5KHt+c_ znZu#RnaKPh-PrA9;!zAIH+-`_exCdqjP&DSC6jrM9A|Do&K+W6nZz)xoHmBV z#xUdep04Y@?)!J&-~Xyd`yAfy*ZcK)KA*3{XSI|g(|;+WcV$jHlo{hW7Pp~QXRFVH zhQlv?Huc=xaCpzLYnu)WgHd&~O$VYgO4psecV_!p2NRQxg7jL%#7d2ASFz*z@*JL% zH*QZsLDspV+uv(iL-#gy<}J=@2vt{nz_Z>93b<^giv%4I2@=$md${!!=3u)}#Flky z{{qS%F=7JJ5mo}`1^L#Qv7oiT&Efs!F;z!-Z|%rEmv!_M@MqjSJZZb3E%rr}!zeZ9 zqYN$PGgFe4lo4L4)R^e6RB@Gb(tkpqC@_%LQ^Rp*#NBZy33#CFSO=+yoH{OZ;yvrv zI=TDC3a7WyA8s`f;Xe(?Sn!_*2#&hj1a$>6Uj#LpV0M6iQQf`v_pK(6_k)L21^>ku zv*Q#{O82)v+26j6&cB9;K<3&@Y?K3k1Q)vxvZ`y>0mZ7BBK(&TjIaW~-^jldVaSs< z_(0?CXFrYm-}fN@ zu4^~u-}T`|bL))JC2f=U%Z#GEVD^pilj^;no*Xh{E%+yg+hi9r5P^F0G74t@$Fm>jyfJzhU-zWMIg zkITqYYum(MkadF9{FNAeQu+COFG}B}8)|d}rPh1o%2?rxDogE#mnuI4*yms5=p*Yx z1=d#YVEab7zZUMyqaF0kb=;1w+m!Q(S_3&qmep)%(b(*{q}wvvd9r>sppT<1(Bz3y zTb5CZFCsFu)}Ft>7W})fTgTrJLHOH11;bBUN6YsHKU)pui4m=BiA%@}UaZb_y_Li7 zbKHqPme>EiYFojcnt<)dF%e;xN~#Jw)5bf)b>KsVa+DTEx9{?UojZ5lx%?!@sPWl+ zCO4GlyLs&%;0@QW`+egm#V6y|H`%UYBSa>`C1;nrtL6eEMCRnDD#8DHx!kSv)|;NR zGcA!*$_!bf;J8G`hQOK2FF;w?6*M(Z4wzJ&v7{hKn5`WN_QgX7bggcG_j8&Itt&I@ z?eU6!YcBlXSKUT`sd7C`g<+lfg&3&hy?uM4`=9N_vu%pDD$FP1?-0@aYyNH4_l?D& zaaqyH+J=QLKB2yENcS!!WL^1lQRu&~yOqwFCOLI8^%mMNoS&D>-}Ki?D_yd8J7g$# zc9zAmoLtD#4^@OQ4PGc+B9DIf^zhkj_0`GV*uiWxRiA+a5{fQG#cD!j#Dwq27?|W#qmHx^l6!LAhzu|M5r4H%$QRKUZ z-v?_){!RgdqdHwBD{p6xfA%e(O+8yW+O!1KuX7FUQtyJ~$q*Z}ejTiM2&18eEkTFh zCRH4O76p+OOBKWWTU(A|k2Kv!QsL3lkWQzP(|cRy(Idunva0MWXAyaQHQ zTzJk6r8D`g#f7^miEOl7Kios~`VLtMu$iGeA5X_K{impvM*}}E{`qGNF{LrjRl78% z!1iZLUr0)k-bc$(x%;3L$Em`8E`3+PM6=m&IRH0&m+|-hX6|n{g|)J7&-E=N{lMvs zz;UPCZg#yPl&s!;xKBq)B|Svuxm@;}hICo-T+K9*ioGrZZ`J(Ufr$L&uTCpg8yf*@ zb>{(fZ*lef-5C?$8r{RAZsc8k<hqHW+*xE-WO6YDHcboe1p`192WMVp4%>AQy_s@Et~PZ326=W0IpLj= z*B46>-dFQQ+(_!1>$y`H;@nx6;ia)r1Aju~zgyV>5%xv*k({dal=oER!dKdoctYtY z&DDY{o$W+?p5gtsW8G@<+hLyhvWwn^CO)&1hCAi>`DQWgemXAp`#q*{6#Y&L-erXt zY?_%YI^ZHSSDxy0!vZ6|04?m$?O&vVWqoly`*2hH^TgN*UrsSi1)}n08y$=wnBU!* zZfsxWLa?%oclZA8tuj@^PGQzDy)ukg-m7=B-^soC5^20zVdk0N`#y6tW2GPUFlL|j zz{$APT2`*RkBinpmj4q%{JhLbML<2D7Z&=&lmEj|1tVmh_SdE;t}?VF6Vr?o+VBS5 z7^62!`)EmuT`^Gq*7aNY0Reaz5#Z|iTXuPyQ1!X?xB+3u?#(tG23;L5`w5MXqZ6HZ z-8yZ-hIcsUpwNxe1w~a37`;$hDZFI09ZabJ@%)xoCbpif@V2I$j-7Tt~CmJ z^XLS3Mo7cN1#ZmnliHd0Hu!v|6qJIaxRiuM`3wX)tDgQcs~2Ugz<1<3_@R{_8lML! zw4)}~zos7SmjWeV?q}SKwv#7r1?IAI##uaH3#IohP>St#YPB>C-^Im!B00@pDEJDt z6g9I^z-M`i9i#kbi9A?L*lv`h5$ zF+mj*j$yF z%=_4#>WS-5XuFM|efV?R73ZeY12NhwT+PlM0eXBZKafQh zRi8~UBsRV1RT^;~nZn>c4djr_y^+#wlYNDRivKJ)c?Acr}h{^cSY#hrYk8QbrK|{+1>ft+s7FKKll_7P9bRcs;qH ztqtEiUIhJ0Xc?GNZBHre-+E_KJ<-Fdy9zm6A$YL89Nyon2%+S9(ng-DdGSt_`?(B< z4`7NeJy+Foe4Wm*R-(z@GbRg5^%pD-1g@T{&CRT-s0FcT8-G!MW4!IhrVe}TzA>7a zm9OFDR&jG|=l=F4bz1iH!m=sOfeSI)%esCWyR31u_R>_i&KU9{p3`*Vgh|k*>9P@oKVtnix}n};R9j^!)8vCg6E0!m z>L=edQa&}R5Rb81 z_w6yp%vDP2N3Zn?t?Vjk=s~~1RnVEx$DItadCTZ$w$4?vS1t%_WZN<-diU0N5~&x+ z*a-4O$SFRs6k7}1_ly;=8Fg7qxvTdj#zoleCb?c`%bnCz9n}XaFT@`(>~eAX7;m=I z0plG#a}A%L)U$CsRP6#r$q(sFew)L=Unsf?GaQ#z{zPgO?J1$A7!!kdb+z6vqno!V zF3wf|s(H*ebt6sO#r>HXB%y;3ZC=t1+JaXD`^C1c;n_29McI4j94&ccnYEX34i<|; z>CBj?-Z=3u_$k$RLh}{u$M3ZnPhD#on!;`b(F3s*?;o;5))gayZS+@WRO;b{7wN)Q&Tud8D`-#oK&b!7ENYN<&dThwph~-K zKss%&SmfXXQk_i%_QV3Vv~5bop)|W^cvZ(8`CM>`yqO~D6~?sp-f1Hi3h+;UssL{p z^j!qq5|xbEdf97s=*P%ME_G4jH%L5mQ)sDIEM)`s%EgQO0Ya9trT8H(lS_~kS(G|N z+k#T=R2Quie&8*lkW!XSt@_#`Fkrs0Iv+DRliR>jAy!VKgw(_S3inmJ=wr7uD=OCGMS7)wCcBqGs}|?J z(o-=NGp6MA*kF*5$zRw(ui;2n7)+ygcvN?DJ(J$a6g9U9@Q86t$#-P0@uij1#2vfU zJznaEt@Jas%5D>Lo^ED4M~g>byH-PXFmJi~$$$u;`Ja9~#|E5igR-~O+ETKSK07|! zxTw~$B$!*kB(slx&i1VjD=hWpo-!>AbMgdu%xo7Obw2eV!1&4x7eeO#3zRvwo^aTE z0mO3Im-TJYGEq1vc5vTxaX4_#VJ>zB41x-dWXp15+71_?CS7kvU|$;!^%*qjHl!_T z_)Z)^iwn`)+AIcs!nT8z+AhY3=wv%hd;@BV-(HiUz)UO3XL)MI8;a zTk4PUhHXb+wNayXIEJPlwdRZf;v-w$5TARQ04YU>W5ehpN!umqje2O!U)3wi5>EOz z7nE$@g}Q`Hf4g8vihp9sCmB<7>z^`;&z=%v`|X9jSSZscvP!v*_64(xnWw>tD+4aI z;upo_$xhh@GvIg@cIwwUFZ^nMT`w2vXU>00z8V187m<~o;%ReJkF?-wp?^bhSjQ(8~2WbKr4g^#i`mS7z6@y`QwV^37VTDrqtveSb^sW>llY2QsK<1$FaB1bAQSo7NX zEbd|G$J$57p%!9$(l)p_RirBoxpFbd)MR$76D(MzV3u$Qv=Q@Z`8*uZKV`BTAh04q ze6)73ef|D2!D_W_ViICGw!O2+a&w6Ln+hA$9ttY;C8IO|PX183o=n!MAQaX3lYU|Z zEA7lFz0{Y~m8~{iVg7K5Hcw5ICD|J zg)&9aiMm3=&W1|+_K#+Pc$nk>nsQ`R3O{FD>AZwZY<&c5r+?(6u3z(F^?R0izdwLB zB0-l=2~|J6N!z^&FNmzYd+DT0$LJXbOd>iNhd02AdS{~gt9-%qL7r$3w8>YyTuKtT z+VR%i(@DUE#rCQT?|gfwy0Y!I7GD3@wbnMC#no2J5G&t&5jJ@r)jzGRis;i{O$jh) zb*ThP4baq5(+;{)w{KF!CsN8uw>$tCfd;TaK>GfVfsGj@OZvrIyGPH!FGDJY{)$92Q!g?#T9i&E47#Fa_2u;1T1`)Se$AbpKVOExQno!_yrIzY@8#o`mUWF zHjfOSi&==|zYpDJ*d0s^tE;&3bueWcT@cVw3yh>ShR@gcWWzI)?KZAVrqwP%UJ0(g zVyvUMp-&(rV1mAEJ;J5>eu3mJ!jFbVoJ=ppMr?}>mmTp7h$IyZVzMg z2AKPD%to(RFsw(w)IsU3^lRuhQ}Cr_XTse6qVIU3+~mdl<#bGEn<-c)a(FXmDh_8i zu2}P68-3NkIn-`6TUWKV#dO*`>yDqe7X7X~V#}P|(}!$*iU4A^aI~WJmEoZu-s#>I z(=-n7x{6iCYL&&f_o4_7&7+lwm}syxcaByTNo&8`WrwEf0`mfKSMja!YCi26!*nK2 zou|SAYRc$5z_0Zy#iadBJ(6l>ixTuD*h;KZ@p=-kNp7fR81l4s?+at{CT`$U_ZrCq z2Mm&*5W=V$Mzs||+HK-YC64T8r>9%qb@?7$QEca(4R4~JKkAkI6j}64ZHh0M@$op2 zv9{9h8H=AnPG~mMXcL16Y0>(=m{5GCvU7Qn&B}%L*QRI?$ipmyT$LS4Khbvg#SLPO z#djrjJ8|}DkD0-!<~NoSFY83ro9^Uv`iQZC=GLnl1)`53UK{mg7gB8-JtsjdC|eZ4 zy=U^okRx^U453Bn&_kPjq0^Ol2HYCE>vN$!m}hkgAo>8cGR|1-rEyzp>vQT#>P^JL z;H4x4BP4@VlGo~7rc8coE7{P}lz7cCmJIFdU<7yoparE|0P+yeu6dP~sm81D!yjyy zb-&pNG?ze?D*!#lEBgD1?>>(%{k%$hYx=$hcii(s<2?ZvGag_dhNLb8AWL)TQcT*>H)vXJ=~7#zkAIIV~xhwck7&Q{H4+^S=e9ClotT?2abYhlv(&tQR?-GQfCWBb^fbm>i3O1T;!V&2q2WGe61BQ}S`LdS zH{r-9q?e5!h7C%548 z-8S~mN@Jl^Qh^xv$S#eWkk&UyIO3L7yL))%1ruLX(@+*#AV1+z>)t!cfN}fls=kE2 zKXH4&;7EkbHfc{rkB5pQGnj`Wx@2Ef>6fU1b}6(tj;X_{SNbWltBi$%WhZGfCN_o8 zh!xt%VnyrE%!N&85*`3AzI_7G#9WED&b<9%=JUm#RM`PU^~t1xL{H^s4yNFn2o(>zRs4>=-t+DH}&d{|6{V9hxAf*7~^5I#%F(0stv{~c1 zzhzh8P%gh4>fZz4`m3oj*TChDk*Rn+-tZTfyOdX}?N1C>ukAAoNp|mrhFLE-YiY@4 zpN{WB8&iQ?U8Eiv^KvYh$9ad8dSNVU1T7|ymOpiATU>hoz+4LMx_y8E$^`aJC@xzr z84O!uV087*-Akla*SS#X`F(~YzO9M~49Iu*$!ZBET59Qxi6QG~;zz&6(C%+HX|yi6 zGHSMPwJC$9_-#(cUS|5s??epX=h2^}wxHBeGkZhlCmsHr%6GPV4u$z7{ZK^nzD!y# zZQi3o)q5C@@h@jE`^^fLT`l&#z0{{88q zGLVq|g2TGg4z3Q<*ZV45YJ6U#aF6{~1OOeYd0Vi6>BC1pls@T`;WMyJ=8kG0rxvKM z6%VI^ZG2aM1nN0v8r*0;@x=O!=*g^JG(BU|QDTMjF{ys{^ziMZ`FMjr&VKJoy*Jz^ zLEj6F@>T7w+!@-%2)DSEYI07Z(g8xP5V;T(b`EZ-nb|PR91% zVw@8G8=@-K8WTCkE~#2jkKNshR<5$1Rf^4NZVgjZ#}AJz=N4?24z#@7c|2MZ0{5tJ zuG!-q15=u>yQ$y0vDb}^h$V0?9yoxLA{)*lYj2K@JZcW^;Ed+J-921==uOVS_OoAe zD9z{5L(aKNlkfL6G^+g4`$NEheKKq`*`lEEbu&~NovZ-#jCq|_z^21otiO0)k}qxj zSr;^AWg5qrk5+8wQ$zjpEps(1<$6yH+d2WnEP%N4F+l8;`Bk)sp;hl z$&v~y$&xgCfpsr7!^H#oquE(GoWt?lq(;_zS5stbpr9_FMx3P|l2dn>e<=GL2;sX~ zSvX3qsVd~D#@H#m6f9!88RR4b7J9tgAOX7 zoxpZZqKY5y>q~^3YWQ_ondZHI&M(JWj0#gP>krm~0FU*@ zH!k^7fa-z1?yCx|r_b;M*(1>_9RJmfla#q7W?Wvwx!D%f*o~Z^>A7@LhTPIa2UAj~ zzf{FkQf*$FPi)dWy|~%o{kgp-Ry#D}AD4Dn9=B*pGi%cBN$A{>rzUb2s~aSBs}@yn zwdnM5eqREqcRm;2NZ;m`*Q6a=$I-PfY;~oSL17M+K7^Ydqwqr63eKHk+#L2-wjqc4 zbtW(_Zzj@du+yrpkZf0fAx-H?gV*}_E3(c z>X=If`wVtSyfC)^V-tFkvoCnspr2N4VP%p0)EHgkQ_2)YbgIkfhJK+16lh0r_qo!n zR~HIAl1Hh_mDm%$ou5)m4X;LE-M+|sEcFj|8;}rKy`EB! zvgo?~FGA}o5_k6NUQ}fgesh(?S!q`R!fO(m5AXQMqNZsuMtNNCZcI^#(vp1bQ?^BX zDKD-H!TE5b?ZMCm)dEaV9TsYKZX(u*$8?u@O_5UU+0Za9&E29WbMmbpWA|JiD1K^S z-tCh)+!M%oG90~RN@^9{*+}>!j_u?!-s-8DF7Mm2ct`IgV! zPl4X+_Uo1Sg@E+)`lXT0ZvDMtc8~w(& zkkn?YW2vN;d7U~tqOekXl4Gpyna|~6kkQ2g!?EL?D45IRr=zfKd=x)_8s6_u3jdfr z+WXr4vCcXXmtC4#)3)$7#Ws%f@1w`Zkyd;)6%rv-ZFi30yl>jI2YHoHT()OJ>w@^PR!%$Ar` zN4#+C5N!3;j?oPyqQtTapTIm336hx8o_@1iWzH6&z0eBNTP7PWn{{boYrGwR zQwxm!#R%*au#p^WXu4~cpOSEVR&kjkc@<~G{Z{n4$PMp`s5rro-FC}knPJF1I><3g zN&3dKUb8Pvubm_<av34Ptuw{~JTa2~+p)k9o?S)<97%nZ82Q!qlAmFe;;X<}eHsp}4y$sn z(lT1P(afEO4$idbeU$!|^U8hki;~zQybK!Wy#y^#O=gUG>#+1$)x;JGr_9HC)rn@P z%}iItL&@u#Hpl>YlqIGoz>D`kE>NdXo(|Y>Z6V0HN+o@eKiV;O+$0x@nVMK0>eH>Q zq;NmZtV_UEHBX7MuhF~xa#j~VB1G6jLj?ni;sbw3hWYSfI#f59rEMS_xkY>6B>ruI z2k<>VPY+U6AP%qPt@zw6Vx>f|`W%!sZ&UYXAzzV9?iVV;RrF2^xXAlq5LvpO4PXIL zRfuaoq7+(i)t#spVKGN~jfKcO-gPYLXpX_Hc#HMrvwWbg zv_x$j?8n#f8yka$W%J{7x_Aa7?^k~vZDJ^qp{3=xu`PnJlpqy}2(0ZWI;~Hfu}Th< zy=5@svWh_LQZ&f{NbCffToCVZuOXitj=OrC(N8JoJ0Z zU7{Oz2c7Jecyn=SU=|G{mXs4JGKH54gVZiW4Avt2++2Ck`DI1?gg2k;{|-UK^AR#(%vbJC50L0vVUw@2 zS4s*QMRRvUx6wUL$)Hmw@Le^b)PAM+C@@jU#$h4pP_AJ~xeGMiH~APMD&}Z~v627b zk0rQiRsl6E_15xvni!dp1>iyvVyeC9j#ZB%73r!dz@tB5B_qf>qf-#mK;d;9;eBh* zBi~Z>mGTX!|DoJY`1(h08)>S}tgaxES&%$01vk}@%ngPHd4JAVh@-1-G^?yK@!oKB=gyjnHYX>xianpQ2y;+ z4rkTeN@7$d)z&K1?%@ZKIAiT>iRv+^mk@5CI38WM`(i+{c7)7hD(QUbBqifffirH@ zuptF+cr*h&ALYB@{@xnj&Ml9oJf#D_@3O~mCXGI7xE2^HU+NzwH{K~V1`}sJau2lI z-cE00#p=PL*<4n&sa0hgZ_Q+nZ2nf_`n6XHe{*2|fre+pAA5LYfkn5E`_eQ#bAbe7 z3O>xBdNdxLv=HJqXqf~k+-^!L2oS{sX1DD<`TTzY1sW2@fNos4uS!HyVzAtcQX#!V+T(tR>2<0mWz1h!UwvO z-%Sb6G$fN1tX#K#pASOwq+$Cbuq>SS+%jykHGm*6CT)oo*y7_jKd+5!k16_K?Eng|?fv-7S$xhL&!v9fg;$C7Gd+8(m8f7WkXJrGTY_-C zAuxb&ynk^!-HOdQc3er4{|NU$;NHO3pTIm4KBmg#mn^QSbe0i=fM#IHt_UUrLqf5J zb+L0b?7fbM1qh(aCi0MGHk49+p`x*4Y$^;M{)FI0{dteOO**jYR!vb+r7eU}J2IvY z2_wF7j#FJ8CEWd@C}USRu-93rI(xJxlvl}}0Wz-LcNB!7C&skrcGD01n6l);TXf+? zCTRT4qTJ2IJz!P8aKHeMST}!B1fexorJbS89C2J3P}|!`KU*3d!(AR%v&48)P^_>3 z>W3XA?lfdt>7Aq*y%j2qSfOl0=K!DtY?>cU|HEglSpi6RI*0}(Wslt+z4q>!tS4=c z#6as@xpcUL2pgoBh4uyVx(B*NXLXC71{sol`@Ft-2Lk=Z8=l!rNmYl1UUU=Rqtjbb z*QzetypKLs)r70>PX=X#4)qM9pbYma&}!s}MMF?U54TVP&rBPfXX_wqT6xZl@T&xW zp!)+*DoEH|Q>z?-rOarLm8TcacIVA0)2J@<2iFAL|I<>4ry~qe&kDWhgF+ zR_UcDFvj@fNH38fPNUaoU#y;)Zr&d+CMpO;63Cv&IlPSUexfPm? z&D`GKRvtPDW}&8}8Izv|vUCrU+Ca)sP?sM*|34S;n|L>!!xa4BVH^*VHPGQ*&9~Oe z-0BCMZWMsiU9G|^r&NYLr<+tRj2NzO8{Tn6#fV%y6_XcuH=P{DNUR1c z&on&qYwNNiZ$lAhzoEklpk~gD@h~T)nU<#hD(;^Fo4~Y~qsJqVKTB-wF1)N;OMv|6 zVLnqONzYT05Pf!K%X;9!vIj&eaG3QzBaCvIt8uL z5wmwlt_h_<(nNxabi0RPSQa=)AsbiesZPmL!9Y)-7VlDZVwTQAoMIV0k0kmwADSOD zB@XAm*Ty5sqRgD{7V-z$%VTRpU2RwNXgG2=y%mkJE3JyU6=Fd%BM+;`b0)%EQ=S^H z5)rGPN{WmWTDQP6XRgqMCilMwC5sx{=Jxj#7AbA` z;jTDbfB*A@Y5&?j6QeIs2ZY+V|I3t4?_YokWEJ#qXUskCy@A#^CF3CY@|!e6Ko`~u zeE;^~{7u&$+U`Pifl^R$V&)=ppY7PkKP~>V5J96r#Y?U#$_%)wl&a8n^~eOYFjyj(|L6=ZY>V)8@=8Ax!4W*$ zkz2W?M+97Y>$n(yL<^{=PmZ7 z>g}k^hSD;xhyrG{R?_`<)Xk|CV~?;&SSEHCR&T$MkdX5Ua7K{XXbG|IDHsBoqYp~- z0du#*tfH6WX5N0EC(+955|2wB`GF+X=#cPe(=;8YBp`z{qAnLUb3A&H(|i> zY(I*AlH!*CLq^*jOVyh2h>+Q&=!c)~V+NmxM*G6{716iA%660AAz5u8q&VCbaOr?n z8&&>{#~T)T28kYvpPBcZt5v0d;9D{O>WILA>XM!(Qbg!CVED)6M_n*V(c1e05U8YQ z%a5b^JMA}=_{{|^WvL~zrJ+qr5H7!y{WfDXPolJ~3AzwBwBlU@kO z?F`j|cwTbU2R(&QJsN@4a4z^Nbz=3>AMvRprLom%-rCc<12NopBX(s0&Q5nII=cqm z*ag?vU()NSY73T`1b}HhsRSgL6Yrht4awv-0C_5Qk;;p$xlV!PL-LZ^WPP^>7@srN)1O{~Q%WpT9>W^K6p)Otnf)XTe#~n$!Zk!!1EX*(IjWJpD z?m!!E0-uz{Lu$(?7IzBB0=T}0Q28ZHfQ3JsJ>34u`t-}OQ}JW3x0#5{q?_htJENtz z*5-1+ufIAwQtnjZ&xK?^S_ECsH$Qb7)VEC*7O>DHoiR8vBhy|r-i6lMtvKdhfZsiP zx8rH7xdf+0kMp#<;xJV9IBYKQ;i7Z4FV3%&9_l;Rmc?NKwAlYA9ctq%i|L@U(eBgp18JrJB zpfq6@Do4f)MnCf3;N`xk;j|jve(-5+ZNtrLKLVupKTkQRNp7spRB)kQ_i~p5-fjqJ zY`a2K9Po^rUy)e;oAaUNZxRH+Cg>ys3=wkEckn_0B1$uC6jBwa_5>uV_^k*oKNKpsA6BnuiV52y87^HYd? z#__uuW$lmxO3N=bIF#$&ct4mcrqm(b6^Uoc2gq%d%SkvqeX69{)oGy$vuY$Ke|*HK zZS4wwtq?z!gM0IzL~Yx2zjg7~?e5Itri8SlCL#HU&(0-e^eY7XxS2CrYApNyDdO2> zRl}zL>-Xy0rIYAmA=8c2g87D5ogE3!md}WaidI%W+6*Z3by7$t-I6%2$Az_BX$lytxI28WP#5) z8L4fwadeG)dbC3wJw^*N5D&?xR;RF65l{CS`qzDSjFJ2ITbd|7Fop$a8(x@4$0W#0 zi62Y`H$4K6Cx;ORSxqd@U4I#lAbe}gZX=5Xgj)Qyj$sCw$mM-6K;*}LMvY_?md7}XmcEc zEk(zIZ?%UbX^mm+)(Bh$pl zzdLgNS~$QMfl*0p{pThTLy%W8flAmO(hclIzn(X+e00={?-t)!XPqpEJa_ z#D5(Lz@@<@_!rQ1J6MpS;)@-#msvt|-}rVIkW8ueHQ{w%H%VdyjIL#{rk&}xQBxw4X> zL6v_N{i#6qdjcpI(iNP`zDJqVymkJGPiKtXb1vo0IWoT!hTj7dcHSifB!7{QiVRO* z{6-oYSy=Rz56Q$&>Wrx)Nm)b*FXbfa7lVq`T5b}*X52aYf@mdSm-N!rZo&@q2d3)3 zi1TkRwzj{(Y;)z?QL;uBaapxncS?uxAo)V@(<8z8ov)+32Qs;F2ildYjA5@O`nPrX z%gML94JG87^D4|_Y8(k#hGu?1tTPIdnp9r_)sK5vx08lar|wH3q~7MGs~Tget^?yE+4o{@Gx?y;D8dPT+qCs5j?G!Efls;EV2RW zi8JX(gpPvpGfNYA7HOeCYie_)A3Uqsjj-tN;zZi%KLvT>?1rc`cCYeva_UB1BouqQ z)?aY3@ri(dvMMC_v=JriG7$Fi!8itfTOmY=2dY>3`P6@o{93qLiwba{)Yutq2JkeN zbuK=_ZPTS`Pp|u%9gaiX!BOU$L2FZBC%tZs$UPPRQPfAKU@Oer>-+TBEp6|AT_;Ca=QOIIOT(c z6-L;ybPI1}q_;_ab}_P34FYT7dQ>4)}a@8x}d#Q5#S*Pst@N8lWrTs))jZLpwSe$VU=Mz`>z1<|KA!E zmu-wN4E(vbp|N%59KW6si3B|tL}Pon_+Ia%mYTTad}A1{tXpyM8mR|1a^F3s1Eq4r zUVzZ{%pW^dw{jhN7IXq3u`2VKMyqHt@mZ5P$Falm@!f|Yd{L4PlGl#)i)}ToXF=7Sh6MeoU1mCd4)g)Je~C0m$VhYjDFn~%9#A1= zCd~EEE`dym3OAQF>MqK*O`mtH@8Af5C;gk_tgYH_46+s^*a0f-YA!@ISsOo}Jv6Yb z2-Q^$p`|X$M)TnE(6C0(9GC|^k zf=-Y;+Rihe+)ZUz*_HS|-^f)^<6|eBaJ5U#v=J_uP6^7s|P`=U8~jGIwfk zvijg+VMKpr`VA+HxQxz)H_Z+XHwC6}6$z`%FxRj%LC3o$DzwJ#xDve3RD_xuNZ4%z z%C){^D1{NAORNd4I-cT}Ty0D~w{Qw|zC7j<@JaRItp9bWRua^s3joR&DxW4S9BNv6 z(lCxC&&wD>!5f@lp8RunVEz4ad9AVc{-ua4VG6ekR0C3OO!w0w*YhtYK)5f;9-8e| zn2QOSUa1&YZI6!59Ige1cEXF&IiIRYgsLTas3g8kpBc5n6kxiOr&_1Fy!skwB}-c$ zhEkWr(`|+Xcp%WuT)c1i^%!DsZ$1Q9L|R;7_)*18`1|=^heE&r=zNKppKwtJiYtL%oWm~woj8gH&J)`Crzk$I15`vDU z)ee8zcr$K%fT{r%2QylT@X%g*WYZVlh4swp4CN7MWI#MOlUhjk5SlZDW`XfeN zyV5Q_ohHVKY@I=l%@zg7wa=aUgH{m~$1?u7h&Re}n)k~ZX05t!41%VkUp!~R!v5!5 zY!QO*ZB4yNCT~-IYLWC z!e0DBY!#RF0!7(0DglA;5@ok}mmhQdLP6|b|G#|2HoDx=_9SU;oI^L5PZpIf{K7KA z%0vltac!e3HuF?)tgHNzf+k(X&sRzzFT5kB2Pa%;{;PEioXo5}g&0+0jMnlicXzV& z#-FH=6oK0ML743gN)WP^fYP?F{Rj?9O0|K%9L%Ys@F5c~s+3RpRk!K-S{Pt+ZSl)|=T&Q>=U zRSK8f_+;glChuJlR$CX$xlZ2wVJm0<2aVQPJ#l9ov^V%G6UnF^E1otyKU?K9*y$PY zV8BOL6bz12$|w7CrCf~2 zJf%jR%a^|aWs(m*IgOxF$RLN24Wg2$t^RLXT}Ee? z@>$O>6NA;r8b6ALz%%+M-{jYO@1H{)E2hFI!>G6eN?hrq`GwLqq zzLfJfJ%y1y>7RclfGP#LY)fKtq2Ej|VQO5(2dq&=T?1YN`Dk|p&&QnKguthie@P6# zX$@Ez(}`@-uKXgB?Op)`CEAlRhT-?SMOM)=ae?zSn`E^~IAg!X&i%vvORuj}20@GV zPm4DA84o`i6KPUXHm>`*{QLtg!bn?#7)X$3)|(0wpUtEft(w2 z!O^lEqCVO!x9DyDRUKm}xb_r+)z<162?s%4SZm2jo@TmbxFaM@49qRQ*`(zdvYh)_ zu?BpvMlwJE)Vcy{5C9!c#HCYw^O-l>eg-D-2DSY zw9=O3%qT^IG5NL@4X$`^$CYoEW*FusqiOgBCk%J*C*^kW%*r3IunnuJj>-{P$KI&4~sDMSkhOo1v&L`+8uDOFS^yAoA0bod}RLZ%FS z;UKHlC+(o~9xV@#GtCiLfu5>Z{_qK%z(tSe^=UOhiS8);l)<(D0mOJ;W-@eHCluN4 z_EwoJyuW#P^5h-9o&BbdYr@Z8bkcrT1jJbpyi90qw%_Ll`cW}u#)YiX*H^hrOAKRP zmm}@9TuZ~b@#1CxENFi8h*oo{#?Y`0Jsrg=@6Hsq47eTHhNuRRyI?!vditP@_@UqA zQzSS7yF8ZK_fbDmbZ(f2OR6%KWiI%f@S`yzr{q=DkJB4{1Cw|tD!1=#t+-~yVC4nz zCanXnho7EkeK>BZJRC$f-2I=A3~4-+XA}~2+47lKM{!&f(QiE%KWok$gpO=-m@?f5GNmxRwYz#(r0c#wePT9yMs z{E4q;R>)}ggA?NF26sowFr4f%Avh>uGk-(~T!yzG6=$!Krw=t64ZgjdMqYvYZ2v3z zuCcU@0)&T8!crA~Djaa{s5h0NhJn}la&0Ekw4MNAE`o3bui8c*-e+qEzWh=Vdv5UQ zk@g3pv{S<)$nbz-4mI7FJi&sP*4Mnl6I#SpO2DX_n`F0cGW)tfreuGLaa%(Z-X@Qt zV?7o!r>o6JtNi^>(T>30D)3{SYO!Wlio+Wu(uC+IKM6&@E0f+Tz9eIDxUSQzc&3Lc$BC*&=)rCHwp>A4ERakjap0U zCG%W1aBq<#^IC7t(T!PONeX$P5_A-ga2bp?b_YH0^(ZpH1oN_bQol|@B8{nK!}p?Y z&C9o!%h#}I@J&kE(+RfeA>1#^*UU+fgW;k>U|3Ey0ug<0Oa8Nk>-x|O8`$d zjHYte3&GF+upVlK^)s7oy9VzLf9k7WS%SML^+xMP!M(umXHi7Q?2k`pvz4wcjQd?+ZZxD0|K5K4eH@wcJY;KNqp z9!Sa`*ArjckSH)-10mOVi!>ceI<>sLV0rVQ;rOh$UyAK5Wz!m`x1ejUd8dOU_l|^b zIpgBI7&J`wWc^!njzYVd1y$KD7{#*M5DtpU))b%@kBsg8x$_`jJPg_?oxTN%Hrim6 z;LftBuEH~J-clR@EzJzoa*wtHPjP*ZlM*9Z&*QRrUG3bHIbB~7J^r@varT-P1mw~K zoS{yA$MQCp9jZ}nr!7qy4<0>o{yl#NT*h&^^7ga-`OC~W2(gQn{V z2mJWK>kH)HB_@De`3hBoU@jxRo^Z1zPD7GzrLGT9mDl&`8g)FLIKvKxx?@#-M2E#$ zURi5O(5SGBPx)M_ORm8o{*ATqgBreVDy%`H?U0oRu3st2%EA&mTw@J{=cI%1HlrZj z#)VCHkTT7#HLgG(<_izo=n^iNHs?w!^9xzbl&=g}cx%K8-9PSpI=*kN^!l)eRN&K; zBx7=8IIZnXo2#3{U<%)<1oM`;)1RIqtXe=!iHc)JU!i5M+1=KM-wlIipUN%I9X~_! zCbt!LrGIh@&DL+@2IA>wqG=Y2!aACMWr`s0ybKxqV%SK zASIw8AkqX9Iz$9TL_xarNEL-32!xIWrB{`Jp{R62ks2Wc?p(p`yZ8RS?~ZZKz2p3K z#@J(nVP&m3pZWCiTl$~BW$&lw89iCBli;50q6nuDQpdP9^qWA}em62? zHTKX{b%?6R`Y{5H$^`=H%I=M^bY%<8;1|+iB2q%_N$Q|kda`=#beWgF0mk@A^{0ih zjSy;;dnXP3JB7x3)H^ecP$@(3YFd27EiEVzZniYN(%LS^C+#}buzIv|q+Z ziaQ^Pu6|8lV^vVw?ta=TaqcDydeS6v?@FG_JDY8ukd>n5%MH70vJN61Tq*s6QOIFfuLWjVca4s-wwc~9BKz4sq7HrSB?2IMvkBF`JdA&3%> zeS`(nzC>a}uHQuvPJy-s{h_dM3|&owaXO7{FEKZJh@ik^;SRvqaSq1KYFNJCONVfl z{*+-AIi`s|nYV%?P`?b$a7F`!FZ`^E&)}+msb{zIZ+kq%V&az)WG~M>$C2dk^z_#2)W^OeZ(pdb&*=rAS ztW@G}E|Ec{ z%y;Y~HQ)kcTp($qwO!Fu(edku{==+b0yZXMb_+FLew)Cgh1ve(lnXgwW$G5@t^+dx zFn-pLlf`RRd4F7uKZ%FjO@@`9hhojL4rwryX8bA02?9}rOx+bQQUAs0D)tmyaOs{V z2JiCORLj_sAMlyTu^yDtlni27DTyZ(YZKkz+>wJN&lsi?H>Vcz z^9kpR&nXZZ579V<2XmbNs(YQshyo8J*tg==@GeLbQmczxl%mUDe|6jn3CUI1+^LxS zc7k^*SWrdMO9rHL58C{f3cRO~^Z{r+e{2!!U?x&k=bRa5PBdKTYKcgLUe|IdjGF|EWO+vPR6 zWVq8E(c8UmYAPZm3F6(q$`pSuEP#{S(T)HA{4t<)S>apkP-Ck2$%0qoKO(>GJ)=5y zl6UN+?TN9O`suUC{OcmB02A*|gB~z0f0cotO~}N=6TBQcb2qOYxWVRncQp3s{^Vml9b^U$};Q>HJluP~!)k#TDwb5-~stK9{&#ey!WDMur_ z0!FF}1r3xUO{@7;?;vrid~%%n)f%Q@;hGUw^;)OEoWUwe$Yab)M?)sLBN zg)f!cI`#^%OYHTvZTTV|?ce95oY)Z&1Bgj&BLuih&{!TlOzfVzb~+VM58zixl&N>? zqlK-V)M91hm-Y)9*L|fqs3BqCBNIGX(|ETNEogkYZwDoYii)^%3kDO6P#1*o1Cr2! zi6BZHenO1Xw|1OrA46`rfq82M{{$O;uKbDGd2i9mNl+{TI2YhNA+CVFcEuE9jHr>p zD4^?I9+V&5ZV~AAzWzau0ktFZ0J?}zxh{1kPR~VjN?(I}DIMTucx_KI;p>fbAGHIy z09PCg7_25?_VJ#4BL~6Etw-To0QR`Bl4$ZF9c6%wb`iiINH;R`$*8SzA9sVV#$to- zwE;}Iha_urb2(96NXbG4JAgw%$Uv**cHmeYHqdJ(A1FYBP^TF_-Y4%m_`bEMNikUZE$i5uI*(K^W_%l0NcV6Bk#c&y2t0`yV8k!3 z+eJ1Rv=uEsehR^-f!C*J9`vfv7!5wygD`3g7>d6~ z_de7A1j_i+W2h~u5{!uW^BJVWyQj$fV6^Xt+}sVzkcpb&^z(oCh-13#Qy%&ERP%G= zUE}~fo3rdztihKjLZ>UwJ`4M;-fNU%4)InC_vNRozx9m@f854ek9SMGIPgJ5@+3&m z1KEg#a&rbgKGB~5YDU=>Xn<2FOcVp}RxP*L&LYP!R2i9gJ;%{w*@I+GMzaxQz}Ig!JpCKf>F$C5O?=0kJpYvnt`Cb&s$CMji>4+Y zA2KdJN%IvJl`k=iej1#-g>~6(wJ>xm!HI6(p9sz|rTHV*3`jMAX!U22AS?SS{^NwL zS$>mN@&lTj!T8dcO;vMM&&wP4HFe1@HfgB0F9N5 z=p=d!l(jD`$=KFCu8uN?s^8xPV(NRVP!|4&v!;DJB=XnGSro@t9#mK>H&rXuP#j*! z>GGdkuxT)gXSPiS$P;!WC0}|@`H;IPb<$}`nF6Cqwy(U0BsP5KRK|U`^E+*4tr|cB z)T3pyT2JzAdq_^tZ6rH5cH3PY6rEF5|I5Dc_k^)dU&|4stgHs9CjM6H5Q9@uTX*Kg z;jw-HEr4Uo4VI_md*^P;d*I)(c8^H(UAkZwRW0JPd*k-#hqmpyt-HiVqTHO=J1xVc zr$VA!pNHq|yRGZ29teT=LC?;e%m*0}uUa5dyzTwZ@Lz6(Z4HxD9xXeC%t2VN0Ae!= z%v`xeb(w&%4FdSR zP`JD17wZfFpZgj4=O~~dMF7(XYWC3fA`>dr8+iha;XZsfEafj~lFYPSyhsv606E$6 zvB2E_`EqEj#Z3!J{@w1%NypIn@r>>t{%%a*^)+YpiAXoM5CK7x2ZBAuSy8v@xB5j< z4*#F<8fBuUPo|R2@jd6?5e*&~<_QgA$f`E0@y(%tKlUET_71Qgw9yQmSh@``YJS8! zJTRxbBxhe?(WUQJJZ{*8%rn6aqjNXtDy!XU^}bu<#+vM2knAF2aI2Q*k+rAVwp0YMGf%dgOv>tiRr?J8d5 zB-jt>$@;{tXe+OPaI?pP5AT63(+fkeI#VA&8C|Jx^O7b_zCdHb`wsSu`59 z?<_3+O~>g4C9B!g7{W_`r-A0_@{vi|deqUSEBN^}nVy0U8&;pe;60CglqWyUutkC< z$KJ6uvqdB%SPYBC4Ds-=#O=9NoE z&kUj3$QvdXF5IY(p7iED;d&e$t`TY!D$NFgPXGQP)+4|+4dZ8<2LGpn3->c2A4QAt zDmf>)oZL)yl-Zas$@9xiD*p2Im*wdxDRvwUqTnuzg<$)m*q%wTjo7h#-7|C>@)_h` z?nISa*ITl3KDMNFqqn|aIzSnLYamJ}?rQU6GI0GWn&AFAhXYdNS0%Mv3&W?+YUA*7 z875orfwrQ|H2Jj2@M>q|S25`(gbBdajesq|M(9I~COtwB%rw4!N zyTP>LXk0H~+_-eMM|pLL>@_-8VkLwPW1o@1ZJu8WvnjACNp>GGjvCcTOh~Z$7(|Gn z)CC)RO7`SbXI3iKAlea;FV*pdQ|Mr))1z$TaFt*&%Dv6$U&CRKFf8xW1#JuJIFo4* z+V7^u0|c$+?})r0qK^93AKN@#5;FZOW#fy}l;1B}Hbx{P@+mYj^!tZKXmpiSkLjW1 z>r-*p2CFw~axoBfgP^v&M&Ub4v6lw3m9Fs8V7yCtt)etGD<;Hf$7m}NerOtZzg3aD9ZeR z@mXw1gnzvUj|i`GNAu0HGH+qC&@G&{UF0uPKk;2!JEG=Va7J*0QI?8_ioBEmmh?pR z1&k?%i4OR{7oZ<`?9eagzJt<5UyB9*+_mQ#Fo<24!H3sM)#YbyxMu7by4Zpp`dSrZ zWbHnZ=_jc8JZsUN=+n?VLjI}$-5gV5*(m#cVvYAiAF0Z2Bn1813}~q>LM?ABeU|Gt zuKgt+8%fj4=LW=EP@h5!3tDhFB^BXfJ*X9QF4nK0L_S$5q%9r3?m9OZs5AeoJw7^X zb(6?vnhBOiS91XXwK70IOsT)X!)Y>Tcy2@gqy=o^e2uOqRRx`5e?)48Jmg6ZFZAZY z5fkQnf33W5Z)m8T{sgq(xI9idj@QGkI_UVXd?&J$} z8%liWLyHR;L@>|h`}L(~#a6Xd*|iLh)IcS^kS}K+=EB~mO~$5cnrv_nQ|dFNo>Le5R6XJXhL*a-b7DQ>Jp!d>7#H#9LS=Ifo0HfGo{v@C z>9|jer{jz=9^G7DsHpar`l+V!tFDTDIt@1$?N>dz%J5;xU16?f3a@Ci zjUV1Tk`c(vc=#L3Q}qJ%v1;YJA_?g5$45m=FioZIDGKP*)@D?G1~dht z27|5!yD$YzP^fNU<(k$G5*;hUmDf6tHM`XwV%DmdowBX$3S8y2n?}95?XjkPjX$+qMuRJYN(=#0biClx+N>JWZH*?`ciow&SPClV zUl_2zZ9iz08*@}XL+uQ8o%xqA#@1z>7rg-F>&2}D8aLBjMo^K}?kV#Kyt)|h2Oypm z18--g(ihSoj}L=BbDhfMr>4FZ<|Ev0L1z|o$D9mORn@*v+`L+h=fm-lw?yaO7pvo) z2ZdIfm=;FrdwC*R((2#aMK2+VL zJBKu4%uj8NPuYBID*H5ZIk694YC{P|N%%5Ae0rvuCb)GYI698Fa@S>tw(Fahnrj>e zKTupR-ldMu(E;w|fl)b_Dr1p(G?b>+kI@dwzpgVi&#|+!FV)t1Bz;||bmv?w^A+K~ zbv$t3nJy5qMlk+$KuTUkNIwKcrfq0mJ#xlMP^xF*gP8mBBACx-*znHpvYGbVa_RSY z^vjn|-oH||b2A}@>H1g_VwWi{78)TcYO@e&igt8tLn|rrTsl4SELq zW%Lc#sRoH9_hDN~=KXp|RrHxR1l)yshV_D)RZSf$?awzxKQ0m9J}?K*Krf|uZ+h;f zQhu05<)q^0i(XQz(FgN3g~C6b(U((j!YPDo9Q+@-`l-6i@#Aq_D)frHB{^l?`8Ety z3z_v1+@!#*)tJ1h-1FJuj%kf6AwA?2x|+$nB)8nUx*=54CYth106#+zxkICZj~8v+ zCYm;oAw#q)rztGs%8AWp@q_Mg!qxn6z>?2KUoE_>LgZ|Q88;Sxo*|gZAWHLN zNcGzzeQE=dyMg^+L^_+$1sK^r%^5!`?-a(Cm|zM!sPR1GZQQ9NL`3ovV$#s2VCA#F z+eRw$P(}0%z?(Hq=}yHRzkbXhL-L%KQG#*f4~`2q5vhzSX0e;!)7-{~?&_Z{3B-O( zChDNPKLtGVQX5$IEBK5Dt?xXyMtN#U?R;Bn3HH@b^~P@jZ7LU7IyPmL^-AHZDqL`c zd$o9GkVH@1y&G*gSRF0iFH2J0H0g=ozsZE`EJg>|^m~j5ylLC&cdJ#HC$}aeTsGGi z8|T+u7J?=>*7qh}$)bhCBVbIb=W~LlHIJr+Nt=V7x`s}L_uE?LlX&ru93BoX5>)4e z>Xq!j@?l7%ue2Fvhtz9M?W4W2wgg{2e0b$GKUFEwgNq)Os+Z`N-}2#Fp99rRY|Q-O z(s`cJA_vmf1Bb4Es+#G`!(VxELT(}FV~%m=Nwsk=0FQfoxWZto?49qaYqMr9>jA+? zL{haYCFSXP6Aba$yr`{Tvu~r)f}8Z9V&-kKlx;)*$;8@VyWkMGAi`L$XXaC<%XY@!93ORY1Od>U!#VCoZZP z&^KV825p9335fP5A2L`?!lv{1J5X)du!4b9M#vNcHejF&pzWDq?+m+-XymfVnV1+4 z;HIY_6Ar+H`HB+Q$0V=6uR5h`^xKIjXIvrF(XIVz_0G-C7M$(==w%r-tCL(TQkB&0 z4zevoqtSN4h=%Hr7wY+XX}n{#w0s;5;*Nna-w&&CqMVzrfiJJL?zO+chdrtm%h$M&f@{oCHlS=asXC&8jY z?F^{id|OOlpO=X2Nn;b^OAka}J@2h^pA0>#^3`YS+G>IEr&yKA&=XiR!|SK9#mT@P zLi+kXXtgWb$fVDFr8lsKAh-414{k9CzNeQUJg$)kKL;2HNb@iH5YA7(`6eSDgR7gURT!dB<8WF%Sg5toKH zBE=Y;EHV8?ydNwNw%NcE5bT?ARBga(duu@Q6l(JAeIw-k@aR;kvC|*on7;Y#%Wmy& z6*t=j+Mvx^Rplcp1Fkr0uCtb1@O4-PWj%OH52`@8{Xuk7`9R`cV`u z2NfTEC-uiOM1z-D=vx%wG6?{AzWAVJ6h((=9>7!0xwg@{j5l=c!<0CGkGx*^{P}lU zdPx3DE=_$rMWgj7v-*jVfDS{Xf_PylF*Yh$@s`ZrB&ry^Tp`-U;K#1Y8;0p}-JUR+ ztJ_!qEEN-dSE45kj-#%dab=_I$||q+qHJ)hkGPmb-eKYJaKi>)#8cuvZ=SNpy;D>@ zB+S9I()U(k@KoMqY3|}HVWMo)hI#7UZCw>ugfm}I;MAB$c0gTp+&WQ6hRnPDmaBMO zo#wHhM9+E%kYeD;BY^RKs-Em8*>k#V`T9x+Dw@W1wf|j0Soq_Ymai!H?T4Qd4d*2d};MyDsL1n%z6wI>w0H zrX6$a;*oI5+1ZnfxOgJ)!FqjR^XquE=xKOlKvr{RAc0g>V7IP*)aOiav~ZT9sAzn* zo|dCePGi5lzkjujUhOpm?dgdjP2e?+#|*~U(#itU4IIA2k51kC*ogSpyEOH0nEtiE zyEKULja9vrS}zOrBR&cw%ncM|T(APzw4@Ud_w}m2gKZgX*vT?9S zs7?EPy}vn@#_QE=m$}dHQL4)7y%j$Ahnh`w1?$QM&&^qvnQz9JKJt^8UWeU*A(O!- zT?O|L*licQ@6VX4G3RC;GH=vYIum5S<9(+xDQwdyGt4ptMvYtP;OZb!;XUsj5^Io- zi;SHPfBZ~4(>T3RS9t`*Ky?$(jL;0bolnUaiJ5Gv;fu=6#XNY#OBWC?(IY7-nYXUL zKxTfcvSiTyQWV_)T&xDN+QzJP|0x4{W*#OnwRaQAxKWvmfPLlIV&3`no29>ADDm#A z?XVl(x_!xfL#^>j`B3zlB0(|D>kVoU{=mwvKqn~=pO=O6%Ofs;j^}`c5#s`$FLvqV3R!g0 zdb-nL(TWY%-`Bh(MIM8HH@DI9q?fb=?>({fr_{@g&zalNOYx~~WqS|A(`Y32$x8L0 zuGengU$z?x$ddiO$+NH=T<8EyI3i~Eaqpg=ix<@_q-KN;{Gd2L1L=mZ=C>d7obm9@s;@hay>4d5Mv!6C1gnJ-~HhRWLEGdVRS9`=$whyD|2-!G)eSfIxM z{ir=g0xo^2V^Qx4?}=0HAq&71u7`L$vW?pd{n_Idr;JnE0Kdq_iU>hOL^ez?_OU|8 zVAM=MoFAi1!b&1D0Of#@dsL!6^;&^0AXrmh$S!nTD*oszUu)blyC+;Kz}eaIXV_L2 zYGrs~tJ&_A-rOy^x!Lvh@oPUE?2zZzGJ_)=2A}Wm!+P6QPsGH)=NqpscVW~5U%xEf zMfV~pdP}GSWP}B}aW^KaN{nLW!K)nd8pt6d4FzNC8%|3P*AK8yTeXb%dw4eoH{KZ4 z?~}Ge4H;`QKgv=T#ptm3cxk+(84j|k>iwyw?fki19e;rAUJ|QiOv7M-AqL`?-pTNg zJ|1B{Xe=DHt>a!_cU#e+G4np_k5_$%8CPE5PZjkp#j;Ird>L9L74VP(OAG@oUtX`^ zhI`ohhI`8 zZOT}?mP5HLDzAaJ&<#}`+o-!#8^+<3>cB;6k4$JcF*7mh)zLYmcU3nfT_Nq5em#2Z z>DCr%__b`eI1pSg*Sg3)%~IJC=dsYu?0?0ECDkLvgnMc2RwP?{N!o-d1|JP3VScq$ zscp`Q6Kg)@d%#K6ih7i%oOyM1b>@u7RS*IMk?=E}l$mF@Mk_k&@oL73z9(l-y(;rA zKKLcX1|*9$MtnP(Qsj+!pC}i6psU$#-2-p4DpBF>T2YMRX=gUSPv!)0j})yFqr1(7@rwon=z-E90ER1DLlfM-rqV#3X4{%1au=yD ze6q*bU#};$0_U&78Y8N_eJ2QQ$4GEW8s%><00{>u*C1a=gK#(CtF+(NJ)+``Xc06l zNx^DbgBUa}=-YFf_O@kPnfmh&^u+u3I(PIoUZhxmZnUHjJ~w_7;aa1s4-ex(fDv(l zp$hi1P(CsDD&vX=?=6`weWn)*>HzCaCm1GY`EyIT_u=&il!w9%c;0M@p5#<&#~?EAnh`R5?B z*hHM5>u{-P99lYr2augUD%_$a=kL(e`!KF-k+PnxRYuI6rji7bG?nm&v&xn)j|p5a z)M9Ynu&k1MW5o&m0cdEk9z|$Z?Q}JL&L4tJ?+3Q8bQGZ*(Dn2WS14pqzTiTBnf<{r zZKaWFTIIVP@?^|M%B6Ec%BJSn_fe-G9e1Ofe;^m%OjiuNLxicM(olHpH~i`=QNlUh z!Q~e0bAR;$py@|uYCC&dtRB|_Rb+r8dM&TV@Tv(-d%f~jZa8$H{1$#@h^tJUq}Hmg zJ!di1#8AJdZo7P8za=J3)P8y|E8O&46-_Y5NkHec>e}6K^C$hBp~=_-{|j*mPZQXMi;CRYqTCR=^o4H1*G_&d>XQtxm&Vn^!rn?7tpe z*n>_#@ey?dNIP6*^Zlpx{uRQSZ1vm>mcr!Blj16E+^!XDBRuojqiU=)Dv3_!2;`!V zf|pVn6?%eK3x2Gbz0qM^*H^k(PahpF)zc~dF3W%Q9i}})&Lwt16%nwX*)MYywAUHX z07Uw-bGZvHFVIM&az9!Ry-gGB`}Qnxd6J{v*pLZHIX)mNKHbU!AFgAxz<8_%1_!QD zLTNI@hb&bg8@!`6XIk1?lQZazT0d!8iyz+Wd?%&S@5QP-7EJ_H5D0HF3dL4WPz(lD zbDAXD&+)X(?x;*rV#x6%!$U&Gx!IP-+F7#LJ8LbUs+S-f*Pl6HY?X$#wyk_PIXE;g zVYH9(EMh-CAag0W+EgTQ9=H0jXPMM&yZC5JN=oVl*mLHspQwU`oB#@?{YM2CE&|x( zgjd0WcQ|k$>9YGcX`Q1uC`J)E?ia>6Z6%EFCqK_p#L?8lBP-SMmV0aiNqh|iq&cm1 z6%B%q{Hc9GDXCc0_BeR)`bN;q@!bKPegJV#rbjl}db94!D9eEAtIZ$Bx7#_mia|mY z200>Q`Rei$lk9DaACs>WM-G)C--V=HRb%N~*I z(eHHoN4?xb4p^UsA~2nOoYO}yncJ|hsrxTzd)Lvo?)Y>zLXI53Ij+zyIkYw4?LjZa z9uZLB$5K#3w{b|OPLiHQ^Z_AZQr7S#z@53g4NMEpy>WXH|8^_n8zZtly4pVww-BKP zAaa%bK2;pvZ;p8Z=}~``#nvujB$2_5T^7Lk6o%*ZEzo{tOXu?efe#F zp5=5K<0c@6;Ujej1=P$Xl}NKJehpS%2<;)jXcsz%Vz-t_ok2Jv!`6*o##8k`O-JT})@W4n!e}O% zV!s!Km_J~juV*jTV;A)5QO+&weBg6k#ViTXV4KOhv?9q@^1yG;ilWdSrcKNvl9Tz$=ti4_JDENxcUrH=z>B9NKe;bPm+u23&1 zIQ3oqK(1tbu!xK%RKMb{op2RVRfkFRVuN#`Co6*CX@H z(Z==pwIw#ZN|$ctsN>`J%o0`uhI@MiKiO8S0@rWWETAf*ytZeC zf%3s;4>ZhS8`Y#xs3SCwjC`dp+L|h-($w3>>}o&<&Si8L;ccE@#1K|getZeMzxiX0 zp5}p9a15$ui?C^=0mP{u@);j1$bdl&g#}4%m&O zn&R@8dX)y^J}MVfmkBg-0UDvaGT?1E<69uTFCui>`sOQjc>wD(ym*-Bq?`52>!?rd zQTP0%=HE3N>A0~?Pfp+DLGe9WppW)axylbRcnz~ZGZscg4YNEb1mG|7OkfMY(AD^+ zn7pGIPEWOqqO3eT9{HXI;dJ7q%&wonSFtoLB}ak!;1WWOc2bO55+nG|y^>~OmBgzX zt6mnD{9w`Yz|$cvoi&YmY@Sw_tcF{i7PqJK_V0q-4|hL3I~U=u4`q2kpAMvHL0tnO zc-Yft{(>Tx%2V~2GnwT!kmvu&H1T`u!}{JxDYeZ$i!Ri5$mOP(7eH0bH!KWdObf}J zci~^;O$a{oi6EQW`XusnapAYquhK<#U3Zw;&{~H)SK`J(#YIe-NEOG^DQTMrzZBC< zBZ;3Tw{lX38CUkF`EG0>^sVzuTL{~79=&>nMxpcQJ7EpYVEe%{4_2cI;7bPHfyPXP(lidSD$hEb6k$x-HpD^n3?Ka!7eVwU|^N06)7%k5Guug%P*i)HvTQG2QT(N=B@MAzJ zd+G?ptJpMBAIN6Z*kho|04{B#pX`^s^l`|aCb`}$U&wT6e2*J z*U5xjOsiRa+si2D2N;=!t|t0z$V9R6yXfbMR>ymc)0OA^9!4LP?|f3*}+q1cY4Ef6+)B#yZw6H1d_PL`2)6$E?Prhi1O z)pLsq{%ZT>qtY~&ONXNw-dp7o$I4wo~%G`I_xFn71w-y~*f% z+PePZPy{6SCEfKb_0yO08|GtG?LeYR63Qk;hS3DS!=4|5PmB6No2(S(Cb`MW&8t!? z-k>WG(za*jpqlc?d8QSRsvE3x!K=Xv#?CIq=AxRqhrWqlmD|&kSKhKuLpd3%pYDSV+RsffL~@DgkF@r_i(gHc=mNe> zynyl|$e|KMa>Mu5b{4A>zB94|o_0LJnZ(xkd(u6_Myih)m_Qi%#%7XG+~AWT2Rxx~ zOi17jwm^Wk;rF{cyPn`n?f84A7<3Q!3|gWiKJ1JY%pq;mRgc*z7m?5 zF;@Bj7s%^pWQ$&4dhzn3M&I!Xki$pZ8bO+Z*rB?dm>hhSUr69^9y% zZgf$cUjS;IHnTm$21ss8kE5PJsznsGeCe(P3Rdu(&tfq5*^587wDH7KtpI zFSwTzWdF#)?;5i{7(#s(&UH!h0LOIiIOt)p78-k>cP8l9Ows`v0>#j zy~e&Q_>K^izvFe?iLb!SZJ_6X1MsZjsoV49%tsqd?2uy&$^kr69q*BL0;d8d z;fl$rU1{{NTROV+)x|xzxsJaL5;bedHqFYoau~RBH|HDGI25d`wa*dnkG{>mR@hF1 z(0Aau2vuG6K)J1`Su?w?&L+RpN*HJ_Ua!IJb+Y5qjK%OB+ZVMBOi1GEzI8L=m+{7W zVk{=+Gv218x52X=`?3)&Ot+D`YRyB^g`>29w5qMFq?O&U92ao|Sm%9(1N8g2xGo$c z$8_}fl2a__(H5(hFlwXOiDL&DOdiqHqX^Q`4k15ha0lvvXGC3YKucR^L{MH!RW%oQ zdsCJ?E9^?I+uJHd`2u&l`qf)GBl#Dh#>L-BY^W5WqEVQPz5IS|=&oeZciEv&y^rRq ziQbv2PNlMjwOh=AYrKr0E$bKiw5U-gwzr$_M~1C(9L=z~xw*Sa<6V7x3rH*!^|o(( ziI5EEdSDx6qs9W43JUI>`4(@KQGu0JLrrSDxQ{L1D z7rzlgr~UDww}(6DN@I!Rr0mAr5fDs34H^Jx3wjurvenUbMq!VX-PE)?XpI0W+!RzS zhw==xEwe3gW6#Ff*_Q9osHclbNGBTB9^wJ>L2rHpSveZGwu9vOj(*tby`h%=e^;k% zxvD35BHfLqut!e2)Vt~`2mmuutbYpzOXWb|>=td~ob=y%9yuLYZ_foXKE&f9rDZ%h zz`X^Dc4~PZYTkg#-9Z0Px!FymJJ4`NmcXpU$mY%V9r*SRPyj%_P{yqs0;XVe_G#h2 z)kxz}HU4{9PQR$+zPW-|hB5>HxS`;Z;gl-1=|?f%t3j(h6||RmSc00#)O$Mcu*b)2 zFobHPlr~2(fvemM6xRBXr#)8Pb#*)Mx4LoulXf623hqs9UEXo|l=EqJsd^3L_ zqQT&t-HWQj5>Xj^?S$k1))9YtJBT8{dGOkJE31h7UN@@}VD7`MpvfR0yeEfv3z3`! zVlmY4h*_m6$X4$dB0vntGyc#L^_0t4!#WOh9vruSS*h7QKWH3q*Nnpi z>Mw%CY)92XoKg%49S_^I6?euEg5R1mp|z}(ecyR{R!$@Wck6t0#}PAWZ~A2Yt6I!- z@SWxTYa_xV+87y1X@^AYnUrMbo?T6EY7~pxa3j`o`40iqER`5kS!9OfQcn^h4Lx0r zJlULm{Xs5;ShAeI>&DVracziqJud$~Rk{ER?bOpC+B*aGgA^>%LpT2WBD?M~S}ZwE za6}$vXyN&^W%KHUywixn)Q+zB`yrTGf$1WM(3C7o`Qj#vV>2xVup7@0_78CfH-rS} z<{j5d>b4N;Oam#~oBE z&+CX6lKhao(U#|6FDcf$OUG2a86KBG4rnXs``$nH3K zQ039g(5|MJ>By-TZ$$G<4Ysr-hN=(1BY&UVg#vfCx#KrZEkUQB`!qh&ZsM~1Xg@q^ zKL`rtk=TsZ<;3a!)gEIIC?NpFsp8fFVROO)S%W#_d(ue0k zaxG>-jIB_tpYehGA$8X!>^o%5nj2G;{WU1del)CdSR2m#-6@b!TpqB(W4M8jd#F8u z>=1Ji(mS6b9C^IBMLHw(@!}<53!(8(0)|W7Lt1LF_rMO~5~hye>7`ebjPUyJ8+97} zo|{Cw)=KLJN($+Dh6Wx$Z=H8&YQN@xJZ7R)`4Kj3t)x*p5#On8LGjMGl*(mFL@*-H z0Nk^OzT8cX>6wPrXTcgiUG2j8TQ;}Vzb93@HIUqg1`i-Ew9v$b{w{fry$dU6` zgUc4*FU$L;n!Mf@Bf|cMs&9co%Foof5o$Kqd+h5^U>C4KutRr@mA&32s&a;Ei`ro!z7( z{`IKq9#`Gn1M#jS`XI5Q9z5VVq-gjvC&OEedWOrlnz@^6{C!Onxqx+|Y5<=7Km3;b z!SJ{=Mf9RO;9mjM6C=toh01!}0-2rlKG1(L-rt`RojtjdsK; zSYQBLdi$8u65Pph8A-CC-Uf4KA&Gcd)OfUI6_i1Cw8t4{B5ux?yJJD|y2S6>%$)Rl zV4qg&Jav}3m{tCU0+SGYo8lw#+97r}OYo%bL7%#zQt5_?F@x5wlI!O*f2;JIi$BLX z+nPt2J-K_-{MQa@Qr8uw3y=RX&|f)!;`|a*6yzN}^C-SIrKp!iikQ6U2;@^`Dnp

4o}wlMkTxk(vj0! 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%0DxKDBxtE(d;25yAh6Ys)Rud=GidpL%h(ZV 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 + Архитектура сервиса генерации описаний + Пользователь обращается к REST API. Сервер передаёт задачи через RabbitMQ воркерам. Воркеры сохраняют описания в общем Docker volume, откуда сервер читает их. Для компонента CALLBACKS воркеры также отправляют уведомления серверу через RabbitMQ. + + + + + + + + + + + + + + + + + + Пользователь + HTTP-клиент + + + Сервер + REST API + + + + HTTP + + + + RabbitMQ + + Задачи + + + Уведомления + + + + Воркеры + + Воркер 1 + + Воркер 2 + + + + + + + + + Общий Docker volume + Файлы с описаниями + + + Чтение описаний + + Запись описаний + + + Уведомления для компонента CALLBACKS + diff --git a/homework/05-message-queue/readme.md b/homework/05-message-queue/readme.md new file mode 100644 index 0000000..c088e83 --- /dev/null +++ b/homework/05-message-queue/readme.md @@ -0,0 +1,152 @@ +# Практика с RabbitMQ + +В этом задании вы научитесь работать с брокером сообщений [RabbitMQ](https://rabbitmq.com/getstarted.html). Вам предстоит реализовать сервис, который принимает URL изображения, асинхронно генерирует описание, сохраняет его в файл и возвращает по запросу. Генерация описания имитируется готовой функцией из заготовки. + +## Архитектура и интерфейс сервиса + +Очередь сообщений позволяет передать длительную задачу на асинхронную обработку. Она помогает распределять работу между несколькими воркерами и сглаживать всплески нагрузки. В этом задании вам также предстоит обеспечить обработку задач при временных отказах. + +Архитектура сервиса + +1. Сервер с REST API принимает запросы пользователей и передаёт задачи на генерацию описаний через RabbitMQ. +2. Воркеры получают задачи, генерируют описания и записывают их в общее хранилище, откуда сервер читает результаты. В задании хранилище — это [Docker volume](https://docs.docker.com/engine/storage/volumes/), см. [docker-compose.yml](docker-compose.yml). + +Версия RabbitMQ для проверки задана в [docker-compose.yml](docker-compose.yml). Используйте это окружение для локального тестирования. + +### REST API + +Тела запросов и успешных ответов передаются в JSON. Успешный запрос возвращает код `200`; формат тела ответа при ошибке не регламентируется. Идентификатор изображения может быть строкой или целым числом; в ответах POST и GET он должен иметь одинаковый тип. + +``` +POST /api/v1.0/images +Принимает URL изображения для обработки и возвращает id изображения. +Каждый запрос получает новый уникальный id, даже при повторной отправке того же URL. +Если JSON некорректен или image_url отсутствует, пуст или не является строкой, +верните код 400. + +Тело запроса: +{ + "image_url": str +} + +Тело ответа: +{ + "image_id": str | int +} +``` + +``` +GET /api/v1.0/images +Возвращает id всех обработанных изображений без повторов. Порядок не важен. + +Тело ответа: +{ + "image_ids": List[str | int] +} +``` + +``` +GET /api/v1.0/images/ +Возвращает описание для данного изображения, если оно уже было обработано, +и код 404, если id неизвестен или обработка ещё не завершена. + +Тело ответа: +{ + "caption": str +} +``` + +## Компоненты задания и оценивание + +За выполнение задания можно получить 10 баллов: + +| Группа тестов | Баллы | Что проверяется | +| --- | ---: | --- | +| `BASIC_API` | 1 | Пустой список результатов, отклонение некорректных запросов и ответ `404` для неизвестного ID. | +| `BASIC_PROCESSING` | 3 | Передача задач через RabbitMQ, обработка воркерами и получение результатов через REST API без отказов. | +| `CALLBACKS` | 2 | Уведомления о завершении обработки через RabbitMQ: сервер отвечает на `GET .../images` без чтения списка файлов в общей директории. | +| `FAULT_TOLERANCE_1` | 1 | Работа сервиса после простоя соединений с брокером. | +| `FAULT_TOLERANCE_2` | 1 | Сохранность запросов при недоступности и перезапуске брокера во время отправки задач. | +| `FAULT_TOLERANCE_3` | 1 | Завершение задач после отказа одного или обоих воркеров. | +| `FAULT_TOLERANCE_4` | 1 | Восстановление после отказов брокера и воркеров, сохранность уведомлений и обработка повторных доставок. | + +Баллы за каждую группу начисляются при прохождении всех её тестов. Провал одной группы не обнуляет баллы за остальные. Группа `FAULT_TOLERANCE_4` предполагает реализацию уведомлений через RabbitMQ; передавать в них сами описания изображений не требуется. + +При проверке отказоустойчивости уже запущенный REST API должен принимать корректные запросы с кодом `200` и возвращать готовые результаты, даже когда брокер или воркеры недоступны. Запрос считается принятым, если POST вернул `200`. После восстановления брокера, связи и хотя бы одного воркера каждый принятый запрос должен быть обработан, а его описание — доступно через API. Таймаут каждого HTTP-запроса в тестах — 3 секунды. + +Сервер и общее хранилище в этих сценариях не отказывают: восстановление после их перезапуска или потери данных не требуется. Повторная доставка задач и уведомлений возможна. Повторная обработка задачи допустима, но её ID должен появляться в списке результатов только один раз. + +Приложите отчёт `solution/readme.md` с описанием устройства решения и выполненных компонентов. Для отказоустойчивости обоснуйте, почему принятые запросы не теряются. На защите нужно объяснить работу реализованных компонентов и подтвердить это обоснование. Общие требования к отчёту приведены в [правилах курса](../readme.md#отчёт). + +## Заготовки для решения + +В папке `solution/server` находится заготовка для сервера с реализацией REST API на Flask. + +В папке `solution/worker` содержится заготовка для воркера. Для генерации описания используйте функцию `produce_image_caption`, передавая ей строку `image_url`. Скачивать и сохранять само изображение не нужно: функция имитирует его обработку. В файл сохраняется только полученное описание в кодировке UTF-8. + +Очередь задач должна называться `task_queue`, а описания должны сохраняться в файлы `/data/{image_id}.txt`. Эти соглашения используются в тестах. + +Для взаимодействия с RabbitMQ на Python предлагается использовать библиотеку [Pika](https://github.com/pika/pika) (см. семинар 5). + +Весь код решения должен размещаться в папке `solution`. При сдаче решения в тестирующую систему отправляется только эта папка, изменения вне неё учитываться не будут. + +## Порядок выполнения задания + +Начните с [материалов семинара 5](../../materials/05-indirect-comm/seminar/) и примера [work_queues](../../materials/05-indirect-comm/seminar/work_queues/). Дополнительно можно обращаться к официальным [туториалам RabbitMQ](https://www.rabbitmq.com/tutorials). + +1. Реализуйте передачу задач от REST API через RabbitMQ, обработку воркерами и получение результатов. Проверьте группы `BASIC_API` и `BASIC_PROCESSING`. При необходимости измените Dockerfile сервера и воркера. +2. Добавьте уведомления сервера о завершении обработки и проверьте группу `CALLBACKS`. Полезный пример обмена запросами и ответами есть в [туториале по RPC](https://www.rabbitmq.com/tutorials/tutorial-six-python). При использовании Pika учитывайте [ограничения потокобезопасности](https://pika.github.io/pika/latest/faq/). +3. Работу над отказоустойчивостью начните с `test_heartbeats_timeout` (`FAULT_TOLERANCE_1`). Изучите [документацию по heartbeat](https://www.rabbitmq.com/docs/heartbeats) и настройки в [rabbitmq.conf](tests/rabbitmq.conf). Для `BlockingConnection` полезно [обсуждение работы соединения при редкой отправке сообщений](https://github.com/pika/pika/discussions/1382). +4. Подумайте, в каких случаях ваше решение может потерять принятый запрос. Изучите [подтверждения доставки в RabbitMQ](https://www.rabbitmq.com/docs/confirms); для Pika также полезен [пример асинхронного отправителя](https://github.com/pika/pika/blob/main/examples/asynchronous_publisher_example.py). Проверьте отдельно отказы брокера при отправке задач (`FAULT_TOLERANCE_2`) и отказы воркеров (`FAULT_TOLERANCE_3`). +5. Перейдите к комплексным сценариям `FAULT_TOLERANCE_4`: перезапуску брокера, сохранности уведомлений и повторной доставке. Сопоставьте поведение решения с [руководством RabbitMQ по надёжности](https://www.rabbitmq.com/docs/reliability). Подумайте, какие моменты отказа ещё стоит проверить самостоятельно. +6. Подготовьте отчёт с обоснованием отказоустойчивости реализованных компонентов, запустите все тесты и сдайте решение с отчётом в тестирующую систему. + +При отладке сопоставляйте логи компонентов с состоянием очередей в веб-интерфейсе RabbitMQ: `http://localhost:15672`, логин и пароль — `guest`. Как на семинаре, обращайте внимание на сообщения Ready и Unacked. + +## Тестирование решения + +Тесты, проверяющие решение, находятся в папке `tests`. + +Бонусы за пробелы в тестах начисляются по [общим правилам](../readme.md#бонусы-за-пробелы-в-тестах). + +### Локальное тестирование + +Команды ниже выполняйте из папки задания. Для локального запуска тестов установите зависимости из `tests/requirements.txt`. + +Перед запуском тестов соберите образы: + +``` +docker compose build +``` + +Запуск всех тестов выполняется с помощью команды: + +``` +python3 tests/main.py +``` + +Отдельный тест можно запустить так: + +``` +pytest -vs --tb=short tests/test_server.py::test_single_image +``` + +Для тестирования в окружении, аналогичном тестирующей системе, выполните из папки задания: + +```bash +docker run --privileged --pull always --rm -v ./solution:/hw/solution distsys.ru/course/message-queue:latest +``` + +### Проверка в тестирующей системе + +Отправьте ваше решение в тестирующую систему следуя [инструкции](../readme.md) и дождитесь результатов. + +## ЧаВо + +**Можно ли реализовать решение не на Python?** + +Да. Замените заготовки и Dockerfile сервера и воркера. Реализуйте аналог функции-заглушки, возвращающий строку по `image_url`; точное совпадение результата с Python-версией не требуется. + +**Можно ли обрабатывать изображения локально на сервере?** + +Нет. Описания должны генерироваться только воркерами. Решение, которое генерирует их на сервере, не засчитывается (0 баллов). diff --git a/homework/05-message-queue/solution/server/Dockerfile b/homework/05-message-queue/solution/server/Dockerfile new file mode 100644 index 0000000..27a1a1b --- /dev/null +++ b/homework/05-message-queue/solution/server/Dockerfile @@ -0,0 +1,13 @@ +# syntax=docker/dockerfile:1 +FROM python:3.12-slim + +RUN apt-get update && apt-get install -y curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /server +COPY requirements.txt . +RUN --mount=type=cache,id=distsys-course-pip,target=/root/.cache/pip,sharing=locked \ + pip install -r requirements.txt +COPY . . + +CMD ["python3", "-u", "server.py"] diff --git a/homework/05-message-queue/solution/server/requirements.txt b/homework/05-message-queue/solution/server/requirements.txt new file mode 100644 index 0000000..c772219 --- /dev/null +++ b/homework/05-message-queue/solution/server/requirements.txt @@ -0,0 +1,2 @@ +flask==3.1.2 +pika==1.3.2 \ No newline at end of file diff --git a/homework/05-message-queue/solution/server/server.py b/homework/05-message-queue/solution/server/server.py new file mode 100644 index 0000000..95b9886 --- /dev/null +++ b/homework/05-message-queue/solution/server/server.py @@ -0,0 +1,51 @@ +from flask import Flask, request +from typing import List, Optional + + +class Server: + # TODO: Implement API server + def __init__(self, mq_host, mq_port, data_dir): + pass + + def add_image(self, image_url: str) -> str: + raise NotImplementedError + + def get_processed_images(self) -> List[str]: + raise NotImplementedError + + def get_image_caption(self, image_id: str) -> Optional[str]: + raise NotImplementedError + + +def create_app() -> Flask: + app = Flask(__name__) + + server = Server('rabbitmq', 5672, '/data') + + @app.route('/api/v1.0/images', methods=['POST']) + def add_image(): + body = request.get_json(force=True) + if not isinstance(body, dict) or not isinstance(body.get('image_url'), str) or not body['image_url']: + return 'image_url must be a nonempty string', 400 + image_id = server.add_image(body['image_url']) + return {"image_id": image_id} + + @app.route('/api/v1.0/images', methods=['GET']) + def get_processed_images(): + image_ids = server.get_processed_images() + return {"image_ids": image_ids} + + @app.route('/api/v1.0/images/', methods=['GET']) + def get_image_caption(image_id): + result = server.get_image_caption(image_id) + if result is None: + return "Image not found.", 404 + else: + return {'caption': result} + + return app + + +if __name__ == '__main__': + app = create_app() + app.run(host='0.0.0.0', port=5000) diff --git a/homework/05-message-queue/solution/worker/Dockerfile b/homework/05-message-queue/solution/worker/Dockerfile new file mode 100644 index 0000000..93ed6fd --- /dev/null +++ b/homework/05-message-queue/solution/worker/Dockerfile @@ -0,0 +1,13 @@ +# syntax=docker/dockerfile:1 +FROM python:3.12-slim + +RUN apt-get update && apt-get install -y curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /worker +COPY requirements.txt . +RUN --mount=type=cache,id=distsys-course-pip,target=/root/.cache/pip,sharing=locked \ + pip install -r requirements.txt +COPY . . + +CMD ["python3", "-u", "worker.py"] diff --git a/homework/05-message-queue/solution/worker/requirements.txt b/homework/05-message-queue/solution/worker/requirements.txt new file mode 100644 index 0000000..cde8834 --- /dev/null +++ b/homework/05-message-queue/solution/worker/requirements.txt @@ -0,0 +1 @@ +pika==1.3.2 \ No newline at end of file diff --git a/homework/05-message-queue/solution/worker/worker.py b/homework/05-message-queue/solution/worker/worker.py new file mode 100644 index 0000000..7ad9eca --- /dev/null +++ b/homework/05-message-queue/solution/worker/worker.py @@ -0,0 +1,15 @@ +class Worker: + # TODO: Implement server + def __init__(self, mq_host, mq_port, data_dir): + pass + + def produce_image_caption(self, image_url): + return str(abs(hash(image_url)) % (10 ** 8)) + + def run(self): + raise NotImplementedError + + +if __name__ == '__main__': + worker = Worker('rabbitmq', 5672, '/data') + worker.run() diff --git a/homework/05-message-queue/tests/main.py b/homework/05-message-queue/tests/main.py new file mode 100644 index 0000000..b41112a --- /dev/null +++ b/homework/05-message-queue/tests/main.py @@ -0,0 +1,107 @@ +import argparse +import pathlib +import pytest + +from collections import defaultdict + +SCRIPT_DIR = pathlib.Path(__file__).parent.resolve() + +TEST_GROUPS = { + 'BASIC_API': { + 'tests': [ + 'test_empty_data_dir', + 'test_bad_request', + 'test_nonexistent_image', + ], + 'points': 1 + }, + 'BASIC_PROCESSING': { + 'tests': [ + 'test_task_queue', + 'test_single_image', + 'test_multiple_images', + 'test_captions_generated_on_workers' + ], + 'points': 3 + }, + 'CALLBACKS': { + 'tests': [ + 'test_multiple_images_no_listdir' + ], + 'points': 2 + }, + 'FAULT_TOLERANCE_1': { + 'tests': [ + 'test_heartbeats_timeout' + ], + 'points': 1 + }, + 'FAULT_TOLERANCE_2': { + 'tests': [ + 'test_publisher_confirms' + ], + 'points': 1 + }, + 'FAULT_TOLERANCE_3': { + 'tests': [ + 'test_faulty_worker', + 'test_two_faulty_workers', + ], + 'points': 1 + }, + 'FAULT_TOLERANCE_4': { + 'tests': [ + 'test_faulty_worker_and_rabbit_restart', + 'test_total_eclipse_of_the_heart', + 'test_notifications_survive_rabbit_restart', + 'test_duplicate_deliveries' + ], + 'points': 1 + } +} + +class PassedCounter: + def __init__(self): + self.test_to_group = {} + for group_name, group in TEST_GROUPS.items(): + for test in group['tests']: + self.test_to_group[test] = group_name + self.passed_by_group = defaultdict(int) + + def pytest_report_teststatus(self, report, config): + if report.when == 'call' and report.passed: + test = report.nodeid.split('::')[1].split('[')[0] + group_name = self.test_to_group[test] + self.passed_by_group[group_name] += 1 + + +def main(argv=None): + parser = argparse.ArgumentParser() + parser.add_argument('--ci', action='store_true', + help='Fail unless all reference solution tests pass') + args = parser.parse_args(argv) + counter = PassedCounter() + test_status = pytest.main( + ['-vs', '--tb=short', str(SCRIPT_DIR / 'test_server.py')], plugins=[counter]) + + score = 0 + print() + for group_name, group in TEST_GROUPS.items(): + total = len(group['tests']) + passed = counter.passed_by_group[group_name] + print(f'Test group {group_name}: passed {passed} of {total} tests') + if passed == total: + score += group['points'] + + print(f"\nSCORE: {score}") + if args.ci: + max_score = sum(group['points'] for group in TEST_GROUPS.values()) + return int(test_status) or int(score != max_score) + # A failed student test still produces a valid partial score. + if test_status == pytest.ExitCode.TESTS_FAILED: + return 0 + return int(test_status) + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/homework/05-message-queue/tests/network-fault/Dockerfile b/homework/05-message-queue/tests/network-fault/Dockerfile new file mode 100644 index 0000000..6f18b90 --- /dev/null +++ b/homework/05-message-queue/tests/network-fault/Dockerfile @@ -0,0 +1,4 @@ +FROM docker:dind + +ENTRYPOINT ["sleep"] +CMD ["infinity"] diff --git a/homework/05-message-queue/tests/rabbitmq.conf b/homework/05-message-queue/tests/rabbitmq.conf new file mode 100644 index 0000000..da58e87 --- /dev/null +++ b/homework/05-message-queue/tests/rabbitmq.conf @@ -0,0 +1,6 @@ +heartbeat = 5 +# Refresh queue state before paused workers reach the heartbeat timeout. +collect_statistics_interval = 1000 +# Allow basic solutions to use transient queues before adding fault tolerance. +# https://www.rabbitmq.com/docs/queues#durability +deprecated_features.permit.transient_nonexcl_queues = true diff --git a/homework/05-message-queue/tests/requirements.txt b/homework/05-message-queue/tests/requirements.txt new file mode 100644 index 0000000..412efa8 --- /dev/null +++ b/homework/05-message-queue/tests/requirements.txt @@ -0,0 +1,4 @@ +pytest==8.4.2 +requests==2.32.5 +docker==7.1.0 +loguru==0.7.3 diff --git a/homework/05-message-queue/tests/test_server.py b/homework/05-message-queue/tests/test_server.py new file mode 100644 index 0000000..db12cf5 --- /dev/null +++ b/homework/05-message-queue/tests/test_server.py @@ -0,0 +1,583 @@ +import io +import sys +import tarfile +import docker +import pytest +import requests +import subprocess +import time +import uuid + +from contextlib import contextmanager +from pathlib import Path +from urllib.parse import quote +from loguru import logger + + +IMAGES_ENDPOINT = 'http://localhost:5000/api/v1.0/images' +BROKER_ENDPOINT = 'http://guest:guest@localhost:15672/api' +TASK_QUEUE_ENDPOINT = f'{BROKER_ENDPOINT}/queues/%2F/task_queue' + +logger.remove() +logger.add(sys.stderr, colorize=False, format="=== TEST ===| {time:YYYY-MM-DD HH:mm:ss.SSS} {level} {message}") + + +# Tests =============================================================================================================== + +@pytest.mark.parametrize("services", [['rabbitmq', 'server']]) +def test_empty_data_dir(docker_tester): + logger.info(f"Sending GET {IMAGES_ENDPOINT}") + try: + response = requests.get(IMAGES_ENDPOINT, timeout=3) + except Exception as e: + logger.error(f"Request failed: {e}") + pytest.fail(f"Failed to get images: {e}") + logger.info(f"Got response: {response.status_code} {response.text.rstrip()}") + assert response.status_code == 200 + assert 'image_ids' in response.json() + assert len(response.json()['image_ids']) == 0 + + +@pytest.mark.parametrize("services", [['rabbitmq', 'server']]) +def test_bad_request(docker_tester): + invalid_bodies = [{}, {'image_url': None}, {'image_url': 1}, + {'image_url': ''}, [], None, 'image-url'] + for body in invalid_bodies: + logger.info(f"Sending invalid POST: {body!r}") + response = requests.post(IMAGES_ENDPOINT, json=body, timeout=3) + assert response.status_code == 400, response.text + response = requests.post(IMAGES_ENDPOINT, data='{', + headers={'Content-Type': 'application/json'}, timeout=3) + assert response.status_code == 400, response.text + + +@pytest.mark.parametrize("services", [['rabbitmq', 'server']]) +def test_nonexistent_image(docker_tester): + nonexistent_image_id = str(uuid.uuid4()) + logger.info(f"Sending GET {IMAGES_ENDPOINT}/{nonexistent_image_id}") + try: + response = requests.get(f'{IMAGES_ENDPOINT}/{nonexistent_image_id}', timeout=3) + except Exception as e: + logger.error(f"Request failed: {e}") + pytest.fail(f"Failed to get images: {e}") + logger.info(f"Got response: {response.status_code} {response.text.rstrip()}") + assert response.status_code == 404 + + +@pytest.mark.parametrize("services", [['rabbitmq', 'server']]) +def test_task_queue(docker_tester): + time.sleep(5) + check_task_queue(0, 10) + pending_ids = post_images(10) + check_unprocessed_images(pending_ids, docker_tester) + check_task_queue(10, 10) + time.sleep(5) + check_task_queue(10, 10) + check_unprocessed_images(pending_ids, docker_tester) + + +@pytest.mark.parametrize("services", [['rabbitmq', 'server', 'worker']]) +def test_single_image(docker_tester): + pending_ids = post_images(1) + wait_and_check_results(pending_ids, 10, docker_client=docker_tester) + + +@pytest.mark.parametrize("services", [['rabbitmq', 'server', 'worker']]) +def test_multiple_images(docker_tester): + pending_ids = post_images(10) + wait_and_check_results(pending_ids, 10, docker_client=docker_tester) + + +@pytest.mark.parametrize("services", [['rabbitmq', 'server', 'worker']]) +def test_captions_generated_on_workers(docker_tester): + worker1 = docker_tester.containers.get("distsys-mq-worker-1") + worker2 = docker_tester.containers.get("distsys-mq-worker-2") + wait_for_worker_ready(worker1) + wait_for_worker_ready(worker2) + worker1.pause() + worker2.pause() + + pending_ids = post_images(10) + time.sleep(5) + + check_unprocessed_images(pending_ids, docker_tester) + worker1.unpause() + worker2.unpause() + wait_and_check_results(pending_ids, 10, docker_client=docker_tester) + + +@pytest.mark.parametrize("services", [['rabbitmq', 'server-fdv', 'worker']]) +def test_multiple_images_no_listdir(docker_tester): + pending_ids = post_images(10) + # This server has no access to the workers' result volume. + wait_and_check_results(pending_ids, 10, check_captions=False, docker_client=docker_tester) + + +@pytest.mark.parametrize("services", [['rabbitmq', 'server', 'worker']]) +def test_heartbeats_timeout(docker_tester): + # With heartbeat=5, closing a connection without heartbeats can take 15s. + time.sleep(20) + pending_ids = post_images(10) + wait_and_check_results(pending_ids, 10, docker_client=docker_tester) + + +@pytest.mark.parametrize("services", [['rabbitmq', 'server', 'worker']]) +def test_publisher_confirms(docker_tester): + rabbit = docker_tester.containers.get("distsys-mq-rabbitmq-1") + rabbit.pause() + pending_ids = post_images(10) + time.sleep(5) + rabbit.kill() + time.sleep(1) + rabbit.start() + wait_for_task_queue(lambda queue: True, "broker restart", max_attempts=20) + wait_and_check_results(pending_ids, 10, docker_client=docker_tester) + + +@pytest.mark.parametrize("services", [['rabbitmq', 'server', 'worker']]) +def test_faulty_worker(docker_tester): + worker1 = docker_tester.containers.get("distsys-mq-worker-1") + worker2 = docker_tester.containers.get("distsys-mq-worker-2") + # Prevent the healthy worker from completing the whole batch before the fault. + worker2.kill() + subscribed = wait_for_worker_ready(worker1) + worker1.pause() + pending_ids = post_images(10) + wait_for_pending_tasks(subscribed) + worker1.kill() + worker2.start() + wait_and_check_results(pending_ids, 10, docker_client=docker_tester) + + +@pytest.mark.parametrize("services", [['rabbitmq', 'server', 'worker']]) +def test_two_faulty_workers(docker_tester): + worker1 = docker_tester.containers.get("distsys-mq-worker-1") + worker2 = docker_tester.containers.get("distsys-mq-worker-2") + subscribed1 = wait_for_worker_ready(worker1) + subscribed2 = wait_for_worker_ready(worker2) + worker1.pause() + worker2.pause() + pending_ids = post_images(10) + wait_for_pending_tasks(subscribed1 or subscribed2) + worker1.kill() + worker2.kill() + worker1.start() + wait_and_check_results(pending_ids, 10, docker_client=docker_tester) + + +@pytest.mark.parametrize("services", [['rabbitmq', 'server', 'worker']]) +def test_faulty_worker_and_rabbit_restart(docker_tester): + worker1 = docker_tester.containers.get("distsys-mq-worker-1") + worker2 = docker_tester.containers.get("distsys-mq-worker-2") + rabbit = docker_tester.containers.get("distsys-mq-rabbitmq-1") + # No worker may finish tasks before the broker loses its in-memory state. + worker1.kill() + worker2.kill() + pending_ids = post_images(10) + check_task_queue(10, 10) + rabbit.kill() + rabbit.start() + wait_for_task_queue(lambda queue: True, "broker restart", max_attempts=20) + worker2.start() + wait_and_check_results(pending_ids, 10, docker_client=docker_tester) + + +@pytest.mark.parametrize("services", [['rabbitmq', 'server', 'worker']]) +def test_total_eclipse_of_the_heart(docker_tester): + completed_ids = post_images(10) + wait_and_check_results(completed_ids, 10, docker_client=docker_tester) + + worker1 = docker_tester.containers.get("distsys-mq-worker-1") + worker2 = docker_tester.containers.get("distsys-mq-worker-2") + rabbit = docker_tester.containers.get("distsys-mq-rabbitmq-1") + worker1.kill() + worker2.kill() + rabbit.kill() + + # These URLs repeat the first batch, but every request needs a new ID. + pending_ids = post_images(10, known_ids=completed_ids) + # Also accept requests after the client has detected the broken connection. + time.sleep(20) + later_ids = post_images(10, known_ids=completed_ids | pending_ids) + wait_and_check_results(completed_ids, 10, docker_client=docker_tester) + + rabbit.start() + wait_for_task_queue(lambda queue: True, "broker restart", max_attempts=20) + worker1.start() + worker2.start() + wait_and_check_results(completed_ids | pending_ids | later_ids, 10, docker_client=docker_tester) + + +@pytest.mark.parametrize("services", [['rabbitmq', 'server', 'worker', 'network-fault']]) +def test_notifications_survive_rabbit_restart(docker_tester): + worker1 = docker_tester.containers.get("distsys-mq-worker-1") + worker2 = docker_tester.containers.get("distsys-mq-worker-2") + rabbit = docker_tester.containers.get("distsys-mq-rabbitmq-1") + fault = docker_tester.containers.get("distsys-mq-network-fault-1") + worker1.kill() + worker2.kill() + pending_ids = post_images(10) + check_task_queue(10, 10) + + rabbit.reload() + address = next(network['IPAddress'] for network in + rabbit.attrs['NetworkSettings']['Networks'].values() if network['IPAddress']) + with block_amqp_link(fault, address): + worker1.start() + worker2.start() + wait_for_queued_notifications() + # Notifications cannot reach the API; tasks may still await acknowledgement. + wait_and_check_results(set(), 1, docker_client=docker_tester) + worker1.kill() + worker2.kill() + rabbit.kill() + rabbit.start() + wait_for_task_queue(lambda queue: True, "broker restart", max_attempts=20) + + worker1.start() + wait_and_check_results(pending_ids, 10, docker_client=docker_tester) + + +@pytest.mark.parametrize("services", [['rabbitmq', 'server', 'worker', 'network-fault']]) +def test_duplicate_deliveries(docker_tester): + worker1 = docker_tester.containers.get("distsys-mq-worker-1") + worker2 = docker_tester.containers.get("distsys-mq-worker-2") + server = docker_tester.containers.get("distsys-mq-server-1") + rabbit = docker_tester.containers.get("distsys-mq-rabbitmq-1") + fault = docker_tester.containers.get("distsys-mq-network-fault-1") + worker1.kill() + worker2.kill() + pending_ids = post_images(10) + check_task_queue(10, 10) + task_copies = duplicate_queued_messages('task_queue') + assert task_copies == 20 + check_task_queue(task_copies, 10) + + rabbit.reload() + address = next(network['IPAddress'] for network in + rabbit.attrs['NetworkSettings']['Networks'].values() if network['IPAddress']) + with block_amqp_link(fault, address): + # Close the blocked connection so in-flight notifications become ready. + disconnect_from_broker(server) + worker1.start() + worker2.start() + queues = wait_for_queued_notifications(ready_only=True) + wait_and_check_results(set(), 1, docker_client=docker_tester) + worker1.kill() + worker2.kill() + notification_copies = { + queue['name']: duplicate_queued_messages(queue['name']) + for queue in queues if queue['name'] != 'task_queue' and queue.get('messages', 0) > 0 + } + assert notification_copies + wait_for_queue_counts(notification_copies) + + worker1.start() + wait_and_check_results(pending_ids, 10, docker_client=docker_tester) + # The first complete result list may precede processing of later copies. + wait_for_queue_counts({name: 0 for name in notification_copies}) + wait_and_check_results(pending_ids, 1, docker_client=docker_tester) + + +# Utils =============================================================================================================== + +@pytest.fixture +def docker_tester(services): + print() + try: + run_docker_compose_up(services) + check_server_endpoint() + client = docker.from_env() + yield client + finally: + print() + run_docker_compose_down() + + +def run_docker_compose_up(services): + command = ["docker", "compose", "--ansi", "never", "up", "--force-recreate"] + for service in services: + command.append(service) + subprocess.Popen(command, cwd=Path(__file__).parent.parent.absolute(), stdout=None, stderr=None) + + +def run_docker_compose_down(): + command = ["docker", "compose", "down", "--volumes"] + subprocess.run(command, cwd=Path(__file__).parent.parent.absolute(), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def check_server_endpoint(max_attempts=20): + attempt = 0 + while True: + attempt += 1 + try: + logger.info(f"Sending GET {IMAGES_ENDPOINT}") + response = requests.get(IMAGES_ENDPOINT, timeout=3) + assert response.status_code == 200, response.text + logger.info(f"Attempt {attempt} succeeded: got response, server endpoint is ready") + return + except Exception as e: + logger.error(f"Attempt {attempt} failed: {e}") + if attempt == max_attempts: + logger.error(f"Max attempts reached, give up") + pytest.fail("Server endpoint is not ready") + logger.info(f"Retry in 3 seconds...") + time.sleep(3) + + +def post_images(num_requests, known_ids=None): + known_ids = set() if known_ids is None else known_ids + pending_ids = set() + for i in range(num_requests): + input_data = {"image_url": f"https://somehost.com/some-image-{i}.jpg"} + logger.info(f"Sending POST {IMAGES_ENDPOINT} {input_data}") + try: + response = requests.post(IMAGES_ENDPOINT, json=input_data, timeout=3) + except Exception as e: + logger.error(f"Request failed: {e}") + pytest.fail(f"Failed to post image: {e}") + logger.info(f"Got response: {response.status_code} {response.text.rstrip()}") + assert response.status_code == 200 + assert 'image_id' in response.json() + image_id = response.json()['image_id'] + assert type(image_id) in (str, int), 'image_id must be a string or integer' + assert image_id not in known_ids, f'Reused image_id: {image_id}' + assert image_id not in pending_ids + pending_ids.add(image_id) + return pending_ids + + +def wait_and_check_results(pending_ids, max_attempts, docker_client, check_captions=True): + expected_count = len(pending_ids) + attempt = 0 + while True: + attempt += 1 + try: + logger.info(f"Sending GET {IMAGES_ENDPOINT}") + response = requests.get(IMAGES_ENDPOINT, timeout=3) + logger.info(f"Got response: {response.status_code} {response.text.rstrip()}") + assert response.status_code == 200 + assert 'image_ids' in response.json() + ready_ids = response.json()['image_ids'] + assert isinstance(ready_ids, list), 'image_ids must be a list' + assert all(type(image_id) in (str, int) for image_id in ready_ids) + ready_set = set(ready_ids) + assert len(ready_ids) == len(ready_set), 'image_ids contains duplicates' + assert ready_set <= pending_ids, 'image_ids contains unexpected IDs' + count = len(ready_set) + if ready_set == pending_ids: + if check_captions: + for image_id in pending_ids: + check_image_caption(image_id, docker_client) + logger.info(f"Attempt {attempt} succeeded: got {count} results as expected") + return + else: + logger.info(f"Attempt {attempt} not succeeded: got {count} results, expect {expected_count}") + except Exception as e: + logger.error(f"Attempt {attempt} failed: {e}") + if attempt == max_attempts: + logger.error(f"Max attempts reached, give up") + pytest.fail("Server didn't return expected results") + logger.info(f"Retry in 3 seconds...") + time.sleep(3) + + +def check_image_caption(image_id, docker_client): + logger.info(f"Sending GET {IMAGES_ENDPOINT}/{image_id}") + try: + response = requests.get(f'{IMAGES_ENDPOINT}/{image_id}', timeout=3) + except Exception as e: + logger.error(f"Request failed: {e}") + pytest.fail(f"Failed to get image caption: {e}") + logger.info(f"Got response: {response.status_code} {response.text.rstrip()}") + assert response.status_code == 200 + assert 'caption' in response.json() + assert isinstance(response.json()['caption'], str) + saved_caption = read_saved_caption(image_id, docker_client) + assert response.json()['caption'] == saved_caption, 'API caption differs from the saved result' + + +def read_saved_caption(image_id, docker_client): + # Docker's archive API works even if the solution image has no shell or Python. + server = docker_client.containers.get('distsys-mq-server-1') + stream, _ = server.get_archive(f'/data/{image_id}.txt') + with tarfile.open(fileobj=io.BytesIO(b''.join(stream))) as archive: + files = [member for member in archive.getmembers() if member.isfile()] + assert len(files) == 1, 'Expected one regular caption file' + with archive.extractfile(files[0]) as result: + return result.read().decode('utf-8') + + +def check_unprocessed_images(image_ids, docker_client): + wait_and_check_results(set(), 1, docker_client) + for image_id in image_ids: + response = requests.get(f'{IMAGES_ENDPOINT}/{image_id}', timeout=3) + assert response.status_code == 404, f'Result available without workers: {response.text}' + + +def wait_for_task_queue(predicate, description, max_attempts=10, interval=3): + last_state = None + for attempt in range(1, max_attempts + 1): + try: + response = requests.get(TASK_QUEUE_ENDPOINT, timeout=3) + assert response.status_code == 200, response.text + queue = response.json() + last_state = {key: queue.get(key) for key in + ('messages_ready', 'messages_unacknowledged', 'consumers', 'message_stats')} + if predicate(queue): + logger.info(f"Task queue is ready: {description}") + return queue + except Exception as e: + last_state = str(e) + logger.info(f"Waiting for {description}: {last_state}") + if attempt < max_attempts: + time.sleep(interval) + pytest.fail(f"Task queue did not reach {description}; last state: {last_state}") + + +def check_task_queue(expected_count, max_attempts): + return wait_for_task_queue(lambda queue: queue['messages_ready'] == expected_count, + f'{expected_count} ready messages', max_attempts) + + +def wait_for_worker_ready(worker, max_attempts=10): + last_state = None + for attempt in range(1, max_attempts + 1): + try: + worker.reload() + addresses = {network['IPAddress'] for network in + worker.attrs['NetworkSettings']['Networks'].values() if network['IPAddress']} + response = requests.get(TASK_QUEUE_ENDPOINT, timeout=3) + assert response.status_code == 200, response.text + consumers = response.json().get('consumer_details', []) + if any(consumer['channel_details']['peer_host'] in addresses for consumer in consumers): + return True + # Polling with basic.get is also valid; it has no registered consumer. + response = requests.get(f'{BROKER_ENDPOINT}/channels', timeout=3) + assert response.status_code == 200, response.text + channels = response.json() + last_state = [{'peer_host': channel['connection_details']['peer_host'], + 'message_stats': channel.get('message_stats')} for channel in channels + if channel['connection_details']['peer_host'] in addresses] + for channel in channels: + if channel['connection_details']['peer_host'] in addresses: + stats = channel.get('message_stats') or {} + if any(stats.get(name, 0) > 0 for name in ('get', 'get_no_ack', 'get_empty')): + return False + except Exception as e: + last_state = str(e) + if attempt < max_attempts: + time.sleep(3) + pytest.fail(f"Worker {worker.name} did not start consuming tasks; last state: {last_state}") + + +def wait_for_pending_tasks(subscribed): + if subscribed: + # Delivery statistics can arrive before the unacknowledged count. + # Automatic ACK is deliberately allowed through this barrier: after the + # kill, the missing results must expose the lost deliveries instead. + predicate = lambda queue: (queue['messages_unacknowledged'] > 0 or + any((queue.get('message_stats') or {}).get(name, 0) > 0 + for name in ('deliver', 'deliver_no_ack'))) + description = 'a task delivered to a paused worker' + else: + predicate = lambda queue: queue['messages_ready'] == 10 + description = '10 tasks waiting for a polling worker' + # Statistics refresh every 1s in rabbitmq.conf. + return wait_for_task_queue(predicate, description, max_attempts=10, interval=1) + + +def wait_for_queued_notifications(max_attempts=20, ready_only=False): + last_state = None + for attempt in range(1, max_attempts + 1): + try: + response = requests.get(f'{BROKER_ENDPOINT}/queues/%2F', timeout=3) + assert response.status_code == 200, response.text + queues = response.json() + last_state = {queue['name']: queue.get('messages') for queue in queues} + # Notifications may be batched or spread across multiple queues. + # Workers may keep tasks unacknowledged until the API receives a result. + notification_count = sum(count or 0 for name, count in last_state.items() + if name != 'task_queue') + ready = sum(queue.get('messages_ready', 0) or 0 for queue in queues + if queue['name'] != 'task_queue') + if 'task_queue' in last_state and notification_count > 0 and (not ready_only or ready == notification_count): + return queues + except Exception as e: + last_state = str(e) + if attempt < max_attempts: + time.sleep(1) + pytest.fail(f'No queued notifications reached the required state; last state: {last_state}') + + +@contextmanager +def block_amqp_link(fault, address): + rules = [('INPUT', '-s', address, '--sport'), ('OUTPUT', '-d', address, '--dport')] + installed = [] + try: + for chain, direction, host, port in rules: + rule = [chain, direction, host, '-p', 'tcp', port, '5672', '-j', 'DROP'] + result = fault.exec_run(['iptables', '-I', *rule]) + assert result.exit_code == 0, result.output.decode(errors='replace') + installed.append(rule) + yield + finally: + for rule in reversed(installed): + result = fault.exec_run(['iptables', '-D', *rule]) + assert result.exit_code == 0, result.output.decode(errors='replace') + + +def duplicate_queued_messages(queue_name): + # Consumers/producers of this queue are stopped or disconnected by the test. + messages = [] + while True: + response = requests.post( + f'{BROKER_ENDPOINT}/queues/%2F/{quote(queue_name, safe="")}/get', + json={'count': 100, 'ackmode': 'ack_requeue_false', 'encoding': 'base64'}, + timeout=3) + assert response.status_code == 200, response.text + batch = response.json() + if not batch: + break + messages.extend(batch) + assert messages, f'No messages to duplicate in {queue_name}' + for message in messages: + for _ in range(2): + response = requests.post(f'{BROKER_ENDPOINT}/exchanges/%2F/amq.default/publish', + json={'routing_key': queue_name, 'properties': message['properties'], + 'payload': message['payload'], 'payload_encoding': 'base64'}, timeout=3) + assert response.status_code == 200, response.text + assert response.json()['routed'] is True, f'Copy was not routed to {queue_name}' + logger.info(f'Queued {len(messages) * 2} copies in {queue_name}') + return len(messages) * 2 + + +def disconnect_from_broker(container): + container.reload() + addresses = {network['IPAddress'] for network in + container.attrs['NetworkSettings']['Networks'].values() if network['IPAddress']} + response = requests.get(f'{BROKER_ENDPOINT}/connections', timeout=3) + assert response.status_code == 200, response.text + for connection in response.json(): + if connection['peer_host'] in addresses: + response = requests.delete( + f'{BROKER_ENDPOINT}/connections/{quote(connection["name"], safe="")}', timeout=3) + assert response.status_code in (204, 404), response.text + + +def wait_for_queue_counts(expected, max_attempts=30): + last_state = None + for attempt in range(1, max_attempts + 1): + try: + last_state = {} + for name in expected: + response = requests.get(f'{BROKER_ENDPOINT}/queues/%2F/{quote(name, safe="")}', timeout=3) + assert response.status_code == 200, response.text + last_state[name] = response.json().get('messages') + if last_state == expected: + return + except Exception as e: + last_state = str(e) + if attempt < max_attempts: + time.sleep(1) + pytest.fail(f'Queues did not reach {expected}; last state: {last_state}')