PNG  IHDRX cHRMz&u0`:pQ<bKGD pHYsodtIME MeqIDATxw]Wug^Qd˶ 6`!N:!@xI~)%7%@Bh&`lnjVF29gΨ4E$|>cɚ{gk= %,a KX%,a KX%,a KX%,a KX%,a KX%,a KX%, b` ǟzeאfp]<!SJmɤY޲ڿ,%c ~ع9VH.!Ͳz&QynֺTkRR.BLHi٪:l;@(!MԴ=žI,:o&N'Kù\vRmJ雵֫AWic H@" !: Cé||]k-Ha oݜ:y F())u]aG7*JV@J415p=sZH!=!DRʯvɱh~V\}v/GKY$n]"X"}t@ xS76^[bw4dsce)2dU0 CkMa-U5tvLƀ~mlMwfGE/-]7XAƟ`׮g ewxwC4\[~7@O-Q( a*XGƒ{ ՟}$_y3tĐƤatgvێi|K=uVyrŲlLӪuܿzwk$m87k( `múcE)"@rK( z4$D; 2kW=Xb$V[Ru819קR~qloѱDyįݎ*mxw]y5e4K@ЃI0A D@"BDk_)N\8͜9dz"fK0zɿvM /.:2O{ Nb=M=7>??Zuo32 DLD@D| &+֎C #B8ַ`bOb $D#ͮҪtx]%`ES`Ru[=¾!@Od37LJ0!OIR4m]GZRJu$‡c=%~s@6SKy?CeIh:[vR@Lh | (BhAMy=݃  G"'wzn޺~8ԽSh ~T*A:xR[ܹ?X[uKL_=fDȊ؂p0}7=D$Ekq!/t.*2ʼnDbŞ}DijYaȲ(""6HA;:LzxQ‘(SQQ}*PL*fc\s `/d'QXW, e`#kPGZuŞuO{{wm[&NBTiiI0bukcA9<4@SӊH*؎4U/'2U5.(9JuDfrޱtycU%j(:RUbArLֺN)udA':uGQN"-"Is.*+k@ `Ojs@yU/ H:l;@yyTn}_yw!VkRJ4P)~y#)r,D =ě"Q]ci'%HI4ZL0"MJy 8A{ aN<8D"1#IJi >XjX֔#@>-{vN!8tRݻ^)N_╗FJEk]CT՟ YP:_|H1@ CBk]yKYp|og?*dGvzنzӴzjֺNkC~AbZƷ`.H)=!QͷVTT(| u78y֮}|[8-Vjp%2JPk[}ԉaH8Wpqhwr:vWª<}l77_~{s۴V+RCģ%WRZ\AqHifɤL36: #F:p]Bq/z{0CU6ݳEv_^k7'>sq*+kH%a`0ԣisqにtү04gVgW΂iJiS'3w.w}l6MC2uԯ|>JF5`fV5m`Y**Db1FKNttu]4ccsQNnex/87+}xaUW9y>ͯ骵G{䩓Գ3+vU}~jJ.NFRD7<aJDB1#ҳgSb,+CS?/ VG J?|?,2#M9}B)MiE+G`-wo߫V`fio(}S^4e~V4bHOYb"b#E)dda:'?}׮4繏`{7Z"uny-?ǹ;0MKx{:_pÚmFמ:F " .LFQLG)Q8qN q¯¯3wOvxDb\. BKD9_NN &L:4D{mm o^tֽ:q!ƥ}K+<"m78N< ywsard5+вz~mnG)=}lYݧNj'QJS{S :UYS-952?&O-:W}(!6Mk4+>A>j+i|<<|;ر^߉=HE|V#F)Emm#}/"y GII웻Jі94+v뾧xu~5C95~ūH>c@덉pʃ1/4-A2G%7>m;–Y,cyyaln" ?ƻ!ʪ<{~h~i y.zZB̃/,雋SiC/JFMmBH&&FAbϓO^tubbb_hZ{_QZ-sύodFgO(6]TJA˯#`۶ɟ( %$&+V'~hiYy>922 Wp74Zkq+Ovn錄c>8~GqܲcWꂎz@"1A.}T)uiW4="jJ2W7mU/N0gcqܗOO}?9/wìXžΏ0 >֩(V^Rh32!Hj5`;O28؇2#ݕf3 ?sJd8NJ@7O0 b־?lldщ̡&|9C.8RTWwxWy46ah嘦mh٤&l zCy!PY?: CJyв]dm4ǜҐR޻RլhX{FƯanшQI@x' ao(kUUuxW_Ñ줮[w8 FRJ(8˼)_mQ _!RJhm=!cVmm ?sFOnll6Qk}alY}; "baӌ~M0w,Ggw2W:G/k2%R,_=u`WU R.9T"v,<\Ik޽/2110Ӿxc0gyC&Ny޽JҢrV6N ``یeA16"J³+Rj*;BϜkZPJaÍ<Jyw:NP8/D$ 011z֊Ⱳ3ι֘k1V_"h!JPIΣ'ɜ* aEAd:ݺ>y<}Lp&PlRfTb1]o .2EW\ͮ]38؋rTJsǏP@芎sF\> P^+dYJLbJ C-xϐn> ι$nj,;Ǖa FU *择|h ~izť3ᤓ`K'-f tL7JK+vf2)V'-sFuB4i+m+@My=O҈0"|Yxoj,3]:cо3 $#uŘ%Y"y죯LebqtҢVzq¼X)~>4L׶m~[1_k?kxֺQ`\ |ٛY4Ѯr!)N9{56(iNq}O()Em]=F&u?$HypWUeB\k]JɩSع9 Zqg4ZĊo oMcjZBU]B\TUd34ݝ~:7ڶSUsB0Z3srx 7`:5xcx !qZA!;%͚7&P H<WL!džOb5kF)xor^aujƍ7 Ǡ8/p^(L>ὴ-B,{ۇWzֺ^k]3\EE@7>lYBȝR.oHnXO/}sB|.i@ɥDB4tcm,@ӣgdtJ!lH$_vN166L__'Z)y&kH;:,Y7=J 9cG) V\hjiE;gya~%ks_nC~Er er)muuMg2;֫R)Md) ,¶ 2-wr#F7<-BBn~_(o=KO㭇[Xv eN_SMgSҐ BS헃D%g_N:/pe -wkG*9yYSZS.9cREL !k}<4_Xs#FmҶ:7R$i,fi!~' # !6/S6y@kZkZcX)%5V4P]VGYq%H1!;e1MV<!ϐHO021Dp= HMs~~a)ަu7G^];git!Frl]H/L$=AeUvZE4P\.,xi {-~p?2b#amXAHq)MWǾI_r`S Hz&|{ +ʖ_= (YS(_g0a03M`I&'9vl?MM+m~}*xT۲(fY*V4x@29s{DaY"toGNTO+xCAO~4Ϳ;p`Ѫ:>Ҵ7K 3}+0 387x\)a"/E>qpWB=1 ¨"MP(\xp߫́A3+J] n[ʼnӼaTbZUWb={~2ooKױӰp(CS\S筐R*JغV&&"FA}J>G֐p1ٸbk7 ŘH$JoN <8s^yk_[;gy-;߉DV{c B yce% aJhDȶ 2IdйIB/^n0tNtџdcKj4϶v~- CBcgqx9= PJ) dMsjpYB] GD4RDWX +h{y`,3ꊕ$`zj*N^TP4L:Iz9~6s) Ga:?y*J~?OrMwP\](21sZUD ?ܟQ5Q%ggW6QdO+\@ ̪X'GxN @'4=ˋ+*VwN ne_|(/BDfj5(Dq<*tNt1х!MV.C0 32b#?n0pzj#!38}޴o1KovCJ`8ŗ_"]] rDUy޲@ Ȗ-;xџ'^Y`zEd?0„ DAL18IS]VGq\4o !swV7ˣι%4FѮ~}6)OgS[~Q vcYbL!wG3 7띸*E Pql8=jT\꘿I(z<[6OrR8ºC~ډ]=rNl[g|v TMTղb-o}OrP^Q]<98S¤!k)G(Vkwyqyr޽Nv`N/e p/~NAOk \I:G6]4+K;j$R:Mi #*[AȚT,ʰ,;N{HZTGMoּy) ]%dHء9Պ䠬|<45,\=[bƟ8QXeB3- &dҩ^{>/86bXmZ]]yޚN[(WAHL$YAgDKp=5GHjU&99v簪C0vygln*P)9^͞}lMuiH!̍#DoRBn9l@ xA/_v=ȺT{7Yt2N"4!YN`ae >Q<XMydEB`VU}u]嫇.%e^ánE87Mu\t`cP=AD/G)sI"@MP;)]%fH9'FNsj1pVhY&9=0pfuJ&gޤx+k:!r˭wkl03׼Ku C &ѓYt{.O.zҏ z}/tf_wEp2gvX)GN#I ݭ߽v/ .& и(ZF{e"=V!{zW`, ]+LGz"(UJp|j( #V4, 8B 0 9OkRrlɱl94)'VH9=9W|>PS['G(*I1==C<5"Pg+x'K5EMd؞Af8lG ?D FtoB[je?{k3zQ vZ;%Ɠ,]E>KZ+T/ EJxOZ1i #T<@ I}q9/t'zi(EMqw`mYkU6;[t4DPeckeM;H}_g pMww}k6#H㶏+b8雡Sxp)&C $@'b,fPߑt$RbJ'vznuS ~8='72_`{q纶|Q)Xk}cPz9p7O:'|G~8wx(a 0QCko|0ASD>Ip=4Q, d|F8RcU"/KM opKle M3#i0c%<7׿p&pZq[TR"BpqauIp$ 8~Ĩ!8Սx\ւdT>>Z40ks7 z2IQ}ItԀ<-%S⍤};zIb$I 5K}Q͙D8UguWE$Jh )cu4N tZl+[]M4k8֦Zeq֮M7uIqG 1==tLtR,ƜSrHYt&QP윯Lg' I,3@P'}'R˪e/%-Auv·ñ\> vDJzlӾNv5:|K/Jb6KI9)Zh*ZAi`?S {aiVDԲuy5W7pWeQJk֤#5&V<̺@/GH?^τZL|IJNvI:'P=Ϛt"¨=cud S Q.Ki0 !cJy;LJR;G{BJy޺[^8fK6)=yʊ+(k|&xQ2`L?Ȓ2@Mf 0C`6-%pKpm')c$׻K5[J*U[/#hH!6acB JA _|uMvDyk y)6OPYjœ50VT K}cǻP[ $:]4MEA.y)|B)cf-A?(e|lɉ#P9V)[9t.EiQPDѠ3ϴ;E:+Օ t ȥ~|_N2,ZJLt4! %ա]u {+=p.GhNcŞQI?Nd'yeh n7zi1DB)1S | S#ًZs2|Ɛy$F SxeX{7Vl.Src3E℃Q>b6G ўYCmtկ~=K0f(=LrAS GN'ɹ9<\!a`)֕y[uՍ[09` 9 +57ts6}b4{oqd+J5fa/,97J#6yν99mRWxJyѡyu_TJc`~W>l^q#Ts#2"nD1%fS)FU w{ܯ R{ ˎ󅃏џDsZSQS;LV;7 Od1&1n$ N /.q3~eNɪ]E#oM~}v֯FڦwyZ=<<>Xo稯lfMFV6p02|*=tV!c~]fa5Y^Q_WN|Vs 0ҘދU97OI'N2'8N֭fgg-}V%y]U4 峧p*91#9U kCac_AFңĪy뚇Y_AiuYyTTYЗ-(!JFLt›17uTozc. S;7A&&<ԋ5y;Ro+:' *eYJkWR[@F %SHWP 72k4 qLd'J "zB6{AC0ƁA6U.'F3:Ȅ(9ΜL;D]m8ڥ9}dU "v!;*13Rg^fJyShyy5auA?ɩGHRjo^]׽S)Fm\toy 4WQS@mE#%5ʈfFYDX ~D5Ϡ9tE9So_aU4?Ѽm%&c{n>.KW1Tlb}:j uGi(JgcYj0qn+>) %\!4{LaJso d||u//P_y7iRJ߬nHOy) l+@$($VFIQ9%EeKʈU. ia&FY̒mZ=)+qqoQn >L!qCiDB;Y<%} OgBxB!ØuG)WG9y(Ą{_yesuZmZZey'Wg#C~1Cev@0D $a@˲(.._GimA:uyw֬%;@!JkQVM_Ow:P.s\)ot- ˹"`B,e CRtaEUP<0'}r3[>?G8xU~Nqu;Wm8\RIkբ^5@k+5(By'L&'gBJ3ݶ!/㮻w҅ yqPWUg<e"Qy*167΃sJ\oz]T*UQ<\FԎ`HaNmڜ6DysCask8wP8y9``GJ9lF\G g's Nn͵MLN֪u$| /|7=]O)6s !ĴAKh]q_ap $HH'\1jB^s\|- W1:=6lJBqjY^LsPk""`]w)󭃈,(HC ?䔨Y$Sʣ{4Z+0NvQkhol6C.婧/u]FwiVjZka&%6\F*Ny#8O,22+|Db~d ~Çwc N:FuuCe&oZ(l;@ee-+Wn`44AMK➝2BRՈt7g*1gph9N) *"TF*R(#'88pm=}X]u[i7bEc|\~EMn}P瘊J)K.0i1M6=7'_\kaZ(Th{K*GJyytw"IO-PWJk)..axӝ47"89Cc7ĐBiZx 7m!fy|ϿF9CbȩV 9V-՛^pV̌ɄS#Bv4-@]Vxt-Z, &ֺ*diؠ2^VXbs֔Ìl.jQ]Y[47gj=幽ex)A0ip׳ W2[ᎇhuE^~q흙L} #-b۸oFJ_QP3r6jr+"nfzRJTUqoaۍ /$d8Mx'ݓ= OՃ| )$2mcM*cЙj}f };n YG w0Ia!1Q.oYfr]DyISaP}"dIӗթO67jqR ҊƐƈaɤGG|h;t]䗖oSv|iZqX)oalv;۩meEJ\!8=$4QU4Xo&VEĊ YS^E#d,yX_> ۘ-e\ "Wa6uLĜZi`aD9.% w~mB(02G[6y.773a7 /=o7D)$Z 66 $bY^\CuP. (x'"J60׿Y:Oi;F{w佩b+\Yi`TDWa~|VH)8q/=9!g߆2Y)?ND)%?Ǐ`k/sn:;O299yB=a[Ng 3˲N}vLNy;*?x?~L&=xyӴ~}q{qE*IQ^^ͧvü{Huu=R|>JyUlZV, B~/YF!Y\u_ݼF{_C)LD]m {H 0ihhadd nUkf3oٺCvE\)QJi+֥@tDJkB$1!Đr0XQ|q?d2) Ӣ_}qv-< FŊ߫%roppVBwü~JidY4:}L6M7f٬F "?71<2#?Jyy4뷢<_a7_=Q E=S1И/9{+93֮E{ǂw{))?maÆm(uLE#lïZ  ~d];+]h j?!|$F}*"4(v'8s<ŏUkm7^7no1w2ؗ}TrͿEk>p'8OB7d7R(A 9.*Mi^ͳ; eeUwS+C)uO@ =Sy]` }l8^ZzRXj[^iUɺ$tj))<sbDJfg=Pk_{xaKo1:-uyG0M ԃ\0Lvuy'ȱc2Ji AdyVgVh!{]/&}}ċJ#%d !+87<;qN޼Nفl|1N:8ya  8}k¾+-$4FiZYÔXk*I&'@iI99)HSh4+2G:tGhS^繿 Kتm0 вDk}֚+QT4;sC}rՅE,8CX-e~>G&'9xpW,%Fh,Ry56Y–hW-(v_,? ; qrBk4-V7HQ;ˇ^Gv1JVV%,ik;D_W!))+BoS4QsTM;gt+ndS-~:11Sgv!0qRVh!"Ȋ(̦Yl.]PQWgٳE'`%W1{ndΗBk|Ž7ʒR~,lnoa&:ü$ 3<a[CBݮwt"o\ePJ=Hz"_c^Z.#ˆ*x z̝grY]tdkP*:97YľXyBkD4N.C_[;F9`8& !AMO c `@BA& Ost\-\NX+Xp < !bj3C&QL+*&kAQ=04}cC!9~820G'PC9xa!w&bo_1 Sw"ܱ V )Yl3+ס2KoXOx]"`^WOy :3GO0g;%Yv㐫(R/r (s } u B &FeYZh0y> =2<Ϟc/ -u= c&׭,.0"g"7 6T!vl#sc>{u/Oh Bᾈ)۴74]x7 gMӒ"d]U)}" v4co[ ɡs 5Gg=XR14?5A}D "b{0$L .\4y{_fe:kVS\\O]c^W52LSBDM! C3Dhr̦RtArx4&agaN3Cf<Ԉp4~ B'"1@.b_/xQ} _߃҉/gٓ2Qkqp0շpZ2fԫYz< 4L.Cyυι1t@鎫Fe sYfsF}^ V}N<_`p)alٶ "(XEAVZ<)2},:Ir*#m_YӼ R%a||EƼIJ,,+f"96r/}0jE/)s)cjW#w'Sʯ5<66lj$a~3Kʛy 2:cZ:Yh))+a߭K::N,Q F'qB]={.]h85C9cr=}*rk?vwV렵ٸW Rs%}rNAkDv|uFLBkWY YkX מ|)1!$#3%y?pF<@<Rr0}: }\J [5FRxY<9"SQdE(Q*Qʻ)q1E0B_O24[U'],lOb ]~WjHޏTQ5Syu wq)xnw8~)c 쫬gٲߠ H% k5dƝk> kEj,0% b"vi2Wس_CuK)K{n|>t{P1򨾜j>'kEkƗBg*H%'_aY6Bn!TL&ɌOb{c`'d^{t\i^[uɐ[}q0lM˕G:‚4kb祔c^:?bpg… +37stH:0}en6x˟%/<]BL&* 5&fK9Mq)/iyqtA%kUe[ڛKN]Ě^,"`/ s[EQQm?|XJ߅92m]G.E΃ח U*Cn.j_)Tѧj̿30ڇ!A0=͜ar I3$C^-9#|pk!)?7.x9 @OO;WƝZBFU keZ75F6Tc6"ZȚs2y/1 ʵ:u4xa`C>6Rb/Yм)^=+~uRd`/|_8xbB0?Ft||Z\##|K 0>>zxv8۴吅q 8ĥ)"6>~\8:qM}#͚'ĉ#p\׶ l#bA?)|g g9|8jP(cr,BwV (WliVxxᡁ@0Okn;ɥh$_ckCgriv}>=wGzβ KkBɛ[˪ !J)h&k2%07δt}!d<9;I&0wV/ v 0<H}L&8ob%Hi|޶o&h1L|u֦y~󛱢8fٲUsւ)0oiFx2}X[zVYr_;N(w]_4B@OanC?gĦx>мgx>ΛToZoOMp>40>V Oy V9iq!4 LN,ˢu{jsz]|"R޻&'ƚ{53ўFu(<٪9:΋]B;)B>1::8;~)Yt|0(pw2N%&X,URBK)3\zz&}ax4;ǟ(tLNg{N|Ǽ\G#C9g$^\}p?556]/RP.90 k,U8/u776s ʪ_01چ|\N 0VV*3H鴃J7iI!wG_^ypl}r*jɤSR 5QN@ iZ#1ٰy;_\3\BQQ x:WJv츟ٯ$"@6 S#qe딇(/P( Dy~TOϻ<4:-+F`0||;Xl-"uw$Цi󼕝mKʩorz"mϺ$F:~E'ҐvD\y?Rr8_He@ e~O,T.(ފR*cY^m|cVR[8 JҡSm!ΆԨb)RHG{?MpqrmN>߶Y)\p,d#xۆWY*,l6]v0h15M˙MS8+EdI='LBJIH7_9{Caз*Lq,dt >+~ّeʏ?xԕ4bBAŚjﵫ!'\Ը$WNvKO}ӽmSşذqsOy?\[,d@'73'j%kOe`1.g2"e =YIzS2|zŐƄa\U,dP;jhhhaxǶ?КZ՚.q SE+XrbOu%\GتX(H,N^~]JyEZQKceTQ]VGYqnah;y$cQahT&QPZ*iZ8UQQM.qo/T\7X"u?Mttl2Xq(IoW{R^ ux*SYJ! 4S.Jy~ BROS[V|žKNɛP(L6V^|cR7i7nZW1Fd@ Ara{詑|(T*dN]Ko?s=@ |_EvF]׍kR)eBJc" MUUbY6`~V޴dJKß&~'d3i WWWWWW
Current Directory: /opt/imh-python/lib/python3.9/site-packages/restic
Viewing File: /opt/imh-python/lib/python3.9/site-packages/restic/base.py
"""Main Restic objects and functions""" from typing import Literal, TextIO, Union, Optional, overload from collections.abc import Generator from pathlib import PurePath import json from urllib.parse import quote as url_quote import functools from subprocess import CalledProcessError, PIPE import logging import boto3 from botocore.client import Config as BotoConfig from botocore.exceptions import BotoCoreError, ClientError from boto3.resources.base import ServiceResource as S3Bucket import cproc from .data import ( ResticRepo, Snapshot, ResticCmd, SnapPath, SnapFile, SnapDir, Backup, SQLBackupGroup, SQLBackupItem, UserBackupDicts, UserBackups, _tag_quote, ) from .exc import ( ResticError, ResticBadIndexError, ResticLockedError, ) RESTIC_PATH = '/usr/bin/restic' def locked_retry(func): """Wraps a function and makes it automatically retry on ResticLockedError""" @functools.wraps(func) def _locked_retry(self, *args, **kwargs): try: return func(self, *args, **kwargs) except ResticLockedError as exc: if not exc.unlock_ok(): raise self.unlock() return func(self, *args, **kwargs) return _locked_retry class Restic: """Handles restic commands Args: endpoint (str): S3 server base url with https:// (excluding s3: prefix) cluster (str | None): Optional cluster name. Only used by repr() repo (ResticRepo): dataclass containing bucket name and key info lim (int, float, ProcLimit, None): max load limit. This can be a static limit if you set it to a float or int, or for more flexibility, use a ``ProcLimit`` instance. If unset, processes will not be ratelimited tmp_dir (Path | str): TMPDIR to use. Defaults to '/var/tmp/restic'. cache_dir (Path, str | None): RESTIC_CACHE_DIR to use. Set this to None to disable cache. Defaults to '/var/cache/restic/%(bucket)s' gomaxprocs (int, None): max CPU cores to allow restic to use """ __module__ = 'restic' def __init__( self, *, endpoint: str, cluster: Union[str, None] = None, repo: ResticRepo, lim: Union[int, float, cproc.ProcLimit, None] = None, tmp_dir: Union[PurePath, str] = '/var/tmp/restic', cache_dir: Union[PurePath, str, None] = '/var/cache/restic/%(bucket)s', gomaxprocs: Union[int, None] = 2, ): self.endpoint = endpoint self.cluster = cluster self.repo = repo self.lim = lim self.tmp_dir = str(tmp_dir) self.gomaxprocs = gomaxprocs if cache_dir is None: self.cache_dir = None else: self.cache_dir = str(cache_dir) % {'bucket': repo.bucket} self.rebuild_index = self.repair_index def __repr__(self): if self.cluster: return f'Restic<{self.cluster}:{self.repo.bucket}>' return f'Restic<{self.repo.bucket}>' @property def env(self) -> dict[str, str]: """Restic environment variable dict""" ret = { 'TMPDIR': self.tmp_dir, 'GOGC': '1', 'RESTIC_PASSWORD': self.repo.restic_pass, 'AWS_ACCESS_KEY_ID': self.repo.access_key, 'AWS_SECRET_ACCESS_KEY': self.repo.secret_key, } if self.cache_dir: ret['RESTIC_CACHE_DIR'] = self.cache_dir if self.gomaxprocs is not None and self.gomaxprocs > 0: ret['GOMAXPROCS'] = str(self.gomaxprocs) return ret def s3_bucket(self, timeout=30, retries=2) -> S3Bucket: """Gets a boto3 s3.Bucket for this repo Args: timeout (int): network timeout in secs retries (int): times to retry on timeout Returns: s3.Bucket: boto3 resource for this bucket """ config = BotoConfig( connect_timeout=timeout, retries={'max_attempts': retries}, read_timeout=timeout, ) return boto3.resource( 's3', endpoint_url=self.endpoint, aws_access_key_id=self.repo.access_key, aws_secret_access_key=self.repo.secret_key, config=config, ).Bucket(self.repo.bucket) def init_key_exists(self) -> Union[bool, None]: """Check if the /keys/ path has items in it, meaning that restic has been initialized Raises: botocore.exceptions.ClientError: connection problem botocore.exceptions.BotoCoreError: boto3 error Returns: (bool | None): whether the keys path existed in the bucket, or None if the bucket did not exist at all """ try: return ( len(list(self.s3_bucket().objects.filter(Prefix='keys/'))) > 0 ) except ClientError as exc: if 'NoSuchBucket' in str(exc): return None raise def prepare(self) -> bool: """Prepare access to this repo if needed and preemptively fix issues connecting to it. Plugins should probably run this first Returns: bool: whether this repo is ready for access """ try: self.init_repo() except (BotoCoreError, ClientError) as exc: logging.error('%s %s: %s', self, type(exc).__name__, exc) return False except ResticLockedError as exc: if not exc.unlock_ok(): logging.error( '%s: could not automatically unlock\n%s', exc.name, exc ) return False try: self.unlock() except ResticError: pass # we'll try init_repo() one more time except ResticBadIndexError as exc: try: self.rebuild_index() except ResticError: pass # we'll try init_repo() one more time except ResticError as exc: logging.error('%s: %s', exc.name, exc) return False try: self.init_repo() except (BotoCoreError, ClientError) as exc: logging.error('%s %s: %s', self, type(exc).__name__, exc) return False except ResticError as exc: logging.error('%s: %s', exc.name, exc) return False return True def init_repo(self) -> bool: """Initializes a restic repo if it hasn't been done already Raises: botocore.exceptions.ClientError: connection problem botocore.exceptions.BotoCoreError: boto3 error ResticError: if the restic init command fails with an error Returns: bool: True if initialized or False if already done """ if exists := self.init_key_exists(): return False if exists is None: # bucket was missing bucket = self.s3_bucket() bucket.create() bucket.wait_until_exists() ret = self.build('init').run(stdout=PIPE, no_lim=True) if 'already initialized' in ret.stderr: return False if 'created restic repository' not in ret.stdout: raise ResticError(ret) return True def unlock(self): """Run restic unlock Raises: ResticError: if the restic unlock command fails """ try: self.build('unlock').run(check=True, no_lim=True) except CalledProcessError as exc: raise ResticError(exc) from exc @locked_retry def repair_index(self): """Run restic repair index Raises: ResticError: if the command fails """ # There's also a 'repair packs' and 'repair snapshots' command, but # automating that is tricky and dangerous, so we're leaving that to # manual troubleshooting only. try: self.build('repair', 'index', '--read-all-packs').run( check=True, no_lim=True ) except CalledProcessError as exc: raise ResticError(exc) from exc @locked_retry def prune(self, no_lim: bool = True): """Run restic prune Args: no_lim (bool): do not CPU limit the command as it runs regardless of the lim arg in Restic Raises: ResticError: if the command fails """ if self.cache_dir: args = ('prune', '--cleanup-cache') else: args = ('prune',) try: self.build(*args).run(check=True, no_lim=no_lim) except CalledProcessError as exc: raise ResticError(exc) from exc def build(self, *args) -> ResticCmd: """Build a ResticCmd object that can be used to execute the requested restic command Args: *args (tuple[str]): restic subcommand and args Returns: ResticCmd: a restic command executor """ cmd = self._basecmd cmd.extend(args) return ResticCmd(cmd, restic=self) @property def _basecmd(self) -> list[str]: cmd = [ RESTIC_PATH, f'--repo=s3:{self.endpoint}/{self.repo.bucket}', ] if not self.cache_dir: cmd.append('--no-cache') return cmd @locked_retry def snapshots( self, *, tags: Union[list[str], None] = None, timeout: Union[int, float, None] = None, ) -> list[Snapshot]: """Get a list of restic snapshots in this repo. If this server is a Backup Manager client, see the ``get_backups()`` function instead args: tags (list[str], optional): only consider snapshots which include this tag list. To check with AND logic, specify a single tag as ``["tag1,tag2"]``. To check for either tag, specify independently as ``["tag1", "tag2"]`` timeout (float | int | None): optional command timeout Raises: ResticError: if the restic snapshots command failed subprocess.TimeoutExpired: if timeout was specified and exceeded Returns: list[Snapshot]: Snapshots found in the repo """ args = ['snapshots', '--json'] if tags: for tag in tags: args.extend(['--tag', _tag_quote(tag, url_quote)]) try: ret = self.build(*args).run( stdout=PIPE, check=True, no_lim=True, timeout=timeout ) except CalledProcessError as exc: raise ResticError(exc) from exc return [Snapshot(restic=self, data=x) for x in json.loads(ret.stdout)] def backup( self, paths: list[Union[str, PurePath]], *, tags: list[str], bwlimit: Union[int, None] = None, excludes: Optional[list[Union[str, PurePath]]] = None, exclude_files: Optional[list[Union[str, PurePath]]] = None, quiet: bool = True, ) -> ResticCmd: """Crafts a ResticCmd to backup a list of paths. Warning: return codes 0 and 3 should be considered success. See https://github.com/restic/restic/pull/2546 Args: paths (list[str | PurePath]): list of paths to backup tags (list[str]): list of labels for the snapshot bwlimit (int | None): limits uploads to a max in KiB/s. Defaults to unlimited excludes (list[str | PurePath], optional): list of paths to exclude exclude_files (list[str | PurePath], optional): list of paths of files containing glob patterns to exclude quiet (bool): add the --quiet flag. Defaults True Returns: ResticCmd: a restic command executor """ args = self._backup_args(tags, bwlimit, quiet) if excludes is not None: for exclude in excludes: args.extend(['--exclude', str(exclude)]) if exclude_files is not None: for exclude_file in exclude_files: args.extend(['--exclude-file', str(exclude_file)]) args.extend([str(x) for x in paths]) return self.build(*args) def upload( self, stream: Union[str, TextIO], /, path: Union[str, PurePath], tags: list[str], bwlimit: Union[int, None] = None, quiet: bool = True, ) -> None: """Uploads a stream or str to the restic repo Args: stream (str | TextIO): data source to upload, such as the stdout of a mysqldump process path (str | PurePath): the --stdin-filename to use. This isn't necessarily where the data came from, but is where restic will say it did in the snapshot's metadata tags (list[str]): list of labels for the snapshot bwlimit (int | None): limits uploads to a max in KiB/s. Defaults to unlimited quiet (bool): add the --quiet flag. Defaults True Raises: ResticError: if the restic command failed """ cmd = self.upload_cmd(path, tags, bwlimit, quiet) try: if isinstance(stream, str): cmd.run(input=stream, check=True) else: cmd.run(stdin=stream, check=True) except CalledProcessError as exc: raise ResticError(exc) from exc def upload_cmd( self, path: Union[str, PurePath], tags: list[str], bwlimit: Union[int, None] = None, quiet: bool = True, ) -> ResticCmd: """Like `upload` but returns a ResticCmd rather than running directly Args: path (str | PurePath): the --stdin-filename to use. This isn't necessarily where the data came from, but is where restic will say it did in the snapshot's metadata tags (list[str]): list of labels for the snapshot bwlimit (int | None): limits uploads to a max in KiB/s. Defaults to unlimited quiet (bool): add the --quiet flag. Defaults True Returns: ResticCmd: restic command executor """ args = self._backup_args(tags, bwlimit, quiet) args.extend(['--stdin', '--stdin-filename', str(path)]) return self.build(*args) def dump( self, snap: Union[Snapshot, str], filename: Union[str, PurePath] ) -> ResticCmd: """Crafts a ResticCmd to dump a file from the specified snapshot ID Args: snap (Snapshot | str): snapshot or snapshot ID to dump data from filename (str | PurePath): filename to retrieve Returns: ResticCmd: restic command executor """ snap_id = snap.id if isinstance(snap, Snapshot) else snap return self.build('dump', snap_id, str(filename)) def restore( self, snap: Union[Snapshot, str], *, includes: Optional[list[Union[str, PurePath]]] = None, excludes: Optional[list[Union[str, PurePath]]] = None, target: Union[str, PurePath] = '/', ) -> ResticCmd: """Crafts a ResticCmd to restore a snapshot Args: snap (str): snapshot or snapshot ID to restore from includes (list[str | PurePath], optional): --include paths excludes (list[str | PurePath], optional): --exclude paths target (str | PurePath): base directory prefix to restore to. Defaults to '/', which restores to the original path Returns: ResticCmd: restic command executor """ snap_id = snap.id if isinstance(snap, Snapshot) else snap args = ['restore', '--target', str(target)] if includes is not None: for include in includes: args.extend(['--include', str(include)]) if excludes is not None: for exclude in excludes: args.extend(['--exclude', str(exclude)]) args.append(snap_id) return self.build(*args) @staticmethod def _backup_args( tags: list[str], bwlimit: Union[int, None], quiet: bool ) -> list[str]: """Builds the base backup subcommand""" args = ['backup'] if quiet: args.append('--quiet') if bwlimit is not None and bwlimit > 0: args.extend(['--limit-upload', str(bwlimit)]) for tag in tags: args.extend(['--tag', _tag_quote(tag, url_quote)]) return args @locked_retry def forget(self, *ids, prune: bool = False, no_lim: bool = True): """Run restic forget Args: *ids (Snapshot | str): snapshots to remove, specified by either a Snapshot object or snapshot ID str prune (bool): whether to automatically run prune if at least one snapshot was removed. Defaults to False no_lim (bool): do not CPU limit the command as it runs regardless of the lim arg in Restic Raises: ResticError: if the restic forget command fails """ args = ['forget'] if prune: if self.cache_dir: args.extend(['--prune', '--cleanup-cache']) else: args.append('--prune') args.extend([x.id if isinstance(x, Snapshot) else x for x in ids]) try: self.build(*args).run(check=True, no_lim=no_lim) except CalledProcessError as exc: raise ResticError(exc) from exc def listdir( self, snap: Union[Snapshot, str], path: Union[str, PurePath] ) -> list[Union[SnapFile, SnapDir]]: """Like ``scandir`` but return a list instead of a Generator. Returns files/dirs found in a requested path in a Snapshot Args: snap (Snapshot | str): snapshot to list the contents of, supplied either by its Snapshot instance of snapshot ID path (str | PurePath): full path inside the snapshot to list the contents of Raises: ValueError: requested path was not a full path ResticError: Any error listing snapshot contents from restic Returns: list[SnapFile | SnapDir]: files or directories """ return list(self.scandir(snap=snap, path=path)) @locked_retry def scandir( self, snap: Union[Snapshot, str], path: Union[str, PurePath] ) -> Generator[Union[SnapFile, SnapDir], None, None]: """Iterates over files/dirs found in a requested path in a Snapshot Args: snap (Snapshot | str): snapshot to list the contents of, supplied either by its Snapshot instance of snapshot ID path (str | PurePath): full path inside the snapshot to list the contents of Raises: ValueError: requested path was not a full path ResticError: Any error listing snapshot contents from restic Yields: Generator[SnapFile | SnapDir, None, None]: files or directories """ snap_id = snap.id if isinstance(snap, Snapshot) else snap path = str(path) if not path.startswith('/'): raise ValueError('path must be a full path') path = path.rstrip('/') if path == '': path = '/' cmd = self.build('ls', '--json', '--long', snap_id, path) try: lines = cmd.run( stdout=PIPE, check=True, no_lim=True ).stdout.splitlines() except CalledProcessError as exc: raise ResticError(exc) from exc # stdout is formatted as line-delimited JSON dicts if len(lines) < 2: return snapshot = Snapshot(restic=self, data=json.loads(lines.pop(0))) while lines: data = json.loads(lines.pop(0)) yield SnapPath( snapshot=snapshot, restic=self, name=data['name'], type=data['type'], path=data['path'], uid=data['uid'], gid=data['gid'], mode=data.get('mode', None), permissions=data.get('permissions', None), ) @overload def get_backups( self, user: Optional[str] = None, *, timeout: Union[int, float, None] = None, serialize: Literal[True], snapshots: Optional[list[Snapshot]] = None, ) -> dict[str, UserBackupDicts]: ... @overload def get_backups( self, user: Optional[str] = None, *, timeout: Union[int, float, None] = None, serialize: Literal[False], snapshots: Optional[list[Snapshot]] = None, ) -> dict[str, UserBackups]: ... def get_backups( self, user: Optional[str] = None, *, timeout: Union[int, float, None] = None, serialize: bool = True, snapshots: Optional[list[Snapshot]] = None, ) -> dict[str, dict[str, list]]: """Get backups for a backups 3.x Backup Manager client Args: user (str, optional): only list backups found for this user timeout (int | float | None): timeout for the underlying restic snapshots command serialize (bool): if True, return as a json-serializable dict. If False, return as Backup, SQLBackupGroup, and SQLBackupItem objects snapshots (list[Snapshot], optional): if provided, scan the backups found in this list rather than executing ``restic snapshots`` Returns: dict[str, dict[str, list]]: Top-level keys are usernames. Second-level are the type of backup. Values are a list of backups found which may be dicts or objects depending on the serialize arg """ tag = None if user is None else f'user:{user}' if snapshots: all_snaps = snapshots.copy() if tag: all_snaps = [x for x in all_snaps if tag in x.tags] else: all_snaps = self.snapshots( tags=None if user is None else [tag], timeout=timeout, ) out = {} sqls = [] sql_map = {} while all_snaps: try: bak = Backup(all_snaps.pop()) except (ValueError, KeyError): continue # snapshot did not contain a valid backup if isinstance(bak, SQLBackupItem): sqls.append(bak) else: if bak.user not in out: out[bak.user] = {} if bak.type not in out[bak.user]: out[bak.user][bak.type] = [] out[bak.user][bak.type].append(bak) if isinstance(bak, SQLBackupGroup): sql_map[(bak.type, bak.user, bak.time)] = bak for bak in sqls: key = (bak.type, bak.user, bak.time) if key in sql_map: sql_map[key].dbs[bak.dbname] = bak # else the backup run was interrupted; backup-runner will delete # this snapshot on its next run if serialize: self.serialize(out) return out @staticmethod def serialize(backups): """Converts the return of get_backups(serialize=False) to a json-serializable dict""" for user in backups: for bak_type, objs in backups[user].items(): backups[user][bak_type] = [x.serialize() for x in objs] class S3Tool: """Wrapper object for moving items around in an S3 Bucket Args: s3_bucket (S3Bucket): service resource from ``Restic.s3_bucket()`` """ def __init__(self, s3_bucket: S3Bucket): self.moved = [] self.bucket = s3_bucket def copy(self, src: str, dest: str): """Copy an item in the S3 bucket""" return self.bucket.copy({'Bucket': self.bucket.name, 'Key': src}, dest) def delete(self, key: str): """Delete an item in the S3 bucket""" return self.bucket.delete_objects( Bucket=self.bucket.name, Delete={'Objects': [{'Key': key}]} ) def move(self, src: str, dest: str): """Move an item in the S3 bucket""" self.copy(src, dest) self.delete(src) self.moved.append(dest)