From ea094ed0b61891fe49bc43b80d7e270a1cd746dd Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 11 Dec 2023 04:24:27 +0000
Subject: [PATCH 01/31] chore(deps): bump github.com/spf13/viper from 1.17.0 to
1.18.1 (#2524)
Bumps [github.com/spf13/viper](https://github.com/spf13/viper) from 1.17.0 to 1.18.1.
- [Release notes](https://github.com/spf13/viper/releases)
- [Commits](https://github.com/spf13/viper/compare/v1.17.0...v1.18.1)
---
updated-dependencies:
- dependency-name: github.com/spf13/viper
dependency-type: direct:production
update-type: version-update:semver-minor
...
Signed-off-by: dependabot[bot]
+ Authentication is set to{' '}
+ required, however, there
+ are no login providers configured. Please see the documentation
+ for more information.
+
- Authentication is set to{' '}
- required,
- however, there are no login providers configured.
- Please see the documentation for more information.
-
+ No Providers
+
+
Login to Flipt
@@ -154,58 +199,7 @@ function InnerLogin() {
- No Providers
-
-
An open source, self-hosted, enterprise-ready, feature management solution
+An enterprise-ready, GRPC powered, GitOps enabled, feature management solution
Display dates and times in UTC timezone
++ {inTimezone(new Date().toUTCString())} +
1YFvbn|KR|^AcEq0OKqvgfCDI@# zuMm(Q|02E3)`ZianBeKBl?jR&5U>|`{^-VM%8>F@J3;^>W~0aa8woTKxbvqWuq|Jx z5QakolIsOv_ELiRZA}^h4=TXOX2pxD9izSSP|=T{f>SL}Mj6jq8PqBbh{yWgBVR+{ z|hms#HDVNYsXP)b3FzDI)`b>Sd08LHG1}sCvgzCoIX3+A7qW(g)S^z*Hq8>1N zxKMjo3*|(|Jx6nM^Y9Nme{O_hGGaQPD-jfe0om<3fJp#?2u*_qJ@kQ~Blto-A14zO z_#3?lH_BdDw|=eQQ3#HE&BH+Wf&K7U&1On)&P#a`obDR>>~<2^O^7~|siT7cx({)1 z9yEA$fLv`N_%c|b!vOdzydZAm&Mz;P;02@704oz8pvi>_MR+gk)AHCk?ki>%5I_Kc z9CfZblPN&}BA^OEWdrddx aH!WW6m(*@ltCo8pXB;H+^i P^@e4)^c_ z1~@@NH6=t?OCIym`D9?= n)p#ubOouG|HOlH9- z$}UeXo2lk+d|`+Y?&%5lMx7C^) xl}4s-M*3Crna_`FxN_sW!d+n9kDog*34^b z$4Z5hD^US=cV|aOIsr>S1qc8FlZ$3Fb+8$Qpr*lulmP_AqAXCUB2+*Lw!aDhf8a(4 zz_kgB1T^4oJO~JIalD3NAU}=|Bse=)3`YV00W5X`h!s0{w5qr@f(9T?5Ci}eQ4j zSAEY7HsEYRZmdxSv?RAaJ@SSW09b}&XmE9P!u)3t;hg}~Yx$vpt4@qS1xyJd1aC#q zok#(=M2aEO?brG0*LZt-D#hz&bc3HWfkPoGo39U;eucZxfG|Ue6W-unU42+zKYRC1 z2%tcHBk~J{%O%aA%3xAc@i%i>SwUMI;_2&r{%f2YpY83UK`7MQ>*wC5pBvCDNMtJ_ z6k97&h2x8ht1F}@00`bK80zci>Miy5dI4Y^gdl;D(3)tiSwSJN5<^%Uc&P{mZ+xhj z&sWp&xY~bCjR2uH+{<-}JHSz=KSCk4v{<>gKq)RNSCz{8x@dqf)c>!s5&7$dz)~mO zVB$zf=w1{J?)0N9PlWIe#i2l60mNwqU$zJH(H|y6FQyY@@H^$IWy`q&gvu5eDwWDv z<%|ddFgFJPEx+AjS6);l(4D~0t;ofR6mG99)7SZJ0CX~7gluj^O$2 uCHIN8yY3%Ac9>pqQ#|; z#SW8N8#?Z2AYoMyRA4**1^G!32SN%p$Z&Ds%LpU*4NF(D7f^|vogJ1zi9mwO%XbDj z{W$_?y+{r0G88ohf; 040-xlO2K)u*dU!7{}W3WS}qM8UcTcC*o00C`S{bF_F9@fhr0H7UN zhg5ciZzN>f_+pB@o8zYxy*oR80LVkP@@1t=fEJ{mYXLw@y{JGE0J!Szu8)#m5VYZJ z<0Bm_0rFq)kDKZ71woNsOd;Caa@n@FEZ>cR4usg@!63k2fItAr-`LV3>4gC4?fOVU zdY3XnBB<5|=Eto|z!nHJS(^y()oPu@hdyu*#p47(v=O0Qy&UvQlUA?VI2uI_hOida zB7+O(0o1ImPI3|4iG{QV6>dYeDWEL4^uUq4H}~Osgkw8iUmheuilTGhi~0G3T5s zXXbMfr@Kezp8KQw-G_&r?Q9wcZ|&RXR`v}*2?(AA!wtol6h;cj!QHJ-`hc*6{l&Wh zSP( kc7?0Y}}^MQXuAQt9olnCHMj Fv96M2m4JnG2>{1eJU^#=5kFfdC6KV^a*%e!P^3cJ(vlo;`gR8|2tnIU zg24qa@6mc7DfnVQ;edFU{_a;TE5SYzH0^_gEAqgr*=JBJB2~(GERX--E7z7*2pV3v zw%8K{akS##TRSNSk4N?i!AjFDD=|H<84&A@oWS=6JK%r-UI{>R&Po(;suM>_i!dL< zzP9DKsusR5C<)?4Bj_DR(CuU>;eXgIJ+luEMj`lCo0u2nht0@=`h E9Z)T7_UpK3i_)-&jh>p@efN7D6Ft`yFTC$OBYh8F-@${EuS F2OudR9N-89hxI6WZ4m+y zfF=Q-EYYjHMEHqVz=BAiBcKE&$1RgHArwts GNP_1p z7Kp_k8x?AS^>ICHZf$#Zrx$rZSV;pv7?R3PmJur2Ck^p`YE|NW6H^J?+Y56-;9UeM z0D_5Zm!u%je-7>-2H5w<%t&A0`47wp>qEw7ZG4f>6a`lTND^hoMT(42;V3!B)T5oe zDY!+Rhq*!M&u5V!7l;9bBDCO2>kiy+0M@S#N{iFG=@jqKHp{?~2(L*k012-i5Rw!E zFCVNtM2!G6ImEaxfa09f5wednvF9fMVkk=s1*h`hWM82;% KKAqXAu#}9lVWdphbM;yp+ 2 a2b@{GT-Ln& zAb>CFqC-G@g6*!WnyQPt?$qdjgyvinr?c#zegTIvTMOU4l`8-M002ovPDHLkV1kNz BEENC% diff --git a/logos/users/ocrolus.svg b/logos/users/ocrolus.svg deleted file mode 100644 index e18698cafa..0000000000 --- a/logos/users/ocrolus.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/logos/users/paradigm.png b/logos/users/paradigm.png deleted file mode 100644 index 983aa1026f18f02883741a82a71b03a7526dbde6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7048 zcmbVRby!qe*FQ6Kch>+Cf;31CFeohz(hVccP{I(7Lr5y!AR(Ygs-$#WKpI35kr+Y{ z>5z^O?!E7O?;GFu$9K-NpS{*zzu#JW?{)Us`#dLFS6h{YkdY7o01|aICH<=!eO>Tz z0RU7Zt!H_qU^^;mDFVRTc%m~~oGTq-ucogB0D;_BxNrbCxyJthfQP~W@Y4nWWHJDN z&NHV`4|-(?hnuKB(9#0 AT}hy;8vro^*WRuqKm)||Pgx(t`zHnr0FllB z_MaHDtNQv t-Pi z$nOweS2+$7EnUc6gf|>=TToa~m_wcr0)ari9UjQ&E2;cRztZG5oP2#fWrT$M{rv^~ z#RL)FjzS{R($Yf0qC%pg0#^tDp8yYETcm)859c3F{^3Un?ql!m?CI-_@PJ(VwY5V$ z^p)e_xDNEs>yLiIk d{SXToSy0!?32nq}RH=3{Wga3tgZTW-t`?&rH2fcPC zqw9=>yO}CEyTd(vuDT{KDl9Gq{XNcqnEvYN->4@4JN0j-f2ZC>xFNg^JZ qwP;TROotM|2EHmB=Pswt1QYBLWTbMM933f>hWg+ z0KK5PlA<9Jv|)*tV%Z(g`n|>7Ov2FV#Q<9OjlF%8?!ZR}w>#0cAy~_3s+bV^$TX~o z_RyzFVtoWQb~tfwpX=77f%HNhn5fN3Ng;1K&>};85JNxXsoj3} A*Q_0foRXZ%;8>nzwVy;b$A@ftHGPf>E+ zT(S4HXFtk#KsZ6g2=Df(97%5oog($in`k3+&p6g%HvxU5q79yE{@lD9WRlrOYh+nd zp89L>QJ(c=!1Q+k0s1CfThXwDXUftK3D?fZM7uqC_}S{dCQjO^9;_SBxt~gh$34Zt z!<5J{8pX<<8a+md)CZQ;AN*pJ#jhyI_;z#9e-lnL;QxZRgtm(D@hE=&P;O;e9#TDn z$orzHs>XE2Jm|%X9~KAu#t)1KZb)2srPo|4yF??>!*`-e?0eqC8{N(`2-LW_@?%@) zzOCm%rg_o|Cl5bEaj%}@PCA-SQfyk5KeM$l6c{#5e;pN^CRaW^F#qyW Hxco&{|l%kHtB5`<0eOEq6qTReh z%|?{ Nb&QxzDzW8F!qeJsI3flyNSh*%!6=pNq7>E+ zj 17Ycy)>d(k!WzG5 l_uR@nMhwx zDyVx*C5{y>yyF8)R^)-u-%U6p@Ml}^?I?TTjgO8JH$3R`6o@?c6TR~ekW;g`ypSc^ zY=ZpgB( `=Phij#{3vl_`_AIqq(7 zSTjl0!Rv$MI&{|E#u=4P1GC^77u-5Rd9?F`jStQh%1=Ktp&X>!X!T6&aTHrvj_f(w zq$r3~{pb0O$YGeakC9G*h_V2;mX<-J64vsBYC#_Va^dJ&&6yqN)Hg-0V;8!OxKujZ zAK0iBdDF57$t-Y%6k!-JrCUuBYm4_e;k9e~diME4Wb~$V8k2M!Cn2JB)I&MAaD;Hd zT}JIi$l=cd>l|zqfVFc&k4!=8xC-xnwmZ2t9Z(`a6==zAK-c&+;=@o}oXgK1<&JMc zRU<7sWwoepSwnTH6mbTN4JHQ}^B+|QOy4hE5cs^(c`oeA^i%8y@9SR`4(eI7;hj5S z6-EUZ(_;^7>fKz+TSsZe`vMBv&mp^_1ymDJZ}-v!@?~==!KX`n)Owl}__Rdmk;I zOnFs4KR387TUStN>#aN25aji#q(#Yoan{drCPhs3OL=aK?`nwUUDE(sgqnDM^SRlm z#D^|_lGe9e4Am{ IgZMGMBSBqx>}mYV{di*G-bdg@@0Sr9eeY1F{<*zo0QU*=)hGH`DY-uRr1a! -taKLttZX4=Kx7W7X&0zzJkVMBDRuFkXwJJv=APUCzTEzN+5Tho z{fkN;h%JBOQnWYWidUoH9@G>pxIn14*(>*R>P*ZdLw0bdkb_n;(1fr0z<;eiCst!% zE7aLIceQtJBZ5P0nLWLCBD{CO=0&jyMPuKNGWv6YG93f}%ITjGdN{T0ji36-`0)0B zS}C;4%GCH(IkLr}v`^pa;TM+CcGgcI`P_snQf{Gh>Q_qj?HRDuPZIIp0Go$4FHr&$ z-bil&NU65D5i38mh)ft$EtEbwtk!nYl3rc(^+ lG`87+ums*cHJ`=#@rQ z*YR%XIQ}vOw=KA qwp5&z&lC83n|#E!$~*5PUB-6+x-~rplj)?{ncpD(OjglQ;0q z^vp)sYtfG#zNi&WM2j2Xpa&Kmg>FGvmt_LWh2L~-#=+|N@tQL{>D744+t+5P935;3 zS|-4P!7)a~GdTJ =FX=gKDm^2xqX&L=U zdG~%N*ORtYXP}h&dvE+(rKl4)KH%Bj@97j|d(Omidm_^3mgfz8AWDh?hxpm((`T`4 zlgE6XMKMLXTT<%F*`>}Wf<0}U7Q;Wk{#1Rht+Ui9Jhw)`S 7{D&!tSGV3H&IVr7@+BgF#w&(cZOJh3d#`cuEE4d?YU#7%HZk(-Pqfta@Gpz} zdI**DKDTgOX})QYg;$p^q8F{Rph*MB t z@=C9P{z6pGZ$K!GWq>m?;#1Kqj_{{%s)L6p407!4G>0QijB;7Vq5H_*t&zPM;eP3m znWNvj@VqOw6SCQXe8X3TRs776s8;&6v#Fhds)60}_FtWh-^lVFtw%OlNi9hjGrXL_ zNlGQmSxKvCIF8cmt&hTODs(K}GTYFZl?`B#xN)-{tKo^jW7;xHPFdI!EgBN=<6(S5 zW_16=MDHoWSJ@C5E+^$(rV%PMa-x4Bzw^P^>oh!styf^~TVW@-MM$l<@Q~PROW#{a zuj*Va+LwRRy7a&t!|m{5mHvUr1COvvBDH4d1q()O)*tq0K`|G-m*{V$;MErG2~O2K z)qcZWazy+xfD)Cv*^s~*5yCisCV4^?0sk)I0~M>X3*&LPr;Tq_9Q92vcyt8j6@qy= zuTo@%+sp)eIv~I&K7w9AQm0v*t x4FSsbdav`^Sn6DOTAy<~srBPaK98vd3jg%5 zB~HsHjgnSq|`i@!bnxuv5SeXkd%ZPK(3t19vRYW?Ta2*q_Q0o)Xg@f)g7UeyZp; z>)?5Klx7hvpdig0l#g*@>O0zpkeYekWw2=p?k?MLO&pP@0u=+}Wp`}E H^LZwJnRcKPGgKZ_& z47Qp1O`mPk=qAOC8>z(8;*3DR;~3I12ZM}YlWF54_=znp4h%#=8!`;usG?l#Da2Bg z*)g4}AW~Eq?1r4-SC+89ok)r78i`3RA($>byr)a)3no9kpZT++3F}jtZ08*Af|0bP zkqjOVAgL@0@(VL@$DnWwE3<-}7$(J4Uw4iT4JDS7uYSuvdL=kLeT3xVEn6B1Z(^tc zlsG@jB~G9)&OpPCw^-EfR_KCtqNmub9>lLYopOgCOl}+(N>EL`>92_TS{PTj`4w`0 z)WozI3u}buMFeq*R)(B&Q7(v)c)XRPR(fU!`YJFCW~uDN)C>d9!7hMK+{7E-Mg^?A zid*NHE$qYE{HjpePHQrA#)jjDQyl0e)`>>6t3c$?qevOK?n=7?v|+b={8MjvesGMU zW<;`1+c0?3+cbiw7jFh I|oBoOf9BogJy@o%u{{bK`U^GvLf~^)pDUFAD^3psca5-oB8FEo|3BN z%) 5S_nYv5*>|L zz3jbY{9*(;SCz#oAcL}rKYokY;^0*(Tok6G*mdAsjlY8@JM6-4pfs+~Jb_wCyC2*7 zBb|TDb0Qr1_?u^?(qp(|ml(8^B+kt&Dw)XLmF9p@yX)eoz$mJvB_-$=td59P@KJ 3bOQ2uq!lr!6XM
hi!#U+LOUe!l2vRX>5q}k}bWcRT%+23=5t@kZ!&+B)UP6(QMb?vRo zhc61L_uZ@C#b865SoXqQvqvU_=~t)*g*GBI_|GYdfX<1QovsmP`A2TO=Jy_0ozz9j zEw13ahjbdP$^>}0Q-3y99Cez{P? J@SzBwmm910uT>qZG5-;ray!%^}**6lO$~nHy7l*DnMnMgpEIEcIqReB9 zxDG_`{O|H_^S*f@ToT_rU8z{$hzJpTBtmd&W=ml41|us)(MYGBqLdP-BRMvNR*rgh*}lTr8e zz%3XzT}ED3Bx&1Q7O#7=r8pFJ=OUe?d>Z=GC=Qe%Oz3Lwy2dK?GVGZS7d`61Bp<-k zR@@!8Jk2d1tA4uX)CchRq%&p{qTecX1`O#Ln^WL@dG}R= =hiZO_tweB zo4-LTGoLLvxVj^LxmxOdS?18@_Z#g9xbxNpHW^%*9JY*2F4a>}bVF1i$4L;30Ed3} zavLRNp!z6}v=^8c490{+kEG8M HNqM(6`WF;l-j@^YEMl+gJnB~$b0Fkt@jho`$%@FSE(#S|Zr|n^*rZe! zL6|hD)Uwa)#<0x8t@ikIr5C`l%Kn*ud1ym^?L$#A`7S9jXe4T8xyuJz*X5g&!~u-K zyKciIZa}PG44IltQ)$lW_KNoQhu6{5QzB_I F#@_NyiELkFK~k zAJ3PMc9>oqc}4Qzks^hT1tR5|K!iqQ6(+VTJcI7%J2x!0Mb%kHB&M12G=!n50JTNi zRQJ7HS<#1?_X=6r4#hF1f S&$~sHkkMjI!kyjGbcbp9vE)Yf>>p^dNxm+xVsp`vs5Ef97R~ zZctn}I`G1#)sQUBlio)p=$3j{mkXuA_4s 8 z&rqaAW2d8x_a2O|&lp_VCSDU9VD=p)pxV?XOfy`4c`7~yLG}^c6f%wyg6ggscPl+P z>~GR W*JWJvFtdjF7O>QA-V+YCjs_xAd!6dRgvsifM;bj`;hdeBYk zPkyy;>TcdkvEbr5YB ml*GSmN&243Ep9?%@jen#?$U}co1T~|ZC=cd z*wWoJzfUhTn^}t7*;~;3NR&!if`wU$WYu!Ro`(iZFdDKKx1cyPrUYRpaGfj z#1wXA?{>5dnDaXq 8phRnbvN_w{j;UfNrW^- zyxd7V-$l@yhdR*%__>=bbH?76uXg7@Ym4uuiA+99snTCIBRwF4iEp9z)$&!*8ie^G z^hC#G^b`^G1r*g%h=zdox+EoZq*G+s^|j7Eae|!mfxbw =Eu|OT~SsRXa7;HaX{X8 z^ITeMXCWfLEh(fyd(VpIQf~5L$`d(q9H;PU8S)`HK2}w-3J9|$a)n-GNdl=^&&bMb zi*!$GE%qo(Y6C;FK2f8;=!keR22*Xhx5x6RdAjPF@e+3x1YTuDz_IO(HQw0vOTJwR zddBs(^nzlA>J;LtJ+rc-t VWl#$Alt$dojv8oii z$++)$LTZjxBRa$~RNKKcnO`DU^wJJU_@1(G RU%7 8hOK;5y28 -Wq#*T^S66DzKzV< zbH?*RCBif9Qx6b@>=-X7S6NURHScLP %zN$&;~R*k)uqRgVLhs97~|Nlc>2n!H$ aiC33cF8?&`WBK*}C3R(OrOG=tq5lJ2Xoma% diff --git a/logos/users/prose.png b/logos/users/prose.png deleted file mode 100644 index 0e67bdbf8e63408b1caceb5aca680f5f81d80634..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9455 zcmeHt^;aBCx9*_9A-HFN2@nAW_XG{DfnY%fcX!R;?(P-{?oN<_;O-3W?j9tEyx(`e zbM6oK54daH>Q%jW?RxgJtEzi-uU*|Cit-ZJ7~~iL003J`QcM{DK!|?Up=hYj=VOt> zPtO9uL0LisP(DJr_uTL|QkOE8lLNec*3kec2>1Y`zmVqxfItpF`9}u;q!B3oqbnmY z{u_e`00fx 5z zfxVfnqnV90@Gmb!-^R&Nke2pup#PMAjnmQ0_ &V7-TwYF8~1A zEGaQz6<35KZ8_%+u6p`6Ss-?`ijL#X14HA&w>FFWMDZRdN?#O5Fmg+Q G z^uGkO951* `RTtChk+J3d}{eWz3u`NVNF zyVsQT`>WzidUW02iKAe>pB|7~5H+0}biFQyYu{|LoUE{^LK z7IYqV=d8NKgj#QaGz;G9JXYzj*r=5#;G9ViUEW?^{@RgZ3tQi%MEO|E_#juB8>U-c zK|-{0bMCY}qC%OGJDiS)WNr}@89?UbL5HOpn=WoA5JAi2s#2GuU1<$TDxEE=`TaZ zP=cMwm3i$cMzz6sWqWeqhP9$CCAx>J{jO4MN|A9&A&PgzThvEK>kUDdxtQpc$(1L9 zbt$9tXW~I3%W>}ygl)>U??)ut-0$pB ~EM@M!e6_bQlbiL{QyV%ZHI0CQZ!+@q zf)WA_j=QL)wk~LX3|g^AD-Pu;a&os RC>G zZgsTkN{zvivVD_Ob^EY2n6#5jk;)%`feJ}7^p#$}{Twrc+QO0QyR93xVtEo0^HB9= zN7TYW?yj&1@O=UW*2In@I3_X37-5|UYVwNxl1hEx){53>k1U#-RZtT>)I@|TvP>qG z`o0u11&Kt}18FN$v~r8U)#TQp0w@Nfl(NMODIJ`qM>2!DZHiM65 nPmwaXv9{ I5NJr57mW ziq;4;FYBNGS!|$d?y>QM@?fKulP%-;;Nq4Qg&{XnW&APZSa{9Y=s5Kync88DG@N+H z-$=+Lv^0Y&ihy5J>fKc%9aW*)?jXl?t6j)cpxem&ew@)<3kw4?N(JV9Q)Xq^1xN5o zr{k$fYBaW)AKfHiey2!z|6J|tXjoUr>*tT#dhQB-Lu&tinxQxu^Ga<*F=M>^d+0=i zToN3)E`lqW%B-SWGA4 c-lB9a9K&dd}ofwwH8E5Z?K$j+Yn>4bSimo z;pduCaAtbf^w4&kg8GAt&zx0m(kvvV861<>_CjP*hkOUmtkS|l>CH5@U&tkGE8#)< z@hg;iK4=pq<~t6}?l|Q D`Hl$V+J^a7-)(L((J^r1NN7m3I1XrF$ z%Wn=_q$i8TmMosVHy+9DXUD7nO7? p2dgvK6(J#tg&U5p^cRiX%Ia2a zqTV1m|8zD=Gv^Vp@E%hG`zEA7hu-r_-^s`q;lT`Zi94|`NBJ=n7NS-HIFPuAeBTR( zUz6H#Y;cjHJ*>pFJ9S!>d;|Tl^K@W+XQ~WO7k@hSu#H)&c3xv}8_uR)1=*Gk2kDh= zg1@zYP7R|iz%Nb){lvRi`USEN8J@mYn7|u%8m1dKJknTCr4Qhy_Ug{oX4a=oYO!^o z(WjUxRWQZES+kLkwKV7eAOzo4m!jID&roL}oa@Q>79ELZYm8b>q7lhANQ5IqUZ&Tz zjA4n5?z U9QiP^J zL{lFHduO)WGek 00CaS*w8Sn4HY3wBc8D?e9g-b~GQ6 zmX~uqm;*v+?_(J)32fLYu|Wc#iNiJC9?8Dl)XSz%pyuEnW0tp?Z5R_mAb`#G`FZRt zWt`DD?q``=BV(;1Ez!+Es0m{WRJ%Bk_UUz~+xNJ2Dn`>_wRhT>%)3Y +aJCese~8 36KNG1b>pADo<&_^wnJ1b0^U16I)kk9F5 zV2-)Eq5x8>mokJ8dJf Qt}mkxFK+X(%Qn+uUBSmjlYM;kV0gazek}Wa5cuP$jFI~}X*g6MM O z1Z7Y={h4lP{D-nTfn){8NECK sKDQIkpuXw7PUcx_hRNH5L{da*>i^nxq_fWs> z>w`RclIe0SkvBhqLv)2w^}EE$eLo{eLT9c?3*HU1z%tF<$O2Dgc `(7>4M}>0rY-- Y>pub9s$RhvIcN_P8)m*WX}cDP!h9 z=Q+Xyw>O?r$Ui!?=azN%Rl$sHZ}v)~FPoGPzUYX4Ot!C~B ku^?v*NaCtg}rZ WWMYf>vRh0t&US#V zwQ0y25HVicJ(zwET=yxR9qbWv$5^Ec5y4Ekim@W r=75(BAv1=}MP_9>dr?2ieY!0S2u>1GZAkJUnbO$>tiybAbWU N5Pc=sr!mWcr$OLPlA7WMYhzzvq`A|Bb1e zWtsJ9OXJ2bxJ{!@r=~iAgD92XF22K<8s)2dx0PTq8Pz-Vm5_A<@A(Jb&2s^=PcLs%4%q@6J0( PRU zp0 &K-^nR42J$TVQ6?U(t5t&-J(yE_lYxbIgL_ z#-v*Us#5SRTh&oft|Hj?fv~0{HUlaZgtPZ6CV*;&-dxF(8lSuORd$T=F{MAr4H7BW z(0S9`+){&+i<-sh;^?;7=d6xv`VB(~=dB_^`i+gvMyVnuX`$twoGv&1_hB(obJjs( z3HBZ ;CzXs}*%j{Jkob7AnpfQ)LEh_yucOJ{b(vnYF(nS|%CA@Nm=pHJ@P} zDu3W)mJ=W+{My3#cWu#b2gyXW-8|7a_&_`BaM{f)%~|T!AJ(R>BeKHG{`7-1nC07# z*1Qi4q+aBsW9#G<^>k65
{ zAm^6_HXxEa*t;XW@4N|9p>2rV0qSQRN5gU5&&SJj9k0Y1jYn}TOuK+siB_< Nn{0Gi&ybK8|l!91KA{U40UCs4CtvC|v5ew*>-E5uwmz z_cL}cMhK7w&YdsFf{IUzn=yWR@syW=*ILQ;F0aYBlqp%!W@S4(-*RLQqtOIcmy*&I z(#rko$};S O3G%i5 zC0)*EhUbKdjlZYSxJytC{^;+rz;3BV?c?F9e&qhAbUDF4<%~5NP =H+QJbF6nx|vRo_=`i7MzVWa2e$aGt?{ZR~Q8viqfQYb>`DJFb-^y%EPw z-W0ZEN}qM<**}<6qx~+K%G4R3 SEnV zQ}RJr)FU*);&n6b`L9MP$FH98_s1_uLz;j*m2>vnSljf~(;gO_ktun+ET)Wm6yP`y zzTSX((n9nn=# -!LZc{d7KE;<9PJNWzY;uOF7T;iYa%mU{vr*E8^? zra_|7J-U36)rO}J8)ondY0*U+^KXZ3ej8Oito`7~$joHjP5gRg{H=qoCS<*2wr|h^ z9EUlS_Lct3@TDF?fzwQ1014JKpQm^SUFx5rTdwrkhq4{#MB!~AXPELIdkzBYD{$Ye z;HPi(JB3IoivC;zHI03Sspz$I$xYV1wYVfCm2Dg7NP8`)ATQx;2IO5KdduOD;^!`c zMmfy$bQ973F;l`*qYb$zpGYM?IsuC?5dDO{T}!~oU&@B|oS??!6sF99W4+}~JyUb4 z+TUpEed2k+cD!@Do)VmoP zcKDq;!3!v~6PheTDjDhjfPqLS@l96qRFdy$9mw13f6BW`N=?_#<@6OgKMdy~$XG#M zU(j9F9lkreVJ}FoyslXW&H4(xLyP7T!Nh^g@CAxJ(&3X$fIM`JW$P`h{F4{K WbY!z_ e1*%jo~YXE;o5i= zx&qnMhNC;LJie&5T`}gm^hEn(*iBK UlH-Y?JL_)Tb;-V1LtP~Cs0;&2|3tsL1@uqtJzVe)4up^ap1HK(RR zTI;MOI+%CdE>m{8)vF&lWi# VAr|)|jQ#bHbY6Iwr>79z9TEW{Pv= zKx#kYY;LI7|Lm4D*kbT)kea!M_v_JPwA)my_)vwV&qqu>F$x0)ws7Dl5$4X*7f(3q zZf1q*S?h~j;Tmw3B@V&sq8+br{ ;xP3Gwn1-&wFj6wt={qp4>z4+RNfTBU=N zcLL@hNfo}`fpjI m}?ss_Z~bT?Tkqn(u8&<4uqp6fSssfdY1xdlt@Q zxHyq7ZxX56{en|eis# ka86{e@w{|XIf&pJ8KCpN@sAyY-+AL>I>jWTVU%_T&4V7jF*d8sy3fb z^_;j>j822>On?tq(b2bo6dB+fu2Fk}hG1?DhC)5^mU8Jpk;kdV<&PoU yG zy+>MaHI!UVbo9mmp+JU}aL#Uo3Uwj&!j1%EDPeWOz4_&hx!KnsX_UY-7I+b{@1$ lfEfY4IT*0za7o|)F3T&j1 zSVPSpCOcZJy1b&sM8e{&!pa1%R==se=dcjgf_9vkH8TppSI%cMD1Xm&9kK5c jv8_>F`{wjz!Lj)mLg%b9OscE;TfU{1 4`N?07KY_U^U1Dor)pE2-Z7iA#h9=PiZVs#3EDel>&KD=4IQ{sAVP zqvdt}qYA!clV~q2BS^y^(3$ffz{(f0DUA|<5t*{B0{%qT7V-kC`86fB8tes8p8B!a z(&_XxUBQ z@NX0uCMx=|4xgf%!&NT1N(*thTvRyy)+TeOS zkAETWVy87f-I9)&q!EE|&}G)XAi$VK7FECH^*BW-%bD^!VSb^4QyQG*FzDCBP`}zC zH%XrvWh+E}R?fwXfr>A#L-ae*Lb|d@1s&<*S4)GnoUIJ3@$juYGDw4;Lzc6!t8$RZ z1kWWebz+L~s_6)Q1;d_)IgOtIaDJ1!iwX|>{Qc!e4^)wknD&b}V*Q(S>IM1HBVTF8 zSIFLOK5{%YL#QA8>J&05)~iuplHZCRI!AZMiHkm&fAB#6%ITPrwdfq(h`aUaASZwl zdpLZnMzU7=J{#_NjkOu(=g%}8qw*g9qGpmk7qZ3ryonjUsTrmZa*e#(e~PMG*RNEi z77S+l8PaHCZfOKuMVets=Zn_j@h`(JuEV1bEYch`4{>>9jhs6werBabHUNrlG`c-@ z;Cd&n@INh+7R|AyBJa>fyBSu^1W-;SVl6s~_WBKSSJc3csg?CQncEz!=RJ{GtW!D9 zM$Fr+yw0_ItHiNz=g{@Oc$-|v%DOT#j~iQVKc}5SmmB3xQIvjqSjYqtNn7IdZnR#H z-dDqeyc5O zts_BWS=y-We4jB%WNMf )&F&zjS_&DZtpHd?nGmI_ 3cl`FOzztu5DeKp zZVn5iEhw>P-@8tc)#&cl{`?x6qfi6o%46lTpk#=knkEVpXpPC tn;-1BbYZCYN~5bsFjbh%qNVX~Vz*LDRqsz^v?_FCERWc5N$ zg!IU8ft7_HmJz|y-}KP_c?aur%Oj6N?!iVW&>5+XX=MiqtgaF#nDf$*8DtQ8 zs^52-y8;;&ZrG&r5pl|&{Z@Z-AjC_bEk=|)skwT<6>Xe*>eE*Ngat#O8_G}U+v#R5 z5qKmxit>EtA`REGeEYp=&PNy4-6kw&x3cK@0aLZR5_xZj_c6%?q6I_PFGrb&d|KWH z4vI<2SZNWQ;pJweynqmdLxu2fDR!4m5GhY0Yq@+KFyLT|n10_~Ix4l{4b=UUB2#Lj zI X9fTIEf)-Z8 SoePG2$f1LI^tbY4}I8wkcE%QbTrU2vNM zNSqk7=MDV;qIsEgr4ePdVr_ja|zyB)55K-c{_i}8T6gWX2f|dw#DgH z=0AQHLC4r6M?Xmn_)HDF>XPNdObEhr+8p1p0fIAwQXgDOCq1m{-T!!#AtLFLci*x_ zDR383?f2I9(01Q;4f`>-gtpyi_z=cJHxnTF@!WR~k2VkTuVxJ0a4M9>jkBc)Ot5+o zn9$#OdkK}WC|syPfWxHI0b{-xO%j^TuG_Ve6UrgYt2pbac4KcHdL3h)v?N`H)lznh z&L3Zg0cvkhc6n}I!{V+N*V*s`0g}Xi@;d^SXHJH`yc%Fpv IC<3X2C9(XbOL3Q@E5~r90=#s4jdNv-i>U+?t)TkPKc4zYLl0Q2AhQJ zpDUbH8hldC0yGb1$dUR7^S!d!+2foH`ZrMT1JM1hQP!oUJI^6#=t$^6&v~yV>u@|Q zl&lGQdM?!-oM-UBC$ov7?*bklxj7;ngaLLr8c7DUTe1Cj!2WIeovFvez>g-T#o`kw z;EeWuuJ}uG(Yl^szX1`9u`+hNw>};6nO$Z#{NSD#6V2yTN32oT+`xTs-*Q$dt1~O> zjkygEj?)UML{mq=6JJ+@cO@u9Ad)4J_A-IB+Kv8`NXz*-2Ywo1Da c<&y#p4 zVU0Dth?MGw3ze%e=!R ~iuibLTOL)LHcndJOD*;UaJ3#rBmB-|eVTP<~ zDVT*Y3lVdr6U;sN6((b?VRG+Ma 8V2?GP8S^GoiDLd@&Jf7)n>G U@le@d(n-%b-*Z16BEs`Vjv^uXKiF`*=N4V5 Igp=TNlvC8@f7yF`uo?9l(@WDxrm - - -- - - - - - - - \ No newline at end of file diff --git a/logos/users/uk-moj.png b/logos/users/uk-moj.png deleted file mode 100644 index 03a26ff0d0828768ee409c013fa12ba0e0a8e26c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12690 zcmd^`- -- zGLu*QH(+?`D!&C(jL>iY6WDh08u9=@bpp}d2b_PJz)i)_696FX`A^44 7&=k`(ehHHaQ4sOwe01AvhoKTgOCWfxhta3TnM~ZTiA;*f)P4<_0`1URUyyQ-K zb~kcSsOjOo0#lkb?XSjJ#FCw?ABlyb?r!PMzvlSc+83`YtJB&dlbA0#*hZKZ%!xJh zPpRXk4RroT{l{4KANBvROO^V62mLPv{~h$d6#RG4ox=Y+FaJZA8(|9v?7cT*5@-3K zfT`)k_F1o>pQk7*jIhdy9zjQ|;+^Rbc*_K3YKb{1A6H*;{|CFK*`v!hG!V?3v%t%q zV;0Oi5EcReD0^spbg)1jefiWWOg;NW(*+^ ?*Ma94T<|(XkAvpM z!Q#s^@^FI9WIL0v9U-xwPx& ts$Spg7Y{Me;+x5W%}{?nD5WTuRNyN6pa`br-*9CS zUO|}z_^Q6h=B0jR^)+`3PhJZ7@?qJHYAclliyRW7G0l4N4BWyXzv~ix@v(*Uv*-bd zn|?r7Qi~*Yj6@gTh1(KCj@Dz-_bkgS^9u75DcHxUg&`q;#L5w9DbQ|BqpQaEI~gQ2 zB~qUrara2#@`(l+C_J!PTASzn4Z$=WKXt>rZOq4@QUUfWJNtsG2Lesl=8ZDmw1en* zRY$cnZDg_vqU_(G$d=!}iG9>xO9j>Clw4;+snV0nGd_*# zC*Uu##Un2=xHX4negM7(Ra=I`sP99pTNUpOPT@8VXxi!UoK~U!@7K{``=brUeXt z?v9golH7GuzqRohULoQt{#$tzBL_6SlS{T?I2y+O*aDPJknZ|PUta7Aj- ZZfgpx>QgfY+Pfgf6eWt+v9=+g %Au#U8hli#*2O9+xLI65lIM< z2~&oEx+SRJq6-e-$e0Odx3=XihuWd?5!PkzojvBti{P_`nJD~c`(7W3YYx232?%I^ z@xAw`L$hB!sK#*25%<2V6uhYMKl1>8#6y2xaiDhv@D^mlF#F$CVYh!ufrnlgYZn!r z6x;|g5gYg4fYWO*eSdelgx?mj9qNTAH+j43^r+=Dn#QF@WMckseAle0`6Lg`MOwW4 zEDaDyk`G_&a{kPfzeoiGUPt1b%W+q<%ee7Y@nB8TDcg3Nu722!LXY|s_Wm$UZs6os zzyqYmjUA5Cr(ElWJV1+1up~i-y?Q;nwpD*dtHFcW@Fdo7lcGrf$(EZDJ1c*zvdLmf z{h3$c)_SlPL8(1`GuAZeA#BUuasWA#2cxQ)Gl2~;N5lKJ(f!Vyi(zSA@^Bq&PL?n? zMul~XDtOlk&ztez#_iP#!^k)qQF7o%f$`DM1iB`h=4ResvIt(JktFK@S7*_&sG@Hb z*YL2fVsV`7c)tt$;c#?Q^Zt1vpV`%eS_F-f#~j&R$wNiaZQYrnMf0~`(TBEjb)$vr zANlk3XzXz_=XU7ktFsK^$=bL0-kM}RD<5t63-xOGN@o0;#HaFJ&;@VWNbj^ZEy!Ub ze(mMyt60;^^VA}S(J1mY=I=1il?Tws+8wT6ug5R{gWv+AxG*oVAA%I|G)|12MBV(R zYPz+)rq>~uE9oA)720m4Udl%E6STm6W8@9dC$SOz#guum$CO6as<{)g2+nYSSwdqU zlxX<`dN|_^#rvt;j4)fk!XJDg#eupl`+lX}_B5{cU+Jq)RI0QTQw}em;S@X&L9Yk2 z71w+_FdBSFN2f#Umpz(y&AcOvKE!ZzT2jT`1;NdWov35h&1{~F{;JaR`Rn^j#A5h1 z>0TbM_2y{5h2X06_CqnJ7h=gN$Py?kDo3bpFu~6c>P|g|MO+vsD>NZX3FPrBFq&F| zRt|r-e%48&qtEPuv~Pt@HOYwA?(hZrRgstfIU4+CDQi{>R( 3^1o_c%aJbVOQV7J 2jj3kQllM-}d z2oDX760(J@-ijMD4YUdW9>+oeB`$_N%I%=NETO?)PbYQuKhF5fCN7e{;gTjs9()gc zHG4Hy8i=B^81}{JNC6N69NHM`HRu5$Ap!Sl=GxR*w?lHwqz;5Yb_V<7Lpc@Pa(!{q z G(ymAn+E(J$>FvS9g4{*YZvgHuSxG z5xK==-V5&YWayFh pZh$I-R(?UQ7*h!?q`HT7Sp7fCyv-bm)8+bRo%|}Ij7e& zQwk6n@ErTQkA$LnSlRw7B)&3Wr42CBpqi2n_EZ2AH8YprUCw6JifvH)s{=iHm)9p} zhO2s-_bA3y*(?QTjig{_nNqOUds#|~x7gwH7L@UcAAL7v`t^-qBr>NY-H31TWA!Jh zS6Ix3@(j &N6b a4~F4wL0m3k z25DS|ddqu%G;D#>op8HY#X+I8b}TUa0Nclr=s*JS*R1_(W3w{_0+$PRX!c_j8#O|h znHCe< s$e%R{GlxnPON zNg!SoV@qCWSlZ~Rf0Y3W!Q0J1lWqA@E109zraFr*=Ddg=p(fT_obzTN{VR oXC6tFlST5LVXuu_5;8&uBUSW zk95=hvK7sVXk5$tNdX%e)o!j4d7^YB9zw5Zc!^ nS|!1?u*c9uGIDzZ^y#$(POf_ZxSDvdG?f+|Olw8H6v%m$S+DBqW~M$hGNk=- z?7sip{^>8Hu2insZqw8dytamr_s@vsA&2)&fdDZ3DQd1e1vMR<+R)OWai+cddZ7Nr z2U9zTX+AWE>DoD_6#n@_|L;M#N!l?B5H9Figq*q%Eq>D8f5EG!UqZM)ZS~1RrlnL7 zAiQnKaUf!4NbJ?Zghef~ce#~lj!SJ?J8r)eMv8XfK;$Ve$K5yEp8H_&>F0eCz8znR z9n!gFB%*A~^AmL;iNo%Eu>W)V(Vcc$-m)wrWpLp0q0FtmV$2I_& %WtGSl7xj0jq%sem}Z@Bpc6_Nq&q?Cipf06N7kL|$ h&<`HZAC#dG< zx$D?KhM1{ry1NOw9v~O5L1P1WZ{4>4d?n%lU5lxoJkGq5xPYoiq(pa%Y}?-P@A67T zsg_C_@&K2LO+qN)R&r*Ie~>P-q4^OubOi&CZUEs;3Yf?9LaBisySuj*&40g{wtqcb zz6-q&j`Xi<%uP_rt7AZ$(;d-enBEJZHd l zAI!s5lrR8ebr2G+4+lFeUR17L(~%=C@zTYC_*_W W-1_1Jt;1DyAH-gaA_4qSD5!>>Z@M!xd>A@B&vQFdU2 z|G8=Tgfq~rZcwF}HkG36v^nce#tJbr>OA(?aFs4qdEA1g4z3+9-UmLhYT*aFAb{6h zQoysc*&ulD;BL$gDM#RRs5}O1zt{!8-!cyy0tYs$WR|FBo1DWgB2Y?YEMD*4kGSvp zcCiyh?DuptD437T=p0j5qTKj0Iv*p=L(Cy)!%3^?@i%ah5cx6Nh{#vC$)&)Ydgp1E z>fG@c-zVfR3J*4V^QayHOUj}jXL+Zrb+b`NeOedLb!C0y3#rPb>>J+H*u}%7(+Ai# zI}t*=we+B`FK8`t-FaS7(`Vjtk!%iOFOB+2g7&7~mF=*Ju_n`q=$td(5r7uAMKQC^ z&?)T;{iMP!3(c2XS$Q@byaGM-eu5bv^91pW#c*LPIsNHM&U)jiLz<=hlUr5c&$6D! zM++8;D!5UD$c}rP`sWw4amV7OA6{$g*e=8?#NvUNoiPk}MbD3K Wtma`tHB|t85FHhxtKYto!ulZ_L|$?37H>|gABnKXtu!@b2EkOvjc;F)x7m}4 zp5iNQq)vw7%|fcc?5ONx6f;C}-#5#Z7IH}Jnm0Jv9+$_XduB+@yQ^7}-&oke7fpu; z{RMR)Fw~8S<>qAX _Jw*Ej7I(L4&fsP$OUCPmjXrn&^sOdXgX z5ac6UZ`1SQT8sM!^M5W1mXXr>j7*$q4Y~80zA*a8-hST*_nn_z>-=c!^wqvAdmU?H z>VRaY9Mxl4?4@5R%({}*^$lyLn{|fmq0!@Z(+`4`ABCQA zB%ydt1OO|8 LmYP)21MX@i z*42G@CLV!r?$buQzh!7{)1L7t4O-^C n# zA@+!SA7QfWyTTj7F~wSk^BeB9Ov)=b&mq}_2iul;Rr%t R$rH*@JT$iTb?)kKpi$O}4I zQZaON$=|PIP|zIP?>!M4E$AwwAE!KB>+VFVdRAU1siRFHhRwCQ^Ocy{myB0}pkUEO z@b(0sflPV#@3EuTpj2Mt+C(SK8s A?zumrsC-Wnq@&(z!51KjXO)+vNp$XG~ig9_5Jrj zOMW;38wX3~gWq22;p{PY$7?V>_ke$gWKuiz2z_JV2BnzoNTDRPXMPusY`A2C+^BYX z0z_qe=omw5=-F0o4bS`wz~ 3>?P}yG#YsQ!Eh3R3eAr1 zms>0oAey$vEDIEJq*F6k-#ZXfjxlTTZ4k*it^2tvNL)b(r1lrlFxDxT{>1G?iAhPA z0Bez+02_5g`IzSDC`>Gw-OOe%qTJ5w(fHHSl9UYyi+^;W4-OB#-K1S2Q{RNrC<&Bf zW~y4_!^POEkCtvT$zGWW@x6O%k)|$3vq%pcI5|6`iTWK&A~>MA!~^J}Y!>WlPc$no z2*LxGTi0(?n5}zuj{i1=-}{M$mFep!C*_}u15n|KZ7nT?Eb3OHooY7W_Wt>uuP!DG zx2 rOLj@I>Oq~wrt71;4HvQ 5><`Y;pPkgCth$NY*0wI#ve>U&!T &2f}~U=a+53kbYk!USPylfA$H^L_uf0f;m90E+4J;#u}?YEym5Cn z?GqLl_BMdaX-784otIyiNH|yvQ_jAUg0wS7zY&x@xUO#}o>Vqb0K`hQg_(`6!K%ZE ztcggEIv?_9sk9MAuv^Rp*L%{|^GShyz0vb|#tsAFx4@C uIW&ZXI-N9x5>!3b5! z18%#|!bc)~!QGMy&IWfvBYw#jg?obgR6)-_^B|mM8338deFSAPiEX1qFmZChpZ$OB z#8?443*uWpNy`qVi;!uM1|skz4k G<@`s_#{9R-+?B+#m*36$uMcw!Pb51Rx#E^f6GS8;o?rwrI@67G1xxd z4vZwRKo#-sNopr|eQ`nsNStL_lygcn{3VRB6{Vf68gaPGsYqmUu1<5RR03C+CWh#Z zEaqTuue#kNYI075oX+%_Z3ke>j=mymf~2@T|0`&zF>o{MouzDGLs3cSqerC--_0;Z zmG{o=T$#mM6_zKCL@?nvF+@z+g7z!i$Zs~Nm$z_TAsIjR3OiwN?2B{X=J@4Wukl5u z9TLsb4k|O0qDl#lp?b+v{D5j2Wsh%X0&(ttzU44?+%qxq_xsw``#o_UY+2OL{l>df z%rM%29mrJ`{ke)PMFt!Y!;v5`>g?Kciw{ D+K&eCrLt zfo^>{&0$`n#7tiaO8#AY6^*m4g&2dl#_DQqu8c (DYj=WM)a>P%rHgED+TZrZ`q*`q1>C8JsQ9b4kxzuzN@eL z$R>Lf4wX-+nVOl%#JFAJHy>KNd_n`)Ft3-+jx@ziOd}>Bw&|>L)pw|l7XB_H&zZt8 zo?i_a`((v^vfguJ{Q+R8UR5*YFQon`MQ$ajXq!#O5xLGU*3nSB#PMZWW_VXrp9O%H z-j+R^Ah+%IAkB$9odCNar{nj>j+ow?dBd{FxVP)KTEn)M6Q2u~r%6JK%l~SLyfHT> zEx9&n0cOqTU0D4f$-Wzo?}e+?&_A#M`AgXUCPCYVVq7sZPupgxbph;o+-C-Zx!(=a zQ@qoN0j#I@3mAZj5rd}8KmqWw5qF)^V;Lf}-!Hh>+Q--luyuWOjFLO4fEHJT@Hj@~ zBbe(d_zY@!Z-iN2;Q{;aLta78wj4+?wo^Y)5WKtA4@=sk@Z_O6_VJK0REhvu3?D-9 zS3dC2)Yl5gHP6f4 &}f^Lu8&{R*L(1Fs3QlgeEft)CvC+2^`DD zbm*Nkrb2#a*A*V&Y#YZMEW{3G$p^fLcPtC?vaSLmk4}4PGwzs#xsE#nGg{{4<)J8q z(kz?qHQ=~B; 2tYB5)~iSTs>9sgekcb zUy-tpN6yxn09K|2t@HpLO@wdiT-#%11uvt0&z>ev{^2+mUdBQWO~;Ly 3 zJIGQaU3zc7qx$@vRHd+*V}l`s i3=dTq|lt;L^a{PKVeVkPFVg(|)+ z9$oQdjuTKQkbx3UbN%Wk4prJ8x>DP_lsL5!ghU@=qJ8fx60i^q7u;8=UAiaWo6{^g z>go2vhQjT0mQw{7QP*qS)LMrpTb-}N `%@NxWf+y+kiddCc*g*^*#LGQR@lvc{{XsYSiML-kccnYCDv|GQx-6Vk zqOPMYuD!1UnxDI=&CR8h1^$`n4rx<+VWQe~X1vPDJV5&ja4U3zsRkpUIa?o%M0iXg zVpkojxZAQP3!NPLL}ciYzX$cfGE5ig{}xP{9f3J5-@fJ(8Kf>{pd1<=oEYYoDQt;- zB5JKu-%@(QnkZg$vo57u6FWDpWgEymEVUCh8B_AJO>e5n2b<6P &sufCc7xk1c(ew )mXKJb?c!^GM~>G=zWE;DCO288bV(PK74)dSs}YQW z5tveBSvIVwRkU87Z9VEAe@G7r0ye`NF?O3ts*Nqa>X&>6{ViHz``|1vrcCrUv+EIG zOfvj(X=|gMJNG(4DRau;lZQBV2A`9b% u9UWpSlZ*pWvSb$NsD z7bhQav9m{uDsAHuYb()Nj@@TvH&!&8Vv6n!K?yAv5twdUL0CZ4$%HgL)f_`BR%f7- z=+mGepQSn#dFk9_vKfr3G!%|G6OMu3#WhvMBGqyp(oQDLs?4dm)G1V0x=%^aRUN?G zi}M{rQEnx`sccm5kfyfm-#_7FSkWG}q)6kpZ?%^y6f8rp-n7TsXBt0lTb$PlUCQ7i zr|4~XBd&2ur?eWu@0lx3XmLjc{=N8%4`5qF)6)FW4*Mb9Q@&rMbQD0HqAF0R0<@{_ z_~D^j=lVVsP6)|JNq*M6<&!vc&u~6oyL-Znb1Iy mm7T)z#um65#t`Vy%vtlrxt&kfZ-PX;4g3>z+WG zLdBf^hR4(})p`j@g{uq5z@SLy22$fqh`B0_#n*(6_YoD`?0pdJw5&;MWC#L)YudB6 za4b;WM$vY7DmF9vzk*6I?{cT#3vB4(VN^gslIy)>=QMmK%&zf?l?~ }Maes> zzhFeV)ums^0D;%uQcJ02rN 7WXG2AOz0xUmRS 1qM@7ImlNso`dsb3^@~G6VYiB4c*GmLiDpHg7u}aya7 2oDfaeq;fTw_rH(O4EM1HFz9W>y%JYoMOz;UnX2d9(Pt2v8mdtHl|Kzt`v z0Hd7A5@c$3w~NPU1tW(w(f`G$bmeXxZyBL-+zLFlLIHpeW&gBqG(71z-NKMHPD^Yj zZTe%ap4#bxhuE7CS;w>;mr%xD$w>iSZ+wU&|L&_J6}y9eK+{TdG_eu^37=#vG*rt~ zQ2w38sAmRp`L&(m0Z2!NVDm!R`0&3jhDg{oJ{E^~f1d(|Ag)tAyPO$T+8zMBFJi$i zPV?H~JsyA@kB3Gj`+1^u<=I|yB~h&0tjp;k5f8>lZ}Lnl7bI8_yP{b`#q>_jyPOyK zY3=uNC9&A&$BLs=nKb`~gsJ8bC7H5u=$|uP@>J6R`4dqUO@xdKn%`{0&gSPGgreLu zdWqR44A0EMd~HrqlN={KQ9Z~U`TPS-mo87t0p>wao_C$uhw7|xHjp_TA8G3I4BMv` z?$w9EIeZtuCymJ62B+#}f%T?Od3I0>Z`#olG}D14tjxM6R;rUgr;2aHv%ZmhrKtaF zFT(XMm!G=65$fmm;~}oou^d-6hy)x(SBa-`{=SQc3&hDPtaYJ6ebB%>fU?gNgu7_6 zed<`TH@x1+g}P^&7b`IX#d^*s#6q(%GjyMzr$h>KaS)lZiRH_WG@ pS8X8h2dnm9y3i?}Pt00Vf0rOtD&c0FW+7sTwas #vKITYzq+w*<9X1E~KO?)vRX4oJF4OQbw?bra^ z0*aQp4F{wEYVu*--NFU+GZFakxB`LftYkICM{X|v@9V4%^p8H(;DpL$BN^B8w@%!M zt45!t**JTDuPOo3Qk-={Fi+V+pDW`BxOxHmD7<)S WVYG~wG>7o;A*kGHeare zUBwKhe^=|6PvAfMN+L7)i`Gs`6uH~QpUGJ+e)Jmu6Nw4Wmf;B}ZxuhMn5j5Xm4~SS z uE;hgf{Fy*epxN~c9+bD9 Mk6wOE<2Ouj3`8 SVODJ@z)SkL@vg<)@T&u3`VbGh zQ;GMCW|NNYHt!^Ls^J!oya+#=$&m1?lW6uJ0*HJ~BB;JQyuz&|n&?*} vsh0)aSPCl4-(k6z{LsegB z{EpZ9d@|KHHs9;zOAa8aYu6jfxL{E6DO4%XBd@N0#W4smVd{l=XcqxL|3H_0Rm_)G z^NeGoBSv`t5FoqDsyJoTs0O>!nQo -ME79LJijIB&t4xZQyi|wvyjV z3z!)h%_en@IrWx7!?P?LTpoE_-om70obslvonN16XXUPBe#jCvHZiM1AEdn!`9*<; zrs%1+|1mD&^6z?^= $c#A!PX5u7r!iHVY_F3!v_e9yYk$tX0z|> zvGV65@;l_KqvI^7a ~E{VJ=zLGA_N!T<}ATH1DDB0H`23U#}Td=TFPzM5m-$ z(pw-q{`%bVnkD@oC}*0?QNB(IBi?5FLJMb=)ET_U8_Eq)4;fbTGHcxcg0*(`8+_ap zidz}W6V9nRG0ViPa=x@v@jO0Wu7}G4HETz4)cg$dF~uIauw`j0%isLX(Cm1g7KPY5 zJ*-_4nwTaXWt=zAM5il80^IKJ!`42Oo-m|Q2NHu*c&Ul{#dh_LuV}whbaIppt&L)< z4WR1B&9FuZca^rjEpg>YB7;mAblSMUH239tbkh|rAwjr~E>hL1cGF$swH?-^G)bVa ze5RyQK;~LVAaa=^qgQxHCBR|(LbL{K&57bk^$Ol31=aAp)r7S%zO8(0TC(YKbTSd@ z`GPz&np-+MKp69Bi_42M_fe$|QXIrQ%V9|r$`DTRT1b$g SI*ti!ShO2H| zc@!&U)^|K*!ENZjT;~MwiF#YpWjO%Cjv<@)+debB`B6Y7lBs~P-P#4%A})S|e;IIR zU(4Te;TWK#+x%g8PAI$Kn;;YTjGE<$>kS=~$;eCy;x+1^qpwewWxziqW%%dQLUpMx zYU(Om870p0QS|;sw99S(umK#6%m@VBvD*`?~N1m4Dw>e5jdUKKDjFca<1wkO}o} z6Ti!3_QM2xo_ HRm0)W&S<1-;Ro9Xr0`GX;S#W9lHV=AZh{7s*g;rh1frRQsZf z)%cX?AQQJwifFzs>r&$#qgMp!@w)9_&j;YX47iOJE92^1^hDx{E%KM_rm(@TQC>el zUszpvxZ8be+Bl%~0cg5^t3`eDbGm)!cYfQ@wvN@(-NtVU)qv%apUToY#haj|`gdxW z%w-l62~8lFRJt<>>caP<%wp;GoYlk@;Is$h9OR-uYTD{^bo>J^53#r~9LM|EZ8zN> z6!Q|j=JSx>kX&I*ONegON8NqciFv$QU_0v|K-S;hg!ydaRFrwBo!lCCwq4#{^Em1~ z@^;-=gn-&_;apNB1D(E9h6opsUG8%dPOM$$k7@1xPA Rxr>cA!A|6NhKt_q Date: Fri, 15 Dec 2023 14:36:32 +0000 Subject: [PATCH 29/31] refactor(storage/fs): adjust the declarative storage abstractions (#2540) * refactor(storage): introduce readonly only storage interface refactor(storage/fs): export SyncedStore ability to set snapshot refactor(storage/fs/local): embed synced store directly refactor(storage/fs/s3): embed synced store directly refactor(storage/fs/git): embed synced store directly refactor(storage/fs/oci): embed synced store directly refactor(cmd/grpc): update calls to declarative backend stores refactor(storage/fs): rename files from source to store refactor(storage/fs/git): simplify condition where reference is a revision refactor(storage/fs): remove store abstraction fix(storage/fs/oci): remove errant error check from store test refactor(storage/fs): rename SyncedStore to Store refactor(storage/fs): add SnapshotStore functional transaction interface * chore(storage/fs): thread context on update * fix(storage/fs/git): call get instead of update when initializing --- internal/cmd/grpc.go | 155 +------- internal/storage/fs/git/source.go | 188 --------- internal/storage/fs/git/store.go | 201 ++++++++++ .../fs/git/{source_test.go => store_test.go} | 111 +++--- internal/storage/fs/local/source.go | 75 ---- internal/storage/fs/local/source_test.go | 67 ---- internal/storage/fs/local/store.go | 83 ++++ internal/storage/fs/local/store_test.go | 64 +++ internal/storage/fs/oci/source.go | 105 ----- internal/storage/fs/oci/store.go | 98 +++++ .../fs/oci/{source_test.go => store_test.go} | 77 ++-- internal/storage/fs/poll.go | 68 ++++ internal/storage/fs/s3/source.go | 124 ------ internal/storage/fs/s3/source_test.go | 124 ------ internal/storage/fs/s3/store.go | 135 +++++++ internal/storage/fs/s3/store_test.go | 122 ++++++ internal/storage/fs/snapshot.go | 163 ++------ internal/storage/fs/snapshot_test.go | 4 +- internal/storage/fs/store.go | 368 ++++++++++++++---- internal/storage/fs/store/store.go | 164 ++++++++ internal/storage/fs/store_test.go | 237 ++++++++--- internal/storage/fs/sync.go | 221 ----------- internal/storage/fs/sync_test.go | 228 ----------- internal/storage/storage.go | 57 ++- 24 files changed, 1576 insertions(+), 1663 deletions(-) delete mode 100644 internal/storage/fs/git/source.go create mode 100644 internal/storage/fs/git/store.go rename internal/storage/fs/git/{source_test.go => store_test.go} (65%) delete mode 100644 internal/storage/fs/local/source.go delete mode 100644 internal/storage/fs/local/source_test.go create mode 100644 internal/storage/fs/local/store.go create mode 100644 internal/storage/fs/local/store_test.go delete mode 100644 internal/storage/fs/oci/source.go create mode 100644 internal/storage/fs/oci/store.go rename internal/storage/fs/oci/{source_test.go => store_test.go} (70%) create mode 100644 internal/storage/fs/poll.go delete mode 100644 internal/storage/fs/s3/source.go delete mode 100644 internal/storage/fs/s3/source_test.go create mode 100644 internal/storage/fs/s3/store.go create mode 100644 internal/storage/fs/s3/store_test.go create mode 100644 internal/storage/fs/store/store.go delete mode 100644 internal/storage/fs/sync.go delete mode 100644 internal/storage/fs/sync_test.go diff --git a/internal/cmd/grpc.go b/internal/cmd/grpc.go index 2eece34831..265404c722 100644 --- a/internal/cmd/grpc.go +++ b/internal/cmd/grpc.go @@ -8,7 +8,6 @@ import ( "fmt" "net" "net/url" - "os" "strconv" "sync" "time" @@ -20,7 +19,6 @@ import ( "go.flipt.io/flipt/internal/config" "go.flipt.io/flipt/internal/containers" "go.flipt.io/flipt/internal/info" - "go.flipt.io/flipt/internal/oci" fliptserver "go.flipt.io/flipt/internal/server" "go.flipt.io/flipt/internal/server/audit" "go.flipt.io/flipt/internal/server/audit/logfile" @@ -33,8 +31,7 @@ import ( middlewaregrpc "go.flipt.io/flipt/internal/server/middleware/grpc" "go.flipt.io/flipt/internal/storage" storagecache "go.flipt.io/flipt/internal/storage/cache" - "go.flipt.io/flipt/internal/storage/fs" - storageoci "go.flipt.io/flipt/internal/storage/fs/oci" + fsstore "go.flipt.io/flipt/internal/storage/fs/store" fliptsql "go.flipt.io/flipt/internal/storage/sql" "go.flipt.io/flipt/internal/storage/sql/mysql" "go.flipt.io/flipt/internal/storage/sql/postgres" @@ -52,7 +49,6 @@ import ( semconv "go.opentelemetry.io/otel/semconv/v1.4.0" "go.uber.org/zap" "go.uber.org/zap/zapcore" - "golang.org/x/crypto/ssh" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials" @@ -60,12 +56,6 @@ import ( "google.golang.org/grpc/reflection" "google.golang.org/grpc/status" - "github.com/go-git/go-git/v5/plumbing/transport/http" - gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh" - "go.flipt.io/flipt/internal/storage/fs/git" - "go.flipt.io/flipt/internal/storage/fs/local" - "go.flipt.io/flipt/internal/storage/fs/s3" - grpc_zap "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap" grpc_recovery "github.com/grpc-ecosystem/go-grpc-middleware/recovery" grpc_ctxtags "github.com/grpc-ecosystem/go-grpc-middleware/tags" @@ -154,119 +144,12 @@ func NewGRPCServer( } logger.Debug("database driver configured", zap.Stringer("driver", driver)) - case config.GitStorageType: - opts := []containers.Option[git.Source]{ - git.WithRef(cfg.Storage.Git.Ref), - git.WithPollInterval(cfg.Storage.Git.PollInterval), - git.WithInsecureTLS(cfg.Storage.Git.InsecureSkipTLS), - } - - if cfg.Storage.Git.CaCertBytes != "" { - opts = append(opts, git.WithCABundle([]byte(cfg.Storage.Git.CaCertBytes))) - } else if cfg.Storage.Git.CaCertPath != "" { - if bytes, err := os.ReadFile(cfg.Storage.Git.CaCertPath); err == nil { - opts = append(opts, git.WithCABundle(bytes)) - } else { - return nil, err - } - } - - auth := cfg.Storage.Git.Authentication - switch { - case auth.BasicAuth != nil: - opts = append(opts, git.WithAuth(&http.BasicAuth{ - Username: auth.BasicAuth.Username, - Password: auth.BasicAuth.Password, - })) - case auth.TokenAuth != nil: - opts = append(opts, git.WithAuth(&http.TokenAuth{ - Token: auth.TokenAuth.AccessToken, - })) - case auth.SSHAuth != nil: - var method *gitssh.PublicKeys - if auth.SSHAuth.PrivateKeyBytes != "" { - method, err = gitssh.NewPublicKeys( - auth.SSHAuth.User, - []byte(auth.SSHAuth.PrivateKeyBytes), - auth.SSHAuth.Password, - ) - } else { - method, err = gitssh.NewPublicKeysFromFile( - auth.SSHAuth.User, - auth.SSHAuth.PrivateKeyPath, - auth.SSHAuth.Password, - ) - } - if err != nil { - return nil, err - } - - // we're protecting against this explicitly so we can disable - // the gosec linting rule - if auth.SSHAuth.InsecureIgnoreHostKey { - // nolint:gosec - method.HostKeyCallback = ssh.InsecureIgnoreHostKey() - } - - opts = append(opts, git.WithAuth(method)) - } - - source, err := git.NewSource(logger, cfg.Storage.Git.Repository, opts...) - if err != nil { - return nil, err - } - - store, err = fs.NewStore(logger, source) - if err != nil { - return nil, err - } - case config.LocalStorageType: - source, err := local.NewSource(logger, cfg.Storage.Local.Path) - if err != nil { - return nil, err - } - - store, err = fs.NewStore(logger, source) - if err != nil { - return nil, err - } - case config.ObjectStorageType: - store, err = NewObjectStore(cfg, logger) - if err != nil { - return nil, err - } - case config.OCIStorageType: - var opts []containers.Option[oci.StoreOptions] - if auth := cfg.Storage.OCI.Authentication; auth != nil { - opts = append(opts, oci.WithCredentials( - auth.Username, - auth.Password, - )) - } - - ocistore, err := oci.NewStore(logger, cfg.Storage.OCI.BundlesDirectory, opts...) - if err != nil { - return nil, err - } - - ref, err := oci.ParseReference(cfg.Storage.OCI.Repository) - if err != nil { - return nil, err - } - - source, err := storageoci.NewSource(logger, ocistore, ref, - storageoci.WithPollInterval(cfg.Storage.OCI.PollInterval), - ) - if err != nil { - return nil, err - } - - store, err = fs.NewStore(logger, source) + default: + // otherwise, attempt to configure a declarative backend store + store, err = fsstore.NewStore(ctx, logger, cfg) if err != nil { return nil, err } - default: - return nil, fmt.Errorf("unexpected storage type: %q", cfg.Storage.Type) } logger.Debug("store enabled", zap.Stringer("store", store)) @@ -507,36 +390,6 @@ func NewGRPCServer( return server, nil } -// NewObjectStore create a new storate.Store from the object config -func NewObjectStore(cfg *config.Config, logger *zap.Logger) (storage.Store, error) { - objectCfg := cfg.Storage.Object - var store storage.Store - // keep this as a case statement in anticipation of - // more object types in the future - // nolint:gocritic - switch objectCfg.Type { - case config.S3ObjectSubStorageType: - opts := []containers.Option[s3.Source]{ - s3.WithPollInterval(objectCfg.S3.PollInterval), - } - if objectCfg.S3.Endpoint != "" { - opts = append(opts, s3.WithEndpoint(objectCfg.S3.Endpoint)) - } - if objectCfg.S3.Region != "" { - opts = append(opts, s3.WithRegion(objectCfg.S3.Region)) - } - source, err := s3.NewSource(logger, objectCfg.S3.Bucket, opts...) - if err != nil { - return nil, err - } - store, err = fs.NewStore(logger, source) - if err != nil { - return nil, err - } - } - return store, nil -} - // Run begins serving gRPC requests. // This methods blocks until Shutdown is called. func (s *GRPCServer) Run() error { diff --git a/internal/storage/fs/git/source.go b/internal/storage/fs/git/source.go deleted file mode 100644 index 7fb52ed345..0000000000 --- a/internal/storage/fs/git/source.go +++ /dev/null @@ -1,188 +0,0 @@ -package git - -import ( - "context" - "errors" - "fmt" - "io/fs" - "time" - - "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/config" - "github.com/go-git/go-git/v5/plumbing" - "github.com/go-git/go-git/v5/plumbing/transport" - "github.com/go-git/go-git/v5/storage/memory" - "go.flipt.io/flipt/internal/containers" - "go.flipt.io/flipt/internal/gitfs" - storagefs "go.flipt.io/flipt/internal/storage/fs" - "go.uber.org/zap" -) - -// Source is an implementation of storage/fs.FSSource -// This implementation is backed by a Git repository and it tracks an upstream reference. -// When subscribing to this source, the upstream reference is tracked -// by polling the upstream on a configurable interval. -type Source struct { - logger *zap.Logger - repo *git.Repository - - url string - ref string - hash plumbing.Hash - interval time.Duration - auth transport.AuthMethod - caBundle []byte - insecureSkipTLS bool -} - -// WithRef configures the target reference to be used when fetching -// and building fs.FS implementations. -// If it is a valid hash, then the fixed SHA value is used. -// Otherwise, it is treated as a reference in the origin upstream. -func WithRef(ref string) containers.Option[Source] { - return func(s *Source) { - if plumbing.IsHash(ref) { - s.hash = plumbing.NewHash(ref) - return - } - - s.ref = ref - } -} - -// WithPollInterval configures the interval in which origin is polled to -// discover any updates to the target reference. -func WithPollInterval(tick time.Duration) containers.Option[Source] { - return func(s *Source) { - s.interval = tick - } -} - -// WithAuth returns an option which configures the auth method used -// by the provided source. -func WithAuth(auth transport.AuthMethod) containers.Option[Source] { - return func(s *Source) { - s.auth = auth - } -} - -// WithInsecureTLS returns an option which configures the insecure TLS -// setting for the provided source. -func WithInsecureTLS(insecureSkipTLS bool) containers.Option[Source] { - return func(s *Source) { - s.insecureSkipTLS = insecureSkipTLS - } -} - -// WithCABundle returns an option which configures the CA Bundle used for -// validating the TLS connection to the provided source. -func WithCABundle(caCertBytes []byte) containers.Option[Source] { - return func(s *Source) { - if caCertBytes != nil { - s.caBundle = caCertBytes - } - } -} - -// NewSource constructs and configures a Source. -// The source uses the connection and credential details provided to build -// fs.FS implementations around a target git repository. -func NewSource(logger *zap.Logger, url string, opts ...containers.Option[Source]) (_ *Source, err error) { - source := &Source{ - logger: logger.With(zap.String("repository", url)), - url: url, - ref: "main", - interval: 30 * time.Second, - } - containers.ApplyAll(source, opts...) - - field := zap.Stringer("ref", plumbing.NewBranchReferenceName(source.ref)) - if source.hash != plumbing.ZeroHash { - field = zap.Stringer("SHA", source.hash) - } - source.logger = source.logger.With(field) - - source.repo, err = git.Clone(memory.NewStorage(), nil, &git.CloneOptions{ - Auth: source.auth, - URL: source.url, - CABundle: source.caBundle, - InsecureSkipTLS: source.insecureSkipTLS, - }) - if err != nil { - return nil, err - } - - return source, nil -} - -// Get builds a new store snapshot based on the configure Git remote and reference. -func (s *Source) Get(context.Context) (_ *storagefs.StoreSnapshot, err error) { - var fs fs.FS - if s.hash != plumbing.ZeroHash { - fs, err = gitfs.NewFromRepoHash(s.logger, s.repo, s.hash) - } else { - fs, err = gitfs.NewFromRepo(s.logger, s.repo, gitfs.WithReference(plumbing.NewRemoteReferenceName("origin", s.ref))) - } - if err != nil { - return nil, err - } - - return storagefs.SnapshotFromFS(s.logger, fs) -} - -// Subscribe feeds gitfs implementations of fs.FS onto the provided channel. -// It blocks until the provided context is cancelled (it will be called in a goroutine). -// It closes the provided channel before it returns. -func (s *Source) Subscribe(ctx context.Context, ch chan<- *storagefs.StoreSnapshot) { - defer close(ch) - - // NOTE: theres is no point subscribing to updates for a git Hash - // as it is atomic and will never change. - if s.hash != plumbing.ZeroHash { - s.logger.Info("skipping subscribe as static SHA has been configured") - return - } - - ticker := time.NewTicker(s.interval) - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - s.logger.Debug("fetching from remote") - if err := s.repo.Fetch(&git.FetchOptions{ - Auth: s.auth, - RefSpecs: []config.RefSpec{ - config.RefSpec(fmt.Sprintf( - "+%s:%s", - plumbing.NewBranchReferenceName(s.ref), - plumbing.NewRemoteReferenceName("origin", s.ref), - )), - }, - }); err != nil { - if errors.Is(err, git.NoErrAlreadyUpToDate) { - s.logger.Debug("store already up to date") - continue - } - - s.logger.Error("failed fetching remote", zap.Error(err)) - continue - } - - snap, err := s.Get(ctx) - if err != nil { - s.logger.Error("failed creating snapshot from fs", zap.Error(err)) - continue - } - - ch <- snap - - s.logger.Debug("finished fetching from remote") - } - } -} - -// String returns an identifier string for the store type. -func (*Source) String() string { - return "git" -} diff --git a/internal/storage/fs/git/store.go b/internal/storage/fs/git/store.go new file mode 100644 index 0000000000..cb312cdbd8 --- /dev/null +++ b/internal/storage/fs/git/store.go @@ -0,0 +1,201 @@ +package git + +import ( + "context" + "errors" + "fmt" + "io/fs" + "sync" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/transport" + "github.com/go-git/go-git/v5/storage/memory" + "go.flipt.io/flipt/internal/containers" + "go.flipt.io/flipt/internal/gitfs" + "go.flipt.io/flipt/internal/storage" + storagefs "go.flipt.io/flipt/internal/storage/fs" + "go.uber.org/zap" +) + +// ensure that the git *Store implements storage.Store +var _ storagefs.SnapshotStore = (*SnapshotStore)(nil) + +// SnapshotStore is an implementation of storage.SnapshotStore +// This implementation is backed by a Git repository and it tracks an upstream reference. +// When subscribing to this source, the upstream reference is tracked +// by polling the upstream on a configurable interval. +type SnapshotStore struct { + logger *zap.Logger + repo *git.Repository + + mu sync.RWMutex + snap storage.ReadOnlyStore + + url string + ref string + hash plumbing.Hash + auth transport.AuthMethod + caBundle []byte + insecureSkipTLS bool + pollOpts []containers.Option[storagefs.Poller] +} + +// WithRef configures the target reference to be used when fetching +// and building fs.FS implementations. +// If it is a valid hash, then the fixed SHA value is used. +// Otherwise, it is treated as a reference in the origin upstream. +func WithRef(ref string) containers.Option[SnapshotStore] { + return func(s *SnapshotStore) { + if plumbing.IsHash(ref) { + s.hash = plumbing.NewHash(ref) + return + } + + s.ref = ref + } +} + +// WithPollOptions configures the poller used to trigger update procedures +func WithPollOptions(opts ...containers.Option[storagefs.Poller]) containers.Option[SnapshotStore] { + return func(s *SnapshotStore) { + s.pollOpts = append(s.pollOpts, opts...) + } +} + +// WithAuth returns an option which configures the auth method used +// by the provided source. +func WithAuth(auth transport.AuthMethod) containers.Option[SnapshotStore] { + return func(s *SnapshotStore) { + s.auth = auth + } +} + +// WithInsecureTLS returns an option which configures the insecure TLS +// setting for the provided source. +func WithInsecureTLS(insecureSkipTLS bool) containers.Option[SnapshotStore] { + return func(s *SnapshotStore) { + s.insecureSkipTLS = insecureSkipTLS + } +} + +// WithCABundle returns an option which configures the CA Bundle used for +// validating the TLS connection to the provided source. +func WithCABundle(caCertBytes []byte) containers.Option[SnapshotStore] { + return func(s *SnapshotStore) { + if caCertBytes != nil { + s.caBundle = caCertBytes + } + } +} + +// NewSnapshotStore constructs and configures a Store. +// The store uses the connection and credential details provided to build +// fs.FS implementations around a target git repository. +func NewSnapshotStore(ctx context.Context, logger *zap.Logger, url string, opts ...containers.Option[SnapshotStore]) (_ *SnapshotStore, err error) { + store := &SnapshotStore{ + logger: logger.With(zap.String("repository", url)), + url: url, + ref: "main", + } + containers.ApplyAll(store, opts...) + + field := zap.Stringer("ref", plumbing.NewBranchReferenceName(store.ref)) + if store.hash != plumbing.ZeroHash { + field = zap.Stringer("SHA", store.hash) + } + store.logger = store.logger.With(field) + + store.repo, err = git.Clone(memory.NewStorage(), nil, &git.CloneOptions{ + Auth: store.auth, + URL: store.url, + CABundle: store.caBundle, + InsecureSkipTLS: store.insecureSkipTLS, + }) + if err != nil { + return nil, err + } + + // fetch snapshot at-least once before returning store + // to ensure we have some state to serve + if err := store.get(ctx); err != nil { + return nil, err + } + + // if the reference is a static hash then it is immutable + // if we have already fetched it once, there is not point updating again + if store.hash == plumbing.ZeroHash { + go storagefs. + NewPoller(store.logger, store.pollOpts...). + Poll(ctx, store.update) + } + + return store, nil +} + +// String returns an identifier string for the store type. +func (*SnapshotStore) String() string { + return "git" +} + +// View accepts a function which takes a *StoreSnapshot. +// The SnapshotStore will supply a snapshot which is valid +// for the lifetime of the provided function call. +func (s *SnapshotStore) View(fn func(storage.ReadOnlyStore) error) error { + s.mu.RLock() + defer s.mu.RUnlock() + return fn(s.snap) +} + +// update fetches from the remote and given that a the target reference +// HEAD updates to a new revision, it builds a snapshot and updates it +// on the store. +func (s *SnapshotStore) update(ctx context.Context) (bool, error) { + if err := s.repo.Fetch(&git.FetchOptions{ + Auth: s.auth, + RefSpecs: []config.RefSpec{ + config.RefSpec(fmt.Sprintf( + "+%s:%s", + plumbing.NewBranchReferenceName(s.ref), + plumbing.NewRemoteReferenceName("origin", s.ref), + )), + }, + }); err != nil { + if !errors.Is(err, git.NoErrAlreadyUpToDate) { + return false, err + } + + return false, nil + } + + if err := s.get(ctx); err != nil { + return false, err + } + + return true, nil +} + +// get builds a new store snapshot based on the configure Git remote and reference. +func (s *SnapshotStore) get(context.Context) (err error) { + var fs fs.FS + if s.hash != plumbing.ZeroHash { + fs, err = gitfs.NewFromRepoHash(s.logger, s.repo, s.hash) + } else { + fs, err = gitfs.NewFromRepo(s.logger, s.repo, gitfs.WithReference(plumbing.NewRemoteReferenceName("origin", s.ref))) + } + if err != nil { + return err + } + + snap, err := storagefs.SnapshotFromFS(s.logger, fs) + if err != nil { + return err + } + + s.mu.Lock() + s.snap = snap + s.mu.Unlock() + + return nil +} diff --git a/internal/storage/fs/git/source_test.go b/internal/storage/fs/git/store_test.go similarity index 65% rename from internal/storage/fs/git/source_test.go rename to internal/storage/fs/git/store_test.go index 89f66ded6f..ec3dfd63dd 100644 --- a/internal/storage/fs/git/source_test.go +++ b/internal/storage/fs/git/store_test.go @@ -9,74 +9,55 @@ import ( "testing" "time" - "github.com/go-git/go-git/v5/plumbing/transport" - "github.com/go-git/go-billy/v5/memfs" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" + "github.com/go-git/go-git/v5/plumbing/transport" "github.com/go-git/go-git/v5/plumbing/transport/http" "github.com/go-git/go-git/v5/storage/memory" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.flipt.io/flipt/internal/containers" - storagefs "go.flipt.io/flipt/internal/storage/fs" + "go.flipt.io/flipt/internal/storage" + "go.flipt.io/flipt/internal/storage/fs" "go.uber.org/zap/zaptest" ) var gitRepoURL = os.Getenv("TEST_GIT_REPO_URL") -func Test_SourceString(t *testing.T) { - require.Equal(t, "git", (&Source{}).String()) -} - -func Test_SourceGet(t *testing.T) { - source, skip := testSource(t) - if skip { - return - } - - snap, err := source.Get(context.Background()) - require.NoError(t, err) - - _, err = snap.GetNamespace(context.TODO(), "production") - require.NoError(t, err) +func Test_Store_String(t *testing.T) { + require.Equal(t, "git", (&SnapshotStore{}).String()) } -func Test_SourceSubscribe_Hash(t *testing.T) { +func Test_Store_Subscribe_Hash(t *testing.T) { head := os.Getenv("TEST_GIT_REPO_HEAD") if head == "" { t.Skip("Set non-empty TEST_GIT_REPO_HEAD env var to run this test.") return } - source, skip := testSource(t, WithRef(head)) - if skip { - return - } - - ch := make(chan *storagefs.StoreSnapshot) - source.Subscribe(context.Background(), ch) - - _, closed := <-ch - assert.False(t, closed, "expected channel to be closed") + // this helper will fail if there is a problem with this option + // the only difference in behaviour is that the poll loop + // will silently (intentionally) not run + testStore(t, WithRef(head)) } -func Test_SourceSubscribe(t *testing.T) { - source, skip := testSource(t) +func Test_Store_Subscribe(t *testing.T) { + ch := make(chan struct{}) + store, skip := testStore(t, WithPollOptions( + fs.WithInterval(time.Second), + fs.WithNotify(t, func(modified bool) { + if modified { + close(ch) + } + }), + )) if skip { return } ctx, cancel := context.WithCancel(context.Background()) - - // prime source - _, err := source.Get(context.Background()) - require.NoError(t, err) - - // start subscription - ch := make(chan *storagefs.StoreSnapshot) - go source.Subscribe(ctx, ch) + t.Cleanup(cancel) // pull repo workdir := memfs.New() @@ -121,10 +102,10 @@ flags: RemoteName: "origin", })) - // assert matching state - var snap *storagefs.StoreSnapshot + // wait until the snapshot is updated or + // we timeout select { - case snap = <-ch: + case <-ch: case <-time.After(time.Minute): t.Fatal("timed out waiting for snapshot") } @@ -133,31 +114,27 @@ flags: t.Log("received new snapshot") - _, err = snap.GetFlag(ctx, "production", "foo") - require.NoError(t, err) - - // ensure closed - cancel() - - _, open := <-ch - require.False(t, open, "expected channel to be closed after cancel") + require.NoError(t, store.View(func(s storage.ReadOnlyStore) error { + _, err = s.GetFlag(ctx, "production", "foo") + return err + })) } -func Test_SourceSelfSignedSkipTLS(t *testing.T) { +func Test_Store_SelfSignedSkipTLS(t *testing.T) { ts := httptest.NewTLSServer(nil) defer ts.Close() // This is not a valid Git source, but it still proves the point that a // well-known server with a self-signed certificate will be accepted by Flipt // when configuring the TLS options for the source gitRepoURL = ts.URL - _, err := testSourceWithError(t, WithInsecureTLS(false)) + _, err := testStoreWithError(t, WithInsecureTLS(false)) require.ErrorContains(t, err, "tls: failed to verify certificate: x509: certificate signed by unknown authority") - _, err = testSourceWithError(t, WithInsecureTLS(true)) + _, err = testStoreWithError(t, WithInsecureTLS(true)) // This time, we don't expect a tls validation error anymore require.ErrorIs(t, err, transport.ErrRepositoryNotFound) } -func Test_SourceSelfSignedCABytes(t *testing.T) { +func Test_Store_SelfSignedCABytes(t *testing.T) { ts := httptest.NewTLSServer(nil) defer ts.Close() var buf bytes.Buffer @@ -172,14 +149,14 @@ func Test_SourceSelfSignedCABytes(t *testing.T) { // well-known server with a self-signed certificate will be accepted by Flipt // when configuring the TLS options for the source gitRepoURL = ts.URL - _, err = testSourceWithError(t) + _, err = testStoreWithError(t) require.ErrorContains(t, err, "tls: failed to verify certificate: x509: certificate signed by unknown authority") - _, err = testSourceWithError(t, WithCABundle(buf.Bytes())) + _, err = testStoreWithError(t, WithCABundle(buf.Bytes())) // This time, we don't expect a tls validation error anymore require.ErrorIs(t, err, transport.ErrRepositoryNotFound) } -func testSource(t *testing.T, opts ...containers.Option[Source]) (*Source, bool) { +func testStore(t *testing.T, opts ...containers.Option[SnapshotStore]) (*SnapshotStore, bool) { t.Helper() if gitRepoURL == "" { @@ -187,10 +164,12 @@ func testSource(t *testing.T, opts ...containers.Option[Source]) (*Source, bool) return nil, true } - source, err := NewSource(zaptest.NewLogger(t), gitRepoURL, - append([]containers.Option[Source]{ + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + source, err := NewSnapshotStore(ctx, zaptest.NewLogger(t), gitRepoURL, + append([]containers.Option[SnapshotStore]{ WithRef("main"), - WithPollInterval(5 * time.Second), WithAuth(&http.BasicAuth{ Username: "root", Password: "password", @@ -203,13 +182,15 @@ func testSource(t *testing.T, opts ...containers.Option[Source]) (*Source, bool) return source, false } -func testSourceWithError(t *testing.T, opts ...containers.Option[Source]) (*Source, error) { +func testStoreWithError(t *testing.T, opts ...containers.Option[SnapshotStore]) (*SnapshotStore, error) { t.Helper() - source, err := NewSource(zaptest.NewLogger(t), gitRepoURL, - append([]containers.Option[Source]{ + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + source, err := NewSnapshotStore(ctx, zaptest.NewLogger(t), gitRepoURL, + append([]containers.Option[SnapshotStore]{ WithRef("main"), - WithPollInterval(5 * time.Second), WithAuth(&http.BasicAuth{ Username: "root", Password: "password", diff --git a/internal/storage/fs/local/source.go b/internal/storage/fs/local/source.go deleted file mode 100644 index 3e4aac6b78..0000000000 --- a/internal/storage/fs/local/source.go +++ /dev/null @@ -1,75 +0,0 @@ -package local - -import ( - "context" - "os" - "time" - - "go.flipt.io/flipt/internal/containers" - storagefs "go.flipt.io/flipt/internal/storage/fs" - "go.uber.org/zap" -) - -// Source represents an implementation of an fs.FSSource for local -// updates on a FS. -type Source struct { - logger *zap.Logger - - dir string - interval time.Duration -} - -// NewSource constructs a Source. -func NewSource(logger *zap.Logger, dir string, opts ...containers.Option[Source]) (*Source, error) { - s := &Source{ - logger: logger, - dir: dir, - interval: 10 * time.Second, - } - - containers.ApplyAll(s, opts...) - - return s, nil -} - -// WithPollInterval configures the interval in which we will restore -// the local fs. -func WithPollInterval(tick time.Duration) containers.Option[Source] { - return func(s *Source) { - s.interval = tick - } -} - -// Get returns an fs.FS for the local filesystem. -func (s *Source) Get(context.Context) (*storagefs.StoreSnapshot, error) { - return storagefs.SnapshotFromFS(s.logger, os.DirFS(s.dir)) -} - -// Subscribe feeds local fs.FS implementations onto the provided channel. -// It blocks until the provided context is cancelled. -func (s *Source) Subscribe(ctx context.Context, ch chan<- *storagefs.StoreSnapshot) { - defer close(ch) - - ticker := time.NewTicker(s.interval) - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - snap, err := s.Get(ctx) - if err != nil { - s.logger.Error("error getting file system from directory", zap.Error(err)) - continue - } - - s.logger.Debug("updating local store snapshot") - - ch <- snap - } - } -} - -// String returns an identifier string for the store type. -func (s *Source) String() string { - return "local" -} diff --git a/internal/storage/fs/local/source_test.go b/internal/storage/fs/local/source_test.go deleted file mode 100644 index 417b63b2e5..0000000000 --- a/internal/storage/fs/local/source_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package local - -import ( - "context" - "os" - "path/filepath" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - storagefs "go.flipt.io/flipt/internal/storage/fs" - "go.uber.org/zap" -) - -func Test_SourceString(t *testing.T) { - assert.Equal(t, "local", (&Source{}).String()) -} - -func Test_SourceGet(t *testing.T) { - s, err := NewSource(zap.NewNop(), "testdata", WithPollInterval(5*time.Second)) - assert.NoError(t, err) - - snap, err := s.Get(context.Background()) - assert.NoError(t, err) - - _, err = snap.GetNamespace(context.TODO(), "production") - require.NoError(t, err) -} - -func Test_SourceSubscribe(t *testing.T) { - s, err := NewSource(zap.NewNop(), "testdata", WithPollInterval(5*time.Second)) - assert.NoError(t, err) - - dir, err := os.Getwd() - assert.NoError(t, err) - - ftc := filepath.Join(dir, "testdata", "a.features.yml") - - defer func() { - _, err := os.Stat(ftc) - if err == nil { - err := os.Remove(ftc) - assert.NoError(t, err) - } - }() - - ctx, cancel := context.WithCancel(context.Background()) - - ch := make(chan *storagefs.StoreSnapshot) - go s.Subscribe(ctx, ch) - - // change the filesystem contents - assert.NoError(t, os.WriteFile(ftc, []byte(`{"namespace":"staging"}`), os.ModePerm)) - - select { - case snap := <-ch: - _, err := snap.GetNamespace(ctx, "staging") - assert.NoError(t, err) - cancel() - - _, open := <-ch - assert.False(t, open, "expected channel to be closed after cancel") - case <-time.After(10 * time.Second): - t.Fatal("event not caught") - } -} diff --git a/internal/storage/fs/local/store.go b/internal/storage/fs/local/store.go new file mode 100644 index 0000000000..62efe2b1b4 --- /dev/null +++ b/internal/storage/fs/local/store.go @@ -0,0 +1,83 @@ +package local + +import ( + "context" + "os" + "sync" + + "go.flipt.io/flipt/internal/containers" + "go.flipt.io/flipt/internal/storage" + storagefs "go.flipt.io/flipt/internal/storage/fs" + "go.uber.org/zap" +) + +var _ storagefs.SnapshotStore = (*SnapshotStore)(nil) + +// SnapshotStore implements storagefs.SnapshotStore which +// is backed by the local filesystem through os.DirFS +type SnapshotStore struct { + logger *zap.Logger + dir string + + mu sync.RWMutex + snap storage.ReadOnlyStore + + pollOpts []containers.Option[storagefs.Poller] +} + +// NewSnapshotStore constructs a new SnapshotStore +func NewSnapshotStore(ctx context.Context, logger *zap.Logger, dir string, opts ...containers.Option[SnapshotStore]) (*SnapshotStore, error) { + s := &SnapshotStore{ + logger: logger, + dir: dir, + } + + containers.ApplyAll(s, opts...) + + // seed initial state an ensure we have state + // before returning + if _, err := s.update(ctx); err != nil { + return nil, err + } + + go storagefs. + NewPoller(logger, s.pollOpts...). + Poll(ctx, s.update) + + return s, nil +} + +// WithPollOptions configures poller options on the store. +func WithPollOptions(opts ...containers.Option[storagefs.Poller]) containers.Option[SnapshotStore] { + return func(s *SnapshotStore) { + s.pollOpts = append(s.pollOpts, opts...) + } +} + +// View passes the current snapshot to the provided function +// while holding a read lock. +func (s *SnapshotStore) View(fn func(storage.ReadOnlyStore) error) error { + s.mu.RLock() + defer s.mu.RUnlock() + return fn(s.snap) +} + +// update fetches a new snapshot from the local filesystem +// and updates the current served reference via a write lock +func (s *SnapshotStore) update(context.Context) (bool, error) { + snap, err := storagefs.SnapshotFromFS(s.logger, os.DirFS(s.dir)) + if err != nil { + return false, err + } + + s.mu.Lock() + s.snap = snap + s.mu.Unlock() + + return true, nil +} + +// String returns an identifier string for the store type. +func (s *SnapshotStore) String() string { + return "local" +} diff --git a/internal/storage/fs/local/store_test.go b/internal/storage/fs/local/store_test.go new file mode 100644 index 0000000000..c98de6c543 --- /dev/null +++ b/internal/storage/fs/local/store_test.go @@ -0,0 +1,64 @@ +package local + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "go.flipt.io/flipt/internal/storage" + storagefs "go.flipt.io/flipt/internal/storage/fs" + "go.uber.org/zap" +) + +func Test_Store_String(t *testing.T) { + assert.Equal(t, "local", (&SnapshotStore{}).String()) +} + +func Test_Store(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + var closed bool + ch := make(chan struct{}) + + s, err := NewSnapshotStore(ctx, zap.NewNop(), "testdata", WithPollOptions( + storagefs.WithInterval(1*time.Second), + storagefs.WithNotify(t, func(modified bool) { + if modified && !closed { + closed = true + close(ch) + } + }), + )) + assert.NoError(t, err) + + dir, err := os.Getwd() + assert.NoError(t, err) + + ftc := filepath.Join(dir, "testdata", "a.features.yml") + + defer func() { + _, err := os.Stat(ftc) + if err == nil { + err := os.Remove(ftc) + assert.NoError(t, err) + } + }() + + // change the filesystem contents + assert.NoError(t, os.WriteFile(ftc, []byte(`{"namespace":"staging"}`), os.ModePerm)) + + select { + case <-ch: + case <-time.After(10 * time.Second): + t.Fatal("event not caught") + } + + assert.NoError(t, s.View(func(s storage.ReadOnlyStore) error { + _, err = s.GetNamespace(ctx, "staging") + return err + })) +} diff --git a/internal/storage/fs/oci/source.go b/internal/storage/fs/oci/source.go deleted file mode 100644 index 0a8d6ee99d..0000000000 --- a/internal/storage/fs/oci/source.go +++ /dev/null @@ -1,105 +0,0 @@ -package oci - -import ( - "context" - "time" - - "github.com/opencontainers/go-digest" - "go.flipt.io/flipt/internal/containers" - "go.flipt.io/flipt/internal/oci" - storagefs "go.flipt.io/flipt/internal/storage/fs" - "go.uber.org/zap" -) - -// Source is an implementation fs.SnapshotSource backed by OCI repositories -// It fetches instances of OCI manifests and uses them to build snapshots from their contents -type Source struct { - logger *zap.Logger - interval time.Duration - - store *oci.Store - ref oci.Reference - - curSnap *storagefs.StoreSnapshot - curDigest digest.Digest -} - -// NewSource constructs and configures a Source. -// The source uses the connection and credential details provided to build -// *storagefs.StoreSnapshot implementations around a target git repository. -func NewSource(logger *zap.Logger, store *oci.Store, ref oci.Reference, opts ...containers.Option[Source]) (_ *Source, err error) { - src := &Source{ - logger: logger, - interval: 30 * time.Second, - store: store, - ref: ref, - } - containers.ApplyAll(src, opts...) - - return src, nil -} - -// WithPollInterval configures the interval in which origin is polled to -// discover any updates to the target reference. -func WithPollInterval(tick time.Duration) containers.Option[Source] { - return func(s *Source) { - s.interval = tick - } -} - -func (s *Source) String() string { - return "oci" -} - -// Get builds a single instance of an *storagefs.StoreSnapshot -func (s *Source) Get(context.Context) (*storagefs.StoreSnapshot, error) { - resp, err := s.store.Fetch(context.Background(), s.ref, oci.IfNoMatch(s.curDigest)) - if err != nil { - return nil, err - } - - if resp.Matched { - return s.curSnap, nil - } - - if s.curSnap, err = storagefs.SnapshotFromFiles(s.logger, resp.Files...); err != nil { - return nil, err - } - - s.curDigest = resp.Digest - - return s.curSnap, nil -} - -// Subscribe feeds implementations of *storagefs.StoreSnapshot onto the provided channel. -// It should block until the provided context is cancelled (it will be called in a goroutine). -// It should close the provided channel before it returns. -func (s *Source) Subscribe(ctx context.Context, ch chan<- *storagefs.StoreSnapshot) { - defer close(ch) - - ticker := time.NewTicker(s.interval) - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - current := s.curDigest - s.logger.Debug("fetching new snapshot", zap.String("current", current.Hex())) - - snap, err := s.Get(ctx) - if err != nil { - s.logger.Error("failed resolving upstream", zap.Error(err)) - continue - } - - if current == s.curDigest { - s.logger.Debug("snapshot already up to date") - continue - } - - ch <- snap - - s.logger.Debug("fetched new reference from remote") - } - } -} diff --git a/internal/storage/fs/oci/store.go b/internal/storage/fs/oci/store.go new file mode 100644 index 0000000000..e99cce0930 --- /dev/null +++ b/internal/storage/fs/oci/store.go @@ -0,0 +1,98 @@ +package oci + +import ( + "context" + "sync" + + "github.com/opencontainers/go-digest" + "go.flipt.io/flipt/internal/containers" + "go.flipt.io/flipt/internal/oci" + "go.flipt.io/flipt/internal/storage" + storagefs "go.flipt.io/flipt/internal/storage/fs" + "go.uber.org/zap" +) + +var _ storagefs.SnapshotStore = (*SnapshotStore)(nil) + +// SnapshotStore is an implementation storage.SnapshotStore backed by OCI repositories. +// It fetches instances of OCI manifests and uses them to build snapshots from their contents. +type SnapshotStore struct { + logger *zap.Logger + + store *oci.Store + ref oci.Reference + + mu sync.RWMutex + snap storage.ReadOnlyStore + lastDigest digest.Digest + + pollOpts []containers.Option[storagefs.Poller] +} + +// View accepts a function which takes a *StoreSnapshot. +// The SnapshotStore will supply a snapshot which is valid +// for the lifetime of the provided function call. +func (s *SnapshotStore) View(fn func(storage.ReadOnlyStore) error) error { + s.mu.RLock() + defer s.mu.RUnlock() + return fn(s.snap) +} + +// NewSnapshotStore constructs and configures a Store. +// The store uses the connection and credential details provided to build +// *storagefs.StoreSnapshot implementations around a target OCI repository. +func NewSnapshotStore(ctx context.Context, logger *zap.Logger, store *oci.Store, ref oci.Reference, opts ...containers.Option[SnapshotStore]) (_ *SnapshotStore, err error) { + s := &SnapshotStore{ + logger: logger, + store: store, + ref: ref, + } + containers.ApplyAll(s, opts...) + + if _, err := s.update(ctx); err != nil { + return nil, err + } + + go storagefs.NewPoller(logger, s.pollOpts...).Poll(ctx, s.update) + + return s, nil +} + +// WithPollOptions configures the options used periodically invoke the update procedure +func WithPollOptions(opts ...containers.Option[storagefs.Poller]) containers.Option[SnapshotStore] { + return func(s *SnapshotStore) { + s.pollOpts = append(s.pollOpts, opts...) + } +} + +func (s *SnapshotStore) String() string { + return "oci" +} + +// update attempts to fetch the latest state for the target OCi repository and tag. +// If the state has not change sinced the last observed image digest it skips +// updating the snapshot and returns false (not modified). +func (s *SnapshotStore) update(ctx context.Context) (bool, error) { + resp, err := s.store.Fetch(ctx, s.ref, oci.IfNoMatch(s.lastDigest)) + if err != nil { + return false, err + } + + // return not modified as the last observed digest matched + // the remote digest + if resp.Matched { + return false, nil + } + + snap, err := storagefs.SnapshotFromFiles(s.logger, resp.Files...) + if err != nil { + return false, err + } + + s.mu.Lock() + s.lastDigest = resp.Digest + s.snap = snap + s.mu.Unlock() + + return true, nil +} diff --git a/internal/storage/fs/oci/source_test.go b/internal/storage/fs/oci/store_test.go similarity index 70% rename from internal/storage/fs/oci/source_test.go rename to internal/storage/fs/oci/store_test.go index ade6770bff..03810a03fe 100644 --- a/internal/storage/fs/oci/source_test.go +++ b/internal/storage/fs/oci/store_test.go @@ -10,41 +10,42 @@ import ( "github.com/opencontainers/go-digest" v1 "github.com/opencontainers/image-spec/specs-go/v1" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.flipt.io/flipt/internal/containers" fliptoci "go.flipt.io/flipt/internal/oci" - storagefs "go.flipt.io/flipt/internal/storage/fs" + "go.flipt.io/flipt/internal/storage" + "go.flipt.io/flipt/internal/storage/fs" "go.uber.org/zap/zaptest" "oras.land/oras-go/v2" "oras.land/oras-go/v2/content/oci" ) func Test_SourceString(t *testing.T) { - require.Equal(t, "oci", (&Source{}).String()) -} - -func Test_SourceGet(t *testing.T) { - source, _ := testSource(t) - - snap, err := source.Get(context.Background()) - require.NoError(t, err) - - _, err = snap.GetNamespace(context.TODO(), "production") - require.NoError(t, err) + require.Equal(t, "oci", (&SnapshotStore{}).String()) } func Test_SourceSubscribe(t *testing.T) { - source, target := testSource(t) + ch := make(chan struct{}) + store, target := testStore(t, WithPollOptions( + fs.WithInterval(time.Second), + fs.WithNotify(t, func(modified bool) { + if modified { + close(ch) + } + }), + )) - ctx, cancel := context.WithCancel(context.Background()) + ctx := context.Background() - // prime source - _, err := source.Get(context.Background()) - require.NoError(t, err) + require.NoError(t, store.View(func(s storage.ReadOnlyStore) error { + _, err := s.GetNamespace(ctx, "production") + require.NoError(t, err) - // start subscription - ch := make(chan *storagefs.StoreSnapshot) - go source.Subscribe(ctx, ch) + _, err = s.GetFlag(ctx, "production", "foo") + require.Error(t, err, "should error as flag should not exist yet") + + return nil + })) updateRepoContents(t, target, layer( @@ -57,34 +58,22 @@ func Test_SourceSubscribe(t *testing.T) { t.Log("waiting for new snapshot") // assert matching state - var snap *storagefs.StoreSnapshot select { - case snap = <-ch: + case <-ch: case <-time.After(time.Minute): t.Fatal("timed out waiting for snapshot") } - require.NoError(t, err) - t.Log("received new snapshot") - _, err = snap.GetFlag(ctx, "production", "foo") - require.NoError(t, err) - - // ensure closed - cancel() - - _, open := <-ch - require.False(t, open, "expected channel to be closed after cancel") - - // fetch again and expected to get the same snapshot - found, err := source.Get(context.Background()) - require.NoError(t, err) - - assert.Equal(t, snap, found) + require.NoError(t, store.View(func(s storage.ReadOnlyStore) error { + _, err := s.GetFlag(ctx, "production", "foo") + require.NoError(t, err) + return nil + })) } -func testSource(t *testing.T) (*Source, oras.Target) { +func testStore(t *testing.T, opts ...containers.Option[SnapshotStore]) (*SnapshotStore, oras.Target) { t.Helper() target, dir, repo := testRepository(t, @@ -97,10 +86,14 @@ func testSource(t *testing.T) (*Source, oras.Target) { ref, err := fliptoci.ParseReference(fmt.Sprintf("flipt://local/%s:latest", repo)) require.NoError(t, err) - source, err := NewSource(zaptest.NewLogger(t), + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + source, err := NewSnapshotStore(ctx, + zaptest.NewLogger(t), store, ref, - WithPollInterval(time.Second)) + opts...) require.NoError(t, err) return source, target diff --git a/internal/storage/fs/poll.go b/internal/storage/fs/poll.go new file mode 100644 index 0000000000..12c9147df6 --- /dev/null +++ b/internal/storage/fs/poll.go @@ -0,0 +1,68 @@ +package fs + +import ( + "context" + "testing" + "time" + + "go.flipt.io/flipt/internal/containers" + "go.uber.org/zap" +) + +type Poller struct { + logger *zap.Logger + + interval time.Duration + notify func(modified bool) +} + +func WithInterval(interval time.Duration) containers.Option[Poller] { + return func(p *Poller) { + p.interval = interval + } +} + +func WithNotify(t *testing.T, n func(modified bool)) containers.Option[Poller] { + t.Helper() + return func(p *Poller) { + p.notify = n + } +} + +func NewPoller(logger *zap.Logger, opts ...containers.Option[Poller]) *Poller { + p := &Poller{ + logger: logger, + interval: 30 * time.Second, + } + containers.ApplyAll(p, opts...) + return p +} + +// Poll is a utility function for a common polling strategy used by lots of declarative +// store implementations. +func (p *Poller) Poll(ctx context.Context, update func(context.Context) (bool, error)) { + ticker := time.NewTicker(p.interval) + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + modified, err := update(ctx) + if err != nil { + p.logger.Error("error getting file system from directory", zap.Error(err)) + continue + } + + if p.notify != nil { + p.notify(modified) + } + + if !modified { + p.logger.Debug("skipping snapshot update as it has not been modified") + continue + } + + p.logger.Debug("snapshot updated") + } + } +} diff --git a/internal/storage/fs/s3/source.go b/internal/storage/fs/s3/source.go deleted file mode 100644 index d7d7cdb01f..0000000000 --- a/internal/storage/fs/s3/source.go +++ /dev/null @@ -1,124 +0,0 @@ -package s3 - -import ( - "context" - "time" - - "github.com/aws/aws-sdk-go-v2/config" - "github.com/aws/aws-sdk-go-v2/service/s3" - - "go.flipt.io/flipt/internal/containers" - "go.flipt.io/flipt/internal/s3fs" - storagefs "go.flipt.io/flipt/internal/storage/fs" - "go.uber.org/zap" -) - -// Source represents an implementation of an fs.SnapshotSource -// This implementation is backed by an S3 bucket -type Source struct { - logger *zap.Logger - s3 *s3.Client - - endpoint string - region string - bucket string - prefix string - interval time.Duration -} - -// NewSource constructs a Source. -func NewSource(logger *zap.Logger, bucket string, opts ...containers.Option[Source]) (*Source, error) { - s := &Source{ - logger: logger, - bucket: bucket, - interval: 60 * time.Second, - } - - containers.ApplyAll(s, opts...) - - cfg, err := config.LoadDefaultConfig(context.Background(), - config.WithRegion(s.region)) - if err != nil { - return nil, err - } - - var s3Opts []func(*s3.Options) - if s.endpoint != "" { - s3Opts = append(s3Opts, func(o *s3.Options) { - o.BaseEndpoint = &s.endpoint - o.UsePathStyle = true - o.Region = s.region - }) - } - s.s3 = s3.NewFromConfig(cfg, s3Opts...) - - return s, nil -} - -// WithPrefix configures the prefix for s3 -func WithPrefix(prefix string) containers.Option[Source] { - return func(s *Source) { - s.prefix = prefix - } -} - -// WithRegion configures the region for s3 -func WithRegion(region string) containers.Option[Source] { - return func(s *Source) { - s.region = region - } -} - -// WithEndpoint configures the region for s3 -func WithEndpoint(endpoint string) containers.Option[Source] { - return func(s *Source) { - s.endpoint = endpoint - } -} - -// WithPollInterval configures the interval in which we will restore -// the s3 fs. -func WithPollInterval(tick time.Duration) containers.Option[Source] { - return func(s *Source) { - s.interval = tick - } -} - -// Get returns a *sourcefs.StoreSnapshot for the local filesystem. -func (s *Source) Get(context.Context) (*storagefs.StoreSnapshot, error) { - fs, err := s3fs.New(s.logger, s.s3, s.bucket, s.prefix) - if err != nil { - return nil, err - } - - return storagefs.SnapshotFromFS(s.logger, fs) -} - -// Subscribe feeds S3 populated *StoreSnapshot instances onto the provided channel. -// It blocks until the provided context is cancelled. -func (s *Source) Subscribe(ctx context.Context, ch chan<- *storagefs.StoreSnapshot) { - defer close(ch) - - ticker := time.NewTicker(s.interval) - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - snap, err := s.Get(ctx) - if err != nil { - s.logger.Error("error getting file system from directory", zap.Error(err)) - continue - } - - s.logger.Debug("updating local store snapshot") - - ch <- snap - } - } -} - -// String returns an identifier string for the store type. -func (s *Source) String() string { - return "s3" -} diff --git a/internal/storage/fs/s3/source_test.go b/internal/storage/fs/s3/source_test.go deleted file mode 100644 index e383b8c045..0000000000 --- a/internal/storage/fs/s3/source_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package s3 - -import ( - "bytes" - "context" - "os" - "testing" - "time" - - "github.com/aws/aws-sdk-go-v2/service/s3" - "github.com/stretchr/testify/require" - "go.flipt.io/flipt/internal/containers" - storagefs "go.flipt.io/flipt/internal/storage/fs" - "go.uber.org/zap/zaptest" -) - -const testBucket = "testdata" - -var minioURL = os.Getenv("TEST_S3_ENDPOINT") - -func Test_SourceString(t *testing.T) { - require.Equal(t, "s3", (&Source{}).String()) -} - -func Test_SourceGet(t *testing.T) { - source, skip := testSource(t) - if skip { - return - } - - snap, err := source.Get(context.Background()) - require.NoError(t, err) - - _, err = snap.GetNamespace(context.TODO(), "production") - require.NoError(t, err) - - _, err = snap.GetNamespace(context.TODO(), "prefix") - require.NoError(t, err) -} - -func Test_SourceGetPrefix(t *testing.T) { - source, skip := testSource(t, WithPrefix("prefix/")) - if skip { - return - } - - snap, err := source.Get(context.Background()) - require.NoError(t, err) - - _, err = snap.GetNamespace(context.TODO(), "production") - require.Error(t, err, "production namespace should have been skipped") - - _, err = snap.GetNamespace(context.TODO(), "prefix") - require.NoError(t, err, "prefix namespace should be present in snapshot") -} - -func Test_SourceSubscribe(t *testing.T) { - source, skip := testSource(t) - if skip { - return - } - - snap, err := source.Get(context.Background()) - require.NoError(t, err) - - _, err = snap.GetNamespace(context.TODO(), "production") - require.NoError(t, err) - - ctx, cancel := context.WithCancel(context.Background()) - - // start subscription - ch := make(chan *storagefs.StoreSnapshot) - go source.Subscribe(ctx, ch) - - updated := []byte(`namespace: production -flags: - - key: foo - name: Foo`) - - buf := bytes.NewReader(updated) - - s3Client := source.s3 - // update features.yml - path := "features.yml" - _, err = s3Client.PutObject(ctx, &s3.PutObjectInput{ - Bucket: &source.bucket, - Key: &path, - Body: buf, - }) - require.NoError(t, err) - - // assert matching state - snap = <-ch - - t.Log("received new snapshot") - - _, err = snap.GetFlag(context.TODO(), "production", "foo") - require.NoError(t, err) - - cancel() - - _, open := <-ch - require.False(t, open, "expected channel to be closed after cancel") -} - -func testSource(t *testing.T, opts ...containers.Option[Source]) (*Source, bool) { - t.Helper() - - if minioURL == "" { - t.Skip("Set non-empty TEST_S3_ENDPOINT env var to run this test.") - return nil, true - } - - source, err := NewSource(zaptest.NewLogger(t), testBucket, - append([]containers.Option[Source]{ - WithEndpoint(minioURL), - WithPollInterval(5 * time.Second), - }, - opts...)..., - ) - require.NoError(t, err) - - return source, false -} diff --git a/internal/storage/fs/s3/store.go b/internal/storage/fs/s3/store.go new file mode 100644 index 0000000000..2003557683 --- /dev/null +++ b/internal/storage/fs/s3/store.go @@ -0,0 +1,135 @@ +package s3 + +import ( + "context" + "sync" + "time" + + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/s3" + + "go.flipt.io/flipt/internal/containers" + "go.flipt.io/flipt/internal/s3fs" + "go.flipt.io/flipt/internal/storage" + storagefs "go.flipt.io/flipt/internal/storage/fs" + "go.uber.org/zap" +) + +var _ storagefs.SnapshotStore = (*SnapshotStore)(nil) + +// SnapshotStore represents an implementation of storage.SnapshotStore +// This implementation is backed by an S3 bucket +type SnapshotStore struct { + logger *zap.Logger + s3 *s3.Client + + mu sync.RWMutex + snap storage.ReadOnlyStore + + endpoint string + region string + bucket string + prefix string + + pollOpts []containers.Option[storagefs.Poller] +} + +// View accepts a function which takes a *StoreSnapshot. +// The SnapshotStore will supply a snapshot which is valid +// for the lifetime of the provided function call. +func (s *SnapshotStore) View(fn func(storage.ReadOnlyStore) error) error { + s.mu.RLock() + defer s.mu.RUnlock() + return fn(s.snap) +} + +// NewSnapshotStore constructs a Store +func NewSnapshotStore(ctx context.Context, logger *zap.Logger, bucket string, opts ...containers.Option[SnapshotStore]) (*SnapshotStore, error) { + s := &SnapshotStore{ + logger: logger, + bucket: bucket, + pollOpts: []containers.Option[storagefs.Poller]{ + storagefs.WithInterval(60 * time.Second), + }, + } + + containers.ApplyAll(s, opts...) + + cfg, err := config.LoadDefaultConfig(context.Background(), + config.WithRegion(s.region)) + if err != nil { + return nil, err + } + + var s3Opts []func(*s3.Options) + if s.endpoint != "" { + s3Opts = append(s3Opts, func(o *s3.Options) { + o.BaseEndpoint = &s.endpoint + o.UsePathStyle = true + o.Region = s.region + }) + } + s.s3 = s3.NewFromConfig(cfg, s3Opts...) + + // fetch snapshot at-least once before returning store + // to ensure we have some state to serve + if _, err := s.update(ctx); err != nil { + return nil, err + } + + go storagefs.NewPoller(s.logger, s.pollOpts...).Poll(ctx, s.update) + + return s, nil +} + +// WithPrefix configures the prefix for s3 +func WithPrefix(prefix string) containers.Option[SnapshotStore] { + return func(s *SnapshotStore) { + s.prefix = prefix + } +} + +// WithRegion configures the region for s3 +func WithRegion(region string) containers.Option[SnapshotStore] { + return func(s *SnapshotStore) { + s.region = region + } +} + +// WithEndpoint configures the region for s3 +func WithEndpoint(endpoint string) containers.Option[SnapshotStore] { + return func(s *SnapshotStore) { + s.endpoint = endpoint + } +} + +// WithPollOptions configures the poller options used when periodically updating snapshot state +func WithPollOptions(opts ...containers.Option[storagefs.Poller]) containers.Option[SnapshotStore] { + return func(s *SnapshotStore) { + s.pollOpts = append(s.pollOpts, opts...) + } +} + +// Update fetches a new snapshot and swaps it out for the current one. +func (s *SnapshotStore) update(context.Context) (bool, error) { + fs, err := s3fs.New(s.logger, s.s3, s.bucket, s.prefix) + if err != nil { + return false, err + } + + snap, err := storagefs.SnapshotFromFS(s.logger, fs) + if err != nil { + return false, err + } + + s.mu.Lock() + s.snap = snap + s.mu.Unlock() + + return true, nil +} + +// String returns an identifier string for the store type. +func (s *SnapshotStore) String() string { + return "s3" +} diff --git a/internal/storage/fs/s3/store_test.go b/internal/storage/fs/s3/store_test.go new file mode 100644 index 0000000000..4c2314b202 --- /dev/null +++ b/internal/storage/fs/s3/store_test.go @@ -0,0 +1,122 @@ +package s3 + +import ( + "bytes" + "context" + "os" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/stretchr/testify/require" + "go.flipt.io/flipt/internal/containers" + "go.flipt.io/flipt/internal/storage" + "go.flipt.io/flipt/internal/storage/fs" + "go.uber.org/zap/zaptest" +) + +const testBucket = "testdata" + +var minioURL = os.Getenv("TEST_S3_ENDPOINT") + +func Test_Store_String(t *testing.T) { + require.Equal(t, "s3", (&SnapshotStore{}).String()) +} + +func Test_Store(t *testing.T) { + ch := make(chan struct{}) + store, skip := testStore(t, WithPollOptions( + fs.WithInterval(time.Second), + fs.WithNotify(t, func(modified bool) { + if modified { + close(ch) + } + }), + )) + if skip { + return + } + + // flag shouldn't be present until we update it + require.Error(t, store.View(func(s storage.ReadOnlyStore) error { + _, err := s.GetFlag(context.TODO(), "production", "foo") + return err + }), "flag should not be defined yet") + + updated := []byte(`namespace: production +flags: + - key: foo + name: Foo`) + + buf := bytes.NewReader(updated) + + s3Client := store.s3 + // update features.yml + path := "features.yml" + _, err := s3Client.PutObject(context.TODO(), &s3.PutObjectInput{ + Bucket: &store.bucket, + Key: &path, + Body: buf, + }) + require.NoError(t, err) + + // assert matching state + select { + case <-ch: + case <-time.After(time.Minute): + t.Fatal("timed out waiting for update") + } + + t.Log("received new snapshot") + + require.NoError(t, store.View(func(s storage.ReadOnlyStore) error { + _, err = s.GetNamespace(context.TODO(), "production") + if err != nil { + return err + } + + _, err = s.GetFlag(context.TODO(), "production", "foo") + if err != nil { + return err + } + + _, err = s.GetNamespace(context.TODO(), "prefix") + return err + })) + +} + +func Test_Store_WithPrefix(t *testing.T) { + store, skip := testStore(t, WithPrefix("prefix")) + if skip { + return + } + + // namespace shouldn't exist as it has been filtered out by the prefix + require.Error(t, store.View(func(s storage.ReadOnlyStore) error { + _, err := s.GetNamespace(context.TODO(), "production") + return err + }), "production namespace shouldn't be retrieavable") +} + +func testStore(t *testing.T, opts ...containers.Option[SnapshotStore]) (*SnapshotStore, bool) { + t.Helper() + + if minioURL == "" { + t.Skip("Set non-empty TEST_S3_ENDPOINT env var to run this test.") + return nil, true + } + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + source, err := NewSnapshotStore(ctx, zaptest.NewLogger(t), testBucket, + append([]containers.Option[SnapshotStore]{ + WithEndpoint(minioURL), + }, + opts...)..., + ) + require.NoError(t, err) + + return source, false +} diff --git a/internal/storage/fs/snapshot.go b/internal/storage/fs/snapshot.go index 37f3c2febb..7f058e0650 100644 --- a/internal/storage/fs/snapshot.go +++ b/internal/storage/fs/snapshot.go @@ -29,10 +29,7 @@ const ( defaultNs = "default" ) -var ( - _ storage.Store = (*StoreSnapshot)(nil) - ErrNotImplemented = errors.New("not implemented") -) +var _ storage.ReadOnlyStore = (*Snapshot)(nil) // FliptIndex represents the structure of a well-known file ".flipt.yml" // at the root of an FS. @@ -42,9 +39,9 @@ type FliptIndex struct { Exclude []string `yaml:"exclude,omitempty"` } -// StoreSnapshot contains the structures necessary for serving +// Snapshot contains the structures necessary for serving // flag state to a client. -type StoreSnapshot struct { +type Snapshot struct { ns map[string]*namespace evalDists map[string][]*storage.EvaluationDistribution now *timestamppb.Timestamp @@ -80,7 +77,7 @@ func newNamespace(key, name string, created *timestamppb.Timestamp) *namespace { // SnapshotFromFS is a convenience function for building a snapshot // directly from an implementation of fs.FS using the list state files // function to source the relevant Flipt configuration files. -func SnapshotFromFS(logger *zap.Logger, fs fs.FS) (*StoreSnapshot, error) { +func SnapshotFromFS(logger *zap.Logger, fs fs.FS) (*Snapshot, error) { files, err := listStateFiles(logger, fs) if err != nil { return nil, err @@ -93,7 +90,7 @@ func SnapshotFromFS(logger *zap.Logger, fs fs.FS) (*StoreSnapshot, error) { // SnapshotFromPaths constructs a StoreSnapshot from the provided // slice of paths resolved against the provided fs.FS. -func SnapshotFromPaths(logger *zap.Logger, ffs fs.FS, paths ...string) (*StoreSnapshot, error) { +func SnapshotFromPaths(logger *zap.Logger, ffs fs.FS, paths ...string) (*Snapshot, error) { var files []fs.File for _, file := range paths { fi, err := ffs.Open(file) @@ -109,9 +106,9 @@ func SnapshotFromPaths(logger *zap.Logger, ffs fs.FS, paths ...string) (*StoreSn // SnapshotFromFiles constructs a StoreSnapshot from the provided slice // of fs.File implementations. -func SnapshotFromFiles(logger *zap.Logger, files ...fs.File) (*StoreSnapshot, error) { +func SnapshotFromFiles(logger *zap.Logger, files ...fs.File) (*Snapshot, error) { now := flipt.Now() - s := StoreSnapshot{ + s := Snapshot{ ns: map[string]*namespace{ defaultNs: newNamespace("default", "Default", now), }, @@ -309,7 +306,7 @@ func listStateFiles(logger *zap.Logger, source fs.FS) ([]string, error) { return filenames, nil } -func (ss *StoreSnapshot) addDoc(doc *ext.Document) error { +func (ss *Snapshot) addDoc(doc *ext.Document) error { ns := ss.ns[doc.Namespace] if ns == nil { ns = newNamespace(doc.Namespace, doc.Namespace, ss.now) @@ -593,11 +590,11 @@ func (ss *StoreSnapshot) addDoc(doc *ext.Document) error { return nil } -func (ss StoreSnapshot) String() string { +func (ss Snapshot) String() string { return "snapshot" } -func (ss *StoreSnapshot) GetRule(ctx context.Context, namespaceKey string, id string) (rule *flipt.Rule, _ error) { +func (ss *Snapshot) GetRule(ctx context.Context, namespaceKey string, id string) (rule *flipt.Rule, _ error) { ns, err := ss.getNamespace(namespaceKey) if err != nil { return nil, err @@ -612,7 +609,7 @@ func (ss *StoreSnapshot) GetRule(ctx context.Context, namespaceKey string, id st return rule, nil } -func (ss *StoreSnapshot) ListRules(ctx context.Context, namespaceKey string, flagKey string, opts ...storage.QueryOption) (set storage.ResultSet[*flipt.Rule], _ error) { +func (ss *Snapshot) ListRules(ctx context.Context, namespaceKey string, flagKey string, opts ...storage.QueryOption) (set storage.ResultSet[*flipt.Rule], _ error) { ns, err := ss.getNamespace(namespaceKey) if err != nil { return set, err @@ -630,7 +627,7 @@ func (ss *StoreSnapshot) ListRules(ctx context.Context, namespaceKey string, fla }, rules...) } -func (ss *StoreSnapshot) CountRules(ctx context.Context, namespaceKey, flagKey string) (uint64, error) { +func (ss *Snapshot) CountRules(ctx context.Context, namespaceKey, flagKey string) (uint64, error) { ns, err := ss.getNamespace(namespaceKey) if err != nil { return 0, err @@ -646,35 +643,7 @@ func (ss *StoreSnapshot) CountRules(ctx context.Context, namespaceKey, flagKey s return count, nil } -func (ss *StoreSnapshot) CreateRule(ctx context.Context, r *flipt.CreateRuleRequest) (*flipt.Rule, error) { - return nil, ErrNotImplemented -} - -func (ss *StoreSnapshot) UpdateRule(ctx context.Context, r *flipt.UpdateRuleRequest) (*flipt.Rule, error) { - return nil, ErrNotImplemented -} - -func (ss *StoreSnapshot) DeleteRule(ctx context.Context, r *flipt.DeleteRuleRequest) error { - return ErrNotImplemented -} - -func (ss *StoreSnapshot) OrderRules(ctx context.Context, r *flipt.OrderRulesRequest) error { - return ErrNotImplemented -} - -func (ss *StoreSnapshot) CreateDistribution(ctx context.Context, r *flipt.CreateDistributionRequest) (*flipt.Distribution, error) { - return nil, ErrNotImplemented -} - -func (ss *StoreSnapshot) UpdateDistribution(ctx context.Context, r *flipt.UpdateDistributionRequest) (*flipt.Distribution, error) { - return nil, ErrNotImplemented -} - -func (ss *StoreSnapshot) DeleteDistribution(ctx context.Context, r *flipt.DeleteDistributionRequest) error { - return ErrNotImplemented -} - -func (ss *StoreSnapshot) GetSegment(ctx context.Context, namespaceKey string, key string) (*flipt.Segment, error) { +func (ss *Snapshot) GetSegment(ctx context.Context, namespaceKey string, key string) (*flipt.Segment, error) { ns, err := ss.getNamespace(namespaceKey) if err != nil { return nil, err @@ -688,7 +657,7 @@ func (ss *StoreSnapshot) GetSegment(ctx context.Context, namespaceKey string, ke return segment, nil } -func (ss *StoreSnapshot) ListSegments(ctx context.Context, namespaceKey string, opts ...storage.QueryOption) (set storage.ResultSet[*flipt.Segment], err error) { +func (ss *Snapshot) ListSegments(ctx context.Context, namespaceKey string, opts ...storage.QueryOption) (set storage.ResultSet[*flipt.Segment], err error) { ns, err := ss.getNamespace(namespaceKey) if err != nil { return set, err @@ -704,7 +673,7 @@ func (ss *StoreSnapshot) ListSegments(ctx context.Context, namespaceKey string, }, segments...) } -func (ss *StoreSnapshot) CountSegments(ctx context.Context, namespaceKey string) (uint64, error) { +func (ss *Snapshot) CountSegments(ctx context.Context, namespaceKey string) (uint64, error) { ns, err := ss.getNamespace(namespaceKey) if err != nil { return 0, err @@ -713,31 +682,7 @@ func (ss *StoreSnapshot) CountSegments(ctx context.Context, namespaceKey string) return uint64(len(ns.segments)), nil } -func (ss *StoreSnapshot) CreateSegment(ctx context.Context, r *flipt.CreateSegmentRequest) (*flipt.Segment, error) { - return nil, ErrNotImplemented -} - -func (ss *StoreSnapshot) UpdateSegment(ctx context.Context, r *flipt.UpdateSegmentRequest) (*flipt.Segment, error) { - return nil, ErrNotImplemented -} - -func (ss *StoreSnapshot) DeleteSegment(ctx context.Context, r *flipt.DeleteSegmentRequest) error { - return ErrNotImplemented -} - -func (ss *StoreSnapshot) CreateConstraint(ctx context.Context, r *flipt.CreateConstraintRequest) (*flipt.Constraint, error) { - return nil, ErrNotImplemented -} - -func (ss *StoreSnapshot) UpdateConstraint(ctx context.Context, r *flipt.UpdateConstraintRequest) (*flipt.Constraint, error) { - return nil, ErrNotImplemented -} - -func (ss *StoreSnapshot) DeleteConstraint(ctx context.Context, r *flipt.DeleteConstraintRequest) error { - return ErrNotImplemented -} - -func (ss *StoreSnapshot) GetNamespace(ctx context.Context, key string) (*flipt.Namespace, error) { +func (ss *Snapshot) GetNamespace(ctx context.Context, key string) (*flipt.Namespace, error) { ns, err := ss.getNamespace(key) if err != nil { return nil, err @@ -746,7 +691,7 @@ func (ss *StoreSnapshot) GetNamespace(ctx context.Context, key string) (*flipt.N return ns.resource, nil } -func (ss *StoreSnapshot) ListNamespaces(ctx context.Context, opts ...storage.QueryOption) (set storage.ResultSet[*flipt.Namespace], err error) { +func (ss *Snapshot) ListNamespaces(ctx context.Context, opts ...storage.QueryOption) (set storage.ResultSet[*flipt.Namespace], err error) { ns := make([]*flipt.Namespace, 0, len(ss.ns)) for _, n := range ss.ns { ns = append(ns, n.resource) @@ -757,23 +702,11 @@ func (ss *StoreSnapshot) ListNamespaces(ctx context.Context, opts ...storage.Que }, ns...) } -func (ss *StoreSnapshot) CountNamespaces(ctx context.Context) (uint64, error) { +func (ss *Snapshot) CountNamespaces(ctx context.Context) (uint64, error) { return uint64(len(ss.ns)), nil } -func (ss *StoreSnapshot) CreateNamespace(ctx context.Context, r *flipt.CreateNamespaceRequest) (*flipt.Namespace, error) { - return nil, ErrNotImplemented -} - -func (ss *StoreSnapshot) UpdateNamespace(ctx context.Context, r *flipt.UpdateNamespaceRequest) (*flipt.Namespace, error) { - return nil, ErrNotImplemented -} - -func (ss *StoreSnapshot) DeleteNamespace(ctx context.Context, r *flipt.DeleteNamespaceRequest) error { - return ErrNotImplemented -} - -func (ss *StoreSnapshot) GetFlag(ctx context.Context, namespaceKey string, key string) (*flipt.Flag, error) { +func (ss *Snapshot) GetFlag(ctx context.Context, namespaceKey string, key string) (*flipt.Flag, error) { ns, err := ss.getNamespace(namespaceKey) if err != nil { return nil, err @@ -787,7 +720,7 @@ func (ss *StoreSnapshot) GetFlag(ctx context.Context, namespaceKey string, key s return flag, nil } -func (ss *StoreSnapshot) ListFlags(ctx context.Context, namespaceKey string, opts ...storage.QueryOption) (set storage.ResultSet[*flipt.Flag], err error) { +func (ss *Snapshot) ListFlags(ctx context.Context, namespaceKey string, opts ...storage.QueryOption) (set storage.ResultSet[*flipt.Flag], err error) { ns, err := ss.getNamespace(namespaceKey) if err != nil { return set, err @@ -803,7 +736,7 @@ func (ss *StoreSnapshot) ListFlags(ctx context.Context, namespaceKey string, opt }, flags...) } -func (ss *StoreSnapshot) CountFlags(ctx context.Context, namespaceKey string) (uint64, error) { +func (ss *Snapshot) CountFlags(ctx context.Context, namespaceKey string) (uint64, error) { ns, err := ss.getNamespace(namespaceKey) if err != nil { return 0, err @@ -812,31 +745,7 @@ func (ss *StoreSnapshot) CountFlags(ctx context.Context, namespaceKey string) (u return uint64(len(ns.flags)), nil } -func (ss *StoreSnapshot) CreateFlag(ctx context.Context, r *flipt.CreateFlagRequest) (*flipt.Flag, error) { - return nil, ErrNotImplemented -} - -func (ss *StoreSnapshot) UpdateFlag(ctx context.Context, r *flipt.UpdateFlagRequest) (*flipt.Flag, error) { - return nil, ErrNotImplemented -} - -func (ss *StoreSnapshot) DeleteFlag(ctx context.Context, r *flipt.DeleteFlagRequest) error { - return ErrNotImplemented -} - -func (ss *StoreSnapshot) CreateVariant(ctx context.Context, r *flipt.CreateVariantRequest) (*flipt.Variant, error) { - return nil, ErrNotImplemented -} - -func (ss *StoreSnapshot) UpdateVariant(ctx context.Context, r *flipt.UpdateVariantRequest) (*flipt.Variant, error) { - return nil, ErrNotImplemented -} - -func (ss *StoreSnapshot) DeleteVariant(ctx context.Context, r *flipt.DeleteVariantRequest) error { - return ErrNotImplemented -} - -func (ss *StoreSnapshot) GetEvaluationRules(ctx context.Context, namespaceKey string, flagKey string) ([]*storage.EvaluationRule, error) { +func (ss *Snapshot) GetEvaluationRules(ctx context.Context, namespaceKey string, flagKey string) ([]*storage.EvaluationRule, error) { ns, ok := ss.ns[namespaceKey] if !ok { return nil, errs.ErrNotFoundf("namespaced %q", namespaceKey) @@ -850,7 +759,7 @@ func (ss *StoreSnapshot) GetEvaluationRules(ctx context.Context, namespaceKey st return rules, nil } -func (ss *StoreSnapshot) GetEvaluationDistributions(ctx context.Context, ruleID string) ([]*storage.EvaluationDistribution, error) { +func (ss *Snapshot) GetEvaluationDistributions(ctx context.Context, ruleID string) ([]*storage.EvaluationDistribution, error) { dists, ok := ss.evalDists[ruleID] if !ok { return nil, errs.ErrNotFoundf("rule %q", ruleID) @@ -859,7 +768,7 @@ func (ss *StoreSnapshot) GetEvaluationDistributions(ctx context.Context, ruleID return dists, nil } -func (ss *StoreSnapshot) GetEvaluationRollouts(ctx context.Context, namespaceKey, flagKey string) ([]*storage.EvaluationRollout, error) { +func (ss *Snapshot) GetEvaluationRollouts(ctx context.Context, namespaceKey, flagKey string) ([]*storage.EvaluationRollout, error) { ns, ok := ss.ns[namespaceKey] if !ok { return nil, errs.ErrNotFoundf("namespaced %q", namespaceKey) @@ -873,7 +782,7 @@ func (ss *StoreSnapshot) GetEvaluationRollouts(ctx context.Context, namespaceKey return rollouts, nil } -func (ss *StoreSnapshot) GetRollout(ctx context.Context, namespaceKey, id string) (*flipt.Rollout, error) { +func (ss *Snapshot) GetRollout(ctx context.Context, namespaceKey, id string) (*flipt.Rollout, error) { ns, err := ss.getNamespace(namespaceKey) if err != nil { return nil, err @@ -887,7 +796,7 @@ func (ss *StoreSnapshot) GetRollout(ctx context.Context, namespaceKey, id string return rollout, nil } -func (ss *StoreSnapshot) ListRollouts(ctx context.Context, namespaceKey, flagKey string, opts ...storage.QueryOption) (set storage.ResultSet[*flipt.Rollout], err error) { +func (ss *Snapshot) ListRollouts(ctx context.Context, namespaceKey, flagKey string, opts ...storage.QueryOption) (set storage.ResultSet[*flipt.Rollout], err error) { ns, err := ss.getNamespace(namespaceKey) if err != nil { return set, err @@ -905,7 +814,7 @@ func (ss *StoreSnapshot) ListRollouts(ctx context.Context, namespaceKey, flagKey }, rollouts...) } -func (ss *StoreSnapshot) CountRollouts(ctx context.Context, namespaceKey, flagKey string) (uint64, error) { +func (ss *Snapshot) CountRollouts(ctx context.Context, namespaceKey, flagKey string) (uint64, error) { ns, err := ss.getNamespace(namespaceKey) if err != nil { return 0, err @@ -921,22 +830,6 @@ func (ss *StoreSnapshot) CountRollouts(ctx context.Context, namespaceKey, flagKe return count, nil } -func (ss *StoreSnapshot) CreateRollout(ctx context.Context, r *flipt.CreateRolloutRequest) (*flipt.Rollout, error) { - return nil, ErrNotImplemented -} - -func (ss *StoreSnapshot) UpdateRollout(ctx context.Context, r *flipt.UpdateRolloutRequest) (*flipt.Rollout, error) { - return nil, ErrNotImplemented -} - -func (ss *StoreSnapshot) DeleteRollout(ctx context.Context, r *flipt.DeleteRolloutRequest) error { - return ErrNotImplemented -} - -func (ss *StoreSnapshot) OrderRollouts(ctx context.Context, r *flipt.OrderRolloutsRequest) error { - return ErrNotImplemented -} - func findByKey[T interface{ GetKey() string }](key string, ts ...T) (t T, _ bool) { return find(func(t T) bool { return t.GetKey() == key }, ts...) } @@ -1003,7 +896,7 @@ func paginate[T any](params storage.QueryParams, less func(i, j int) bool, items return set, nil } -func (ss *StoreSnapshot) getNamespace(key string) (namespace, error) { +func (ss *Snapshot) getNamespace(key string) (namespace, error) { ns, ok := ss.ns[key] if !ok { return namespace{}, errs.ErrNotFoundf("namespace %q", key) diff --git a/internal/storage/fs/snapshot_test.go b/internal/storage/fs/snapshot_test.go index 1a4334d71b..3b0ae2f1fb 100644 --- a/internal/storage/fs/snapshot_test.go +++ b/internal/storage/fs/snapshot_test.go @@ -113,7 +113,7 @@ func TestFSWithIndex(t *testing.T) { type FSIndexSuite struct { suite.Suite - store storage.Store + store storage.ReadOnlyStore } func (fis *FSIndexSuite) TestCountFlag() { @@ -790,7 +790,7 @@ func (fis *FSIndexSuite) TestCountRules() { type FSWithoutIndexSuite struct { suite.Suite - store storage.Store + store storage.ReadOnlyStore } func TestFSWithoutIndex(t *testing.T) { diff --git a/internal/storage/fs/store.go b/internal/storage/fs/store.go index 925bf628dd..21358bae82 100644 --- a/internal/storage/fs/store.go +++ b/internal/storage/fs/store.go @@ -2,108 +2,336 @@ package fs import ( "context" + "errors" "fmt" "path" - "go.uber.org/zap" + "go.flipt.io/flipt/internal/storage" + "go.flipt.io/flipt/rpc/flipt" ) -// SnapshotSource produces instances of the storage snapshot. -// A single snapshot can be produced via Get or a channel -// may be provided to Subscribe in order to received -// new instances when new state becomes available. -type SnapshotSource interface { - fmt.Stringer +var ( + _ storage.Store = (*Store)(nil) - // Get builds a single instance of a *SnapshotSource - Get(context.Context) (*StoreSnapshot, error) + // ErrNotImplemented is returned when a method has intentionally not been implemented + // This is usually reserved for the store write actions when the store is read-only + // but still needs to implement storage.Store + ErrNotImplemented = errors.New("not implemented") +) - // Subscribe feeds instances of *SnapshotSource onto the provided channel. - // It should block until the provided context is cancelled (it will be called in a goroutine). - // It should close the provided channel before it returns. - Subscribe(context.Context, chan<- *StoreSnapshot) +// SnapshotStore is a type which has a single function View. +// View is a functional transaction interface for reading a snapshot +// during the lifetime of a supplied function. +type SnapshotStore interface { + // View accepts a function which takes a *StoreSnapshot. + // The SnapshotStore will supply a snapshot which is valid + // for the lifetime of the provided function call. + View(func(storage.ReadOnlyStore) error) error + fmt.Stringer } -// Store is an implementation of storage.Store backed by an SnapshotSource. -// The store subscribes to the source for instances of *SnapshotSource with new contents. -// When a new fs is received the contents is fetched and built into a snapshot -// of Flipt feature flag state. +// Store embeds a StoreSnapshot and wraps the Store methods with a read-write mutex +// to synchronize reads with atomic replacements of the embedded snapshot. type Store struct { - *syncedStore + viewer SnapshotStore +} + +func NewStore(viewer SnapshotStore) *Store { + return &Store{viewer: viewer} +} + +func (s *Store) String() string { + return path.Join("declarative", s.viewer.String()) +} + +func (s *Store) GetFlag(ctx context.Context, namespaceKey string, key string) (flag *flipt.Flag, err error) { + if namespaceKey == "" { + namespaceKey = flipt.DefaultNamespace + } + + return flag, s.viewer.View(func(ss storage.ReadOnlyStore) error { + flag, err = ss.GetFlag(ctx, namespaceKey, key) + return err + }) +} + +func (s *Store) ListFlags(ctx context.Context, namespaceKey string, opts ...storage.QueryOption) (set storage.ResultSet[*flipt.Flag], err error) { + if namespaceKey == "" { + namespaceKey = flipt.DefaultNamespace + } + + return set, s.viewer.View(func(ss storage.ReadOnlyStore) error { + set, err = ss.ListFlags(ctx, namespaceKey, opts...) + return err + }) +} + +func (s *Store) CountFlags(ctx context.Context, namespaceKey string) (count uint64, err error) { + if namespaceKey == "" { + namespaceKey = flipt.DefaultNamespace + } + + return count, s.viewer.View(func(ss storage.ReadOnlyStore) error { + count, err = ss.CountFlags(ctx, namespaceKey) + return err + }) +} + +func (s *Store) GetRule(ctx context.Context, namespaceKey string, id string) (rule *flipt.Rule, err error) { + if namespaceKey == "" { + namespaceKey = flipt.DefaultNamespace + } + + return rule, s.viewer.View(func(ss storage.ReadOnlyStore) error { + rule, err = ss.GetRule(ctx, namespaceKey, id) + return err + }) +} + +func (s *Store) ListRules(ctx context.Context, namespaceKey string, flagKey string, opts ...storage.QueryOption) (set storage.ResultSet[*flipt.Rule], err error) { + if namespaceKey == "" { + namespaceKey = flipt.DefaultNamespace + } + + return set, s.viewer.View(func(ss storage.ReadOnlyStore) error { + set, err = ss.ListRules(ctx, namespaceKey, flagKey, opts...) + return err + }) +} + +func (s *Store) CountRules(ctx context.Context, namespaceKey, flagKey string) (count uint64, err error) { + if namespaceKey == "" { + namespaceKey = flipt.DefaultNamespace + } + + return count, s.viewer.View(func(ss storage.ReadOnlyStore) error { + count, err = ss.CountRules(ctx, namespaceKey, flagKey) + return err + }) +} + +func (s *Store) GetSegment(ctx context.Context, namespaceKey string, key string) (segment *flipt.Segment, err error) { + if namespaceKey == "" { + namespaceKey = flipt.DefaultNamespace + } + + return segment, s.viewer.View(func(ss storage.ReadOnlyStore) error { + segment, err = ss.GetSegment(ctx, namespaceKey, key) + return err + }) +} + +func (s *Store) ListSegments(ctx context.Context, namespaceKey string, opts ...storage.QueryOption) (set storage.ResultSet[*flipt.Segment], err error) { + if namespaceKey == "" { + namespaceKey = flipt.DefaultNamespace + } + + return set, s.viewer.View(func(ss storage.ReadOnlyStore) error { + set, err = ss.ListSegments(ctx, namespaceKey, opts...) + return err + }) +} + +func (s *Store) CountSegments(ctx context.Context, namespaceKey string) (count uint64, err error) { + if namespaceKey == "" { + namespaceKey = flipt.DefaultNamespace + } + + return count, s.viewer.View(func(ss storage.ReadOnlyStore) error { + count, err = ss.CountSegments(ctx, namespaceKey) + return err + }) +} + +func (s *Store) GetEvaluationRules(ctx context.Context, namespaceKey string, flagKey string) (rules []*storage.EvaluationRule, err error) { + if namespaceKey == "" { + namespaceKey = flipt.DefaultNamespace + } + + return rules, s.viewer.View(func(ss storage.ReadOnlyStore) error { + rules, err = ss.GetEvaluationRules(ctx, namespaceKey, flagKey) + return err + }) +} + +func (s *Store) GetEvaluationDistributions(ctx context.Context, ruleID string) (dists []*storage.EvaluationDistribution, err error) { + return dists, s.viewer.View(func(ss storage.ReadOnlyStore) error { + dists, err = ss.GetEvaluationDistributions(ctx, ruleID) + return err + }) +} + +func (s *Store) GetEvaluationRollouts(ctx context.Context, namespaceKey, flagKey string) (rollouts []*storage.EvaluationRollout, err error) { + if namespaceKey == "" { + namespaceKey = flipt.DefaultNamespace + } - logger *zap.Logger - source SnapshotSource + return rollouts, s.viewer.View(func(ss storage.ReadOnlyStore) error { + rollouts, err = ss.GetEvaluationRollouts(ctx, namespaceKey, flagKey) + return err + }) +} - // notify is used for test purposes - // it is invoked if defined when a snapshot update finishes - notify func() +func (s *Store) GetNamespace(ctx context.Context, key string) (ns *flipt.Namespace, err error) { + if key == "" { + key = flipt.DefaultNamespace + } - cancel context.CancelFunc - done chan struct{} + return ns, s.viewer.View(func(ss storage.ReadOnlyStore) error { + ns, err = ss.GetNamespace(ctx, key) + return err + }) } -func (l *Store) updateSnapshot(storeSnapshot *StoreSnapshot) { - l.mu.Lock() - l.Store = storeSnapshot - l.mu.Unlock() +func (s *Store) ListNamespaces(ctx context.Context, opts ...storage.QueryOption) (set storage.ResultSet[*flipt.Namespace], err error) { + return set, s.viewer.View(func(ss storage.ReadOnlyStore) error { + set, err = ss.ListNamespaces(ctx, opts...) + return err + }) +} - // NOTE: this is really just a trick for unit tests - // It is used to signal that an update occurred - // so we dont have to e.g. sleep to know when - // to check state. - if l.notify != nil { - l.notify() +func (s *Store) CountNamespaces(ctx context.Context) (count uint64, err error) { + return count, s.viewer.View(func(ss storage.ReadOnlyStore) error { + count, err = ss.CountNamespaces(ctx) + return err + }) +} + +func (s *Store) GetRollout(ctx context.Context, namespaceKey, id string) (rollout *flipt.Rollout, err error) { + if namespaceKey == "" { + namespaceKey = flipt.DefaultNamespace } + + return rollout, s.viewer.View(func(ss storage.ReadOnlyStore) error { + rollout, err = ss.GetRollout(ctx, namespaceKey, id) + return err + }) } -// NewStore constructs and configure a Store. -// The store creates a background goroutine which feeds a channel of *SnapshotSource. -func NewStore(logger *zap.Logger, source SnapshotSource) (*Store, error) { - store := &Store{ - syncedStore: &syncedStore{}, - logger: logger, - source: source, - done: make(chan struct{}), +func (s *Store) ListRollouts(ctx context.Context, namespaceKey, flagKey string, opts ...storage.QueryOption) (set storage.ResultSet[*flipt.Rollout], err error) { + if namespaceKey == "" { + namespaceKey = flipt.DefaultNamespace } - // get an initial snapshot from source. - f, err := source.Get(context.Background()) - if err != nil { - return nil, err + return set, s.viewer.View(func(ss storage.ReadOnlyStore) error { + set, err = ss.ListRollouts(ctx, namespaceKey, flagKey, opts...) + return err + }) +} + +func (s *Store) CountRollouts(ctx context.Context, namespaceKey, flagKey string) (count uint64, err error) { + if namespaceKey == "" { + namespaceKey = flipt.DefaultNamespace } - store.updateSnapshot(f) + return count, s.viewer.View(func(ss storage.ReadOnlyStore) error { + count, err = ss.CountRollouts(ctx, namespaceKey, flagKey) + return err + }) +} + +// unimplemented write paths below + +func (s *Store) CreateNamespace(ctx context.Context, r *flipt.CreateNamespaceRequest) (*flipt.Namespace, error) { + return nil, ErrNotImplemented +} + +func (s *Store) UpdateNamespace(ctx context.Context, r *flipt.UpdateNamespaceRequest) (*flipt.Namespace, error) { + return nil, ErrNotImplemented +} - var ctx context.Context - ctx, store.cancel = context.WithCancel(context.Background()) +func (s *Store) DeleteNamespace(ctx context.Context, r *flipt.DeleteNamespaceRequest) error { + return ErrNotImplemented +} - ch := make(chan *StoreSnapshot) - go source.Subscribe(ctx, ch) +func (s *Store) CreateFlag(ctx context.Context, r *flipt.CreateFlagRequest) (*flipt.Flag, error) { + return nil, ErrNotImplemented +} - go func() { - defer close(store.done) - for snap := range ch { - logger.Debug("received new snapshot") - store.updateSnapshot(snap) - logger.Debug("updated latest snapshot") - } +func (s *Store) UpdateFlag(ctx context.Context, r *flipt.UpdateFlagRequest) (*flipt.Flag, error) { + return nil, ErrNotImplemented +} - logger.Info("source subscription closed") - }() +func (s *Store) DeleteFlag(ctx context.Context, r *flipt.DeleteFlagRequest) error { + return ErrNotImplemented +} - return store, nil +func (s *Store) CreateVariant(ctx context.Context, r *flipt.CreateVariantRequest) (*flipt.Variant, error) { + return nil, ErrNotImplemented } -// Close cancels the polling routine and waits for the routine to return. -func (l *Store) Close() error { - l.cancel() +func (s *Store) UpdateVariant(ctx context.Context, r *flipt.UpdateVariantRequest) (*flipt.Variant, error) { + return nil, ErrNotImplemented +} + +func (s *Store) DeleteVariant(ctx context.Context, r *flipt.DeleteVariantRequest) error { + return ErrNotImplemented +} + +func (s *Store) CreateSegment(ctx context.Context, r *flipt.CreateSegmentRequest) (*flipt.Segment, error) { + return nil, ErrNotImplemented +} + +func (s *Store) UpdateSegment(ctx context.Context, r *flipt.UpdateSegmentRequest) (*flipt.Segment, error) { + return nil, ErrNotImplemented +} - <-l.done +func (s *Store) DeleteSegment(ctx context.Context, r *flipt.DeleteSegmentRequest) error { + return ErrNotImplemented +} + +func (s *Store) CreateConstraint(ctx context.Context, r *flipt.CreateConstraintRequest) (*flipt.Constraint, error) { + return nil, ErrNotImplemented +} + +func (s *Store) UpdateConstraint(ctx context.Context, r *flipt.UpdateConstraintRequest) (*flipt.Constraint, error) { + return nil, ErrNotImplemented +} + +func (s *Store) DeleteConstraint(ctx context.Context, r *flipt.DeleteConstraintRequest) error { + return ErrNotImplemented +} + +func (s *Store) CreateRule(ctx context.Context, r *flipt.CreateRuleRequest) (*flipt.Rule, error) { + return nil, ErrNotImplemented +} + +func (s *Store) UpdateRule(ctx context.Context, r *flipt.UpdateRuleRequest) (*flipt.Rule, error) { + return nil, ErrNotImplemented +} + +func (s *Store) DeleteRule(ctx context.Context, r *flipt.DeleteRuleRequest) error { + return ErrNotImplemented +} + +func (s *Store) OrderRules(ctx context.Context, r *flipt.OrderRulesRequest) error { + return ErrNotImplemented +} + +func (s *Store) CreateDistribution(ctx context.Context, r *flipt.CreateDistributionRequest) (*flipt.Distribution, error) { + return nil, ErrNotImplemented +} + +func (s *Store) UpdateDistribution(ctx context.Context, r *flipt.UpdateDistributionRequest) (*flipt.Distribution, error) { + return nil, ErrNotImplemented +} + +func (s *Store) DeleteDistribution(ctx context.Context, r *flipt.DeleteDistributionRequest) error { + return ErrNotImplemented +} + +func (s *Store) CreateRollout(ctx context.Context, r *flipt.CreateRolloutRequest) (*flipt.Rollout, error) { + return nil, ErrNotImplemented +} + +func (s *Store) UpdateRollout(ctx context.Context, r *flipt.UpdateRolloutRequest) (*flipt.Rollout, error) { + return nil, ErrNotImplemented +} - return nil +func (s *Store) DeleteRollout(ctx context.Context, r *flipt.DeleteRolloutRequest) error { + return ErrNotImplemented } -// String returns an identifier string for the store type. -func (l *Store) String() string { - return path.Join("filesystem", l.source.String()) +func (s *Store) OrderRollouts(ctx context.Context, r *flipt.OrderRolloutsRequest) error { + return ErrNotImplemented } diff --git a/internal/storage/fs/store/store.go b/internal/storage/fs/store/store.go new file mode 100644 index 0000000000..0afece5d99 --- /dev/null +++ b/internal/storage/fs/store/store.go @@ -0,0 +1,164 @@ +package store + +import ( + "context" + "fmt" + "os" + + "github.com/go-git/go-git/v5/plumbing/transport/http" + gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh" + "go.flipt.io/flipt/internal/config" + "go.flipt.io/flipt/internal/containers" + "go.flipt.io/flipt/internal/oci" + "go.flipt.io/flipt/internal/storage" + storagefs "go.flipt.io/flipt/internal/storage/fs" + "go.flipt.io/flipt/internal/storage/fs/git" + "go.flipt.io/flipt/internal/storage/fs/local" + storageoci "go.flipt.io/flipt/internal/storage/fs/oci" + "go.flipt.io/flipt/internal/storage/fs/s3" + "go.uber.org/zap" + "golang.org/x/crypto/ssh" +) + +// NewStore is a constructor that handles all the known declarative backend storage types +// Given the provided storage type is know, the relevant backend is configured and returned +func NewStore(ctx context.Context, logger *zap.Logger, cfg *config.Config) (_ storage.Store, err error) { + switch cfg.Storage.Type { + case config.GitStorageType: + opts := []containers.Option[git.SnapshotStore]{ + git.WithRef(cfg.Storage.Git.Ref), + git.WithPollOptions( + storagefs.WithInterval(cfg.Storage.Git.PollInterval), + ), + git.WithInsecureTLS(cfg.Storage.Git.InsecureSkipTLS), + } + + if cfg.Storage.Git.CaCertBytes != "" { + opts = append(opts, git.WithCABundle([]byte(cfg.Storage.Git.CaCertBytes))) + } else if cfg.Storage.Git.CaCertPath != "" { + if bytes, err := os.ReadFile(cfg.Storage.Git.CaCertPath); err == nil { + opts = append(opts, git.WithCABundle(bytes)) + } else { + return nil, err + } + } + + auth := cfg.Storage.Git.Authentication + switch { + case auth.BasicAuth != nil: + opts = append(opts, git.WithAuth(&http.BasicAuth{ + Username: auth.BasicAuth.Username, + Password: auth.BasicAuth.Password, + })) + case auth.TokenAuth != nil: + opts = append(opts, git.WithAuth(&http.TokenAuth{ + Token: auth.TokenAuth.AccessToken, + })) + case auth.SSHAuth != nil: + var method *gitssh.PublicKeys + if auth.SSHAuth.PrivateKeyBytes != "" { + method, err = gitssh.NewPublicKeys( + auth.SSHAuth.User, + []byte(auth.SSHAuth.PrivateKeyBytes), + auth.SSHAuth.Password, + ) + } else { + method, err = gitssh.NewPublicKeysFromFile( + auth.SSHAuth.User, + auth.SSHAuth.PrivateKeyPath, + auth.SSHAuth.Password, + ) + } + if err != nil { + return nil, err + } + + // we're protecting against this explicitly so we can disable + // the gosec linting rule + if auth.SSHAuth.InsecureIgnoreHostKey { + // nolint:gosec + method.HostKeyCallback = ssh.InsecureIgnoreHostKey() + } + + opts = append(opts, git.WithAuth(method)) + } + + snapStore, err := git.NewSnapshotStore(ctx, logger, cfg.Storage.Git.Repository, opts...) + if err != nil { + return nil, err + } + + return storagefs.NewStore(snapStore), nil + case config.LocalStorageType: + snapStore, err := local.NewSnapshotStore(ctx, logger, cfg.Storage.Local.Path) + if err != nil { + return nil, err + } + + return storagefs.NewStore(snapStore), nil + case config.ObjectStorageType: + return newObjectStore(ctx, cfg, logger) + case config.OCIStorageType: + var opts []containers.Option[oci.StoreOptions] + if auth := cfg.Storage.OCI.Authentication; auth != nil { + opts = append(opts, oci.WithCredentials( + auth.Username, + auth.Password, + )) + } + + ocistore, err := oci.NewStore(logger, cfg.Storage.OCI.BundlesDirectory, opts...) + if err != nil { + return nil, err + } + + ref, err := oci.ParseReference(cfg.Storage.OCI.Repository) + if err != nil { + return nil, err + } + + snapStore, err := storageoci.NewSnapshotStore(ctx, logger, ocistore, ref, + storageoci.WithPollOptions( + storagefs.WithInterval(cfg.Storage.OCI.PollInterval), + ), + ) + if err != nil { + return nil, err + } + + return storagefs.NewStore(snapStore), nil + } + + return nil, fmt.Errorf("unexpected storage type: %q", cfg.Storage.Type) +} + +// newObjectStore create a new storate.Store from the object config +func newObjectStore(ctx context.Context, cfg *config.Config, logger *zap.Logger) (store storage.Store, err error) { + objectCfg := cfg.Storage.Object + // keep this as a case statement in anticipation of + // more object types in the future + // nolint:gocritic + switch objectCfg.Type { + case config.S3ObjectSubStorageType: + opts := []containers.Option[s3.SnapshotStore]{ + s3.WithPollOptions( + storagefs.WithInterval(objectCfg.S3.PollInterval), + ), + } + if objectCfg.S3.Endpoint != "" { + opts = append(opts, s3.WithEndpoint(objectCfg.S3.Endpoint)) + } + if objectCfg.S3.Region != "" { + opts = append(opts, s3.WithRegion(objectCfg.S3.Region)) + } + + snapStore, err := s3.NewSnapshotStore(ctx, logger, objectCfg.S3.Bucket, opts...) + if err != nil { + return nil, err + } + + return storagefs.NewStore(snapStore), nil + } + + return nil, fmt.Errorf("unexpected object storage subtype: %q", objectCfg.Type) +} diff --git a/internal/storage/fs/store_test.go b/internal/storage/fs/store_test.go index 41cae2631b..780ab6059b 100644 --- a/internal/storage/fs/store_test.go +++ b/internal/storage/fs/store_test.go @@ -2,89 +2,212 @@ package fs import ( "context" - "io/fs" "testing" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - "github.com/stretchr/testify/suite" - "go.uber.org/zap/zaptest" + "go.flipt.io/flipt/internal/common" + "go.flipt.io/flipt/internal/storage" + "go.flipt.io/flipt/rpc/flipt" ) -func Test_Store(t *testing.T) { - var ( - logger = zaptest.NewLogger(t) - notify = make(chan struct{}) - source = source{ - get: mustSub(t, testdata, "testdata/valid/explicit_index"), - ch: make(chan *StoreSnapshot), - } - ) +func TestGetFlag(t *testing.T) { + storeMock := newSnapshotStoreMock() + ss := NewStore(storeMock) - store, err := NewStore(logger, source) + storeMock.On("GetFlag", mock.Anything, flipt.DefaultNamespace, "foo").Return(&flipt.Flag{}, nil) + + _, err := ss.GetFlag(context.TODO(), "", "foo") require.NoError(t, err) +} - // register a function to be called when updates have - // finished - store.notify = func() { - notify <- struct{}{} - } +func TestListFlags(t *testing.T) { + storeMock := newSnapshotStoreMock() + ss := NewStore(storeMock) + + storeMock.On("ListFlags", mock.Anything, flipt.DefaultNamespace, mock.Anything).Return(storage.ResultSet[*flipt.Flag]{}, nil) + + _, err := ss.ListFlags(context.TODO(), "") + require.NoError(t, err) +} + +func TestCountFlags(t *testing.T) { + storeMock := newSnapshotStoreMock() + ss := NewStore(storeMock) + + storeMock.On("CountFlags", mock.Anything, flipt.DefaultNamespace).Return(uint64(0), nil) + + _, err := ss.CountFlags(context.TODO(), "") + require.NoError(t, err) +} + +func TestGetRule(t *testing.T) { + storeMock := newSnapshotStoreMock() + ss := NewStore(storeMock) + + storeMock.On("GetRule", mock.Anything, flipt.DefaultNamespace, "").Return(&flipt.Rule{}, nil) + + _, err := ss.GetRule(context.TODO(), "", "") + require.NoError(t, err) +} + +func TestListRules(t *testing.T) { + storeMock := newSnapshotStoreMock() + ss := NewStore(storeMock) + + storeMock.On("ListRules", mock.Anything, flipt.DefaultNamespace, "", mock.Anything).Return(storage.ResultSet[*flipt.Rule]{}, nil) - assert.Equal(t, "filesystem/test", store.String()) + _, err := ss.ListRules(context.TODO(), "", "") + require.NoError(t, err) +} + +func TestCountRules(t *testing.T) { + storeMock := newSnapshotStoreMock() + ss := NewStore(storeMock) - // run FS with index suite against current store - suite.Run(t, &FSIndexSuite{store: store}) + storeMock.On("CountRules", mock.Anything, flipt.DefaultNamespace, "").Return(uint64(0), nil) - // update snapshot by sending fs without index - source.ch <- mustSub(t, testdata, "testdata/valid/implicit_index") + _, err := ss.CountRules(context.TODO(), "", "") + require.NoError(t, err) +} - // wait for update to apply - <-notify +func TestGetSegment(t *testing.T) { + storeMock := newSnapshotStoreMock() + ss := NewStore(storeMock) - // run FS without index suite against current store - suite.Run(t, &FSWithoutIndexSuite{store: store}) + storeMock.On("GetSegment", mock.Anything, flipt.DefaultNamespace, "").Return(&flipt.Segment{}, nil) - // shutdown store - require.NoError(t, store.Close()) + _, err := ss.GetSegment(context.TODO(), "", "") + require.NoError(t, err) } -type source struct { - get *StoreSnapshot - ch chan *StoreSnapshot +func TestListSegments(t *testing.T) { + storeMock := newSnapshotStoreMock() + ss := NewStore(storeMock) + + storeMock.On("ListSegments", mock.Anything, flipt.DefaultNamespace, mock.Anything).Return(storage.ResultSet[*flipt.Segment]{}, nil) + + _, err := ss.ListSegments(context.TODO(), "") + require.NoError(t, err) } -func (s source) String() string { - return "test" +func TestCountSegments(t *testing.T) { + storeMock := newSnapshotStoreMock() + ss := NewStore(storeMock) + + storeMock.On("CountSegments", mock.Anything, flipt.DefaultNamespace).Return(uint64(0), nil) + + _, err := ss.CountSegments(context.TODO(), "") + require.NoError(t, err) } -// Get builds a single instance of an *StoreSnapshot -func (s source) Get(context.Context) (*StoreSnapshot, error) { - return s.get, nil +func TestGetRollout(t *testing.T) { + storeMock := newSnapshotStoreMock() + ss := NewStore(storeMock) + + storeMock.On("GetRollout", mock.Anything, flipt.DefaultNamespace, "").Return(&flipt.Rollout{}, nil) + + _, err := ss.GetRollout(context.TODO(), "", "") + require.NoError(t, err) } -// Subscribe feeds implementations of *StoreSnapshot onto the provided channel. -// It should block until the provided context is cancelled (it will be called in a goroutine). -// It should close the provided channel before it returns. -func (s source) Subscribe(ctx context.Context, ch chan<- *StoreSnapshot) { - defer close(ch) +func TestListRollouts(t *testing.T) { + storeMock := newSnapshotStoreMock() + ss := NewStore(storeMock) - for { - select { - case <-ctx.Done(): - return - case snap := <-s.ch: - ch <- snap - } - } + storeMock.On("ListRollouts", mock.Anything, flipt.DefaultNamespace, "", mock.Anything).Return(storage.ResultSet[*flipt.Rollout]{}, nil) + + _, err := ss.ListRollouts(context.TODO(), "", "") + require.NoError(t, err) +} + +func TestCountRollouts(t *testing.T) { + storeMock := newSnapshotStoreMock() + ss := NewStore(storeMock) + + storeMock.On("CountRollouts", mock.Anything, flipt.DefaultNamespace, "").Return(uint64(0), nil) + + _, err := ss.CountRollouts(context.TODO(), "", "") + require.NoError(t, err) +} + +func TestGetNamespace(t *testing.T) { + storeMock := newSnapshotStoreMock() + ss := NewStore(storeMock) + + storeMock.On("GetNamespace", mock.Anything, flipt.DefaultNamespace).Return(&flipt.Namespace{}, nil) + + _, err := ss.GetNamespace(context.TODO(), "") + require.NoError(t, err) +} + +func TestListNamespaces(t *testing.T) { + storeMock := newSnapshotStoreMock() + ss := NewStore(storeMock) + + storeMock.On("ListNamespaces", mock.Anything, mock.Anything).Return(storage.ResultSet[*flipt.Namespace]{}, nil) + + _, err := ss.ListNamespaces(context.TODO()) + require.NoError(t, err) +} + +func TestCountNamespaces(t *testing.T) { + storeMock := newSnapshotStoreMock() + ss := NewStore(storeMock) + + storeMock.On("CountNamespaces", mock.Anything).Return(uint64(0), nil) + + _, err := ss.CountNamespaces(context.TODO()) + require.NoError(t, err) } -func mustSub(t *testing.T, f fs.FS, dir string) *StoreSnapshot { - t.Helper() - var err error - f, err = fs.Sub(f, dir) +func TestGetEvaluationRules(t *testing.T) { + storeMock := newSnapshotStoreMock() + ss := NewStore(storeMock) + + storeMock.On("GetEvaluationRules", mock.Anything, flipt.DefaultNamespace, "").Return([]*storage.EvaluationRule{}, nil) + + _, err := ss.GetEvaluationRules(context.TODO(), "", "") + require.NoError(t, err) +} + +func TestGetEvaluationDistributions(t *testing.T) { + storeMock := newSnapshotStoreMock() + ss := NewStore(storeMock) + + storeMock.On("GetEvaluationDistributions", mock.Anything, "").Return([]*storage.EvaluationDistribution{}, nil) + + _, err := ss.GetEvaluationDistributions(context.TODO(), "") require.NoError(t, err) +} + +func TestGetEvaluationRollouts(t *testing.T) { + storeMock := newSnapshotStoreMock() + ss := NewStore(storeMock) + + storeMock.On("GetEvaluationRollouts", mock.Anything, flipt.DefaultNamespace, "").Return([]*storage.EvaluationRollout{}, nil) - snap, err := SnapshotFromFS(zaptest.NewLogger(t), f) + _, err := ss.GetEvaluationRollouts(context.TODO(), "", "") require.NoError(t, err) - return snap +} + +type snapshotStoreMock struct { + *common.StoreMock +} + +func newSnapshotStoreMock() snapshotStoreMock { + return snapshotStoreMock{ + StoreMock: &common.StoreMock{}, + } +} + +// View accepts a function which takes a *StoreSnapshot. +// The SnapshotStore will supply a snapshot which is valid +// for the lifetime of the provided function call. +func (s snapshotStoreMock) View(fn func(storage.ReadOnlyStore) error) error { + return fn(s.StoreMock) +} + +func (s snapshotStoreMock) String() string { + return "mock" } diff --git a/internal/storage/fs/sync.go b/internal/storage/fs/sync.go deleted file mode 100644 index 459e26466e..0000000000 --- a/internal/storage/fs/sync.go +++ /dev/null @@ -1,221 +0,0 @@ -package fs - -import ( - "context" - "sync" - - "go.flipt.io/flipt/internal/storage" - "go.flipt.io/flipt/rpc/flipt" -) - -var _ storage.Store = (*syncedStore)(nil) - -// syncedStore embeds a storeSnapshot and wraps the Store methods with a read-write mutex -// to synchronize reads with swapping out the storeSnapshot. -type syncedStore struct { - storage.Store - - mu sync.RWMutex -} - -func (s *syncedStore) GetFlag(ctx context.Context, namespaceKey string, key string) (*flipt.Flag, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - if namespaceKey == "" { - namespaceKey = flipt.DefaultNamespace - } - - return s.Store.GetFlag(ctx, namespaceKey, key) -} - -func (s *syncedStore) ListFlags(ctx context.Context, namespaceKey string, opts ...storage.QueryOption) (storage.ResultSet[*flipt.Flag], error) { - s.mu.RLock() - defer s.mu.RUnlock() - - if namespaceKey == "" { - namespaceKey = flipt.DefaultNamespace - } - - return s.Store.ListFlags(ctx, namespaceKey, opts...) -} - -func (s *syncedStore) CountFlags(ctx context.Context, namespaceKey string) (uint64, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - if namespaceKey == "" { - namespaceKey = flipt.DefaultNamespace - } - - return s.Store.CountFlags(ctx, namespaceKey) -} - -func (s *syncedStore) GetRule(ctx context.Context, namespaceKey string, id string) (*flipt.Rule, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - if namespaceKey == "" { - namespaceKey = flipt.DefaultNamespace - } - - return s.Store.GetRule(ctx, namespaceKey, id) -} - -func (s *syncedStore) ListRules(ctx context.Context, namespaceKey string, flagKey string, opts ...storage.QueryOption) (storage.ResultSet[*flipt.Rule], error) { - s.mu.RLock() - defer s.mu.RUnlock() - - if namespaceKey == "" { - namespaceKey = flipt.DefaultNamespace - } - - return s.Store.ListRules(ctx, namespaceKey, flagKey, opts...) -} - -func (s *syncedStore) CountRules(ctx context.Context, namespaceKey, flagKey string) (uint64, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - if namespaceKey == "" { - namespaceKey = flipt.DefaultNamespace - } - - return s.Store.CountRules(ctx, namespaceKey, flagKey) -} - -func (s *syncedStore) GetSegment(ctx context.Context, namespaceKey string, key string) (*flipt.Segment, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - if namespaceKey == "" { - namespaceKey = flipt.DefaultNamespace - } - - return s.Store.GetSegment(ctx, namespaceKey, key) -} - -func (s *syncedStore) ListSegments(ctx context.Context, namespaceKey string, opts ...storage.QueryOption) (storage.ResultSet[*flipt.Segment], error) { - s.mu.RLock() - defer s.mu.RUnlock() - - if namespaceKey == "" { - namespaceKey = flipt.DefaultNamespace - } - - return s.Store.ListSegments(ctx, namespaceKey, opts...) -} - -func (s *syncedStore) CountSegments(ctx context.Context, namespaceKey string) (uint64, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - if namespaceKey == "" { - namespaceKey = flipt.DefaultNamespace - } - - return s.Store.CountSegments(ctx, namespaceKey) -} - -func (s *syncedStore) GetEvaluationRules(ctx context.Context, namespaceKey string, flagKey string) ([]*storage.EvaluationRule, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - if namespaceKey == "" { - namespaceKey = flipt.DefaultNamespace - } - - return s.Store.GetEvaluationRules(ctx, namespaceKey, flagKey) -} - -func (s *syncedStore) GetEvaluationDistributions(ctx context.Context, ruleID string) ([]*storage.EvaluationDistribution, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - return s.Store.GetEvaluationDistributions(ctx, ruleID) -} - -func (s *syncedStore) GetEvaluationRollouts(ctx context.Context, namespaceKey, flagKey string) ([]*storage.EvaluationRollout, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - if namespaceKey == "" { - namespaceKey = flipt.DefaultNamespace - } - - return s.Store.GetEvaluationRollouts(ctx, namespaceKey, flagKey) -} - -func (s *syncedStore) GetNamespace(ctx context.Context, key string) (*flipt.Namespace, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - if key == "" { - key = flipt.DefaultNamespace - } - - return s.Store.GetNamespace(ctx, key) -} - -func (s *syncedStore) ListNamespaces(ctx context.Context, opts ...storage.QueryOption) (storage.ResultSet[*flipt.Namespace], error) { - s.mu.RLock() - defer s.mu.RUnlock() - - return s.Store.ListNamespaces(ctx, opts...) -} - -func (s *syncedStore) CountNamespaces(ctx context.Context) (uint64, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - return s.Store.CountNamespaces(ctx) -} - -func (s *syncedStore) GetRollout(ctx context.Context, namespaceKey, id string) (*flipt.Rollout, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - if namespaceKey == "" { - namespaceKey = flipt.DefaultNamespace - } - - return s.Store.GetRollout(ctx, namespaceKey, id) -} - -func (s *syncedStore) ListRollouts(ctx context.Context, namespaceKey, flagKey string, opts ...storage.QueryOption) (storage.ResultSet[*flipt.Rollout], error) { - s.mu.RLock() - defer s.mu.RUnlock() - - if namespaceKey == "" { - namespaceKey = flipt.DefaultNamespace - } - - return s.Store.ListRollouts(ctx, namespaceKey, flagKey, opts...) -} - -func (s *syncedStore) CountRollouts(ctx context.Context, namespaceKey, flagKey string) (uint64, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - if namespaceKey == "" { - namespaceKey = flipt.DefaultNamespace - } - - return s.Store.CountRollouts(ctx, namespaceKey, flagKey) -} - -func (s *syncedStore) CreateRollout(ctx context.Context, r *flipt.CreateRolloutRequest) (*flipt.Rollout, error) { - return nil, ErrNotImplemented -} - -func (s *syncedStore) UpdateRollout(ctx context.Context, r *flipt.UpdateRolloutRequest) (*flipt.Rollout, error) { - return nil, ErrNotImplemented -} - -func (s *syncedStore) DeleteRollout(ctx context.Context, r *flipt.DeleteRolloutRequest) error { - return ErrNotImplemented -} - -func (s *syncedStore) OrderRollouts(ctx context.Context, r *flipt.OrderRolloutsRequest) error { - return ErrNotImplemented -} diff --git a/internal/storage/fs/sync_test.go b/internal/storage/fs/sync_test.go deleted file mode 100644 index 3ee3617975..0000000000 --- a/internal/storage/fs/sync_test.go +++ /dev/null @@ -1,228 +0,0 @@ -package fs - -import ( - "context" - "testing" - - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" - "go.flipt.io/flipt/internal/common" - "go.flipt.io/flipt/internal/storage" - "go.flipt.io/flipt/rpc/flipt" -) - -func TestGetFlag(t *testing.T) { - storeMock := &common.StoreMock{} - ss := &syncedStore{ - Store: storeMock, - } - - storeMock.On("GetFlag", mock.Anything, flipt.DefaultNamespace, "foo").Return(&flipt.Flag{}, nil) - - _, err := ss.GetFlag(context.TODO(), "", "foo") - require.NoError(t, err) -} - -func TestListFlags(t *testing.T) { - storeMock := &common.StoreMock{} - ss := &syncedStore{ - Store: storeMock, - } - - storeMock.On("ListFlags", mock.Anything, flipt.DefaultNamespace, mock.Anything).Return(storage.ResultSet[*flipt.Flag]{}, nil) - - _, err := ss.ListFlags(context.TODO(), "") - require.NoError(t, err) -} - -func TestCountFlags(t *testing.T) { - storeMock := &common.StoreMock{} - ss := &syncedStore{ - Store: storeMock, - } - - storeMock.On("CountFlags", mock.Anything, flipt.DefaultNamespace).Return(uint64(0), nil) - - _, err := ss.CountFlags(context.TODO(), "") - require.NoError(t, err) -} - -func TestGetRule(t *testing.T) { - storeMock := &common.StoreMock{} - ss := &syncedStore{ - Store: storeMock, - } - - storeMock.On("GetRule", mock.Anything, flipt.DefaultNamespace, "").Return(&flipt.Rule{}, nil) - - _, err := ss.GetRule(context.TODO(), "", "") - require.NoError(t, err) -} - -func TestListRules(t *testing.T) { - storeMock := &common.StoreMock{} - ss := &syncedStore{ - Store: storeMock, - } - - storeMock.On("ListRules", mock.Anything, flipt.DefaultNamespace, "", mock.Anything).Return(storage.ResultSet[*flipt.Rule]{}, nil) - - _, err := ss.ListRules(context.TODO(), "", "") - require.NoError(t, err) -} - -func TestCountRules(t *testing.T) { - storeMock := &common.StoreMock{} - ss := &syncedStore{ - Store: storeMock, - } - - storeMock.On("CountRules", mock.Anything, flipt.DefaultNamespace, "").Return(uint64(0), nil) - - _, err := ss.CountRules(context.TODO(), "", "") - require.NoError(t, err) -} - -func TestGetSegment(t *testing.T) { - storeMock := &common.StoreMock{} - ss := &syncedStore{ - Store: storeMock, - } - - storeMock.On("GetSegment", mock.Anything, flipt.DefaultNamespace, "").Return(&flipt.Segment{}, nil) - - _, err := ss.GetSegment(context.TODO(), "", "") - require.NoError(t, err) -} - -func TestListSegments(t *testing.T) { - storeMock := &common.StoreMock{} - ss := &syncedStore{ - Store: storeMock, - } - - storeMock.On("ListSegments", mock.Anything, flipt.DefaultNamespace, mock.Anything).Return(storage.ResultSet[*flipt.Segment]{}, nil) - - _, err := ss.ListSegments(context.TODO(), "") - require.NoError(t, err) -} - -func TestCountSegments(t *testing.T) { - storeMock := &common.StoreMock{} - ss := &syncedStore{ - Store: storeMock, - } - - storeMock.On("CountSegments", mock.Anything, flipt.DefaultNamespace).Return(uint64(0), nil) - - _, err := ss.CountSegments(context.TODO(), "") - require.NoError(t, err) -} - -func TestGetRollout(t *testing.T) { - storeMock := &common.StoreMock{} - ss := &syncedStore{ - Store: storeMock, - } - - storeMock.On("GetRollout", mock.Anything, flipt.DefaultNamespace, "").Return(&flipt.Rollout{}, nil) - - _, err := ss.GetRollout(context.TODO(), "", "") - require.NoError(t, err) -} - -func TestListRollouts(t *testing.T) { - storeMock := &common.StoreMock{} - ss := &syncedStore{ - Store: storeMock, - } - - storeMock.On("ListRollouts", mock.Anything, flipt.DefaultNamespace, "", mock.Anything).Return(storage.ResultSet[*flipt.Rollout]{}, nil) - - _, err := ss.ListRollouts(context.TODO(), "", "") - require.NoError(t, err) -} - -func TestCountRollouts(t *testing.T) { - storeMock := &common.StoreMock{} - ss := &syncedStore{ - Store: storeMock, - } - - storeMock.On("CountRollouts", mock.Anything, flipt.DefaultNamespace, "").Return(uint64(0), nil) - - _, err := ss.CountRollouts(context.TODO(), "", "") - require.NoError(t, err) -} - -func TestGetNamespace(t *testing.T) { - storeMock := &common.StoreMock{} - ss := &syncedStore{ - Store: storeMock, - } - - storeMock.On("GetNamespace", mock.Anything, flipt.DefaultNamespace).Return(&flipt.Namespace{}, nil) - - _, err := ss.GetNamespace(context.TODO(), "") - require.NoError(t, err) -} - -func TestListNamespaces(t *testing.T) { - storeMock := &common.StoreMock{} - ss := &syncedStore{ - Store: storeMock, - } - - storeMock.On("ListNamespaces", mock.Anything, mock.Anything).Return(storage.ResultSet[*flipt.Namespace]{}, nil) - - _, err := ss.ListNamespaces(context.TODO()) - require.NoError(t, err) -} - -func TestCountNamespaces(t *testing.T) { - storeMock := &common.StoreMock{} - ss := &syncedStore{ - Store: storeMock, - } - - storeMock.On("CountNamespaces", mock.Anything).Return(uint64(0), nil) - - _, err := ss.CountNamespaces(context.TODO()) - require.NoError(t, err) -} - -func TestGetEvaluationRules(t *testing.T) { - storeMock := &common.StoreMock{} - ss := &syncedStore{ - Store: storeMock, - } - - storeMock.On("GetEvaluationRules", mock.Anything, flipt.DefaultNamespace, "").Return([]*storage.EvaluationRule{}, nil) - - _, err := ss.GetEvaluationRules(context.TODO(), "", "") - require.NoError(t, err) -} - -func TestGetEvaluationDistributions(t *testing.T) { - storeMock := &common.StoreMock{} - ss := &syncedStore{ - Store: storeMock, - } - - storeMock.On("GetEvaluationDistributions", mock.Anything, "").Return([]*storage.EvaluationDistribution{}, nil) - - _, err := ss.GetEvaluationDistributions(context.TODO(), "") - require.NoError(t, err) -} - -func TestGetEvaluationRollouts(t *testing.T) { - storeMock := &common.StoreMock{} - ss := &syncedStore{ - Store: storeMock, - } - - storeMock.On("GetEvaluationRollouts", mock.Anything, flipt.DefaultNamespace, "").Return([]*storage.EvaluationRollout{}, nil) - - _, err := ss.GetEvaluationRollouts(context.TODO(), "", "") - require.NoError(t, err) -} diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 0f7b5441bf..e18d0c7aac 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -151,6 +151,19 @@ func WithOrder(order Order) QueryOption { } } +// ReadOnlyStore is a storage implementation which only supports +// reading the various types of state configuring within Flipt +type ReadOnlyStore interface { + ReadOnlyNamespaceStore + ReadOnlyFlagStore + ReadOnlySegmentStore + ReadOnlyRuleStore + ReadOnlyRolloutStore + EvaluationStore + fmt.Stringer +} + +// Store supports reading and writing all the resources within Flipt type Store interface { NamespaceStore FlagStore @@ -177,21 +190,31 @@ type EvaluationStore interface { GetEvaluationRollouts(ctx context.Context, namespaceKey, flagKey string) ([]*EvaluationRollout, error) } -// NamespaceStore stores and retrieves namespaces -type NamespaceStore interface { +// ReadOnlyNamespaceStore support retrieval of namespaces only +type ReadOnlyNamespaceStore interface { GetNamespace(ctx context.Context, key string) (*flipt.Namespace, error) ListNamespaces(ctx context.Context, opts ...QueryOption) (ResultSet[*flipt.Namespace], error) CountNamespaces(ctx context.Context) (uint64, error) +} + +// NamespaceStore stores and retrieves namespaces +type NamespaceStore interface { + ReadOnlyNamespaceStore CreateNamespace(ctx context.Context, r *flipt.CreateNamespaceRequest) (*flipt.Namespace, error) UpdateNamespace(ctx context.Context, r *flipt.UpdateNamespaceRequest) (*flipt.Namespace, error) DeleteNamespace(ctx context.Context, r *flipt.DeleteNamespaceRequest) error } -// FlagStore stores and retrieves flags and variants -type FlagStore interface { +// ReadOnlyFlagStore supports retrieval of flags +type ReadOnlyFlagStore interface { GetFlag(ctx context.Context, namespaceKey, key string) (*flipt.Flag, error) ListFlags(ctx context.Context, namespaceKey string, opts ...QueryOption) (ResultSet[*flipt.Flag], error) CountFlags(ctx context.Context, namespaceKey string) (uint64, error) +} + +// FlagStore stores and retrieves flags and variants +type FlagStore interface { + ReadOnlyFlagStore CreateFlag(ctx context.Context, r *flipt.CreateFlagRequest) (*flipt.Flag, error) UpdateFlag(ctx context.Context, r *flipt.UpdateFlagRequest) (*flipt.Flag, error) DeleteFlag(ctx context.Context, r *flipt.DeleteFlagRequest) error @@ -200,11 +223,16 @@ type FlagStore interface { DeleteVariant(ctx context.Context, r *flipt.DeleteVariantRequest) error } -// SegmentStore stores and retrieves segments and constraints -type SegmentStore interface { +// ReadOnlySegmentStore supports retrieval of segments and constraints +type ReadOnlySegmentStore interface { GetSegment(ctx context.Context, namespaceKey, key string) (*flipt.Segment, error) ListSegments(ctx context.Context, namespaceKey string, opts ...QueryOption) (ResultSet[*flipt.Segment], error) CountSegments(ctx context.Context, namespaceKey string) (uint64, error) +} + +// SegmentStore stores and retrieves segments and constraints +type SegmentStore interface { + ReadOnlySegmentStore CreateSegment(ctx context.Context, r *flipt.CreateSegmentRequest) (*flipt.Segment, error) UpdateSegment(ctx context.Context, r *flipt.UpdateSegmentRequest) (*flipt.Segment, error) DeleteSegment(ctx context.Context, r *flipt.DeleteSegmentRequest) error @@ -213,11 +241,16 @@ type SegmentStore interface { DeleteConstraint(ctx context.Context, r *flipt.DeleteConstraintRequest) error } -// RuleStore stores and retrieves rules and distributions -type RuleStore interface { +// ReadOnlyRuleStore supports retrieval of rules and distributions +type ReadOnlyRuleStore interface { GetRule(ctx context.Context, namespaceKey, id string) (*flipt.Rule, error) ListRules(ctx context.Context, namespaceKey, flagKey string, opts ...QueryOption) (ResultSet[*flipt.Rule], error) CountRules(ctx context.Context, namespaceKey, flagKey string) (uint64, error) +} + +// RuleStore stores and retrieves rules and distributions +type RuleStore interface { + ReadOnlyRuleStore CreateRule(ctx context.Context, r *flipt.CreateRuleRequest) (*flipt.Rule, error) UpdateRule(ctx context.Context, r *flipt.UpdateRuleRequest) (*flipt.Rule, error) DeleteRule(ctx context.Context, r *flipt.DeleteRuleRequest) error @@ -227,10 +260,16 @@ type RuleStore interface { DeleteDistribution(ctx context.Context, r *flipt.DeleteDistributionRequest) error } -type RolloutStore interface { +// ReadOnlyRolloutStore supports retrieval of rollouts +type ReadOnlyRolloutStore interface { GetRollout(ctx context.Context, namespaceKey, id string) (*flipt.Rollout, error) ListRollouts(ctx context.Context, namespaceKey, flagKey string, opts ...QueryOption) (ResultSet[*flipt.Rollout], error) CountRollouts(ctx context.Context, namespaceKey, flagKey string) (uint64, error) +} + +// RolloutStore supports storing and retrieving rollouts +type RolloutStore interface { + ReadOnlyRolloutStore CreateRollout(ctx context.Context, r *flipt.CreateRolloutRequest) (*flipt.Rollout, error) UpdateRollout(ctx context.Context, r *flipt.UpdateRolloutRequest) (*flipt.Rollout, error) DeleteRollout(ctx context.Context, r *flipt.DeleteRolloutRequest) error From b7d0b42a166c60d42549ecdcb0bcf931ecbac5e1 Mon Sep 17 00:00:00 2001 From: Mark Phelps <209477+markphelps@users.noreply.github.com> Date: Fri, 15 Dec 2023 13:15:27 -0500 Subject: [PATCH 30/31] chore: Devenv (#2542) * chore: add deploy to railway btn * chore(wip): support devenv * chore: devenv support * chore: update contributing guide/readme * chore: update roadmap link --- .gitignore | 11 ++++ CONTRIBUTING.md | 27 ++++++++- DEVELOPMENT.md | 13 ++++ README.md | 8 ++- devenv.lock | 156 ++++++++++++++++++++++++++++++++++++++++++++++++ devenv.nix | 27 +++++++++ devenv.yaml | 3 + 7 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 devenv.lock create mode 100644 devenv.nix create mode 100644 devenv.yaml diff --git a/.gitignore b/.gitignore index 5d34db7b73..07b7013856 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,14 @@ build/hack/out/ examples/cockroachdb/data playwright-report/ screenshots/ + +# Devenv +.devenv* +devenv.local.nix + +# direnv +.direnv + +# pre-commit +.pre-commit-config.yaml + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b49f5ab086..9e7b74b1eb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,13 +2,34 @@ Checkout our [Development](DEVELOPMENT.md) guide for more information on how to get started developing Flipt. +## What To Work On + +Check out our [public roadmap](https://github.com/orgs/flipt-io/projects/4) to see what we're working on and where you can help. + +Not sure how to get started? You can: + +- [Book a pairing session/code walkthrough](https://calendly.com/flipt-mark/30) with one of our teammates! +- Join our [Discord](https://www.flipt.io/discord), and ask any questions there + +- Dive into any of the open issues, here are some examples: + - [Good First Issues](https://github.com/flipt-io/flipt/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) + - [Backend](https://github.com/flipt-io/flipt/issues?q=is%3Aissue+is%3Aopen+label%3Ago) + - [Frontend](https://github.com/flipt-io/flipt/issues?q=is%3Aopen+is%3Aissue+label%3Aui) + +- Looking for issues by effort? We've got you covered: + - [XS](https://github.com/flipt-io/flipt/issues?q=is%3Aissue+is%3Aopen+label%3Axs) + - [Small](https://github.com/flipt-io/flipt/issues?q=is%3Aissue+is%3Aopen+label%3Asm) + - [Medium](https://github.com/flipt-io/flipt/issues?q=is%3Aissue+is%3Aopen+label%3Amd) + - [Large](https://github.com/flipt-io/flipt/issues?q=is%3Aissue+is%3Aopen+label%3Alg) + - [XL](https://github.com/flipt-io/flipt/issues?q=is%3Aissue+is%3Aopen+label%3Axl) + ## Issues Let us know how we can help! -* Include any **stack traces** with your error -* List versions you are using: Flipt, Go, OS, etc. -* List the contents of your Flipt configuration file. (ex: default.yml) +- Include any **stack traces** with your error +- List versions you are using: Flipt, Go, OS, etc. +- List the contents of your Flipt configuration file. (ex: default.yml) ## Code diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 691cebdc78..440513cdd5 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -2,6 +2,9 @@ The following are instructions for setting up your local machine for Flipt development. For info on using VSCode Remote Containers / GitHub Codespaces, see [#cdes](#cdes) below. +> [!TIP] +> Try our new [devenv](#devenv) solution to quickly get setup developing Flipt! + Also check out our [Contributing](CONTRIBUTING.md) guide for more information on how to get changes merged into the project. ## Requirements @@ -129,3 +132,13 @@ For VSCode Remote Containers (devcontainers), make sure you have [Docker](https: If you have access to [GitHub Codespaces](https://github.com/features/codespaces), simply open Flipt in a codespaces from the `Code` tab in the repo on GitHub or click the button below: [![Open in Codespaces](https://github.com/codespaces/badge.svg)](https://github.com/codespaces/new/?repo=flipt-io/flipt) + +## devenv + +[devenv](devenv.sh) is a solution that creates fast, declarative, reproducible, and composable developer environments using Nix. + +To use it for developing Flipt, you'll first need to install it. See the devenv [getting started](https://devenv.sh/getting-started/) guide for more information. + +Once you have devenv installed, you can run `devenv up` from the root of this repository to start a development environment. + +This will start a Docker container with the Flipt server running on port `8080` and the UI development server running on port `5173`. diff --git a/README.md b/README.md index 9b92da7b06..b88b39a48a 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ - + @@ -48,6 +48,7 @@ Website • Blog • Feedback • + Contributing • Discord @@ -116,7 +117,7 @@ We would love your help! Before submitting a PR, please read over the [Contribut No contribution is too small, whether it be bug reports/fixes, feature requests, documentation updates, or anything else that can help drive the project forward. -Check out our [public roadmap](https://volta.net/embed/eyJzdGF0dXNlcyI6WyJ0cmlhZ2UiLCJiYWNrbG9nIiwidG9kbyIsImluX3Byb2dyZXNzIiwiaW5fcmV2aWV3IiwiZG9uZSIsInJlbGVhc2VkIiwiY2FuY2VsbGVkIl0sImZpbHRlcnMiOnt9LCJvd25lciI6ImZsaXB0LWlvIiwibmFtZSI6ImZsaXB0In0=) to see what we're working on and where you can help. +Check out our [public roadmap](https://github.com/orgs/flipt-io/projects/4) to see what we're working on and where you can help. Not sure how to get started? You can: @@ -158,6 +159,9 @@ Try the latest version of Flipt for yourself. + + + ### Sandbox diff --git a/devenv.lock b/devenv.lock new file mode 100644 index 0000000000..13d1a46e4e --- /dev/null +++ b/devenv.lock @@ -0,0 +1,156 @@ +{ + "nodes": { + "devenv": { + "locked": { + "dir": "src/modules", + "lastModified": 1702549996, + "narHash": "sha256-mEN+8gjWUXRxBCcixeth+jlDNuzxbpFwZNOEc4K22vw=", + "owner": "cachix", + "repo": "devenv", + "rev": "e681a99ffe2d2882f413a5d771129223c838ddce", + "type": "github" + }, + "original": { + "dir": "src/modules", + "owner": "cachix", + "repo": "devenv", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1673956053, + "narHash": "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "35bb57c0c8d8b62bbfd284272c928ceb64ddbde9", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1685518550, + "narHash": "sha256-o2d0KcvaXzTrPRIo0kOLV0/QXHhDQ5DTi+OxcjO8xqY=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "a1720a10a6cfe8234c0e93907ffe81be440f4cef", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "pre-commit-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1660459072, + "narHash": "sha256-8DFJjXG8zqoONA1vXtgeKXy68KdJL5UaXR8NtVMUbx8=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "a20de23b925fd8264fd7fad6454652e142fd7f73", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1702539185, + "narHash": "sha256-KnIRG5NMdLIpEkZTnN5zovNYc0hhXjAgv6pfd5Z4c7U=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "aa9d4729cbc99dabacb50e3994dcefb3ea0f7447", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-stable": { + "locked": { + "lastModified": 1685801374, + "narHash": "sha256-otaSUoFEMM+LjBI1XL/xGB5ao6IwnZOXc47qhIgJe8U=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "c37ca420157f4abc31e26f436c1145f8951ff373", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-23.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "pre-commit-hooks": { + "inputs": { + "flake-compat": "flake-compat", + "flake-utils": "flake-utils", + "gitignore": "gitignore", + "nixpkgs": [ + "nixpkgs" + ], + "nixpkgs-stable": "nixpkgs-stable" + }, + "locked": { + "lastModified": 1702456155, + "narHash": "sha256-I2XhXGAecdGlqi6hPWYT83AQtMgL+aa3ulA85RAEgOk=", + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "rev": "007a45d064c1c32d04e1b8a0de5ef00984c419bc", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "type": "github" + } + }, + "root": { + "inputs": { + "devenv": "devenv", + "nixpkgs": "nixpkgs", + "pre-commit-hooks": "pre-commit-hooks" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/devenv.nix b/devenv.nix new file mode 100644 index 0000000000..aa142d833c --- /dev/null +++ b/devenv.nix @@ -0,0 +1,27 @@ +{ pkgs, ... }: + +{ + # https://devenv.sh/basics/ + # env.GREET = "devenv"; + + # https://devenv.sh/packages/ + packages = [ pkgs.git pkgs.mage pkgs.gcc pkgs.sqlite pkgs.nodejs ]; + + # https://devenv.sh/scripts/ + scripts.hello.exec = "echo 'hello from Flipt!'"; + + # https://devenv.sh/languages/ + languages.go.enable = true; + languages.typescript.enable = true; + + # https://devenv.sh/pre-commit-hooks/ + # pre-commit.hooks.shellcheck.enable = true; + + # https://devenv.sh/processes/ + processes = { + backend.exec = "mage dev"; + frontend.exec = "mage ui:dev"; + }; + + # See full reference at https://devenv.sh/reference/options/ +} diff --git a/devenv.yaml b/devenv.yaml new file mode 100644 index 0000000000..c7cb5cedad --- /dev/null +++ b/devenv.yaml @@ -0,0 +1,3 @@ +inputs: + nixpkgs: + url: github:NixOS/nixpkgs/nixpkgs-unstable From ca1307b3da742dffd16728895738fb75eff8b4cf Mon Sep 17 00:00:00 2001 From: Roman Dmytrenko Date: Sun, 17 Dec 2023 14:54:05 +0200 Subject: [PATCH 31/31] fix: resolved issues with go-git 5.11.0 (#2543) --- build/internal/cmd/gitea/main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build/internal/cmd/gitea/main.go b/build/internal/cmd/gitea/main.go index 3202082b96..e7834fe13f 100644 --- a/build/internal/cmd/gitea/main.go +++ b/build/internal/cmd/gitea/main.go @@ -68,7 +68,7 @@ func main() { fmt.Fprintln(os.Stderr, "Creating Repository from", *testdataDir) repo, err := git.InitWithOptions(memory.NewStorage(), workdir, git.InitOptions{ - DefaultBranch: "main", + DefaultBranch: "refs/heads/main", }) fatalOnError(err) @@ -126,7 +126,7 @@ func main() { repo.Push(&git.PushOptions{ Auth: &githttp.BasicAuth{Username: "root", Password: "password"}, RemoteName: "origin", - RefSpecs: []config.RefSpec{"main:refs/heads/main"}, + RefSpecs: []config.RefSpec{"refs/heads/main:refs/heads/main"}, }) fmt.Fprintln(os.Stderr, "Pushed")