From ff4b09dff8fcb21c9fc4b2671111094f392634aa Mon Sep 17 00:00:00 2001 From: JamesHillyard <73830120+JamesHillyard@users.noreply.github.com> Date: Tue, 15 Oct 2024 22:25:02 +0100 Subject: [PATCH] feat(alerting): Implement new Teams Workflow alert (#847) * POC Teams Workflow Alerting Signed-off-by: James Hillyard * Document Teams Workflow Alert Signed-off-by: James Hillyard * Rename 'teamsworkflow' to 'teams-workflows' Signed-off-by: James Hillyard * Fix README Table Format Signed-off-by: James Hillyard * Fix Test to Expect Correct Emoji Signed-off-by: James Hillyard --------- Signed-off-by: James Hillyard Co-authored-by: TwiN --- .github/assets/teams-workflows-alerts.png | Bin 0 -> 12548 bytes README.md | 68 ++++- alerting/config.go | 4 + alerting/provider/provider.go | 2 + .../provider/teamsworkflows/teamsworkflows.go | 182 ++++++++++++ .../teamsworkflows/teamsworkflows_test.go | 269 ++++++++++++++++++ 6 files changed, 522 insertions(+), 3 deletions(-) create mode 100644 .github/assets/teams-workflows-alerts.png create mode 100644 alerting/provider/teamsworkflows/teamsworkflows.go create mode 100644 alerting/provider/teamsworkflows/teamsworkflows_test.go diff --git a/.github/assets/teams-workflows-alerts.png b/.github/assets/teams-workflows-alerts.png new file mode 100644 index 0000000000000000000000000000000000000000..45cc040ebd3afe6788bf0a3ad4506b576f754f31 GIT binary patch literal 12548 zcmb`uWmr_<*Dnl$bfeNCASqo#OQSF#sB||B-9vXPAl(wujdV&%H$x09CC$+JZvN-X z^Ip$6*ZJ_A55!!1_TFpmz3#Psu_of9vOF#pITjKU67G8i88sv%K(;a6f{fI34YQmef5qXf# z_#JK$CY@n8=GF7NYyTqB=dxe@(FmT;!;H)|(YLaj-Iu7j^x)8(O@TJ6@ZG7WH{ z$-+*fXe3|Do#fG$E~w*hFODf49cvwy_lU>xN&~18>!>@%U8oX>;zRbc5N_*U8sCIQ zq|D3Zq-TPz#Ds*I|FB;4jYVtiwSLIx()9x36i)1=>O(Bw7Ar^*3kFp;r2(hrHQKGR z{sHUcd{p=cQ%p3tuWA2fqbfU?ggfxBF12R$p1o%67UkNPC?vg+n#vQ|ehUaq?M+3R zqoj4k`Cj3mG|E^!@4uwvUHb>xPX%^d_vjLHGHLoBXIj;_wD}JRKV2M2FphoGV$Uw0dsEsDrzbYhrY~*(kgP8@D!eQ4usWg(oU#C%0yq3ZKw_XP zyKK-1{zff!ct$AMef9n4;!pL1seJR1Q{|QHmlqbFV#IHX_^c)w3>r)ah%78mY9KI< zU3il-e6#!Nic;LZyC$#J2QeZz+2(Z_-*=9Z{o8^YmlGYII-%vLeX(o` zXxZoC1-sXlek&Xq)TV&#LT5_2ImCm;SPvS!E)R>Qoc)wvYZ6vrNQW#)JlmX9oPH?Ev=6N0;ijstDwAAbhDOAa_oO(kSDL>5haM6OZ zo!C$z$RC^DG8^yr=oK>zb&m%gvOYDLch=cI$AY_~E~-;_N?pP2`jE@6%K=$a-Ctg_ zy?SXLyF2$9U+J@|`<;!Y(b72whl{BfQJr1CDUr`-Gz{L~-<)qA`8~E^3Q{^a`nX&j zuQ3jfzo3ko?Q``c(Q}F8{cDk;EGH+|SWGE-HmhSlaL};gt7iK$AoSYAb10cJnO&#; zZ|VI40TC(w&^IgNhaFh)r$Pq>2yT;drm%VIqFoESuD*+hn9W?3cTUtUEkm{V{%H=) zpn(0dD6w(%Y_eE;MfSmBL%QKiJm&NQvYE4BwFz0(TTLbmzcPHK?OqZCzHVaMqvf_H zrsPdr=;j zxB2q9RfZ|$-V4_4uWq&w;Y#D~-()%86B9Sj2%Qf| zbAlIANARprm5g@It(4f*;(5*vmlmgSxG9k%(tc|EFa#CkZA$YxFklG6MMG$1J&e4S zrtqxF^`7;0U%8F{tFtKo>M2Q(%kpY(!k)lHh@gNuYGhd%7B$lcLYyB&&t zr)X#T8#!eCesgd$-}}0zX-!Yddh?q>xwTNSyIjdNO(WJbL{IB|4GJ?kDz_a-O2O;7 ztEA;lUeZ^TMh#jBbB^~y#(wD78lp(d`g$CP689MnKbY@1WXD>;?{t94oSgiI8Rgle zUVG$AHqR_lW_TS?w=8Qb?$C04a#!8@$ShLgk%o!a8az&nohSXrdp&>OT;J?fB9=%) zjWED`Up7!#RRf}}Dm1IRf_=V{^JlaJ&tbFl#`9?D8Y1}b>STjwW8~}P;UGDW@y_#t z?#5D>>4yXDP1Ctp#OB3HR;JfDIR3DKFCh#~pZ1_qTzaseC(AY;Di;_eVJ(1xQ<+}W zsi)KJg{s9z3Tyusk$viYwHCI;FCLSg$p?37*8eCzee-a)Es=hyzmZ!XuAPTQzZC(M z4~m`K8+7|YmjdiS^FWpDpBu(xFZA<@LE7`k;BL*z6I7l-|LnwFOrlxhic4cL%LB@d z*2jn2HR5ON=(E*XRffL8D#4(%1!81(Y5MR3bFYx|0Z5-r!rS()t3U+X(3gW&h_b<> zMWv|3#Ui|eti}pK2;CS<{~J^JppoT$BXR1dc&UJhVjW7c;7BN~EQ#|%;-GJEtK_s@ zkg@meyBqJtHsbYr@J$#bN98VZ78F}kbCp<4lzZe_-m7$^Q z7;+uuWSIU#pkgSD6DNbx2HvPj4gE{#Y=xxv_;3$~JR?-)x%T~t>T4Y= z&(wFjxYGBPb?66r+8?jSIabBTI{JnJxMs;`7A9iGa<@Lve3S{tRyQ4hy{3XICq27_w%~=n&K^2L6@A#RVQ<1+@Q8aOviw2yE936TBx|b>5 z$!%R^9{_Z6+Ix;6+e!aoqk<}1Ps{GsR@%zJ{JGY3uP^jA^XvC4-_O1!Eeh@8ei^*8 zW4@{>PVWH`BAR0`b9gHZ=9Lv;Q9PoZIqiDiA3{)GZpv;(|T?i=C!!|7^asLZyimbI5Y1 zEO2ezoG#5?&l@#{jR!X2Q{C@h$@)MdAh-P#t2A>0AQr2DVd;z4C_a32 zB#}p4a{QUVTJuR>yo}+BOcG#YXM$rz}f9D(jMX}@wr&7cQ;~x31!iv7U(do@k^`yM+?8IMt+7bCxr^+%izu&85=$qG?k9Id4W;} zNLASN9|O?Q6Qekq73><<(6kyIa(K~zL^vVVB;7WpNv1(p`cAAB_U6UlfokEmgmkX= zgLsq-Y84r~?pKQ=H3z93e|=~}%XJzCm)uv_?c1*lbbYTkPuHV32c5^nm~0n*aa-{F z+RWF;-kk3-`CUhKK#2$m-$)r6d3zi%4lLKhpiE8+#3~c#WJ|_q@C~n&6IA=Rh#^W zzvYr>lG2QUc3KCW_b)F8B2>iV_ATUwPUeq5K5rVfy#GL7&bzPpFg-q8c5Jra9>!xk zqlgg&Unp|6F#v!|aAXcZE{XHC7U}5noO;clo86B%0G!MJd$~eSfnjAjbhxRDyPMwAO}imXcBYH8Umy)OKv7DCd#vqit{c+0fPj~Fb5 z5y}Sk+K?|EF$-_IUS-bOK&Fdc1B;oA7!^?==eK5PSaM4*#ync6Q&g6?cOmYdyI-8m9%W)?g{UXw>AyThDeUd9D z?`S~NH4t}P4~F6bl#vJd5!UfA1Ha<8gM)}ifePgN+-(F@Xr~va$dP!~x2>=J4mTc6kfj@Ed;Mxn|!?OUigRgEUjK+5k{=PgX3h4nLF>HcMF7bC; zf*ssO4?V;x3IHb_b^x+S;@RU7*Q5PDqz1AacDmOAj>W1gP4!JrE{px?BR^D!-KTi( zOp)pftdgWcpI;c62dRba^6R5ZO2$-(Uji0*jOURa7Dwa$N2@`f38oBvL1LH54P^fJ z%Fn^oQp|pW_SK&ung--9HuPpwtRQJ1p8`LXbOD-85s39f#TJNx?mtau_5VfF`M>qA zXP6r^r5cJYZU?5jlRuTSdqg~rf1Ck3USS(KM&hB7&vs$@4*Wt-B84Lfc}R_DB!!#) zUvu}dOoayLK#DwFPFbNEw3_qs-;AQ$!NTTQ1jq78d~wk?7R@aVz2=k-+2^;>*&8p~ z@jZuG_D}m3lC&9JK+7*TPqVRrTWi&tzXx`W>B)Mpa`rm92!UI^r3i-GB$bHUpTR^n zv*{vL<(01A-eI^Dj6k(|>vHj>6)&;sloQ-FQHsb61{pX?^3Ko&u3 zf3XLvl=wLo>U*_@4vX6RMR~g@7#qKnX*Q1d^Q3$uOb=_ z>1r#n)lzevsi~=gx_Z)7;n{mnQhM)dlOI;=8P20|FWJ;V(!O?RX}lKSii@wU+Q$o& z4|4g7=QbAjCB4xQRH7bLWEWfM_JcsJd~4uyp6GJ4EC#nRk!Cj^PT@X1Y&qIW=Y04l zIzB%ll|rK|#i)|yJ;~K+!7VWz+41<`)wGoc83t+kmZ2wHUvFoR2})d^E}GRzgqb%m|t}Tt*Ax^n8;+2&XRzi|PdD$bw!!DsR7Aj*L2ER_{5K#+LR8@tDh9F@CM5bla!V+ZN{b1!%b&jXr@SdEJyiw z>ORY~!@24_=+_QFOpvhZ?l|eA;PJU}neq~=C-xLyUZ4>D6c!JHGb4zu9E#PGI>oyl2V9zR#p-K>An*)5oT9 zId=aO``VmTo3P$)$vO!e(JjvFdA4(0wNM91AM=V2?ji6nImik$@@T{3rGWt}{-7itQlW#{``r=D&dTx}9s1wazRm=}F0$605OuBrTtu;be*%qKe09-O%&MQ0rH^V#f zx4H+RcU;R}oypf&?K>``pZ117+7*2o1^2<&#Av(9DCi%V+E|v@2uOc~1QfpH_jted zBYJu|fQnQ^6%cN^H`W#XIV*g39WLezz!Zq)9}c+z5SnK16p}3~KTNhSC8kLOQPf-| z8kN*`1#aj9&oD(qwyLdXO1$s6#Kpr-x+)DY;*hF^O(Rjx$p)t=ftnp|J{ungcQE=~|STApb6jOh0UkqfDYDsf=9E z&gQ|miSU$}ey}9FpTwYJSlCW7N7v0ceb#~d9+Q|cB?$cn5}$9tEHeA1fX22%a&Dui z@Jp%s+L|R!rB}`^C`q-yzb20#2@&~)y1}F0y<7BP15@}d z_F#BT-+GaYTg~?V`>Vb=WJiZ-?F_e~)SUY0%(wb;ClfgN{On3YdrCMWcJYkhVF#~eH)lg5 z*;}HGir-znP&h#ah8AjWR_`830khSJMhgHv!^T$cJH`EdBrn4PyS68E!+2fUqx7cw zF5XRX-bjlEMEel#5lw~HudMbAwTX}SdR^ym!G8zUEHdTK(d-jTQiAX)L5BMMw{7D- z@Ovt`Fm1e9`Lffwb*dFGigL%}!gbp&VRnp5?!I{O1^w&^2ESQwm9N2=_^k~>P=BJn znC6OORYns=l2&aJQCIoGO-GHWTQgk)Zs4>ZJ7<)~k1e|{PSLGZ2S^Ut8D1Rw{hAsS`$Oy1navkS>2oWm{_SI^u836Uzhp4J4Jb+;tO4m;SVQ`Y zK8tG!CZf_Od4VT4H|t5~&)F`pxN#1M>o)q*W#k|`z6smVvGp`&sdn>`G}TLr{h^e? z71vlxP@@TjmF+V#8B*r^ig_5y1Xf~JmYqJyU$20J@bjSazoE8-<)U`geey`Fb3aBN znr&Zvqng7vAImy8i-@82+K2p|OPVXtXQHp}5vI1fGA6_JR07i7Q|ua$YQYL*ov^Fz4ZNGl->bAK$GdqNQle^T{rJ-JWuD-g0Gs#P=sZ?viY)+!rWSChcy3?pgqi2Vps6=DM|PLpeS zy7&{AmIQV7tDbto)EF6*;o{RUil|S+qtQ<@tzST#0k7s$!1!gxuY>goF z45BZ+0?q@^O$2fGE~PIC^=J-H8+*xVZn-K%tgoxT9zd%5Lg=kdcy0 zq}0`ddpm@Cawp>5$z%Td zqsALLx|$RBz7YS|UT1X^G2R!CA(RhnLOwh=z+G$Jut%S`;EVQX)BFy59H$PL2c&fe zVlL|%Qhchu-+t#|j^hT8w}S5RHDXHr=t{&YIMvx?B~!A6Drv}~uavWE_gQuLzM-xCM^CPyqD+$FhG<*~Ugr{>=dC#y! zg12K|OAmuWph(`WTr|TfLk&=bxrKtw;ewc<9_=r}#%6skd>7F5&O%-3Tm4p$u1J0Q zZbW#I2kWd6C2k;wq?pBMh66E2GvKQ->zedrP79#&^rZ^BmRRvLppYRK^8c0jeReW_ z1VwD6UgtJ71HE^2e2!ee#?tzxXmg=zAEYX&kVi(ocKc*#YI7y&r8l#wm;GV1rpx`* z$wJYGk@|{VI|Uum(v1a|3>}|6mfcaW(4B8ZJ2?7+!ih7jPk8EvE0C4O9r&{D5zs$G ze0o~tO8sX&2s;4>HXcBW>Ao0N)3J_XR3mr(*QX7Wz>;XFb(02ESz?X5l$=L!8kT0JX$iSXtIjkuDr4EsHEDiuQB-WS>3HuOn?i#fU zd1doyJtkzt7S)OkWbhs(RlH0WIRY5-cX~3Tj5G6pGhq$AR6WP%Ipw3JJ(|rf#Qm2Xs~+>r0?M zbT9n;`vZ?Pe_6)yL1u@8`-W3_%{B)UYs^Q;r}*uc;lcgDUL5wq>9zpIEsU4PvLy@v zsA^VlK5vno+9Be$kAd!TdvVY`MK0`81P2Jn+9l~0$unO7@~w^KQd(JAeNRhc1w-g$MyE??;>NtDKU9O-@(4j6dzxU(1lxZ7SdBkR3UKi!X(8694| z)m%Me7Y%;(tlgwPyD~LTiCJgdfsV(p6KOkBAzi>WU%$lh8gjo_Z#G%y1p5>x?8*)&VdxO*C!ww;z zD1AVEQ`)Jso*CrR>PJ}%9HY}D_=CYPeMe;06HdsukL{^9({2ZGzu?ymF7&ydJ-izw zW`BmE6U;$KcnyfBVNNZ|FGu4;&JW`~SrWk=IWxk8kDogv88^{R!o?8a4%4nWBi$I; zgW)}M22KoKUC6AtDJqY(cZ7}I45Yj9Lf8)8=q>o|I)E6rtpehnQXtTs2X90>09 zTg33zrSZMlE%XsQ02KMG&aSKeeV<9_BF)basaU6y_YXWJH?-e&EkODQW zj+^Y|B2fXWSRHVny7>nH^%jQuNi<+Ll;YPuqwaWTkE-9>BXZ#g{*# zJn`xFGzM$f#aE>`+7vE)cgu~1J;0mJbZ6>z)#M8?5tWE6NhQyw;Omi5~wum zj=)YT1tg)#DkJHsGrR1MtIeQ_C1S!vfq<3Hx|6wOIQc1?(_=*wkCXlSq0~1RfIKre zK?M02qNwr=7({$li|)cG)^3fwBC}u(Vd}K31E@+G-vFQMMlcd0MHK4PKHi0i6o?nF zEw9S|X>Y8_fuxS2AS$*#*><)}Yc0M0jJMWeOu(=|B(N82?Y6>?e&>`ZBh}_5A`S~nCOE7UxC^>`O+o?4W zatfD`6i6ZHY(k!9lZ`_7K1PLL{h}tIO}DWI;5iTrj7_S%mj_7e2AiFLJu0L|U0nCZSw*&Ya_U*|TT2Zn|J}g4` z$}g_RE=T?Eq*|snJvtoeu*C_ren~0cbtH}w0Bz>`n`AZjJE%X%EY=)j+r56>nlQ^2_p+RI&7f<0vi>TBBavaLphz;l|GfE(Xhmajd|iW zgzdKO+fMq{kruk=MCBH^K^^};%OXO%Syf{i-zD&;hE_H-p+Qj3hJfz5~4b6 zaqnyRhtR)aj+=a#V5(DRS{h+Gp?iZDT9==eZ_nNi43raw`8wgru$VFMZHdRB0{O%R z00;&^SL8jS08D_PB=&fI$ZSeSsd~Ba?mO|P5hY5htgz^@^&j(j^7y+;E$$w30P#Rj zadB5BvT39lJp<22nt02&e5<}i0eHdIRVHpVaLLKr#p8OyRFr>BJPJaTLI8FGtJuKZGB*YZWR}K`-VlM!4u$$pV`K*c#j@J#AAI&Ih4Ky!h`& zeK^Ct8(1!nKyftn+vl9=bUb$c3b#qnqj(H-DM}B=*l07rw^A!s`{`{I3Ii-Mx8=<+ zqf5AR%-wM}{?_Blb(4qw)vu_Jyp7hnb)8r1m*F?pPQJs=i?)!5S zyY%MGz#hAARKi_)Z=(Hd{L3#W^Nps zxB_&?k@liBI8am?hu#7Bi^~YdVkVBqkNg41l0W5yD+1c4_ergnO_G%4kN&|6dI^{9 zq@{W-^_bb3=sM}4qIlDQY$?dkDH5It`~BF3&B^y$*l}G30Ton2uVbR@2~ulvGIQn$ zSgjjsbli~pB;9d$I+Q6M5=JYwo#EVN6w*^g#oN$Q*QN?~-*h<%e zEXx;cfsVO_6ywxfna5gb_MV{}Y<)oVamaomxA#4G%fA4Enw-R<*0khcJk{v+sY47LY^K>$4s^8~<2D$u=1$O5_L#tqv&Ii7h3~iB)K-8BPED zBwFe^kMM8#hifx+&H2RESJo=03$PnFGj*YY^HM^65F~SwQY1*>OTHH|Lu4+qCLzq)%~a9(jOBg^IzXo2xDCNIVrXDZYzs1l;k=rdJtesdW0E|1_27m$otfl zg*M#VkAO!)r{9mZFW74&Mx<(tO44`B^S8%`^X?Aulc%X}4c(5gc%9E+-9iOl33P3t z^4=eX6$r=fd!!sB*w^l1(ve0W#LA=2=ZiC#E2@|DVge%m2fF7OH z7Q$EY);qeV)ARRNToweytiGc2Cji`m9*z$SRbBjgMxiSxW*H=AOL;c&nR@R>lsWj8 zNi>DJYpIZ#YKR2bFv#M~vC#|X9`FE1}*>37*L zv)5ViSY-&nmX*IBW~*!^;=m4c2Ta?wgKq~>Lpa*z$Ik3&;mTAwly~?HvHf1DK{4|0kWZ7Q) zF30g7k_Dlpy9FG!o)=!vK7X_pvK`9%w2)@Rp&&EerVh4&Wo(1Hti( z(@E9kxssp6)6Sww-0)M}%7)NnWS+;}J&oW@u;iQmFQ`KEKZ7d&YXWCjgEUO)_4Z3Z z<=OonjT24Q$1Ib|W5&?e4a}~Df1os_;i)P@nb#O0jrl6}b_%w7)%%lwR0U*=|0aNN4e6sMjZ$GrB6+5cO0;CI|WhS%$8jlDoliqrL z8yg_Iq(n7%;>b;!2Rsi&pdXoBub8581LNhLMIgvKjEy58A;^GP zqtA4-E}zuYlu9|JKxFQ!rHxPia5zy6$y2cZ|8f5RuBZLiF95uYxbs8tx7See [Configuring PagerDuty alerts](#configuring-pagerduty-alerts). | `{}` | | `alerting.pushover` | Configuration for alerts of type `pushover`.
See [Configuring Pushover alerts](#configuring-pushover-alerts). | `{}` | | `alerting.slack` | Configuration for alerts of type `slack`.
See [Configuring Slack alerts](#configuring-slack-alerts). | `{}` | -| `alerting.teams` | Configuration for alerts of type `teams`.
See [Configuring Teams alerts](#configuring-teams-alerts). | `{}` | +| `alerting.teams` | Configuration for alerts of type `teams`. *(Deprecated)*
See [Configuring Teams alerts](#configuring-teams-alerts-deprecated). | `{}` | +| `alerting.teams-workflows` | Configuration for alerts of type `teams-workflows`.
See [Configuring Teams Workflow alerts](#configuring-teams-workflow-alerts). | `{}` | | `alerting.telegram` | Configuration for alerts of type `telegram`.
See [Configuring Telegram alerts](#configuring-telegram-alerts). | `{}` | | `alerting.twilio` | Settings for alerts of type `twilio`.
See [Configuring Twilio alerts](#configuring-twilio-alerts). | `{}` | @@ -1176,7 +1178,12 @@ Here's an example of what the notifications look like: ![Slack notifications](.github/assets/slack-alerts.png) -#### Configuring Teams alerts +#### Configuring Teams alerts *(Deprecated)* + +> [!CAUTION] +> **Deprecated:** Office 365 Connectors within Microsoft Teams are being retired ([Source: Microsoft DevBlog](https://devblogs.microsoft.com/microsoft365dev/retirement-of-office-365-connectors-within-microsoft-teams/)). +> Existing connectors will continue to work until December 2025. The new [Teams Workflow Alerts](#configuring-teams-workflow-alerts) should be used with Microsoft Workflows instead of this legacy configuration. + | Parameter | Description | Default | |:-----------------------------------------|:-------------------------------------------------------------------------------------------|:--------------------| | `alerting.teams` | Configuration for alerts of type `teams` | `{}` | @@ -1230,6 +1237,61 @@ Here's an example of what the notifications look like: ![Teams notifications](.github/assets/teams-alerts.png) +#### Configuring Teams Workflow alerts + +> [!NOTE] +> This alert is compatible with Workflows for Microsoft Teams. To setup the workflow and get the webhook URL, follow the [Microsoft Documentation](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498). + +| Parameter | Description | Default | +|:---------------------------------------------------|:-------------------------------------------------------------------------------------------|:-------------------| +| `alerting.teams-workflows` | Configuration for alerts of type `teams` | `{}` | +| `alerting.teams-workflows.webhook-url` | Teams Webhook URL | Required `""` | +| `alerting.teams-workflows.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | +| `alerting.teams-workflows.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | +| `alerting.teams-workflows.title` | Title of the notification | `"⛑ Gatus"` | +| `alerting.teams-workflows.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | +| `alerting.teams-workflows.overrides[].webhook-url` | Teams WorkFlow Webhook URL | `""` | + +```yaml +alerting: + teams-workflows: + webhook-url: "https://********.webhook.office.com/webhookb2/************" + # You can also add group-specific to keys, which will + # override the to key above for the specified groups + overrides: + - group: "core" + webhook-url: "https://********.webhook.office.com/webhookb3/************" + +endpoints: + - name: website + url: "https://twin.sh/health" + interval: 30s + conditions: + - "[STATUS] == 200" + - "[BODY].status == UP" + - "[RESPONSE_TIME] < 300" + alerts: + - type: teams-workflows + description: "healthcheck failed" + send-on-resolved: true + + - name: back-end + group: core + url: "https://example.org/" + interval: 5m + conditions: + - "[STATUS] == 200" + - "[CERTIFICATE_EXPIRATION] > 48h" + alerts: + - type: teams-workflows + description: "healthcheck failed" + send-on-resolved: true +``` + +Here's an example of what the notifications look like: + +![Teams Workflow notifications](.github/assets/teams-workflows-alerts.png) + #### Configuring Telegram alerts | Parameter | Description | Default | diff --git a/alerting/config.go b/alerting/config.go index 040931eb..9148670f 100644 --- a/alerting/config.go +++ b/alerting/config.go @@ -26,6 +26,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/provider/pushover" "github.com/TwiN/gatus/v5/alerting/provider/slack" "github.com/TwiN/gatus/v5/alerting/provider/teams" + "github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows" "github.com/TwiN/gatus/v5/alerting/provider/telegram" "github.com/TwiN/gatus/v5/alerting/provider/twilio" "github.com/TwiN/gatus/v5/alerting/provider/zulip" @@ -90,6 +91,9 @@ type Config struct { // Teams is the configuration for the teams alerting provider Teams *teams.AlertProvider `yaml:"teams,omitempty"` + // TeamsWorkflows is the configuration for the teams alerting provider using the new Workflow App Webhook Connector + TeamsWorkflows *teamsworkflows.AlertProvider `yaml:"teams-workflows,omitempty"` + // Telegram is the configuration for the telegram alerting provider Telegram *telegram.AlertProvider `yaml:"telegram,omitempty"` diff --git a/alerting/provider/provider.go b/alerting/provider/provider.go index 510e9f4e..5bccc8a4 100644 --- a/alerting/provider/provider.go +++ b/alerting/provider/provider.go @@ -20,6 +20,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/provider/pushover" "github.com/TwiN/gatus/v5/alerting/provider/slack" "github.com/TwiN/gatus/v5/alerting/provider/teams" + "github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows" "github.com/TwiN/gatus/v5/alerting/provider/telegram" "github.com/TwiN/gatus/v5/alerting/provider/twilio" "github.com/TwiN/gatus/v5/alerting/provider/zulip" @@ -80,6 +81,7 @@ var ( _ AlertProvider = (*pushover.AlertProvider)(nil) _ AlertProvider = (*slack.AlertProvider)(nil) _ AlertProvider = (*teams.AlertProvider)(nil) + _ AlertProvider = (*teamsworkflows.AlertProvider)(nil) _ AlertProvider = (*telegram.AlertProvider)(nil) _ AlertProvider = (*twilio.AlertProvider)(nil) _ AlertProvider = (*zulip.AlertProvider)(nil) diff --git a/alerting/provider/teamsworkflows/teamsworkflows.go b/alerting/provider/teamsworkflows/teamsworkflows.go new file mode 100644 index 00000000..104e2d26 --- /dev/null +++ b/alerting/provider/teamsworkflows/teamsworkflows.go @@ -0,0 +1,182 @@ +package teamsworkflows + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" +) + +// AlertProvider is the configuration necessary for sending an alert using Teams +type AlertProvider struct { + WebhookURL string `yaml:"webhook-url"` + + // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type + DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` + + // Overrides is a list of Override that may be prioritized over the default configuration + Overrides []Override `yaml:"overrides,omitempty"` + + // Title is the title of the message that will be sent + Title string `yaml:"title,omitempty"` +} + +// Override is a case under which the default integration is overridden +type Override struct { + Group string `yaml:"group"` + WebhookURL string `yaml:"webhook-url"` +} + +// IsValid returns whether the provider's configuration is valid +func (provider *AlertProvider) IsValid() bool { + registeredGroups := make(map[string]bool) + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 { + return false + } + registeredGroups[override.Group] = true + } + } + return len(provider.WebhookURL) > 0 +} + +// Send an alert using the provider +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved)) + request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + response, err := client.GetHTTPClient(nil).Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode > 399 { + body, _ := io.ReadAll(response.Body) + return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body)) + } + return err +} + +// AdaptiveCardBody represents the structure of an Adaptive Card +type AdaptiveCardBody struct { + Type string `json:"type"` + Version string `json:"version"` + Body []CardBody `json:"body"` +} + +// CardBody represents the body of the Adaptive Card +type CardBody struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Wrap bool `json:"wrap"` + Separator bool `json:"separator,omitempty"` + Size string `json:"size,omitempty"` + Weight string `json:"weight,omitempty"` + Items []CardBody `json:"items,omitempty"` + Facts []Fact `json:"facts,omitempty"` + FactSet *FactSetBody `json:"factSet,omitempty"` +} + +// FactSetBody represents the FactSet in the Adaptive Card +type FactSetBody struct { + Type string `json:"type"` + Facts []Fact `json:"facts"` +} + +// Fact represents an individual fact in the FactSet +type Fact struct { + Title string `json:"title"` + Value string `json:"value"` +} + +// buildRequestBody builds the request body for the provider +func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { + var message string + if resolved { + message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row.", ep.DisplayName(), alert.SuccessThreshold) + } else { + message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row.", ep.DisplayName(), alert.FailureThreshold) + } + + // Configure default title if it's not provided + title := "⛑ Gatus" + if provider.Title != "" { + title = provider.Title + } + + // Build the facts from the condition results + var facts []Fact + for _, conditionResult := range result.ConditionResults { + var key string + if conditionResult.Success { + key = "✅" + } else { + key = "❌" + } + facts = append(facts, Fact{ + Title: key, + Value: conditionResult.Condition, + }) + } + + cardContent := AdaptiveCardBody{ + Type: "AdaptiveCard", + Version: "1.4", // Version 1.5 and 1.6 doesn't seem to be supported by Teams as of 27/08/2024 + Body: []CardBody{ + { + Type: "TextBlock", + Text: title, + Size: "Medium", + Weight: "Bolder", + }, + { + Type: "TextBlock", + Text: message, + Wrap: true, + }, + { + Type: "FactSet", + Facts: facts, + }, + }, + } + + attachment := map[string]interface{}{ + "contentType": "application/vnd.microsoft.card.adaptive", + "content": cardContent, + } + + payload := map[string]interface{}{ + "type": "message", + "attachments": []interface{}{attachment}, + } + + bodyAsJSON, _ := json.Marshal(payload) + return bodyAsJSON +} + +// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group +func (provider *AlertProvider) getWebhookURLForGroup(group string) string { + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if group == override.Group { + return override.WebhookURL + } + } + } + return provider.WebhookURL +} + +// GetDefaultAlert returns the provider's default alert configuration +func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { + return provider.DefaultAlert +} diff --git a/alerting/provider/teamsworkflows/teamsworkflows_test.go b/alerting/provider/teamsworkflows/teamsworkflows_test.go new file mode 100644 index 00000000..6e4a9940 --- /dev/null +++ b/alerting/provider/teamsworkflows/teamsworkflows_test.go @@ -0,0 +1,269 @@ +package teamsworkflows + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" + "github.com/TwiN/gatus/v5/test" +) + +func TestAlertDefaultProvider_IsValid(t *testing.T) { + invalidProvider := AlertProvider{WebhookURL: ""} + if invalidProvider.IsValid() { + t.Error("provider shouldn't have been valid") + } + validProvider := AlertProvider{WebhookURL: "http://example.com"} + if !validProvider.IsValid() { + t.Error("provider should've been valid") + } +} + +func TestAlertProvider_IsValidWithOverride(t *testing.T) { + providerWithInvalidOverrideGroup := AlertProvider{ + Overrides: []Override{ + { + WebhookURL: "http://example.com", + Group: "", + }, + }, + } + if providerWithInvalidOverrideGroup.IsValid() { + t.Error("provider Group shouldn't have been valid") + } + providerWithInvalidOverrideTo := AlertProvider{ + Overrides: []Override{ + { + WebhookURL: "", + Group: "group", + }, + }, + } + if providerWithInvalidOverrideTo.IsValid() { + t.Error("provider integration key shouldn't have been valid") + } + providerWithValidOverride := AlertProvider{ + WebhookURL: "http://example.com", + Overrides: []Override{ + { + WebhookURL: "http://example.com", + Group: "group", + }, + }, + } + if !providerWithValidOverride.IsValid() { + t.Error("provider should've been valid") + } +} + +func TestAlertProvider_Send(t *testing.T) { + defer client.InjectHTTPClient(nil) + firstDescription := "description-1" + secondDescription := "description-2" + scenarios := []struct { + Name string + Provider AlertProvider + Alert alert.Alert + Resolved bool + MockRoundTripper test.MockRoundTripper + ExpectedError bool + }{ + { + Name: "triggered", + Provider: AlertProvider{}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} + }), + ExpectedError: false, + }, + { + Name: "triggered-error", + Provider: AlertProvider{}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody} + }), + ExpectedError: true, + }, + { + Name: "resolved", + Provider: AlertProvider{}, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} + }), + ExpectedError: false, + }, + { + Name: "resolved-error", + Provider: AlertProvider{}, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody} + }), + ExpectedError: true, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) + err := scenario.Provider.Send( + &endpoint.Endpoint{Name: "endpoint-name"}, + &scenario.Alert, + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ + {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, + {Condition: "[STATUS] == 200", Success: scenario.Resolved}, + }, + }, + scenario.Resolved, + ) + if scenario.ExpectedError && err == nil { + t.Error("expected error, got none") + } + if !scenario.ExpectedError && err != nil { + t.Error("expected no error, got", err.Error()) + } + }) + } +} + +func TestAlertProvider_buildRequestBody(t *testing.T) { + firstDescription := "description-1" + secondDescription := "description-2" + scenarios := []struct { + Name string + Provider AlertProvider + Alert alert.Alert + NoConditions bool + Resolved bool + ExpectedBody string + }{ + { + Name: "triggered", + Provider: AlertProvider{}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"TextBlock\",\"text\":\"\\u0026#x26D1; Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"\\u0026#x274C;\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"\\u0026#x274C;\",\"value\":\"[STATUS] == 200\"}]}]},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}", + }, + { + Name: "resolved", + Provider: AlertProvider{}, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"TextBlock\",\"text\":\"\\u0026#x26D1; Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"\\u0026#x2705;\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"\\u0026#x2705;\",\"value\":\"[STATUS] == 200\"}]}]},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}", + }, + { + Name: "resolved-with-no-conditions", + NoConditions: true, + Provider: AlertProvider{}, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"TextBlock\",\"text\":\"\\u0026#x26D1; Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false}]},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}", + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + var conditionResults []*endpoint.ConditionResult + if !scenario.NoConditions { + conditionResults = []*endpoint.ConditionResult{ + {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, + {Condition: "[STATUS] == 200", Success: scenario.Resolved}, + } + } + body := scenario.Provider.buildRequestBody( + &endpoint.Endpoint{Name: "endpoint-name"}, + &scenario.Alert, + &endpoint.Result{ConditionResults: conditionResults}, + scenario.Resolved, + ) + if string(body) != scenario.ExpectedBody { + t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body) + } + out := make(map[string]interface{}) + if err := json.Unmarshal(body, &out); err != nil { + t.Error("expected body to be valid JSON, got error:", err.Error()) + } + }) + } +} + +func TestAlertProvider_GetDefaultAlert(t *testing.T) { + if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil { + t.Error("expected default alert to be not nil") + } + if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil { + t.Error("expected default alert to be nil") + } +} + +func TestAlertProvider_getWebhookURLForGroup(t *testing.T) { + tests := []struct { + Name string + Provider AlertProvider + InputGroup string + ExpectedOutput string + }{ + { + Name: "provider-no-override-specify-no-group-should-default", + Provider: AlertProvider{ + WebhookURL: "http://example.com", + Overrides: nil, + }, + InputGroup: "", + ExpectedOutput: "http://example.com", + }, + { + Name: "provider-no-override-specify-group-should-default", + Provider: AlertProvider{ + WebhookURL: "http://example.com", + Overrides: nil, + }, + InputGroup: "group", + ExpectedOutput: "http://example.com", + }, + { + Name: "provider-with-override-specify-no-group-should-default", + Provider: AlertProvider{ + WebhookURL: "http://example.com", + Overrides: []Override{ + { + Group: "group", + WebhookURL: "http://example01.com", + }, + }, + }, + InputGroup: "", + ExpectedOutput: "http://example.com", + }, + { + Name: "provider-with-override-specify-group-should-override", + Provider: AlertProvider{ + WebhookURL: "http://example.com", + Overrides: []Override{ + { + Group: "group", + WebhookURL: "http://example01.com", + }, + }, + }, + InputGroup: "group", + ExpectedOutput: "http://example01.com", + }, + } + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput { + t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput) + } + }) + } +}