Compare commits
158 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83a555e334 | ||
|
|
fcb4c71cda | ||
|
|
5169c1a91a | ||
|
|
126b78fc0e | ||
|
|
8b2cd2e4b3 | ||
|
|
79c9f24c15 | ||
|
|
be9ae6f55d | ||
|
|
539c7635a1 | ||
|
|
d56a8487eb | ||
|
|
a1bbc41b62 | ||
|
|
cdac5790cb | ||
|
|
ff318efb67 | ||
|
|
504bd8f66c | ||
|
|
917ad59306 | ||
|
|
f04dbfa516 | ||
|
|
4e6e57747f | ||
|
|
778019590d | ||
|
|
29cbff6774 | ||
|
|
2fe991319e | ||
|
|
52a73125f9 | ||
|
|
01131755bc | ||
|
|
8060a77b1f | ||
|
|
92bb42d444 | ||
|
|
c758af8791 | ||
|
|
0d38132a15 | ||
|
|
177feba75b | ||
|
|
c6ff6ec583 | ||
|
|
e061c788de | ||
|
|
e537758590 | ||
|
|
1c78f659c2 | ||
|
|
40cbb4b1d4 | ||
|
|
7103d0a9ae | ||
|
|
ff4b09dff8 | ||
|
|
29072da23e | ||
|
|
bb973979d2 | ||
|
|
3a7be5caff | ||
|
|
bd1a544cf7 | ||
|
|
aa51b0ad70 | ||
|
|
fc09981779 | ||
|
|
de2d41ed88 | ||
|
|
59842d5e88 | ||
|
|
b0c2f1eba9 | ||
|
|
f55f39fa33 | ||
|
|
1a0c0af403 | ||
|
|
12b04802ef | ||
|
|
d947a6b6f5 | ||
|
|
54221eff9b | ||
|
|
d04b68979a | ||
|
|
7de5a1fe48 | ||
|
|
dd435a8eaf | ||
|
|
90ffea9fb6 | ||
|
|
c23d0b6f3b | ||
|
|
2c8c456512 | ||
|
|
af00dfdb73 | ||
|
|
b2191391f6 | ||
|
|
5399b8a2fc | ||
|
|
65b77e9525 | ||
|
|
23a5da8dc6 | ||
|
|
11aeec45c9 | ||
|
|
714dd4ba09 | ||
|
|
4a41f8c504 | ||
|
|
7213add6b7 | ||
|
|
ea7bf2c194 | ||
|
|
db8535c3f3 | ||
|
|
fdd00e7851 | ||
|
|
2a94f76244 | ||
|
|
f2c5f5911c | ||
|
|
9d151fcdb4 | ||
|
|
4397dcb5fc | ||
|
|
739e6c75a6 | ||
|
|
dcfdfd423e | ||
|
|
28339684bf | ||
|
|
519500508a | ||
|
|
76a84031c2 | ||
|
|
b88d0b1c34 | ||
|
|
e0ab35e86a | ||
|
|
7efe7429dd | ||
|
|
4393a49900 | ||
|
|
241956b28c | ||
|
|
a4bc3c4dfe | ||
|
|
7cf2b427c9 | ||
|
|
dc9cddfd77 | ||
|
|
f93cebe715 | ||
|
|
f54c45e20e | ||
|
|
cacfbc0185 | ||
|
|
922638e071 | ||
|
|
979d467e36 | ||
|
|
ceb2c7884f | ||
|
|
ae750aa367 | ||
|
|
5aa83ee274 | ||
|
|
1e431c797a | ||
|
|
143a093e20 | ||
|
|
1eba430797 | ||
|
|
6299b630ce | ||
|
|
1fcc6c0cc0 | ||
|
|
408a46f2af | ||
|
|
3269e96f49 | ||
|
|
08742e4af3 | ||
|
|
2a623a59d3 | ||
|
|
3d1b4e566d | ||
|
|
6cbc59b0e8 | ||
|
|
1a7aeb5b35 | ||
|
|
228cd4d1fb | ||
|
|
bdad56e205 | ||
|
|
911902353f | ||
|
|
f76be6df92 | ||
|
|
dd6c4142fb | ||
|
|
1e82d2f07d | ||
|
|
3c246f0c69 | ||
|
|
76111ee133 | ||
|
|
d3a82244a1 | ||
|
|
807599c05e | ||
|
|
de7256e671 | ||
|
|
c6515c4b1c | ||
|
|
522b958d0f | ||
|
|
16366e169e | ||
|
|
ea3ae52f1e | ||
|
|
5a16151bba | ||
|
|
802ad7ff8f | ||
|
|
619b69f480 | ||
|
|
87e029f555 | ||
|
|
71c4d3ade1 | ||
|
|
315f9b7792 | ||
|
|
bde30b2efb | ||
|
|
88cb92745b | ||
|
|
744d63abac | ||
|
|
c1cdf50851 | ||
|
|
2bd268670f | ||
|
|
e88bfa8518 | ||
|
|
0fa3c5d114 | ||
|
|
0903d28b56 | ||
|
|
b50f3f3646 | ||
|
|
1b0dfdd09d | ||
|
|
6d3468d81a | ||
|
|
200d007eca | ||
|
|
24c3a84db9 | ||
|
|
0402bdb774 | ||
|
|
c7af44bcb0 | ||
|
|
494a8594cc | ||
|
|
81dd84e5f2 | ||
|
|
aa7f8131cd | ||
|
|
4dea597726 | ||
|
|
8df77b09ed | ||
|
|
d2b274e609 | ||
|
|
15c81f93d2 | ||
|
|
05565e3d0a | ||
|
|
8fbfba2163 | ||
|
|
0e9df7f00f | ||
|
|
7d570ce148 | ||
|
|
67941865db | ||
|
|
5c5a954b68 | ||
|
|
5f69351b6b | ||
|
|
34313bec7e | ||
|
|
640e455d33 | ||
|
|
2be6a1e5f3 | ||
|
|
225e6ac7ae | ||
|
|
87d9722621 | ||
|
|
fd17dcd204 |
29
.examples/docker-compose-mtls/certs/client/client.crt
Normal file
29
.examples/docker-compose-mtls/certs/client/client.crt
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIFBjCCAu6gAwIBAgIUHJXHAqywj2v25AgX7pDSZ+LX4iAwDQYJKoZIhvcNAQEL
|
||||||
|
BQAwEjEQMA4GA1UEAwwHZXhhbXBsZTAeFw0yNDA0MjUwMTQ1MDFaFw0yOTA0MjQw
|
||||||
|
MTQ1MDFaMBExDzANBgNVBAMMBmNsaWVudDCCAiIwDQYJKoZIhvcNAQEBBQADggIP
|
||||||
|
ADCCAgoCggIBANTmRlS5BNG82mOdrhtRPIBD5U40nEW4CVFm85ZJ4Bge4Ty86juf
|
||||||
|
aoCnI6AEfwpVnJhXPzjUsMBxJFMbiCB+QTJRpxTphtK7orpbwRHjaDZNaLr1MrUO
|
||||||
|
ieADGiHw93zVDikD8FP5vG+2XWWA56hY84Ac0TR9GqPjsW0nobMgBNgsRtbYUD0B
|
||||||
|
T5QOItK180xQRn4jbys5jRnr161S+Sbg6mglz1LBFBCLmZnhZFZ8FAn87gumbnWN
|
||||||
|
etSnu9kX6iOXBIaB+3nuHOL4xmAan8tAyen6mPfkXrE5ogovjqFFMTUJOKQoJVp3
|
||||||
|
zzm/0XYANxoItFGtdjGMTl5IgI220/6kfpn6PYN7y1kYn5EI+UbobD/CuAhd94p6
|
||||||
|
aQwOXU53/l+eNH/XnTsL/32QQ6qdq8sYqevlslk1M39kKNewWYCeRzYlCVscQk14
|
||||||
|
O3fkyXrtRkz30xrzfjvJQ/VzMi+e5UlemsCuCXTVZ5YyBnuWyY+mI6lZICltZSSX
|
||||||
|
VinKzpz+t4Jl7glhKiGHaNAkBX2oLddyf280zw4Cx7nDMPs4uOHONYpm90IxEOJe
|
||||||
|
zgJ9YxPK9aaKv2AoYLbvhYyKrVT+TFqoEsbQk4vK0t0Gc1j5z4dET31CSOuxVnnU
|
||||||
|
LYwtbILFc0uZrbuOAbEbXtjPpw2OGqWagD0QpkE8TjN0Hd0ibyXyUuz5AgMBAAGj
|
||||||
|
VTBTMBEGA1UdEQQKMAiCBmNsaWVudDAdBgNVHQ4EFgQUleILTHG5lT2RhSe9H4fV
|
||||||
|
xUh0bNUwHwYDVR0jBBgwFoAUbh9Tg4oxxnHJTSaa0WLBTesYwxEwDQYJKoZIhvcN
|
||||||
|
AQELBQADggIBABq8zjRrDaljl867MXAlmbV7eJkSnaWRFct+N//jCVNnKMYaxyQm
|
||||||
|
+UG12xYP0U9Zr9vhsqwyTZTQFx/ZFiiz2zfXPtUAppV3AjE67IlKRbec3qmUhj0H
|
||||||
|
Rv20eNNWXTl1XTX5WDV5887TF+HLZm/4W2ZSBbS3V89cFhBLosy7HnBGrP0hACne
|
||||||
|
ZbdQWnnLHJMDKXkZey1H1ZLQQCQdAKGS147firj29M8uzSRHgrR6pvsNQnRT0zDL
|
||||||
|
TlTJoxyGTMaoj+1IZvRsAYMZCRb8Yct/v2i/ukIykFWUJZ+1Z3UZhGrX+gdhLfZM
|
||||||
|
jAP4VQ+vFgwD6NEXAA2DatoRqxbN1ZGJQkvnobWJdZDiYu4hBCs8ugKUTE+0iXWt
|
||||||
|
hSyrAVUspFCIeDN4xsXT5b0j2Ps4bpSAiGx+aDDTPUnd881I6JGCiIavgvdFMLCW
|
||||||
|
yOXJOZvXcNQwsndkob5fZAEqetjrARsHhQuygEq/LnPc6lWsO8O6UzYArEiKWTMx
|
||||||
|
N/5hx12Pb7aaQd1f4P3gmmHMb/YiCQK1Qy5d4v68POeqyrLvAHbvCwEMhBAbnLvw
|
||||||
|
gne3psql8s5wxhnzwYltcBUmmAw1t33CwzRBGEKifRdLGtA9pbua4G/tomcDDjVS
|
||||||
|
ChsHGebJvNxOnsQqoGgozqM2x8ScxmJzIflGxrKmEA8ybHpU0d02Xp3b
|
||||||
|
-----END CERTIFICATE-----
|
||||||
51
.examples/docker-compose-mtls/certs/client/client.key
Normal file
51
.examples/docker-compose-mtls/certs/client/client.key
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
-----BEGIN RSA PRIVATE KEY-----
|
||||||
|
MIIJKQIBAAKCAgEA1OZGVLkE0bzaY52uG1E8gEPlTjScRbgJUWbzlkngGB7hPLzq
|
||||||
|
O59qgKcjoAR/ClWcmFc/ONSwwHEkUxuIIH5BMlGnFOmG0ruiulvBEeNoNk1ouvUy
|
||||||
|
tQ6J4AMaIfD3fNUOKQPwU/m8b7ZdZYDnqFjzgBzRNH0ao+OxbSehsyAE2CxG1thQ
|
||||||
|
PQFPlA4i0rXzTFBGfiNvKzmNGevXrVL5JuDqaCXPUsEUEIuZmeFkVnwUCfzuC6Zu
|
||||||
|
dY161Ke72RfqI5cEhoH7ee4c4vjGYBqfy0DJ6fqY9+ResTmiCi+OoUUxNQk4pCgl
|
||||||
|
WnfPOb/RdgA3Ggi0Ua12MYxOXkiAjbbT/qR+mfo9g3vLWRifkQj5RuhsP8K4CF33
|
||||||
|
inppDA5dTnf+X540f9edOwv/fZBDqp2ryxip6+WyWTUzf2Qo17BZgJ5HNiUJWxxC
|
||||||
|
TXg7d+TJeu1GTPfTGvN+O8lD9XMyL57lSV6awK4JdNVnljIGe5bJj6YjqVkgKW1l
|
||||||
|
JJdWKcrOnP63gmXuCWEqIYdo0CQFfagt13J/bzTPDgLHucMw+zi44c41imb3QjEQ
|
||||||
|
4l7OAn1jE8r1poq/YChgtu+FjIqtVP5MWqgSxtCTi8rS3QZzWPnPh0RPfUJI67FW
|
||||||
|
edQtjC1sgsVzS5mtu44BsRte2M+nDY4apZqAPRCmQTxOM3Qd3SJvJfJS7PkCAwEA
|
||||||
|
AQKCAgAPwAALUStib3aMkLlfpfve1VGyc8FChcySrBYbKS3zOt2Y27T3DOJuesRE
|
||||||
|
7fA5Yyn+5H1129jo87XR5s3ZnDLV4SUw2THd3H8RCwFWgcdPinHUBZhnEpial5V9
|
||||||
|
q1DzzY3gSj1OSRcVVfLE3pYaEIflvhFasQ1L0JLAq4I9OSzX5+FPEEOnWmB5Ey6k
|
||||||
|
/fbuJLDXsLwPAOadDfiFBwgNm0KxdRKdtvugBGPW9s4Fzo9rnxLmjmfKOdmQv96Y
|
||||||
|
FI/Vat0Cgmfd661RZpbDvKnTpIsLdzw3zTpAIYOzqImvCT+3AmP2qPhSdV3sPMeR
|
||||||
|
047qqyLZOVxEFXLQFiGvL4uxYUPy8k0ZI9xkgOfZ/uASozMWsHkaD04+UDi1+kw5
|
||||||
|
nfasZLvOWBW/WE/E1Rfz8IiYTeZbgTnY4CraiLrIRc0LGgD1Df4gNr25+P+LKLyK
|
||||||
|
/WW89dl6/397HOFnA7CHi7DaA8+9uZAjOWhoCNDdqAVa3QpDD/3/iRiih26bjJfH
|
||||||
|
2+sarxU8GovDZFxWd59BUP3jkukCFH+CliQy72JtLXiuPNPAWeGV9UXxtIu40sRX
|
||||||
|
Sax/TQytYi2J9NJFZFMTwVueIfzsWc8dyM+IPAYJQxN94xYKQU4+Rb/wqqHgUfjT
|
||||||
|
1ZQJb8Cmg56IDY/0EPJWQ0qgnE7TZbY2BOEYbpOzdccwUbcEjQKCAQEA8kVyw4Hw
|
||||||
|
nqcDWXjzMhOOoRoF8CNwXBvE2KBzpuAioivGcSkjkm8vLGfQYAbDOVMPFt3xlZS0
|
||||||
|
0lQm894176Kk8BiMqtyPRWWOsv4vYMBTqbehKn09Kbh6lM7d7jO7sh5iWf4jt3Bw
|
||||||
|
Sk4XhZ9oQ/kpnEKiHPymHQY3pVYEyFCGJ8mdS6g/TWiYmjMjkQDVFA4xkiyJ0S5J
|
||||||
|
NGYxI+YXtHVTVNSePKvY0h51EqTxsexAphGjXnQ3xoe6e3tVGBkeEkcZlESFD/91
|
||||||
|
0iqdc5VtKQOwy6Tj4Awk7oK5/u3tfpyIyo31LQIqreTqMO534838lpyp3CbRdvCF
|
||||||
|
QdCNpKFX1gZgmwKCAQEA4Pa9VKO3Aw95fpp0T81xNi+Js/NhdsvQyv9NI9xOKKQU
|
||||||
|
hiWxmYmyyna3zliDGlqtlw113JFTNQYl1k1yi4JQPu2gnj8te9nB0yv0RVxvbTOq
|
||||||
|
u8K1j9Xmj8XVpcKftusQsZ2xu52ONj3ZOOf22wE4Y6mdQcps+rN6XTHRBn7a5b0v
|
||||||
|
ZCvWf4CIttdIh51pZUIbZKHTU51uU7AhTCY/wEUtiHwYTT9Wiy9Lmay5Lh2s2PCz
|
||||||
|
yPE5Y970nOzlSCUl3bVgY1t0xbQtaO5AJ/iuw/vNw+YAiAIPNDUcbcK5njb//+0E
|
||||||
|
uTEtDA6SHeYfsNXGDzxipueKXFHfJLCTXnnT5/1v+wKCAQEA0pF78uNAQJSGe8B9
|
||||||
|
F3waDnmwyYvzv4q/J00l19edIniLrJUF/uM2DBFa8etOyMchKU3UCJ9MHjbX+EOd
|
||||||
|
e19QngGoWWUD/VwMkBQPF7dxv+QDZwudGmLl3+qAx+Uc8O4pq3AQmQJYBq0jEpd/
|
||||||
|
Jv0rpk3f2vPYaQebW8+MrpIWWASK+1QLWPtdD0D9W61uhVTkzth5HF9vbuSXN01o
|
||||||
|
Mwd6WxPFSJRQCihAtui3zV26vtw7sv+t7pbPhT2nsx85nMdBOzXmtQXi4Lz7RpeM
|
||||||
|
XgaAJi91g6jqfIcQo7smHVJuLib9/pWQhL2estLBTzUcocced2Mh0Y+xMofSZFF7
|
||||||
|
J2E5mwKCAQAO9npbUdRPYM0c7ZsE385C42COVobKBv5pMhfoZbPRIjC3R3SLmMwK
|
||||||
|
iWDqWZrGuvdGz79iH0xgf3suyNHwk4dQ2C9RtzQIQ9CPgiHqJx7GLaSSfn3jBkAi
|
||||||
|
me7+6nYDDZl7pth2eSFHXE/BaDRUFr2wa0ypXpRnDF78Kd8URoW6uB2Z1QycSGlP
|
||||||
|
d/w8AO1Mrdvykozix9rZuCJO1VByMme350EaijbwZQHrQ8DBX3nqp//dQqYljWPJ
|
||||||
|
uDv703S0TWcO1LtslvJaQ1aDEhhVsr7Z48dvRGvMdifg6Q29hzz5wcMJqkqrvaBc
|
||||||
|
Wr0K3v0gcEzDey0JvOxRnWj/5KyChqnXAoIBAQDq6Dsks6BjVP4Y1HaA/NWcZxUU
|
||||||
|
EZfNCTA19jIHSUiPbWzWHNdndrUq33HkPorNmFaEIrTqd/viqahr2nXpYiY/7E+V
|
||||||
|
cpn9eSxot5J8DB4VI92UG9kixxY4K7QTMKvV43Rt6BLosW/cHxW5XTNhB4JDK+TO
|
||||||
|
NlHH48fUp2qJh7/qwSikDG130RVHKwK/5Fv3NQyXTw1/n9bhnaC4eSvV39CNSeb5
|
||||||
|
rWNEZcnc9zHT2z1UespzVTxVy4hscrkssXxcCq4bOF4bnDFjfblE43o/KrVr2/Ub
|
||||||
|
jzpXQrAwXNq7pAkIpin0v40lCeTMosSgQLFqMWmtmlCpBVkyEAc9ZYXc3Vs0
|
||||||
|
-----END RSA PRIVATE KEY-----
|
||||||
29
.examples/docker-compose-mtls/certs/server/ca.crt
Normal file
29
.examples/docker-compose-mtls/certs/server/ca.crt
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIE9DCCAtygAwIBAgIUCXgA3IbeA2mn8DQ0E5IxaKBLtf8wDQYJKoZIhvcNAQEL
|
||||||
|
BQAwEjEQMA4GA1UEAwwHZXhhbXBsZTAeFw0yNDA0MjUwMTE5MzRaFw0zNDA0MjMw
|
||||||
|
MTE5MzRaMBIxEDAOBgNVBAMMB2V4YW1wbGUwggIiMA0GCSqGSIb3DQEBAQUAA4IC
|
||||||
|
DwAwggIKAoICAQDLE4aTrVJrAVYksFJt5fIVhEJT5T0cLqvtDRf9hXA5Gowremsl
|
||||||
|
VJPBm4qbdImzJZCfCcbVjFEBw8h9xID1JUqRWjJ8BfTnpa4qc1e+xRtnvC+OsUeT
|
||||||
|
CCgZvK3TZ5vFsaEbRoNGuiaNq9WSTfjLwTxkK6C3Xogm9uDx73PdRob1TNK5A9mE
|
||||||
|
Ws3ZyV91+g1phKdlNMRaK+wUrjUjEMLgr0t5A5t6WKefsGrFUDaT3sye3ZxDYuEa
|
||||||
|
ljt+F8hLVyvkDBAhh6B4S5dQILjp7L3VgOsG7Hx9py1TwCbpWXZEuee/1/2OD8tA
|
||||||
|
ALsxkvRE1w4AZzLPYRL/dOMllLjROQ4VugU8GVpNU7saK5SeWBw3XHyJ9m8vne3R
|
||||||
|
cPWaZTfkwfj8NjCgi9BzBPW8/uw7XZMmQFyTj494OKM3T5JQ5jZ5XD97ONm9h+C/
|
||||||
|
oOmkcWHz6IwEUu7XV5IESxiFlrq8ByAYF98XPhn2wMMrm2OvHMOwrfw2+5U8je5C
|
||||||
|
z70p9kpiGK8qCyjbOl9im975jwFCbl7LSj3Y+0+vRlTG/JA4jNZhXsMJcAxeJpvr
|
||||||
|
pmm/IzN+uXNQzmKzBHVDw+mTUMPziRsUq4q6WrcuQFZa6kQFGNYWI/eWV8o4AAvp
|
||||||
|
HtrOGdSyU19w0QqPW0wHmhsV2XFcn6H/E1Qg6sxWpl45YWJFhNaITxm1EQIDAQAB
|
||||||
|
o0IwQDAOBgNVHQ8BAf8EBAMCAgQwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU
|
||||||
|
bh9Tg4oxxnHJTSaa0WLBTesYwxEwDQYJKoZIhvcNAQELBQADggIBAKvOh81Gag0r
|
||||||
|
0ipYS9aK6rp58b6jPpF6shr3xFiJQVovgSvxNS3aWolh+ZupTCC3H2Q1ZUgatak0
|
||||||
|
VyEJVO4a7Tz+1XlA6KErhnORC6HB/fgr5KEGraO3Q1uWonPal5QU8xHFStbRaXfx
|
||||||
|
hl/k4LLhIdJqcJE+XX/AL8ekZ3NPDtf9+k4V+RBuarLGuKgOtBB8+1qjSpClmW2B
|
||||||
|
DaWPlrLPOr2Sd29WOeWHifwVc6kBGpwM3g5VGdDsNX4Ba5eIG3lX2kUzJ8wNGEf0
|
||||||
|
bZxcVbTBY+D4JaV4WXoeFmajjK3EdizRpJRZw3fM0ZIeqVYysByNu/TovYLJnBPs
|
||||||
|
5AybnO4RzYONKJtZ1GtQgJyG+80/VffDJeBmHKEiYvE6mvOFEBAcU4VLU6sfwfT1
|
||||||
|
y1dZq5G9Km72Fg5kCuYDXTT+PB5VAV3Z6k819tG3TyI4hPlEphpoidRbZ+QS9tK5
|
||||||
|
RgHah9EJoM7tDAN/mUVHJHQhhLJDBn+iCBYgSJVLwoE+F39NO9oFPD/ZxhJkbk9b
|
||||||
|
LkFnpjrVbwD1CNnawX3I2Eytg1IbbzyviQIbpSAEpotk9pCLMAxTR3a08wrVMwst
|
||||||
|
2XVSrgK0uUKsZhCIc+q21k98aeNIINor15humizngyBWYOk8SqV84ZNcD6VlM3Qv
|
||||||
|
ShSKoAkdKxcGG1+MKPt5b7zqvTo8BBPM
|
||||||
|
-----END CERTIFICATE-----
|
||||||
30
.examples/docker-compose-mtls/certs/server/server.crt
Normal file
30
.examples/docker-compose-mtls/certs/server/server.crt
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIFDjCCAvagAwIBAgITc5Ejz7RzBJ2/PcUMsVhj41RtQDANBgkqhkiG9w0BAQsF
|
||||||
|
ADASMRAwDgYDVQQDDAdleGFtcGxlMB4XDTI0MDQyNTAxNDQ1N1oXDTI5MDQyNDAx
|
||||||
|
NDQ1N1owEDEOMAwGA1UEAwwFbmdpbngwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAw
|
||||||
|
ggIKAoICAQCgbLBnVrBdRkBF2XmJgDTiRqWFPQledzCrkHF4eiUvtEytJhkpoRv2
|
||||||
|
+SiRPsjCo3XjwcgQIgSy1sHUV8Sazn7V5ux/XBRovhdhUivzI8JSRYj6qwqdUnOy
|
||||||
|
dG1ZEy/VRLsIVfoFB0jKJrZCXMT256xkYTlsgPePDsduO7IPPrTN0/I/qBvINFet
|
||||||
|
zgWCl2qlZgF4c/MHljo2TR1KlBv0RJUZbfXPwemUazyMrh/MfQHaHE5pfrmMWFGA
|
||||||
|
6yLYHEhG+fy5d3F/1+4J24D2j7deIFmmuJMPSlAPt1UjDm7M/bmoTxDG+1MRXSnN
|
||||||
|
647EzzS0TFZspHe2+yBbw6j0MMiWMzNZX2iXGVcswXwrphe7ro6OITynM76gDTuM
|
||||||
|
ISYXKYHayqW0rHFRlKxMcnmrpf5tBuK7XKyoQv/LbFKI1e+j1bNVe7OZtC88EWRc
|
||||||
|
SD8WDLqo/3rsxJkRXRW/49hO1nynHrknXJEpZeRnTyglS+VCzXYD0XzwzPKN7CyN
|
||||||
|
CHpYpOcWrAMF+EJnE4WRVyJAAt4C1pGhiwn0yCvLEGXXedI/rR5zmUBKitSe7oMT
|
||||||
|
J82H/VaGtwH0lOD9Jjsv9cb+s1c3tChPDKvgGGDaFnlehKg9TM7p+xc9mnEsitfv
|
||||||
|
ovSGzYHk29nQu/S4QrPfWuCNwM2vP9OQ+VJyzDzSyH8iuPPmkfmK5wIDAQABo18w
|
||||||
|
XTAbBgNVHREEFDASggVuZ2lueIIJbG9jYWxob3N0MB0GA1UdDgQWBBT89oboWPBC
|
||||||
|
oNsSbaNquzrjTza6xDAfBgNVHSMEGDAWgBRuH1ODijHGcclNJprRYsFN6xjDETAN
|
||||||
|
BgkqhkiG9w0BAQsFAAOCAgEAeg8QwBTne1IGZMDvIGgs95lifzuTXGVQWEid7VVp
|
||||||
|
MmXGRYsweb0MwTUq3gSUc+3OPibR0i5HCJRR04H4U+cIjR6em1foIV/bW6nTaSls
|
||||||
|
xQAj92eMmzOo/KtOYqMnk//+Da5NvY0myWa/8FgJ7rK1tOZYiTZqFOlIsaiQMHgp
|
||||||
|
/PEkZBP5V57h0PY7T7tEj4SCw3DJ6qzzIdpD8T3+9kXd9dcrrjbivBkkJ23agcG5
|
||||||
|
wBcI862ELNJOD7p7+OFsv7IRsoXXYrydaDg8OJQovh4RccRqVEQu3hZdi7cPb8xJ
|
||||||
|
G7Gxn8SfSVcPg/UObiggydMl8E8QwqWAzJHvl1KUECd5QG6eq984JTR7zQB2iGb6
|
||||||
|
1qq+/d9uciuB2YY2h/0rl3Fjy6J6k3fpQK577TlJjZc0F4WH8fW5bcsyGTszxQLI
|
||||||
|
jQ6FuSOr55lZ9O3R3+95tAdJTrWsxX7j7xMIAXSYrfNt5HM91XNhqISF4SIZOBB6
|
||||||
|
enVrrJ/oCFqVSbYf6RVQz3XmPEEMh+k9KdwvIvwoS9NivLD3QH0RjhTyzHbf+LlR
|
||||||
|
rWM46XhmBwajlpnIuuMp6jZcXnbhTO1SheoRVMdijcnW+zrmx5oyn3peCfPqOVLz
|
||||||
|
95YfJUIFCt+0p/87/0Mm76uVemK6kFKZJQPnfbAdsKF7igPZfUQx6wZZP1qK9ZEU
|
||||||
|
eOk=
|
||||||
|
-----END CERTIFICATE-----
|
||||||
51
.examples/docker-compose-mtls/certs/server/server.key
Normal file
51
.examples/docker-compose-mtls/certs/server/server.key
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
-----BEGIN RSA PRIVATE KEY-----
|
||||||
|
MIIJKQIBAAKCAgEAoGywZ1awXUZARdl5iYA04kalhT0JXncwq5BxeHolL7RMrSYZ
|
||||||
|
KaEb9vkokT7IwqN148HIECIEstbB1FfEms5+1ebsf1wUaL4XYVIr8yPCUkWI+qsK
|
||||||
|
nVJzsnRtWRMv1US7CFX6BQdIyia2QlzE9uesZGE5bID3jw7HbjuyDz60zdPyP6gb
|
||||||
|
yDRXrc4FgpdqpWYBeHPzB5Y6Nk0dSpQb9ESVGW31z8HplGs8jK4fzH0B2hxOaX65
|
||||||
|
jFhRgOsi2BxIRvn8uXdxf9fuCduA9o+3XiBZpriTD0pQD7dVIw5uzP25qE8QxvtT
|
||||||
|
EV0pzeuOxM80tExWbKR3tvsgW8Oo9DDIljMzWV9olxlXLMF8K6YXu66OjiE8pzO+
|
||||||
|
oA07jCEmFymB2sqltKxxUZSsTHJ5q6X+bQbiu1ysqEL/y2xSiNXvo9WzVXuzmbQv
|
||||||
|
PBFkXEg/Fgy6qP967MSZEV0Vv+PYTtZ8px65J1yRKWXkZ08oJUvlQs12A9F88Mzy
|
||||||
|
jewsjQh6WKTnFqwDBfhCZxOFkVciQALeAtaRoYsJ9MgryxBl13nSP60ec5lASorU
|
||||||
|
nu6DEyfNh/1WhrcB9JTg/SY7L/XG/rNXN7QoTwyr4Bhg2hZ5XoSoPUzO6fsXPZpx
|
||||||
|
LIrX76L0hs2B5NvZ0Lv0uEKz31rgjcDNrz/TkPlScsw80sh/Irjz5pH5iucCAwEA
|
||||||
|
AQKCAgADiEEeFV+OvjQ+FXrCl0sSzGFqnJxvMwqkTGrjLzVQZpTlnxggvYZjGrtU
|
||||||
|
71/2QSkgWazxBf66fVYJOeF/Uxqh1RLR/xIH+F+FagzDrr7hltxcQJXcPuuDO2MI
|
||||||
|
+g4skPXZSiNWJwHoSY/ryCUiFpnKIAXmqLRKtxWXDMNv6H6MpaUI18e80cI4dnfS
|
||||||
|
l0jm2Wcg4tSwDxO7DFmfwcEX0MbDp5Mo/ukIto+/vTnAA+Sdi9ACLKMjPvKUdxju
|
||||||
|
TzkcLvbskn+yQ+ve1bFyPFnaPbYboKbESGuY3P2H5xJzewayeQMyjmgW0slP2mbr
|
||||||
|
WHCdo6ynebuVENR2kMlQjx5riDcSMMX5TLGPgNL7ZBf2b52mUgFyQb27eO2WXeyH
|
||||||
|
YLtInlKA44bdi76sDK+s8zYywZnxsUy7xrKhHE5rqz964EfoLRcY/fCm7XnMo6uK
|
||||||
|
VviBtdPebsMqkZOUKSaYSRpUgXILTud5FD+m68FeVjUvQFQqHYEa3gx+rAIjKBIn
|
||||||
|
082NzfDZSHVsvG+iB5q+37R8C0/YUzSb3TXys5pA82YsjIFeQiVE4hrV1yeNIZf6
|
||||||
|
2iaPD/r5H3vt0rFEDINZafC+6bTTRQoq8TOCZFh/Lu+ynXKOPrVUF8/y3sd8+T2v
|
||||||
|
kRDOL37reUotjE1lbO4RhLgHbeWHlT/PPnF7RDKCe6/erg2MqQKCAQEAy3f8B6I8
|
||||||
|
7CP4CZmMDWwHWsjMS/HGZgvPPbmWhaeZZmFyYi7I8MruJPhlhlw6YoUIV9Vvp8zE
|
||||||
|
eLtDvZ5WXuL38aRElWzNyrhrU1/vH4pkaFk+OgRcaleGUof+go0lE8BIYnWoWovo
|
||||||
|
/F7lQMQmHY4SuwF4oj6dpus7jMm41PQqDTsjofdLgwVAGy30LIkVt8qYha77sL8N
|
||||||
|
0ohXomDGik0nVa+i2mOJ0UuooGYF8WhujzVcELcerYvvg9kFDqJaEXdfTx4DRwiz
|
||||||
|
6f5gSbZHME7moqEkcJRtwj8TXSJYRHTI8ngS0xzyV0u2RL3FOxTcgikJIkmU6W3L
|
||||||
|
IcbP6XVlrCdoswKCAQEAydfBcsYcS2mMqCOdKkGVj6zBriT78/5dtPYeId9WkrnX
|
||||||
|
1vz6ErjHQ8vZkduvCm3KkijQvva+DFV0sv24qTyA2BIoDUJdk7cY962nR4Q9FHTX
|
||||||
|
Dkn1kgeKg4TtNdgo2KsIUn7bCibKASCExo6rO3PWiQyF+jTJVDD3rXx7+7N7WJaz
|
||||||
|
zTVt6BNOWoIjTufdXfRWt3wi0H6sSkqvRWoIAaguXkKXH7oBx0gKs+oAVovFvg7A
|
||||||
|
LLEtTszsv2LmbpGWaiT3Ny215mA0ZGI9T4utK7oUgd+DlV0+vj5tFfsye4COpCyG
|
||||||
|
V/ZQ7CBbxHDDak3R3fYy5pOwmh6814wHMyKKfdGm/QKCAQEAiW4Pk3BnyfA5lvJZ
|
||||||
|
gK9ZAF7kbt9tbHvJjR2Pp9Meb+KeCecj3lCTLfGBUZF19hl5GyqU8jgC9LE3/hm2
|
||||||
|
qPyREGwtzufg0G5kP7pqn1kwnLK6ryFG8qUPmys0IyYGxyJ3QdnKzu31fpDyNB7I
|
||||||
|
x+mwiRNjUeMNRTNZ06xk5aHNzYYGeV25aVPgivstE++79ZooDxOz+Rvy0CM7XfgT
|
||||||
|
4lJeoSeyzeOxsOZzjXObzAUHuD8IYlntpLcCHoI1Qj8yqt2ASMYy3IXqT8B7dQ5j
|
||||||
|
YyPH8Ez7efcnc656+8s453QiTnP/8wx4O7Jt+FxdnZxnnJrvCnO82zZHoBbTVBLx
|
||||||
|
i6hKtQKCAQA0j3SWmLRBhwjTuAJzQITb1xbQbF0X2oM4XmbWVzxKFQ75swLD4U4y
|
||||||
|
f2D2tIhOZOy9RtelAsfWmmI7QgrWNyUuHvxDB6cqkiF0Tcoju3HUY+CknenOzxvo
|
||||||
|
x7KltNZeJZuTL+mGKTetN3Sb6Ab7Al05bwNsdlZ/EAlPKf13O/PAy+2iYGlwZ6ad
|
||||||
|
twnOwF5K2xfBzBecx3/CENS3dLcFB3CbpyeHYX6ZEE+JLkRMRTWHGnw8px6vSHnW
|
||||||
|
FMEAxfSvS1T9D3Awv5ilE1f34N2FZ31znGq9eHygOc1aTgGFW6LJabbKLSBBfOOo
|
||||||
|
sdyRUBZ4gGYc2RTB7YMrdhFh5Xq+7NtZAoIBAQCOJ3CLecp/rS+lGy7oyx4f6QDd
|
||||||
|
zH/30Y/uvXLPUj+Ljg9bMTG9chjaKfyApXv6rcQI0d6wrqAunNl1b3opBQjsGCSt
|
||||||
|
bpBV/rGg3sl752og6KU1PCZ2KkVYPjugNhqPGonNh8tlw+1xFyBdt0c68g/auIHq
|
||||||
|
WaT5tWVfP01Ri43RjyCgNtJ2TJUzbA40BteDHPWKeM1lZ6e92fJTp5IjQ/Okc41u
|
||||||
|
Elr7p22fx/N04JTX9G6oGdxM7Gh2Uf4i4PnNOi+C3xqLrtUEi/OLof2UHlatypt9
|
||||||
|
pix0bXJtZE7WfFfesQIxGffVBhgN3UgqhAf2wquHgm1O17JXrmkR6JSYNpKc
|
||||||
|
-----END RSA PRIVATE KEY-----
|
||||||
13
.examples/docker-compose-mtls/config/config.yaml
Normal file
13
.examples/docker-compose-mtls/config/config.yaml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
endpoints:
|
||||||
|
- name: example
|
||||||
|
url: https://nginx
|
||||||
|
interval: 30s
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"
|
||||||
|
client:
|
||||||
|
# mtls
|
||||||
|
insecure: true
|
||||||
|
tls:
|
||||||
|
certificate-file: /certs/client.crt
|
||||||
|
private-key-file: /certs/client.key
|
||||||
|
renegotiation: once
|
||||||
27
.examples/docker-compose-mtls/docker-compose.yml
Normal file
27
.examples/docker-compose-mtls/docker-compose.yml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
version: "3.9"
|
||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
image: nginx:stable
|
||||||
|
volumes:
|
||||||
|
- ./certs/server:/etc/nginx/certs
|
||||||
|
- ./nginx:/etc/nginx/conf.d
|
||||||
|
ports:
|
||||||
|
- "8443:443"
|
||||||
|
networks:
|
||||||
|
- mtls
|
||||||
|
|
||||||
|
gatus:
|
||||||
|
image: twinproduction/gatus:latest
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./config:/config
|
||||||
|
- ./certs/client:/certs
|
||||||
|
environment:
|
||||||
|
- GATUS_CONFIG_PATH=/config
|
||||||
|
networks:
|
||||||
|
- mtls
|
||||||
|
|
||||||
|
networks:
|
||||||
|
mtls:
|
||||||
16
.examples/docker-compose-mtls/nginx/default.conf
Normal file
16
.examples/docker-compose-mtls/nginx/default.conf
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/certs/server.crt;
|
||||||
|
ssl_certificate_key /etc/nginx/certs/server.key;
|
||||||
|
ssl_client_certificate /etc/nginx/certs/ca.crt;
|
||||||
|
ssl_verify_client on;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
if ($ssl_client_verify != SUCCESS) {
|
||||||
|
return 403;
|
||||||
|
}
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html index.htm;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
metrics: true
|
metrics: true
|
||||||
debug: false
|
|
||||||
ui:
|
ui:
|
||||||
header: Example Company
|
header: Example Company
|
||||||
link: https://example.org
|
link: https://example.org
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
storage:
|
storage:
|
||||||
type: postgres
|
type: postgres
|
||||||
path: "postgres://username:password@postgres:5432/gatus?sslmode=disable"
|
path: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable"
|
||||||
|
|
||||||
endpoints:
|
endpoints:
|
||||||
- name: back-end
|
- name: back-end
|
||||||
@@ -32,7 +32,7 @@ endpoints:
|
|||||||
query-name: "example.com"
|
query-name: "example.com"
|
||||||
query-type: "A"
|
query-type: "A"
|
||||||
conditions:
|
conditions:
|
||||||
- "[BODY] == 93.184.216.34"
|
- "[BODY] == 93.184.215.14"
|
||||||
- "[DNS_RCODE] == NOERROR"
|
- "[DNS_RCODE] == NOERROR"
|
||||||
|
|
||||||
- name: icmp-ping
|
- name: icmp-ping
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=username
|
||||||
|
- POSTGRES_PASSWORD=password
|
||||||
|
- POSTGRES_DB=gatus
|
||||||
volumes:
|
volumes:
|
||||||
- ./config:/config
|
- ./config:/config
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ endpoints:
|
|||||||
query-name: "example.com"
|
query-name: "example.com"
|
||||||
query-type: "A"
|
query-type: "A"
|
||||||
conditions:
|
conditions:
|
||||||
- "[BODY] == 93.184.216.34"
|
- "[BODY] == 93.184.215.14"
|
||||||
- "[DNS_RCODE] == NOERROR"
|
- "[DNS_RCODE] == NOERROR"
|
||||||
|
|
||||||
- name: icmp-ping
|
- name: icmp-ping
|
||||||
|
|||||||
BIN
.github/assets/gitea-alerts.png
vendored
Normal file
BIN
.github/assets/gitea-alerts.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 638 KiB |
BIN
.github/assets/gotify-alerts.png
vendored
Normal file
BIN
.github/assets/gotify-alerts.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
BIN
.github/assets/jetbrains-space-alerts.png
vendored
Normal file
BIN
.github/assets/jetbrains-space-alerts.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
BIN
.github/assets/teams-workflows-alerts.png
vendored
Normal file
BIN
.github/assets/teams-workflows-alerts.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
2
.github/dependabot.yml
vendored
2
.github/dependabot.yml
vendored
@@ -7,7 +7,7 @@ updates:
|
|||||||
interval: "daily"
|
interval: "daily"
|
||||||
- package-ecosystem: "gomod"
|
- package-ecosystem: "gomod"
|
||||||
directory: "/"
|
directory: "/"
|
||||||
open-pull-requests-limit: 1
|
open-pull-requests-limit: 3
|
||||||
labels: ["dependencies"]
|
labels: ["dependencies"]
|
||||||
schedule:
|
schedule:
|
||||||
interval: "daily"
|
interval: "daily"
|
||||||
|
|||||||
6
.github/workflows/benchmark.yml
vendored
6
.github/workflows/benchmark.yml
vendored
@@ -20,11 +20,11 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/setup-go@v4
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: 1.19
|
go-version: 1.23.3
|
||||||
repository: "${{ github.event.inputs.repository || 'TwiN/gatus' }}"
|
repository: "${{ github.event.inputs.repository || 'TwiN/gatus' }}"
|
||||||
ref: "${{ github.event.inputs.ref || 'master' }}"
|
ref: "${{ github.event.inputs.ref || 'master' }}"
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- name: Benchmark
|
- name: Benchmark
|
||||||
run: go test -bench=. ./storage/store
|
run: go test -bench=. ./storage/store
|
||||||
|
|||||||
10
.github/workflows/publish-experimental.yml
vendored
10
.github/workflows/publish-experimental.yml
vendored
@@ -5,20 +5,20 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v3
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v3
|
||||||
- name: Get image repository
|
- name: Get image repository
|
||||||
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||||
- name: Login to Docker Registry
|
- name: Login to Docker Registry
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
pull: true
|
pull: true
|
||||||
|
|||||||
16
.github/workflows/publish-latest-to-ghcr.yml
vendored
16
.github/workflows/publish-latest-to-ghcr.yml
vendored
@@ -5,34 +5,34 @@ on:
|
|||||||
branches: [master]
|
branches: [master]
|
||||||
types: [completed]
|
types: [completed]
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}
|
group: ${{ github.event.workflow_run.head_repository.full_name }}::${{ github.event.workflow_run.head_branch }}::${{ github.workflow }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
jobs:
|
jobs:
|
||||||
publish-latest-to-ghcr:
|
publish-latest-to-ghcr:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
if: ${{ (github.event.workflow_run.conclusion == 'success') && (github.event.workflow_run.head_repository.full_name == github.repository) }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v3
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v3
|
||||||
- name: Get image repository
|
- name: Get image repository
|
||||||
run: echo IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
run: echo IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||||
- name: Login to Registry
|
- name: Login to Registry
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
|
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||||
pull: true
|
pull: true
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ env.IMAGE_REPOSITORY }}:latest
|
tags: ${{ env.IMAGE_REPOSITORY }}:latest
|
||||||
|
|||||||
16
.github/workflows/publish-latest.yml
vendored
16
.github/workflows/publish-latest.yml
vendored
@@ -5,30 +5,30 @@ on:
|
|||||||
branches: [master]
|
branches: [master]
|
||||||
types: [completed]
|
types: [completed]
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}
|
group: ${{ github.event.workflow_run.head_repository.full_name }}::${{ github.event.workflow_run.head_branch }}::${{ github.workflow }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
jobs:
|
jobs:
|
||||||
publish-latest:
|
publish-latest:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
if: ${{ (github.event.workflow_run.conclusion == 'success') && (github.event.workflow_run.head_repository.full_name == github.repository) }}
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v3
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v3
|
||||||
- name: Get image repository
|
- name: Get image repository
|
||||||
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||||
- name: Login to Docker Registry
|
- name: Login to Docker Registry
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
|
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||||
pull: true
|
pull: true
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ env.IMAGE_REPOSITORY }}:latest
|
tags: ${{ env.IMAGE_REPOSITORY }}:latest
|
||||||
|
|||||||
12
.github/workflows/publish-release-to-ghcr.yml
vendored
12
.github/workflows/publish-release-to-ghcr.yml
vendored
@@ -10,25 +10,25 @@ jobs:
|
|||||||
packages: write
|
packages: write
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v3
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v3
|
||||||
- name: Get image repository
|
- name: Get image repository
|
||||||
run: echo IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
run: echo IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||||
- name: Get the release
|
- name: Get the release
|
||||||
run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV
|
run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV
|
||||||
- name: Login to Registry
|
- name: Login to Registry
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
|
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||||
pull: true
|
pull: true
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
|
|||||||
12
.github/workflows/publish-release.yml
vendored
12
.github/workflows/publish-release.yml
vendored
@@ -8,24 +8,24 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v3
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v3
|
||||||
- name: Get image repository
|
- name: Get image repository
|
||||||
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||||
- name: Get the release
|
- name: Get the release
|
||||||
run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV
|
run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV
|
||||||
- name: Login to Docker Registry
|
- name: Login to Docker Registry
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
|
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||||
pull: true
|
pull: true
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
|
|||||||
11
.github/workflows/test.yml
vendored
11
.github/workflows/test.yml
vendored
@@ -14,12 +14,12 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/setup-go@v4
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: 1.19
|
go-version: 1.23.3
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- name: Build binary to make sure it works
|
- name: Build binary to make sure it works
|
||||||
run: go build
|
run: go build
|
||||||
- name: Test
|
- name: Test
|
||||||
@@ -28,6 +28,7 @@ jobs:
|
|||||||
# was configured by the "Set up Go" step (otherwise, it'd use sudo's "go" executable)
|
# was configured by the "Set up Go" step (otherwise, it'd use sudo's "go" executable)
|
||||||
run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test ./... -race -coverprofile=coverage.txt -covermode=atomic
|
run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test ./... -race -coverprofile=coverage.txt -covermode=atomic
|
||||||
- name: Codecov
|
- name: Codecov
|
||||||
uses: codecov/codecov-action@v3.1.4
|
uses: codecov/codecov-action@v5.1.2
|
||||||
with:
|
with:
|
||||||
files: ./coverage.txt
|
files: ./coverage.txt
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -17,4 +17,5 @@ node_modules
|
|||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
gatus
|
gatus
|
||||||
config/config.yml
|
config/config.yml
|
||||||
|
config.yaml
|
||||||
@@ -3,6 +3,7 @@ FROM golang:alpine as builder
|
|||||||
RUN apk --update add ca-certificates
|
RUN apk --update add ca-certificates
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . ./
|
COPY . ./
|
||||||
|
RUN go mod tidy
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o gatus .
|
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o gatus .
|
||||||
|
|
||||||
# Run Tests inside docker image if you don't have a configured go environment
|
# Run Tests inside docker image if you don't have a configured go environment
|
||||||
@@ -14,6 +15,8 @@ FROM scratch
|
|||||||
COPY --from=builder /app/gatus .
|
COPY --from=builder /app/gatus .
|
||||||
COPY --from=builder /app/config.yaml ./config/config.yaml
|
COPY --from=builder /app/config.yaml ./config/config.yaml
|
||||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||||
ENV PORT=8080
|
ENV GATUS_CONFIG_PATH=""
|
||||||
|
ENV GATUS_LOG_LEVEL="INFO"
|
||||||
|
ENV PORT="8080"
|
||||||
EXPOSE ${PORT}
|
EXPOSE ${PORT}
|
||||||
ENTRYPOINT ["/gatus"]
|
ENTRYPOINT ["/gatus"]
|
||||||
|
|||||||
9
Makefile
9
Makefile
@@ -1,17 +1,18 @@
|
|||||||
BINARY=gatus
|
BINARY=gatus
|
||||||
|
|
||||||
# Because there's a folder called "test", we need to make the target "test" phony
|
.PHONY: install
|
||||||
.PHONY: test
|
|
||||||
|
|
||||||
install:
|
install:
|
||||||
go build -mod vendor -o $(BINARY) .
|
go build -v -o $(BINARY) .
|
||||||
|
|
||||||
|
.PHONY: run
|
||||||
run:
|
run:
|
||||||
GATUS_CONFIG_PATH=./config.yaml ./$(BINARY)
|
GATUS_CONFIG_PATH=./config.yaml ./$(BINARY)
|
||||||
|
|
||||||
|
.PHONY: clean
|
||||||
clean:
|
clean:
|
||||||
rm $(BINARY)
|
rm $(BINARY)
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
test:
|
test:
|
||||||
go test ./... -cover
|
go test ./... -cover
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
package alert
|
package alert
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/TwiN/logr"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -10,7 +16,7 @@ var (
|
|||||||
ErrAlertWithInvalidDescription = errors.New("alert description must not have \" or \\")
|
ErrAlertWithInvalidDescription = errors.New("alert description must not have \" or \\")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Alert is a core.Endpoint's alert configuration
|
// Alert is endpoint.Endpoint's alert configuration
|
||||||
type Alert struct {
|
type Alert struct {
|
||||||
// Type of alert (required)
|
// Type of alert (required)
|
||||||
Type Type `yaml:"type"`
|
Type Type `yaml:"type"`
|
||||||
@@ -26,20 +32,24 @@ type Alert struct {
|
|||||||
// FailureThreshold is the number of failures in a row needed before triggering the alert
|
// FailureThreshold is the number of failures in a row needed before triggering the alert
|
||||||
FailureThreshold int `yaml:"failure-threshold"`
|
FailureThreshold int `yaml:"failure-threshold"`
|
||||||
|
|
||||||
|
// SuccessThreshold defines how many successful executions must happen in a row before an ongoing incident is marked as resolved
|
||||||
|
SuccessThreshold int `yaml:"success-threshold"`
|
||||||
|
|
||||||
// Description of the alert. Will be included in the alert sent.
|
// Description of the alert. Will be included in the alert sent.
|
||||||
//
|
//
|
||||||
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
|
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
|
||||||
// or not for provider.ParseWithDefaultAlert to work.
|
// or not for provider.ParseWithDefaultAlert to work.
|
||||||
Description *string `yaml:"description"`
|
Description *string `yaml:"description,omitempty"`
|
||||||
|
|
||||||
// SendOnResolved defines whether to send a second notification when the issue has been resolved
|
// SendOnResolved defines whether to send a second notification when the issue has been resolved
|
||||||
//
|
//
|
||||||
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
|
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
|
||||||
// or not for provider.ParseWithDefaultAlert to work. Use Alert.IsSendingOnResolved() for a non-pointer
|
// or not for provider.ParseWithDefaultAlert to work. Use Alert.IsSendingOnResolved() for a non-pointer
|
||||||
SendOnResolved *bool `yaml:"send-on-resolved"`
|
SendOnResolved *bool `yaml:"send-on-resolved,omitempty"`
|
||||||
|
|
||||||
// SuccessThreshold defines how many successful executions must happen in a row before an ongoing incident is marked as resolved
|
// ProviderOverride is an optional field that can be used to override the provider's configuration
|
||||||
SuccessThreshold int `yaml:"success-threshold"`
|
// It is freeform so that it can be used for any provider-specific configuration.
|
||||||
|
ProviderOverride map[string]any `yaml:"provider-override,omitempty"`
|
||||||
|
|
||||||
// ResolveKey is an optional field that is used by some providers (i.e. PagerDuty's dedup_key) to resolve
|
// ResolveKey is an optional field that is used by some providers (i.e. PagerDuty's dedup_key) to resolve
|
||||||
// ongoing/triggered incidents
|
// ongoing/triggered incidents
|
||||||
@@ -71,7 +81,7 @@ func (alert *Alert) ValidateAndSetDefaults() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetDescription retrieves the description of the alert
|
// GetDescription retrieves the description of the alert
|
||||||
func (alert Alert) GetDescription() string {
|
func (alert *Alert) GetDescription() string {
|
||||||
if alert.Description == nil {
|
if alert.Description == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -80,7 +90,7 @@ func (alert Alert) GetDescription() string {
|
|||||||
|
|
||||||
// IsEnabled returns whether an alert is enabled or not
|
// IsEnabled returns whether an alert is enabled or not
|
||||||
// Returns true if not set
|
// Returns true if not set
|
||||||
func (alert Alert) IsEnabled() bool {
|
func (alert *Alert) IsEnabled() bool {
|
||||||
if alert.Enabled == nil {
|
if alert.Enabled == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -88,9 +98,31 @@ func (alert Alert) IsEnabled() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IsSendingOnResolved returns whether an alert is sending on resolve or not
|
// IsSendingOnResolved returns whether an alert is sending on resolve or not
|
||||||
func (alert Alert) IsSendingOnResolved() bool {
|
func (alert *Alert) IsSendingOnResolved() bool {
|
||||||
if alert.SendOnResolved == nil {
|
if alert.SendOnResolved == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return *alert.SendOnResolved
|
return *alert.SendOnResolved
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Checksum returns a checksum of the alert
|
||||||
|
// Used to determine which persisted triggered alert should be deleted on application start
|
||||||
|
func (alert *Alert) Checksum() string {
|
||||||
|
hash := sha256.New()
|
||||||
|
hash.Write([]byte(string(alert.Type) + "_" +
|
||||||
|
strconv.FormatBool(alert.IsEnabled()) + "_" +
|
||||||
|
strconv.FormatBool(alert.IsSendingOnResolved()) + "_" +
|
||||||
|
strconv.Itoa(alert.SuccessThreshold) + "_" +
|
||||||
|
strconv.Itoa(alert.FailureThreshold) + "_" +
|
||||||
|
alert.GetDescription()),
|
||||||
|
)
|
||||||
|
return hex.EncodeToString(hash.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (alert *Alert) ProviderOverrideAsBytes() []byte {
|
||||||
|
yamlBytes, err := yaml.Marshal(alert.ProviderOverride)
|
||||||
|
if err != nil {
|
||||||
|
logr.Warnf("[alert.ProviderOverrideAsBytes] Failed to marshal alert override of type=%s as bytes: %v", alert.Type, err)
|
||||||
|
}
|
||||||
|
return yamlBytes
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package alert
|
package alert
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ func TestAlert_ValidateAndSetDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.name, func(t *testing.T) {
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
if err := scenario.alert.ValidateAndSetDefaults(); err != scenario.expectedError {
|
if err := scenario.alert.ValidateAndSetDefaults(); !errors.Is(err, scenario.expectedError) {
|
||||||
t.Errorf("expected error %v, got %v", scenario.expectedError, err)
|
t.Errorf("expected error %v, got %v", scenario.expectedError, err)
|
||||||
}
|
}
|
||||||
if scenario.alert.SuccessThreshold != scenario.expectedSuccessThreshold {
|
if scenario.alert.SuccessThreshold != scenario.expectedSuccessThreshold {
|
||||||
@@ -52,34 +53,140 @@ func TestAlert_ValidateAndSetDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlert_IsEnabled(t *testing.T) {
|
func TestAlert_IsEnabled(t *testing.T) {
|
||||||
if !(Alert{Enabled: nil}).IsEnabled() {
|
if !(&Alert{Enabled: nil}).IsEnabled() {
|
||||||
t.Error("alert.IsEnabled() should've returned true, because Enabled was set to nil")
|
t.Error("alert.IsEnabled() should've returned true, because Enabled was set to nil")
|
||||||
}
|
}
|
||||||
if value := false; (Alert{Enabled: &value}).IsEnabled() {
|
if value := false; (&Alert{Enabled: &value}).IsEnabled() {
|
||||||
t.Error("alert.IsEnabled() should've returned false, because Enabled was set to false")
|
t.Error("alert.IsEnabled() should've returned false, because Enabled was set to false")
|
||||||
}
|
}
|
||||||
if value := true; !(Alert{Enabled: &value}).IsEnabled() {
|
if value := true; !(&Alert{Enabled: &value}).IsEnabled() {
|
||||||
t.Error("alert.IsEnabled() should've returned true, because Enabled was set to true")
|
t.Error("alert.IsEnabled() should've returned true, because Enabled was set to true")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlert_GetDescription(t *testing.T) {
|
func TestAlert_GetDescription(t *testing.T) {
|
||||||
if (Alert{Description: nil}).GetDescription() != "" {
|
if (&Alert{Description: nil}).GetDescription() != "" {
|
||||||
t.Error("alert.GetDescription() should've returned an empty string, because Description was set to nil")
|
t.Error("alert.GetDescription() should've returned an empty string, because Description was set to nil")
|
||||||
}
|
}
|
||||||
if value := "description"; (Alert{Description: &value}).GetDescription() != value {
|
if value := "description"; (&Alert{Description: &value}).GetDescription() != value {
|
||||||
t.Error("alert.GetDescription() should've returned false, because Description was set to 'description'")
|
t.Error("alert.GetDescription() should've returned false, because Description was set to 'description'")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlert_IsSendingOnResolved(t *testing.T) {
|
func TestAlert_IsSendingOnResolved(t *testing.T) {
|
||||||
if (Alert{SendOnResolved: nil}).IsSendingOnResolved() {
|
if (&Alert{SendOnResolved: nil}).IsSendingOnResolved() {
|
||||||
t.Error("alert.IsSendingOnResolved() should've returned false, because SendOnResolved was set to nil")
|
t.Error("alert.IsSendingOnResolved() should've returned false, because SendOnResolved was set to nil")
|
||||||
}
|
}
|
||||||
if value := false; (Alert{SendOnResolved: &value}).IsSendingOnResolved() {
|
if value := false; (&Alert{SendOnResolved: &value}).IsSendingOnResolved() {
|
||||||
t.Error("alert.IsSendingOnResolved() should've returned false, because SendOnResolved was set to false")
|
t.Error("alert.IsSendingOnResolved() should've returned false, because SendOnResolved was set to false")
|
||||||
}
|
}
|
||||||
if value := true; !(Alert{SendOnResolved: &value}).IsSendingOnResolved() {
|
if value := true; !(&Alert{SendOnResolved: &value}).IsSendingOnResolved() {
|
||||||
t.Error("alert.IsSendingOnResolved() should've returned true, because SendOnResolved was set to true")
|
t.Error("alert.IsSendingOnResolved() should've returned true, because SendOnResolved was set to true")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAlert_Checksum(t *testing.T) {
|
||||||
|
description1, description2 := "a", "b"
|
||||||
|
yes, no := true, false
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
alert Alert
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "barebone",
|
||||||
|
alert: Alert{
|
||||||
|
Type: TypeDiscord,
|
||||||
|
},
|
||||||
|
expected: "fed0580e44ed5701dbba73afa1f14b2c53ca5a7b8067a860441c212916057fe3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with-description-1",
|
||||||
|
alert: Alert{
|
||||||
|
Type: TypeDiscord,
|
||||||
|
Description: &description1,
|
||||||
|
},
|
||||||
|
expected: "005f407ebe506e74a4aeb46f74c28b376debead7011e1b085da3840f72ba9707",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with-description-2",
|
||||||
|
alert: Alert{
|
||||||
|
Type: TypeDiscord,
|
||||||
|
Description: &description2,
|
||||||
|
},
|
||||||
|
expected: "3c2c4a9570cdc614006993c21f79a860a7f5afea10cf70d1a79d3c49342ef2c8",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with-description-2-and-enabled-false",
|
||||||
|
alert: Alert{
|
||||||
|
Type: TypeDiscord,
|
||||||
|
Enabled: &no,
|
||||||
|
Description: &description2,
|
||||||
|
},
|
||||||
|
expected: "837945c2b4cd5e961db3e63e10c348d4f1c3446ba68cf5a48e35a1ae22cf0c22",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with-description-2-and-enabled-true",
|
||||||
|
alert: Alert{
|
||||||
|
Type: TypeDiscord,
|
||||||
|
Enabled: &yes, // it defaults to true if not set, but just to make sure
|
||||||
|
Description: &description2,
|
||||||
|
},
|
||||||
|
expected: "3c2c4a9570cdc614006993c21f79a860a7f5afea10cf70d1a79d3c49342ef2c8",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with-description-2-and-enabled-true-and-send-on-resolved-true",
|
||||||
|
alert: Alert{
|
||||||
|
Type: TypeDiscord,
|
||||||
|
Enabled: &yes,
|
||||||
|
SendOnResolved: &yes,
|
||||||
|
Description: &description2,
|
||||||
|
},
|
||||||
|
expected: "bf1436995a880eb4a352c74c5dfee1f1b5ff6b9fc55aef9bf411b3631adfd80c",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with-description-2-and-failure-threshold-7",
|
||||||
|
alert: Alert{
|
||||||
|
Type: TypeSlack,
|
||||||
|
FailureThreshold: 7,
|
||||||
|
Description: &description2,
|
||||||
|
},
|
||||||
|
expected: "8bd479e18bda393d4c924f5a0d962e825002168dedaa88b445e435db7bacffd3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with-description-2-and-failure-threshold-9",
|
||||||
|
alert: Alert{
|
||||||
|
Type: TypeSlack,
|
||||||
|
FailureThreshold: 9,
|
||||||
|
Description: &description2,
|
||||||
|
},
|
||||||
|
expected: "5abdfce5236e344996d264d526e769c07cb0d3d329a999769a1ff84b157ca6f1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with-description-2-and-success-threshold-5",
|
||||||
|
alert: Alert{
|
||||||
|
Type: TypeSlack,
|
||||||
|
SuccessThreshold: 7,
|
||||||
|
Description: &description2,
|
||||||
|
},
|
||||||
|
expected: "c0000e73626b80e212cfc24830de7094568f648e37f3e16f9e68c7f8ef75c34c",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with-description-2-and-success-threshold-1",
|
||||||
|
alert: Alert{
|
||||||
|
Type: TypeSlack,
|
||||||
|
SuccessThreshold: 1,
|
||||||
|
Description: &description2,
|
||||||
|
},
|
||||||
|
expected: "5c28963b3a76104cfa4a0d79c89dd29ec596c8cfa4b1af210ec83d6d41587b5f",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
|
scenario.alert.ValidateAndSetDefaults()
|
||||||
|
if checksum := scenario.alert.Checksum(); checksum != scenario.expected {
|
||||||
|
t.Errorf("expected checksum %v, got %v", scenario.expected, checksum)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ package alert
|
|||||||
type Type string
|
type Type string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
// TypeAWSSES is the Type for the awsses alerting provider
|
||||||
|
TypeAWSSES Type = "aws-ses"
|
||||||
|
|
||||||
// TypeCustom is the Type for the custom alerting provider
|
// TypeCustom is the Type for the custom alerting provider
|
||||||
TypeCustom Type = "custom"
|
TypeCustom Type = "custom"
|
||||||
|
|
||||||
@@ -20,9 +23,18 @@ const (
|
|||||||
// TypeGitLab is the Type for the gitlab alerting provider
|
// TypeGitLab is the Type for the gitlab alerting provider
|
||||||
TypeGitLab Type = "gitlab"
|
TypeGitLab Type = "gitlab"
|
||||||
|
|
||||||
|
// TypeGitea is the Type for the gitea alerting provider
|
||||||
|
TypeGitea Type = "gitea"
|
||||||
|
|
||||||
// TypeGoogleChat is the Type for the googlechat alerting provider
|
// TypeGoogleChat is the Type for the googlechat alerting provider
|
||||||
TypeGoogleChat Type = "googlechat"
|
TypeGoogleChat Type = "googlechat"
|
||||||
|
|
||||||
|
// TypeGotify is the Type for the gotify alerting provider
|
||||||
|
TypeGotify Type = "gotify"
|
||||||
|
|
||||||
|
// TypeJetBrainsSpace is the Type for the jetbrains alerting provider
|
||||||
|
TypeJetBrainsSpace Type = "jetbrainsspace"
|
||||||
|
|
||||||
// TypeMatrix is the Type for the matrix alerting provider
|
// TypeMatrix is the Type for the matrix alerting provider
|
||||||
TypeMatrix Type = "matrix"
|
TypeMatrix Type = "matrix"
|
||||||
|
|
||||||
@@ -50,9 +62,15 @@ const (
|
|||||||
// TypeTeams is the Type for the teams alerting provider
|
// TypeTeams is the Type for the teams alerting provider
|
||||||
TypeTeams Type = "teams"
|
TypeTeams Type = "teams"
|
||||||
|
|
||||||
|
// TypeTeamsWorkflows is the Type for the teams-workflows alerting provider
|
||||||
|
TypeTeamsWorkflows Type = "teams-workflows"
|
||||||
|
|
||||||
// TypeTelegram is the Type for the telegram alerting provider
|
// TypeTelegram is the Type for the telegram alerting provider
|
||||||
TypeTelegram Type = "telegram"
|
TypeTelegram Type = "telegram"
|
||||||
|
|
||||||
// TypeTwilio is the Type for the twilio alerting provider
|
// TypeTwilio is the Type for the twilio alerting provider
|
||||||
TypeTwilio Type = "twilio"
|
TypeTwilio Type = "twilio"
|
||||||
|
|
||||||
|
// TypeZulip is the Type for the Zulip alerting provider
|
||||||
|
TypeZulip Type = "zulip"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
package alerting
|
package alerting
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider"
|
"github.com/TwiN/gatus/v5/alerting/provider"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/awsses"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/custom"
|
"github.com/TwiN/gatus/v5/alerting/provider/custom"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/discord"
|
"github.com/TwiN/gatus/v5/alerting/provider/discord"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/email"
|
"github.com/TwiN/gatus/v5/alerting/provider/email"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/gitea"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/github"
|
"github.com/TwiN/gatus/v5/alerting/provider/github"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
|
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
|
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/gotify"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
|
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
|
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
|
||||||
@@ -22,12 +25,18 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
|
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/slack"
|
"github.com/TwiN/gatus/v5/alerting/provider/slack"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/teams"
|
"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/telegram"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
|
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/zulip"
|
||||||
|
"github.com/TwiN/logr"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config is the configuration for alerting providers
|
// Config is the configuration for alerting providers
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
// AWSSimpleEmailService is the configuration for the aws-ses alerting provider
|
||||||
|
AWSSimpleEmailService *awsses.AlertProvider `yaml:"aws-ses,omitempty"`
|
||||||
|
|
||||||
// Custom is the configuration for the custom alerting provider
|
// Custom is the configuration for the custom alerting provider
|
||||||
Custom *custom.AlertProvider `yaml:"custom,omitempty"`
|
Custom *custom.AlertProvider `yaml:"custom,omitempty"`
|
||||||
|
|
||||||
@@ -43,9 +52,18 @@ type Config struct {
|
|||||||
// GitLab is the configuration for the gitlab alerting provider
|
// GitLab is the configuration for the gitlab alerting provider
|
||||||
GitLab *gitlab.AlertProvider `yaml:"gitlab,omitempty"`
|
GitLab *gitlab.AlertProvider `yaml:"gitlab,omitempty"`
|
||||||
|
|
||||||
|
// Gitea is the configuration for the gitea alerting provider
|
||||||
|
Gitea *gitea.AlertProvider `yaml:"gitea,omitempty"`
|
||||||
|
|
||||||
// GoogleChat is the configuration for the googlechat alerting provider
|
// GoogleChat is the configuration for the googlechat alerting provider
|
||||||
GoogleChat *googlechat.AlertProvider `yaml:"googlechat,omitempty"`
|
GoogleChat *googlechat.AlertProvider `yaml:"googlechat,omitempty"`
|
||||||
|
|
||||||
|
// Gotify is the configuration for the gotify alerting provider
|
||||||
|
Gotify *gotify.AlertProvider `yaml:"gotify,omitempty"`
|
||||||
|
|
||||||
|
// JetBrainsSpace is the configuration for the jetbrains space alerting provider
|
||||||
|
JetBrainsSpace *jetbrainsspace.AlertProvider `yaml:"jetbrainsspace,omitempty"`
|
||||||
|
|
||||||
// Matrix is the configuration for the matrix alerting provider
|
// Matrix is the configuration for the matrix alerting provider
|
||||||
Matrix *matrix.AlertProvider `yaml:"matrix,omitempty"`
|
Matrix *matrix.AlertProvider `yaml:"matrix,omitempty"`
|
||||||
|
|
||||||
@@ -73,11 +91,17 @@ type Config struct {
|
|||||||
// Teams is the configuration for the teams alerting provider
|
// Teams is the configuration for the teams alerting provider
|
||||||
Teams *teams.AlertProvider `yaml:"teams,omitempty"`
|
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 is the configuration for the telegram alerting provider
|
||||||
Telegram *telegram.AlertProvider `yaml:"telegram,omitempty"`
|
Telegram *telegram.AlertProvider `yaml:"telegram,omitempty"`
|
||||||
|
|
||||||
// Twilio is the configuration for the twilio alerting provider
|
// Twilio is the configuration for the twilio alerting provider
|
||||||
Twilio *twilio.AlertProvider `yaml:"twilio,omitempty"`
|
Twilio *twilio.AlertProvider `yaml:"twilio,omitempty"`
|
||||||
|
|
||||||
|
// Zulip is the configuration for the zulip alerting provider
|
||||||
|
Zulip *zulip.AlertProvider `yaml:"zulip,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding alert.Type
|
// GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding alert.Type
|
||||||
@@ -85,7 +109,8 @@ func (config *Config) GetAlertingProviderByAlertType(alertType alert.Type) provi
|
|||||||
entityType := reflect.TypeOf(config).Elem()
|
entityType := reflect.TypeOf(config).Elem()
|
||||||
for i := 0; i < entityType.NumField(); i++ {
|
for i := 0; i < entityType.NumField(); i++ {
|
||||||
field := entityType.Field(i)
|
field := entityType.Field(i)
|
||||||
if strings.ToLower(field.Name) == string(alertType) {
|
tag := strings.Split(field.Tag.Get("yaml"), ",")[0]
|
||||||
|
if tag == string(alertType) {
|
||||||
fieldValue := reflect.ValueOf(config).Elem().Field(i)
|
fieldValue := reflect.ValueOf(config).Elem().Field(i)
|
||||||
if fieldValue.IsNil() {
|
if fieldValue.IsNil() {
|
||||||
return nil
|
return nil
|
||||||
@@ -93,7 +118,7 @@ func (config *Config) GetAlertingProviderByAlertType(alertType alert.Type) provi
|
|||||||
return fieldValue.Interface().(provider.AlertProvider)
|
return fieldValue.Interface().(provider.AlertProvider)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.Printf("[alerting][GetAlertingProviderByAlertType] No alerting provider found for alert type %s", alertType)
|
logr.Infof("[alerting.GetAlertingProviderByAlertType] No alerting provider found for alert type %s", alertType)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
228
alerting/provider/awsses/awsses.go
Normal file
228
alerting/provider/awsses/awsses.go
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
package awsses
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/TwiN/logr"
|
||||||
|
"github.com/aws/aws-sdk-go/aws"
|
||||||
|
"github.com/aws/aws-sdk-go/aws/awserr"
|
||||||
|
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||||
|
"github.com/aws/aws-sdk-go/aws/session"
|
||||||
|
"github.com/aws/aws-sdk-go/service/ses"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
CharSet = "UTF-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
ErrMissingFromOrToFields = errors.New("from and to fields are required")
|
||||||
|
ErrInvalidAWSAuthConfig = errors.New("either both or neither of access-key-id and secret-access-key must be specified")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
AccessKeyID string `yaml:"access-key-id"`
|
||||||
|
SecretAccessKey string `yaml:"secret-access-key"`
|
||||||
|
Region string `yaml:"region"`
|
||||||
|
|
||||||
|
From string `yaml:"from"`
|
||||||
|
To string `yaml:"to"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.From) == 0 || len(cfg.To) == 0 {
|
||||||
|
return ErrMissingFromOrToFields
|
||||||
|
}
|
||||||
|
if !((len(cfg.AccessKeyID) == 0 && len(cfg.SecretAccessKey) == 0) || (len(cfg.AccessKeyID) > 0 && len(cfg.SecretAccessKey) > 0)) {
|
||||||
|
// if both AccessKeyID and SecretAccessKey are specified, we'll use these to authenticate,
|
||||||
|
// otherwise if neither are specified, then we'll fall back on IAM authentication.
|
||||||
|
return ErrInvalidAWSAuthConfig
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.AccessKeyID) > 0 {
|
||||||
|
cfg.AccessKeyID = override.AccessKeyID
|
||||||
|
}
|
||||||
|
if len(override.SecretAccessKey) > 0 {
|
||||||
|
cfg.SecretAccessKey = override.SecretAccessKey
|
||||||
|
}
|
||||||
|
if len(override.Region) > 0 {
|
||||||
|
cfg.Region = override.Region
|
||||||
|
}
|
||||||
|
if len(override.From) > 0 {
|
||||||
|
cfg.From = override.From
|
||||||
|
}
|
||||||
|
if len(override.To) > 0 {
|
||||||
|
cfg.To = override.To
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using AWS Simple Email Service
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override is a case under which the default integration is overridden
|
||||||
|
type Override struct {
|
||||||
|
Group string `yaml:"group"`
|
||||||
|
Config `yaml:",inline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the provider's configuration
|
||||||
|
func (provider *AlertProvider) Validate() error {
|
||||||
|
registeredGroups := make(map[string]bool)
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.To) == 0 {
|
||||||
|
return ErrDuplicateGroupOverride
|
||||||
|
}
|
||||||
|
registeredGroups[override.Group] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return provider.DefaultConfig.Validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send an alert using the provider
|
||||||
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
awsSession, err := provider.createSession(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
svc := ses.New(awsSession)
|
||||||
|
subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
|
||||||
|
emails := strings.Split(cfg.To, ",")
|
||||||
|
|
||||||
|
input := &ses.SendEmailInput{
|
||||||
|
Destination: &ses.Destination{
|
||||||
|
ToAddresses: aws.StringSlice(emails),
|
||||||
|
},
|
||||||
|
Message: &ses.Message{
|
||||||
|
Body: &ses.Body{
|
||||||
|
Text: &ses.Content{
|
||||||
|
Charset: aws.String(CharSet),
|
||||||
|
Data: aws.String(body),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Subject: &ses.Content{
|
||||||
|
Charset: aws.String(CharSet),
|
||||||
|
Data: aws.String(subject),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Source: aws.String(cfg.From),
|
||||||
|
}
|
||||||
|
if _, err = svc.SendEmail(input); err != nil {
|
||||||
|
if aerr, ok := err.(awserr.Error); ok {
|
||||||
|
switch aerr.Code() {
|
||||||
|
case ses.ErrCodeMessageRejected:
|
||||||
|
logr.Error(ses.ErrCodeMessageRejected + ": " + aerr.Error())
|
||||||
|
case ses.ErrCodeMailFromDomainNotVerifiedException:
|
||||||
|
logr.Error(ses.ErrCodeMailFromDomainNotVerifiedException + ": " + aerr.Error())
|
||||||
|
case ses.ErrCodeConfigurationSetDoesNotExistException:
|
||||||
|
logr.Error(ses.ErrCodeConfigurationSetDoesNotExistException + ": " + aerr.Error())
|
||||||
|
default:
|
||||||
|
logr.Error(aerr.Error())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Print the error, cast err to awserr.Error to get the Code and
|
||||||
|
// Message from an error.
|
||||||
|
logr.Error(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) createSession(cfg *Config) (*session.Session, error) {
|
||||||
|
awsConfig := &aws.Config{
|
||||||
|
Region: aws.String(cfg.Region),
|
||||||
|
}
|
||||||
|
if len(cfg.AccessKeyID) > 0 && len(cfg.SecretAccessKey) > 0 {
|
||||||
|
awsConfig.Credentials = credentials.NewStaticCredentials(cfg.AccessKeyID, cfg.SecretAccessKey, "")
|
||||||
|
}
|
||||||
|
return session.NewSession(awsConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMessageSubjectAndBody builds the message subject and body
|
||||||
|
func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) {
|
||||||
|
var subject, message string
|
||||||
|
if resolved {
|
||||||
|
subject = fmt.Sprintf("[%s] Alert resolved", ep.DisplayName())
|
||||||
|
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||||
|
} else {
|
||||||
|
subject = fmt.Sprintf("[%s] Alert triggered", ep.DisplayName())
|
||||||
|
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||||
|
}
|
||||||
|
var formattedConditionResults string
|
||||||
|
if len(result.ConditionResults) > 0 {
|
||||||
|
formattedConditionResults = "\n\nCondition results:\n"
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
var prefix string
|
||||||
|
if conditionResult.Success {
|
||||||
|
prefix = "✅"
|
||||||
|
} else {
|
||||||
|
prefix = "❌"
|
||||||
|
}
|
||||||
|
formattedConditionResults += fmt.Sprintf("%s %s\n", prefix, conditionResult.Condition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var description string
|
||||||
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
|
description = "\n\nAlert description: " + alertDescription
|
||||||
|
}
|
||||||
|
return subject, message + description + formattedConditionResults
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
|
return provider.DefaultAlert
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
250
alerting/provider/awsses/awsses_test.go
Normal file
250
alerting/provider/awsses/awsses_test.go
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
package awsses
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
|
invalidProvider := AlertProvider{}
|
||||||
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
|
t.Error("provider shouldn't have been valid")
|
||||||
|
}
|
||||||
|
invalidProviderWithOneKey := AlertProvider{DefaultConfig: Config{From: "from@example.com", To: "to@example.com", AccessKeyID: "1"}}
|
||||||
|
if err := invalidProviderWithOneKey.Validate(); err == nil {
|
||||||
|
t.Error("provider shouldn't have been valid")
|
||||||
|
}
|
||||||
|
validProvider := AlertProvider{DefaultConfig: Config{From: "from@example.com", To: "to@example.com"}}
|
||||||
|
if err := validProvider.Validate(); err != nil {
|
||||||
|
t.Error("provider should've been valid")
|
||||||
|
}
|
||||||
|
validProviderWithKeys := AlertProvider{DefaultConfig: Config{From: "from@example.com", To: "to@example.com", AccessKeyID: "1", SecretAccessKey: "1"}}
|
||||||
|
if err := validProviderWithKeys.Validate(); err != nil {
|
||||||
|
t.Error("provider should've been valid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||||
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Config: Config{To: "to@example.com"},
|
||||||
|
Group: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||||
|
t.Error("provider Group shouldn't have been valid")
|
||||||
|
}
|
||||||
|
providerWithInvalidOverrideTo := AlertProvider{
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Config: Config{To: ""},
|
||||||
|
Group: "group",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
|
||||||
|
t.Error("provider integration key shouldn't have been valid")
|
||||||
|
}
|
||||||
|
providerWithValidOverride := AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
From: "from@example.com",
|
||||||
|
To: "to@example.com",
|
||||||
|
},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Config: Config{To: "to@example.com"},
|
||||||
|
Group: "group",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := providerWithValidOverride.Validate(); err != nil {
|
||||||
|
t.Error("provider should've been valid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||||
|
firstDescription := "description-1"
|
||||||
|
secondDescription := "description-2"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
ExpectedSubject string
|
||||||
|
ExpectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedSubject: "[endpoint-name] Alert triggered",
|
||||||
|
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedSubject: "[endpoint-name] Alert resolved",
|
||||||
|
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
subject, body := scenario.Provider.buildMessageSubjectAndBody(
|
||||||
|
&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 subject != scenario.ExpectedSubject {
|
||||||
|
t.Errorf("expected subject to be %s, got %s", scenario.ExpectedSubject, subject)
|
||||||
|
}
|
||||||
|
if body != scenario.ExpectedBody {
|
||||||
|
t.Errorf("expected body to be %s, got %s", scenario.ExpectedBody, body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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_getConfigWithOverrides(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputGroup string
|
||||||
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
From: "from@example.com",
|
||||||
|
To: "to@example.com",
|
||||||
|
},
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
From: "from@example.com",
|
||||||
|
To: "to@example.com",
|
||||||
|
},
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
From: "from@example.com",
|
||||||
|
To: "to@example.com",
|
||||||
|
},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{To: "groupto@example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
From: "from@example.com",
|
||||||
|
To: "to@example.com",
|
||||||
|
},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{To: "groupto@example.com", SecretAccessKey: "wow", AccessKeyID: "noway"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{From: "from@example.com", To: "groupto@example.com", SecretAccessKey: "wow", AccessKeyID: "noway"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-group-but-alert-override-should-override-group-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
From: "from@example.com",
|
||||||
|
To: "to@example.com",
|
||||||
|
},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{From: "from@example.com", To: "groupto@example.com", SecretAccessKey: "sekrit"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{
|
||||||
|
ProviderOverride: map[string]any{
|
||||||
|
"to": "alertto@example.com",
|
||||||
|
"access-key-id": 123,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ExpectedOutput: Config{To: "alertto@example.com", From: "from@example.com", AccessKeyID: "123", SecretAccessKey: "sekrit"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.From != scenario.ExpectedOutput.From {
|
||||||
|
t.Errorf("expected From to be %s, got %s", scenario.ExpectedOutput.From, got.From)
|
||||||
|
}
|
||||||
|
if got.To != scenario.ExpectedOutput.To {
|
||||||
|
t.Errorf("expected To to be %s, got %s", scenario.ExpectedOutput.To, got.To)
|
||||||
|
}
|
||||||
|
if got.AccessKeyID != scenario.ExpectedOutput.AccessKeyID {
|
||||||
|
t.Errorf("expected AccessKeyID to be %s, got %s", scenario.ExpectedOutput.AccessKeyID, got.AccessKeyID)
|
||||||
|
}
|
||||||
|
if got.SecretAccessKey != scenario.ExpectedOutput.SecretAccessKey {
|
||||||
|
t.Errorf("expected SecretAccessKey to be %s, got %s", scenario.ExpectedOutput.SecretAccessKey, got.SecretAccessKey)
|
||||||
|
}
|
||||||
|
if got.Region != scenario.ExpectedOutput.Region {
|
||||||
|
t.Errorf("expected Region to be %s, got %s", scenario.ExpectedOutput.Region, got.Region)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package custom
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -9,12 +10,15 @@ import (
|
|||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using a custom HTTP request
|
var (
|
||||||
// Technically, all alert providers should be reachable using the custom alert provider
|
ErrURLNotSet = errors.New("url not set")
|
||||||
type AlertProvider struct {
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
URL string `yaml:"url"`
|
URL string `yaml:"url"`
|
||||||
Method string `yaml:"method,omitempty"`
|
Method string `yaml:"method,omitempty"`
|
||||||
Body string `yaml:"body,omitempty"`
|
Body string `yaml:"body,omitempty"`
|
||||||
@@ -23,64 +27,66 @@ type AlertProvider struct {
|
|||||||
|
|
||||||
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
||||||
ClientConfig *client.Config `yaml:"client,omitempty"`
|
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.URL) == 0 {
|
||||||
|
return ErrURLNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if override.ClientConfig != nil {
|
||||||
|
cfg.ClientConfig = override.ClientConfig
|
||||||
|
}
|
||||||
|
if len(override.URL) > 0 {
|
||||||
|
cfg.URL = override.URL
|
||||||
|
}
|
||||||
|
if len(override.Method) > 0 {
|
||||||
|
cfg.Method = override.Method
|
||||||
|
}
|
||||||
|
if len(override.Body) > 0 {
|
||||||
|
cfg.Body = override.Body
|
||||||
|
}
|
||||||
|
if len(override.Headers) > 0 {
|
||||||
|
cfg.Headers = override.Headers
|
||||||
|
}
|
||||||
|
if len(override.Placeholders) > 0 {
|
||||||
|
cfg.Placeholders = override.Placeholders
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using a custom HTTP request
|
||||||
|
// Technically, all alert providers should be reachable using the custom alert provider
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// Override is a case under which the default integration is overridden
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
type Override struct {
|
||||||
if provider.ClientConfig == nil {
|
Group string `yaml:"group"`
|
||||||
provider.ClientConfig = client.GetDefaultConfig()
|
Config `yaml:",inline"`
|
||||||
}
|
|
||||||
return len(provider.URL) > 0 && provider.ClientConfig != nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAlertStatePlaceholderValue returns the Placeholder value for ALERT_TRIGGERED_OR_RESOLVED if configured
|
// Validate the provider's configuration
|
||||||
func (provider *AlertProvider) GetAlertStatePlaceholderValue(resolved bool) string {
|
func (provider *AlertProvider) Validate() error {
|
||||||
status := "TRIGGERED"
|
return provider.DefaultConfig.Validate()
|
||||||
if resolved {
|
|
||||||
status = "RESOLVED"
|
|
||||||
}
|
|
||||||
if _, ok := provider.Placeholders["ALERT_TRIGGERED_OR_RESOLVED"]; ok {
|
|
||||||
if val, ok := provider.Placeholders["ALERT_TRIGGERED_OR_RESOLVED"][status]; ok {
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return status
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *AlertProvider) buildHTTPRequest(endpoint *core.Endpoint, alert *alert.Alert, resolved bool) *http.Request {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
body, url, method := provider.Body, provider.URL, provider.Method
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
body = strings.ReplaceAll(body, "[ALERT_DESCRIPTION]", alert.GetDescription())
|
if err != nil {
|
||||||
url = strings.ReplaceAll(url, "[ALERT_DESCRIPTION]", alert.GetDescription())
|
return err
|
||||||
body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", endpoint.Name)
|
|
||||||
url = strings.ReplaceAll(url, "[ENDPOINT_NAME]", endpoint.Name)
|
|
||||||
body = strings.ReplaceAll(body, "[ENDPOINT_GROUP]", endpoint.Group)
|
|
||||||
url = strings.ReplaceAll(url, "[ENDPOINT_GROUP]", endpoint.Group)
|
|
||||||
body = strings.ReplaceAll(body, "[ENDPOINT_URL]", endpoint.URL)
|
|
||||||
url = strings.ReplaceAll(url, "[ENDPOINT_URL]", endpoint.URL)
|
|
||||||
if resolved {
|
|
||||||
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
|
|
||||||
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
|
|
||||||
} else {
|
|
||||||
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
|
|
||||||
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
|
|
||||||
}
|
}
|
||||||
if len(method) == 0 {
|
request := provider.buildHTTPRequest(cfg, ep, alert, result, resolved)
|
||||||
method = http.MethodGet
|
response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
|
||||||
}
|
|
||||||
bodyBuffer := bytes.NewBuffer([]byte(body))
|
|
||||||
request, _ := http.NewRequest(method, url, bodyBuffer)
|
|
||||||
for k, v := range provider.Headers {
|
|
||||||
request.Header.Set(k, v)
|
|
||||||
}
|
|
||||||
return request
|
|
||||||
}
|
|
||||||
|
|
||||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
|
||||||
request := provider.buildHTTPRequest(endpoint, alert, resolved)
|
|
||||||
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -92,7 +98,82 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) buildHTTPRequest(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) *http.Request {
|
||||||
|
body, url, method := cfg.Body, cfg.URL, cfg.Method
|
||||||
|
body = strings.ReplaceAll(body, "[ALERT_DESCRIPTION]", alert.GetDescription())
|
||||||
|
url = strings.ReplaceAll(url, "[ALERT_DESCRIPTION]", alert.GetDescription())
|
||||||
|
body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", ep.Name)
|
||||||
|
url = strings.ReplaceAll(url, "[ENDPOINT_NAME]", ep.Name)
|
||||||
|
body = strings.ReplaceAll(body, "[ENDPOINT_GROUP]", ep.Group)
|
||||||
|
url = strings.ReplaceAll(url, "[ENDPOINT_GROUP]", ep.Group)
|
||||||
|
body = strings.ReplaceAll(body, "[ENDPOINT_URL]", ep.URL)
|
||||||
|
url = strings.ReplaceAll(url, "[ENDPOINT_URL]", ep.URL)
|
||||||
|
body = strings.ReplaceAll(body, "[RESULT_ERRORS]", strings.Join(result.Errors, ","))
|
||||||
|
url = strings.ReplaceAll(url, "[RESULT_ERRORS]", strings.Join(result.Errors, ","))
|
||||||
|
if resolved {
|
||||||
|
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, true))
|
||||||
|
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, true))
|
||||||
|
} else {
|
||||||
|
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, false))
|
||||||
|
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, false))
|
||||||
|
}
|
||||||
|
if len(method) == 0 {
|
||||||
|
method = http.MethodGet
|
||||||
|
}
|
||||||
|
bodyBuffer := bytes.NewBuffer([]byte(body))
|
||||||
|
request, _ := http.NewRequest(method, url, bodyBuffer)
|
||||||
|
for k, v := range cfg.Headers {
|
||||||
|
request.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAlertStatePlaceholderValue returns the Placeholder value for ALERT_TRIGGERED_OR_RESOLVED if configured
|
||||||
|
func (provider *AlertProvider) GetAlertStatePlaceholderValue(cfg *Config, resolved bool) string {
|
||||||
|
status := "TRIGGERED"
|
||||||
|
if resolved {
|
||||||
|
status = "RESOLVED"
|
||||||
|
}
|
||||||
|
if _, ok := cfg.Placeholders["ALERT_TRIGGERED_OR_RESOLVED"]; ok {
|
||||||
|
if val, ok := cfg.Placeholders["ALERT_TRIGGERED_OR_RESOLVED"][status]; ok {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,28 +8,22 @@ import (
|
|||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/TwiN/gatus/v5/test"
|
"github.com/TwiN/gatus/v5/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
t.Run("invalid-provider", func(t *testing.T) {
|
t.Run("invalid-provider", func(t *testing.T) {
|
||||||
invalidProvider := AlertProvider{URL: ""}
|
invalidProvider := AlertProvider{DefaultConfig: Config{URL: ""}}
|
||||||
if invalidProvider.IsValid() {
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
t.Error("provider shouldn't have been valid")
|
t.Error("provider shouldn't have been valid")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
t.Run("valid-provider", func(t *testing.T) {
|
t.Run("valid-provider", func(t *testing.T) {
|
||||||
validProvider := AlertProvider{URL: "https://example.com"}
|
validProvider := AlertProvider{DefaultConfig: Config{URL: "https://example.com"}}
|
||||||
if validProvider.ClientConfig != nil {
|
if err := validProvider.Validate(); err != nil {
|
||||||
t.Error("provider client config should have been nil prior to IsValid() being executed")
|
|
||||||
}
|
|
||||||
if !validProvider.IsValid() {
|
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
if validProvider.ClientConfig == nil {
|
|
||||||
t.Error("provider client config should have been set after IsValid() was executed")
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +41,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -57,7 +51,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "triggered-error",
|
Name: "triggered-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -67,7 +61,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -77,7 +71,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved-error",
|
Name: "resolved-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -90,10 +84,10 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
err := scenario.Provider.Send(
|
err := scenario.Provider.Send(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
},
|
},
|
||||||
@@ -111,9 +105,11 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_buildHTTPRequest(t *testing.T) {
|
func TestAlertProvider_buildHTTPRequest(t *testing.T) {
|
||||||
customAlertProvider := &AlertProvider{
|
alertProvider := &AlertProvider{
|
||||||
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]&url=[ENDPOINT_URL]",
|
DefaultConfig: Config{
|
||||||
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ENDPOINT_URL],[ALERT_TRIGGERED_OR_RESOLVED]",
|
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]&url=[ENDPOINT_URL]",
|
||||||
|
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ENDPOINT_URL],[ALERT_TRIGGERED_OR_RESOLVED]",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
alertDescription := "alert-description"
|
alertDescription := "alert-description"
|
||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
@@ -123,13 +119,13 @@ func TestAlertProvider_buildHTTPRequest(t *testing.T) {
|
|||||||
ExpectedBody string
|
ExpectedBody string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
AlertProvider: customAlertProvider,
|
AlertProvider: alertProvider,
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=RESOLVED&description=alert-description&url=https://example.com",
|
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=RESOLVED&description=alert-description&url=https://example.com",
|
||||||
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,RESOLVED",
|
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,RESOLVED",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
AlertProvider: customAlertProvider,
|
AlertProvider: alertProvider,
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=TRIGGERED&description=alert-description&url=https://example.com",
|
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=TRIGGERED&description=alert-description&url=https://example.com",
|
||||||
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,TRIGGERED",
|
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,TRIGGERED",
|
||||||
@@ -137,9 +133,60 @@ func TestAlertProvider_buildHTTPRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(fmt.Sprintf("resolved-%v-with-default-placeholders", scenario.Resolved), func(t *testing.T) {
|
t.Run(fmt.Sprintf("resolved-%v-with-default-placeholders", scenario.Resolved), func(t *testing.T) {
|
||||||
request := customAlertProvider.buildHTTPRequest(
|
request := alertProvider.buildHTTPRequest(
|
||||||
&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group", URL: "https://example.com"},
|
&alertProvider.DefaultConfig,
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group", URL: "https://example.com"},
|
||||||
&alert.Alert{Description: &alertDescription},
|
&alert.Alert{Description: &alertDescription},
|
||||||
|
&endpoint.Result{Errors: []string{}},
|
||||||
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if request.URL.String() != scenario.ExpectedURL {
|
||||||
|
t.Error("expected URL to be", scenario.ExpectedURL, "got", request.URL.String())
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(request.Body)
|
||||||
|
if string(body) != scenario.ExpectedBody {
|
||||||
|
t.Error("expected body to be", scenario.ExpectedBody, "got", string(body))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProviderWithResultErrors_buildHTTPRequest(t *testing.T) {
|
||||||
|
alertProvider := &AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]&url=[ENDPOINT_URL]&error=[RESULT_ERRORS]",
|
||||||
|
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ENDPOINT_URL],[ALERT_TRIGGERED_OR_RESOLVED],[RESULT_ERRORS]",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
alertDescription := "alert-description"
|
||||||
|
scenarios := []struct {
|
||||||
|
AlertProvider *AlertProvider
|
||||||
|
Resolved bool
|
||||||
|
ExpectedURL string
|
||||||
|
ExpectedBody string
|
||||||
|
Errors []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
AlertProvider: alertProvider,
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=RESOLVED&description=alert-description&url=https://example.com&error=",
|
||||||
|
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,RESOLVED,",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
AlertProvider: alertProvider,
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=TRIGGERED&description=alert-description&url=https://example.com&error=error1,error2",
|
||||||
|
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,TRIGGERED,error1,error2",
|
||||||
|
Errors: []string{"error1", "error2"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(fmt.Sprintf("resolved-%v-with-default-placeholders-and-result-errors", scenario.Resolved), func(t *testing.T) {
|
||||||
|
request := alertProvider.buildHTTPRequest(
|
||||||
|
&alertProvider.DefaultConfig,
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group", URL: "https://example.com"},
|
||||||
|
&alert.Alert{Description: &alertDescription},
|
||||||
|
&endpoint.Result{Errors: scenario.Errors},
|
||||||
scenario.Resolved,
|
scenario.Resolved,
|
||||||
)
|
)
|
||||||
if request.URL.String() != scenario.ExpectedURL {
|
if request.URL.String() != scenario.ExpectedURL {
|
||||||
@@ -154,14 +201,16 @@ func TestAlertProvider_buildHTTPRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
|
func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
|
||||||
customAlertProvider := &AlertProvider{
|
alertProvider := &AlertProvider{
|
||||||
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
DefaultConfig: Config{
|
||||||
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
||||||
Headers: nil,
|
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
||||||
Placeholders: map[string]map[string]string{
|
Headers: nil,
|
||||||
"ALERT_TRIGGERED_OR_RESOLVED": {
|
Placeholders: map[string]map[string]string{
|
||||||
"RESOLVED": "fixed",
|
"ALERT_TRIGGERED_OR_RESOLVED": {
|
||||||
"TRIGGERED": "boom",
|
"RESOLVED": "fixed",
|
||||||
|
"TRIGGERED": "boom",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -173,13 +222,13 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
|
|||||||
ExpectedBody string
|
ExpectedBody string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
AlertProvider: customAlertProvider,
|
AlertProvider: alertProvider,
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=fixed&description=alert-description",
|
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=fixed&description=alert-description",
|
||||||
ExpectedBody: "endpoint-name,endpoint-group,alert-description,fixed",
|
ExpectedBody: "endpoint-name,endpoint-group,alert-description,fixed",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
AlertProvider: customAlertProvider,
|
AlertProvider: alertProvider,
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=boom&description=alert-description",
|
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=boom&description=alert-description",
|
||||||
ExpectedBody: "endpoint-name,endpoint-group,alert-description,boom",
|
ExpectedBody: "endpoint-name,endpoint-group,alert-description,boom",
|
||||||
@@ -187,9 +236,11 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(fmt.Sprintf("resolved-%v-with-custom-placeholders", scenario.Resolved), func(t *testing.T) {
|
t.Run(fmt.Sprintf("resolved-%v-with-custom-placeholders", scenario.Resolved), func(t *testing.T) {
|
||||||
request := customAlertProvider.buildHTTPRequest(
|
request := alertProvider.buildHTTPRequest(
|
||||||
&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
&alertProvider.DefaultConfig,
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||||
&alert.Alert{Description: &alertDescription},
|
&alert.Alert{Description: &alertDescription},
|
||||||
|
&endpoint.Result{},
|
||||||
scenario.Resolved,
|
scenario.Resolved,
|
||||||
)
|
)
|
||||||
if request.URL.String() != scenario.ExpectedURL {
|
if request.URL.String() != scenario.ExpectedURL {
|
||||||
@@ -204,23 +255,141 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
|
func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
|
||||||
customAlertProvider := &AlertProvider{
|
alertProvider := &AlertProvider{
|
||||||
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
DefaultConfig: Config{
|
||||||
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
||||||
|
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if customAlertProvider.GetAlertStatePlaceholderValue(true) != "RESOLVED" {
|
if alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, true) != "RESOLVED" {
|
||||||
t.Error("expected RESOLVED, got", customAlertProvider.GetAlertStatePlaceholderValue(true))
|
t.Error("expected RESOLVED, got", alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, true))
|
||||||
}
|
}
|
||||||
if customAlertProvider.GetAlertStatePlaceholderValue(false) != "TRIGGERED" {
|
if alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, false) != "TRIGGERED" {
|
||||||
t.Error("expected TRIGGERED, got", customAlertProvider.GetAlertStatePlaceholderValue(false))
|
t.Error("expected TRIGGERED, got", alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, false))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
t.Error("expected default alert to be not nil")
|
t.Error("expected default alert to be not nil")
|
||||||
}
|
}
|
||||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
t.Error("expected default alert to be nil")
|
t.Error("expected default alert to be nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputGroup string
|
||||||
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "http://example.com", Body: "default-body"},
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{URL: "http://example.com", Body: "default-body"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "http://example.com"},
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{URL: "http://example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "http://example.com"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{URL: "http://group-example.com", Headers: map[string]string{"Cache": "true"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{URL: "http://example.com", Headers: map[string]string{"Cache": "true"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "http://example.com", Body: "default-body"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{URL: "http://group-example.com", Body: "group-body"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{URL: "http://group-example.com", Body: "group-body"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "http://example.com"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{URL: "http://group-example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"url": "http://alert-example.com", "body": "alert-body"}},
|
||||||
|
ExpectedOutput: Config{URL: "http://alert-example.com", Body: "alert-body"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-partial-overrides",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "http://example.com"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{Method: "POST"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"body": "alert-body"}},
|
||||||
|
ExpectedOutput: Config{URL: "http://example.com", Body: "alert-body", Method: "POST"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.URL != scenario.ExpectedOutput.URL {
|
||||||
|
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.URL, got.URL)
|
||||||
|
}
|
||||||
|
if got.Body != scenario.ExpectedOutput.Body {
|
||||||
|
t.Errorf("expected body to be %s, got %s", scenario.ExpectedOutput.Body, got.Body)
|
||||||
|
}
|
||||||
|
if got.Headers != nil {
|
||||||
|
for key, value := range scenario.ExpectedOutput.Headers {
|
||||||
|
if got.Headers[key] != value {
|
||||||
|
t.Errorf("expected header %s to be %s, got %s", key, value, got.Headers[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,18 +3,46 @@ package discord
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrWebhookURLNotSet = errors.New("webhook-url not set")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
WebhookURL string `yaml:"webhook-url"`
|
||||||
|
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.WebhookURL) == 0 {
|
||||||
|
return ErrWebhookURLNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.WebhookURL) > 0 {
|
||||||
|
cfg.WebhookURL = override.WebhookURL
|
||||||
|
}
|
||||||
|
if len(override.Title) > 0 {
|
||||||
|
cfg.Title = override.Title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Discord
|
// AlertProvider is the configuration necessary for sending an alert using Discord
|
||||||
type AlertProvider struct {
|
type AlertProvider struct {
|
||||||
WebhookURL string `yaml:"webhook-url"`
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
@@ -23,30 +51,33 @@ type AlertProvider struct {
|
|||||||
Overrides []Override `yaml:"overrides,omitempty"`
|
Overrides []Override `yaml:"overrides,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override is a case under which the default integration is overridden
|
|
||||||
type Override struct {
|
type Override struct {
|
||||||
Group string `yaml:"group"`
|
Group string `yaml:"group"`
|
||||||
WebhookURL string `yaml:"webhook-url"`
|
Config `yaml:",inline"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// Validate the provider's configuration
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
func (provider *AlertProvider) Validate() error {
|
||||||
registeredGroups := make(map[string]bool)
|
registeredGroups := make(map[string]bool)
|
||||||
if provider.Overrides != nil {
|
if provider.Overrides != nil {
|
||||||
for _, override := range provider.Overrides {
|
for _, override := range provider.Overrides {
|
||||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
|
||||||
return false
|
return ErrDuplicateGroupOverride
|
||||||
}
|
}
|
||||||
registeredGroups[override.Group] = true
|
registeredGroups[override.Group] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return len(provider.WebhookURL) > 0
|
return provider.DefaultConfig.Validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||||
|
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -72,7 +103,7 @@ type Embed struct {
|
|||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Color int `json:"color"`
|
Color int `json:"color"`
|
||||||
Fields []Field `json:"fields"`
|
Fields []Field `json:"fields,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Field struct {
|
type Field struct {
|
||||||
@@ -82,16 +113,17 @@ type Field struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildRequestBody builds the request body for the provider
|
// buildRequestBody builds the request body for the provider
|
||||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
|
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
var message, results string
|
var message string
|
||||||
var colorCode int
|
var colorCode int
|
||||||
if resolved {
|
if resolved {
|
||||||
message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
|
message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||||
colorCode = 3066993
|
colorCode = 3066993
|
||||||
} else {
|
} else {
|
||||||
message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||||
colorCode = 15158332
|
colorCode = 15158332
|
||||||
}
|
}
|
||||||
|
var formattedConditionResults string
|
||||||
for _, conditionResult := range result.ConditionResults {
|
for _, conditionResult := range result.ConditionResults {
|
||||||
var prefix string
|
var prefix string
|
||||||
if conditionResult.Success {
|
if conditionResult.Success {
|
||||||
@@ -99,45 +131,69 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
|
|||||||
} else {
|
} else {
|
||||||
prefix = ":x:"
|
prefix = ":x:"
|
||||||
}
|
}
|
||||||
results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
||||||
}
|
}
|
||||||
var description string
|
var description string
|
||||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
description = ":\n> " + alertDescription
|
description = ":\n> " + alertDescription
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(Body{
|
title := ":helmet_with_white_cross: Gatus"
|
||||||
|
if cfg.Title != "" {
|
||||||
|
title = cfg.Title
|
||||||
|
}
|
||||||
|
body := Body{
|
||||||
Content: "",
|
Content: "",
|
||||||
Embeds: []Embed{
|
Embeds: []Embed{
|
||||||
{
|
{
|
||||||
Title: ":helmet_with_white_cross: Gatus",
|
Title: title,
|
||||||
Description: message + description,
|
Description: message + description,
|
||||||
Color: colorCode,
|
Color: colorCode,
|
||||||
Fields: []Field{
|
|
||||||
{
|
|
||||||
Name: "Condition results",
|
|
||||||
Value: results,
|
|
||||||
Inline: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
if len(formattedConditionResults) > 0 {
|
||||||
|
body.Embeds[0].Fields = append(body.Embeds[0].Fields, Field{
|
||||||
|
Name: "Condition results",
|
||||||
|
Value: formattedConditionResults,
|
||||||
|
Inline: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
bodyAsJSON, _ := json.Marshal(body)
|
||||||
|
return bodyAsJSON
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,54 +7,56 @@ import (
|
|||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/TwiN/gatus/v5/test"
|
"github.com/TwiN/gatus/v5/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
invalidProvider := AlertProvider{WebhookURL: ""}
|
invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
|
||||||
if invalidProvider.IsValid() {
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
t.Error("provider shouldn't have been valid")
|
t.Error("provider shouldn't have been valid")
|
||||||
}
|
}
|
||||||
validProvider := AlertProvider{WebhookURL: "http://example.com"}
|
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
|
||||||
if !validProvider.IsValid() {
|
if err := validProvider.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||||
providerWithInvalidOverrideGroup := AlertProvider{
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
WebhookURL: "http://example.com",
|
Config: Config{WebhookURL: "http://example.com"},
|
||||||
Group: "",
|
Group: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if providerWithInvalidOverrideGroup.IsValid() {
|
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||||
t.Error("provider Group shouldn't have been valid")
|
t.Error("provider Group shouldn't have been valid")
|
||||||
}
|
}
|
||||||
providerWithInvalidOverrideTo := AlertProvider{
|
providerWithInvalidOverrideTo := AlertProvider{
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
WebhookURL: "",
|
Config: Config{WebhookURL: ""},
|
||||||
Group: "group",
|
Group: "group",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if providerWithInvalidOverrideTo.IsValid() {
|
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
|
||||||
t.Error("provider integration key shouldn't have been valid")
|
t.Error("provider integration key shouldn't have been valid")
|
||||||
}
|
}
|
||||||
providerWithValidOverride := AlertProvider{
|
providerWithValidOverride := AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{
|
||||||
|
WebhookURL: "http://example.com",
|
||||||
|
},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
WebhookURL: "http://example.com",
|
Config: Config{WebhookURL: "http://example.com"},
|
||||||
Group: "group",
|
Group: "group",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if !providerWithValidOverride.IsValid() {
|
if err := providerWithValidOverride.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,6 +65,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
defer client.InjectHTTPClient(nil)
|
defer client.InjectHTTPClient(nil)
|
||||||
firstDescription := "description-1"
|
firstDescription := "description-1"
|
||||||
secondDescription := "description-2"
|
secondDescription := "description-2"
|
||||||
|
title := "provider-title"
|
||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
Name string
|
Name string
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
@@ -73,7 +76,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -83,7 +86,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "triggered-error",
|
Name: "triggered-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -93,7 +96,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -103,7 +106,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved-error",
|
Name: "resolved-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -111,15 +114,35 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}),
|
}),
|
||||||
ExpectedError: true,
|
ExpectedError: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-with-modified-title",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com", Title: title}},
|
||||||
|
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-with-webhook-override",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3, ProviderOverride: map[string]any{"webhook-url": "http://example01.com"}},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
err := scenario.Provider.Send(
|
err := scenario.Provider.Send(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
},
|
},
|
||||||
@@ -139,10 +162,12 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||||
firstDescription := "description-1"
|
firstDescription := "description-1"
|
||||||
secondDescription := "description-2"
|
secondDescription := "description-2"
|
||||||
|
title := "provider-title"
|
||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
Name string
|
Name string
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
Alert alert.Alert
|
Alert alert.Alert
|
||||||
|
NoConditions bool
|
||||||
Resolved bool
|
Resolved bool
|
||||||
ExpectedBody string
|
ExpectedBody string
|
||||||
}{
|
}{
|
||||||
@@ -160,18 +185,38 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"description\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"color\":3066993,\"fields\":[{\"name\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n:white_check_mark: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}",
|
ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"description\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"color\":3066993,\"fields\":[{\"name\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n:white_check_mark: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-with-modified-title",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{Title: title}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\"provider-title\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332,\"fields\":[{\"name\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n:x: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-with-no-conditions",
|
||||||
|
NoConditions: true,
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{Title: title}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\"provider-title\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332}]}",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
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},
|
||||||
|
{Condition: "[BODY] != \"\"", Success: scenario.Resolved},
|
||||||
|
}
|
||||||
|
}
|
||||||
body := scenario.Provider.buildRequestBody(
|
body := scenario.Provider.buildRequestBody(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&scenario.Provider.DefaultConfig,
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: conditionResults,
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
|
||||||
{Condition: "[BODY] != \"\"", Success: scenario.Resolved},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
scenario.Resolved,
|
scenario.Resolved,
|
||||||
)
|
)
|
||||||
@@ -187,72 +232,100 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
t.Error("expected default alert to be not nil")
|
t.Error("expected default alert to be not nil")
|
||||||
}
|
}
|
||||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
t.Error("expected default alert to be nil")
|
t.Error("expected default alert to be nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
tests := []struct {
|
scenarios := []struct {
|
||||||
Name string
|
Name string
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
InputGroup string
|
InputGroup string
|
||||||
ExpectedOutput string
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "provider-no-override-specify-no-group-should-default",
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: nil,
|
Overrides: nil,
|
||||||
},
|
},
|
||||||
InputGroup: "",
|
InputGroup: "",
|
||||||
ExpectedOutput: "http://example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-no-override-specify-group-should-default",
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: nil,
|
Overrides: nil,
|
||||||
},
|
},
|
||||||
InputGroup: "group",
|
InputGroup: "group",
|
||||||
ExpectedOutput: "http://example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-with-override-specify-no-group-should-default",
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
WebhookURL: "http://example01.com",
|
Config: Config{WebhookURL: "http://group-example.com"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InputGroup: "",
|
InputGroup: "",
|
||||||
ExpectedOutput: "http://example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-with-override-specify-group-should-override",
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
WebhookURL: "http://example01.com",
|
Config: Config{WebhookURL: "http://group-example.com"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InputGroup: "group",
|
InputGroup: "group",
|
||||||
ExpectedOutput: "http://example01.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://group-example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{WebhookURL: "http://group-example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, scenario := range scenarios {
|
||||||
t.Run(tt.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
|
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||||
t.Errorf("AlertProvider.getWebhookURLForGroup() = %v, want %v", got, tt.ExpectedOutput)
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
|
||||||
|
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
package email
|
package email
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
gomail "gopkg.in/mail.v2"
|
gomail "gopkg.in/mail.v2"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using SMTP
|
var (
|
||||||
type AlertProvider struct {
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
ErrMissingFromOrToFields = errors.New("from and to fields are required")
|
||||||
|
ErrInvalidPort = errors.New("port must be between 1 and 65535 inclusively")
|
||||||
|
ErrMissingHost = errors.New("host is required")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
From string `yaml:"from"`
|
From string `yaml:"from"`
|
||||||
Username string `yaml:"username"`
|
Username string `yaml:"username"`
|
||||||
Password string `yaml:"password"`
|
Password string `yaml:"password"`
|
||||||
@@ -19,6 +29,51 @@ type AlertProvider struct {
|
|||||||
Port int `yaml:"port"`
|
Port int `yaml:"port"`
|
||||||
To string `yaml:"to"`
|
To string `yaml:"to"`
|
||||||
|
|
||||||
|
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
||||||
|
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.From) == 0 || len(cfg.To) == 0 {
|
||||||
|
return ErrMissingFromOrToFields
|
||||||
|
}
|
||||||
|
if cfg.Port < 1 || cfg.Port > math.MaxUint16 {
|
||||||
|
return ErrInvalidPort
|
||||||
|
}
|
||||||
|
if len(cfg.Host) == 0 {
|
||||||
|
return ErrMissingHost
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if override.ClientConfig != nil {
|
||||||
|
cfg.ClientConfig = override.ClientConfig
|
||||||
|
}
|
||||||
|
if len(override.From) > 0 {
|
||||||
|
cfg.From = override.From
|
||||||
|
}
|
||||||
|
if len(override.Username) > 0 {
|
||||||
|
cfg.Username = override.Username
|
||||||
|
}
|
||||||
|
if len(override.Password) > 0 {
|
||||||
|
cfg.Password = override.Password
|
||||||
|
}
|
||||||
|
if len(override.Host) > 0 {
|
||||||
|
cfg.Host = override.Host
|
||||||
|
}
|
||||||
|
if override.Port > 0 {
|
||||||
|
cfg.Port = override.Port
|
||||||
|
}
|
||||||
|
if len(override.To) > 0 {
|
||||||
|
cfg.To = override.To
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using SMTP
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
@@ -28,81 +83,124 @@ type AlertProvider struct {
|
|||||||
|
|
||||||
// Override is a case under which the default integration is overridden
|
// Override is a case under which the default integration is overridden
|
||||||
type Override struct {
|
type Override struct {
|
||||||
Group string `yaml:"group"`
|
Group string `yaml:"group"`
|
||||||
To string `yaml:"to"`
|
Config `yaml:",inline"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// Validate the provider's configuration
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
func (provider *AlertProvider) Validate() error {
|
||||||
registeredGroups := make(map[string]bool)
|
registeredGroups := make(map[string]bool)
|
||||||
if provider.Overrides != nil {
|
if provider.Overrides != nil {
|
||||||
for _, override := range provider.Overrides {
|
for _, override := range provider.Overrides {
|
||||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.To) == 0 {
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.To) == 0 {
|
||||||
return false
|
return ErrDuplicateGroupOverride
|
||||||
}
|
}
|
||||||
registeredGroups[override.Group] = true
|
registeredGroups[override.Group] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return provider.DefaultConfig.Validate()
|
||||||
return len(provider.From) > 0 && len(provider.Password) > 0 && len(provider.Host) > 0 && len(provider.To) > 0 && provider.Port > 0 && provider.Port < math.MaxUint16
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
var username string
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
if len(provider.Username) > 0 {
|
if err != nil {
|
||||||
username = provider.Username
|
return err
|
||||||
} else {
|
|
||||||
username = provider.From
|
|
||||||
}
|
}
|
||||||
subject, body := provider.buildMessageSubjectAndBody(endpoint, alert, result, resolved)
|
var username string
|
||||||
|
if len(cfg.Username) > 0 {
|
||||||
|
username = cfg.Username
|
||||||
|
} else {
|
||||||
|
username = cfg.From
|
||||||
|
}
|
||||||
|
subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
|
||||||
m := gomail.NewMessage()
|
m := gomail.NewMessage()
|
||||||
m.SetHeader("From", provider.From)
|
m.SetHeader("From", cfg.From)
|
||||||
m.SetHeader("To", strings.Split(provider.getToForGroup(endpoint.Group), ",")...)
|
m.SetHeader("To", strings.Split(cfg.To, ",")...)
|
||||||
m.SetHeader("Subject", subject)
|
m.SetHeader("Subject", subject)
|
||||||
m.SetBody("text/plain", body)
|
m.SetBody("text/plain", body)
|
||||||
d := gomail.NewDialer(provider.Host, provider.Port, username, provider.Password)
|
var d *gomail.Dialer
|
||||||
|
if len(cfg.Password) == 0 {
|
||||||
|
// Get the domain in the From address
|
||||||
|
localName := "localhost"
|
||||||
|
fromParts := strings.Split(cfg.From, `@`)
|
||||||
|
if len(fromParts) == 2 {
|
||||||
|
localName = fromParts[1]
|
||||||
|
}
|
||||||
|
// Create a dialer with no authentication
|
||||||
|
d = &gomail.Dialer{Host: cfg.Host, Port: cfg.Port, LocalName: localName}
|
||||||
|
} else {
|
||||||
|
// Create an authenticated dialer
|
||||||
|
d = gomail.NewDialer(cfg.Host, cfg.Port, username, cfg.Password)
|
||||||
|
}
|
||||||
|
if cfg.ClientConfig != nil && cfg.ClientConfig.Insecure {
|
||||||
|
d.TLSConfig = &tls.Config{InsecureSkipVerify: true}
|
||||||
|
}
|
||||||
return d.DialAndSend(m)
|
return d.DialAndSend(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildMessageSubjectAndBody builds the message subject and body
|
// buildMessageSubjectAndBody builds the message subject and body
|
||||||
func (provider *AlertProvider) buildMessageSubjectAndBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) (string, string) {
|
func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) {
|
||||||
var message, results string
|
var subject, message string
|
||||||
subject := fmt.Sprintf("[%s] Alert triggered", endpoint.DisplayName())
|
|
||||||
if resolved {
|
if resolved {
|
||||||
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
|
subject = fmt.Sprintf("[%s] Alert resolved", ep.DisplayName())
|
||||||
|
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||||
} else {
|
} else {
|
||||||
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
subject = fmt.Sprintf("[%s] Alert triggered", ep.DisplayName())
|
||||||
|
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||||
}
|
}
|
||||||
for _, conditionResult := range result.ConditionResults {
|
var formattedConditionResults string
|
||||||
var prefix string
|
if len(result.ConditionResults) > 0 {
|
||||||
if conditionResult.Success {
|
formattedConditionResults = "\n\nCondition results:\n"
|
||||||
prefix = "✅"
|
for _, conditionResult := range result.ConditionResults {
|
||||||
} else {
|
var prefix string
|
||||||
prefix = "❌"
|
if conditionResult.Success {
|
||||||
|
prefix = "✅"
|
||||||
|
} else {
|
||||||
|
prefix = "❌"
|
||||||
|
}
|
||||||
|
formattedConditionResults += fmt.Sprintf("%s %s\n", prefix, conditionResult.Condition)
|
||||||
}
|
}
|
||||||
results += fmt.Sprintf("%s %s\n", prefix, conditionResult.Condition)
|
|
||||||
}
|
}
|
||||||
var description string
|
var description string
|
||||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
description = "\n\nAlert description: " + alertDescription
|
description = "\n\nAlert description: " + alertDescription
|
||||||
}
|
}
|
||||||
return subject, message + description + "\n\nCondition results:\n" + results
|
return subject, message + description + formattedConditionResults
|
||||||
}
|
|
||||||
|
|
||||||
// getToForGroup returns the appropriate email integration to for a given group
|
|
||||||
func (provider *AlertProvider) getToForGroup(group string) string {
|
|
||||||
if provider.Overrides != nil {
|
|
||||||
for _, override := range provider.Overrides {
|
|
||||||
if group == override.Group {
|
|
||||||
return override.To
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return provider.To
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,57 +4,66 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
invalidProvider := AlertProvider{}
|
invalidProvider := AlertProvider{}
|
||||||
if invalidProvider.IsValid() {
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
t.Error("provider shouldn't have been valid")
|
t.Error("provider shouldn't have been valid")
|
||||||
}
|
}
|
||||||
validProvider := AlertProvider{From: "from@example.com", Password: "password", Host: "smtp.gmail.com", Port: 587, To: "to@example.com"}
|
validProvider := AlertProvider{DefaultConfig: Config{From: "from@example.com", Password: "password", Host: "smtp.gmail.com", Port: 587, To: "to@example.com"}}
|
||||||
if !validProvider.IsValid() {
|
if err := validProvider.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
func TestAlertProvider_ValidateWithNoCredentials(t *testing.T) {
|
||||||
|
validProvider := AlertProvider{DefaultConfig: Config{From: "from@example.com", Host: "smtp-relay.gmail.com", Port: 587, To: "to@example.com"}}
|
||||||
|
if err := validProvider.Validate(); err != nil {
|
||||||
|
t.Error("provider should've been valid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||||
providerWithInvalidOverrideGroup := AlertProvider{
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
To: "to@example.com",
|
Config: Config{To: "to@example.com"},
|
||||||
Group: "",
|
Group: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if providerWithInvalidOverrideGroup.IsValid() {
|
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||||
t.Error("provider Group shouldn't have been valid")
|
t.Error("provider Group shouldn't have been valid")
|
||||||
}
|
}
|
||||||
providerWithInvalidOverrideTo := AlertProvider{
|
providerWithInvalidOverrideTo := AlertProvider{
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
To: "",
|
Config: Config{To: ""},
|
||||||
Group: "group",
|
Group: "group",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if providerWithInvalidOverrideTo.IsValid() {
|
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
|
||||||
t.Error("provider integration key shouldn't have been valid")
|
t.Error("provider integration key shouldn't have been valid")
|
||||||
}
|
}
|
||||||
providerWithValidOverride := AlertProvider{
|
providerWithValidOverride := AlertProvider{
|
||||||
From: "from@example.com",
|
DefaultConfig: Config{
|
||||||
Password: "password",
|
From: "from@example.com",
|
||||||
Host: "smtp.gmail.com",
|
Password: "password",
|
||||||
Port: 587,
|
Host: "smtp.gmail.com",
|
||||||
To: "to@example.com",
|
Port: 587,
|
||||||
|
To: "to@example.com",
|
||||||
|
},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
To: "to@example.com",
|
Config: Config{To: "to@example.com"},
|
||||||
Group: "group",
|
Group: "group",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if !providerWithValidOverride.IsValid() {
|
if err := providerWithValidOverride.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,17 +92,17 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedSubject: "[endpoint-name] Alert triggered",
|
ExpectedSubject: "[endpoint-name] Alert resolved",
|
||||||
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
|
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
subject, body := scenario.Provider.buildMessageSubjectAndBody(
|
subject, body := scenario.Provider.buildMessageSubjectAndBody(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
},
|
},
|
||||||
@@ -111,72 +120,112 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
t.Error("expected default alert to be not nil")
|
t.Error("expected default alert to be not nil")
|
||||||
}
|
}
|
||||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
t.Error("expected default alert to be nil")
|
t.Error("expected default alert to be nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_getToForGroup(t *testing.T) {
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
tests := []struct {
|
scenarios := []struct {
|
||||||
Name string
|
Name string
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
InputGroup string
|
InputGroup string
|
||||||
ExpectedOutput string
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "provider-no-override-specify-no-group-should-default",
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
To: "to@example.com",
|
DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
|
||||||
Overrides: nil,
|
Overrides: nil,
|
||||||
},
|
},
|
||||||
InputGroup: "",
|
InputGroup: "",
|
||||||
ExpectedOutput: "to@example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-no-override-specify-group-should-default",
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
To: "to@example.com",
|
DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
|
||||||
Overrides: nil,
|
Overrides: nil,
|
||||||
},
|
},
|
||||||
InputGroup: "group",
|
InputGroup: "group",
|
||||||
ExpectedOutput: "to@example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-with-override-specify-no-group-should-default",
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
To: "to@example.com",
|
DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
To: "to01@example.com",
|
Config: Config{To: "to01@example.com"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InputGroup: "",
|
InputGroup: "",
|
||||||
ExpectedOutput: "to@example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-with-override-specify-group-should-override",
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
To: "to@example.com",
|
DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
To: "to01@example.com",
|
Config: Config{To: "group-to@example.com"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InputGroup: "group",
|
InputGroup: "group",
|
||||||
ExpectedOutput: "to01@example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{From: "from@example.com", To: "group-to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{To: "group-to@example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"to": "alert-to@example.com", "host": "smtp.example.com", "port": 588, "password": "hunter2"}},
|
||||||
|
ExpectedOutput: Config{From: "from@example.com", To: "alert-to@example.com", Host: "smtp.example.com", Port: 588, Password: "hunter2"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, scenario := range scenarios {
|
||||||
t.Run(tt.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
if got := tt.Provider.getToForGroup(tt.InputGroup); got != tt.ExpectedOutput {
|
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||||
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.From != scenario.ExpectedOutput.From {
|
||||||
|
t.Errorf("expected from to be %s, got %s", scenario.ExpectedOutput.From, got.From)
|
||||||
|
}
|
||||||
|
if got.To != scenario.ExpectedOutput.To {
|
||||||
|
t.Errorf("expected to be %s, got %s", scenario.ExpectedOutput.To, got.To)
|
||||||
|
}
|
||||||
|
if got.Host != scenario.ExpectedOutput.Host {
|
||||||
|
t.Errorf("expected host to be %s, got %s", scenario.ExpectedOutput.Host, got.Host)
|
||||||
|
}
|
||||||
|
if got.Port != scenario.ExpectedOutput.Port {
|
||||||
|
t.Errorf("expected port to be %d, got %d", scenario.ExpectedOutput.Port, got.Port)
|
||||||
|
}
|
||||||
|
if got.Password != scenario.ExpectedOutput.Password {
|
||||||
|
t.Errorf("expected password to be %s, got %s", scenario.ExpectedOutput.Password, got.Password)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
217
alerting/provider/gitea/gitea.go
Normal file
217
alerting/provider/gitea/gitea.go
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"code.gitea.io/sdk/gitea"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrRepositoryURLNotSet = errors.New("repository-url not set")
|
||||||
|
ErrInvalidRepositoryURL = errors.New("invalid repository-url")
|
||||||
|
ErrTokenNotSet = errors.New("token not set")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
RepositoryURL string `yaml:"repository-url"` // The URL of the Gitea repository to create issues in
|
||||||
|
Token string `yaml:"token"` // Token requires at least RW on issues and RO on metadata
|
||||||
|
Assignees []string `yaml:"assignees,omitempty"` // Assignees is a list of users to assign the issue to
|
||||||
|
|
||||||
|
username string
|
||||||
|
repositoryOwner string
|
||||||
|
repositoryName string
|
||||||
|
giteaClient *gitea.Client
|
||||||
|
|
||||||
|
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
||||||
|
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.RepositoryURL) == 0 {
|
||||||
|
return ErrRepositoryURLNotSet
|
||||||
|
}
|
||||||
|
if len(cfg.Token) == 0 {
|
||||||
|
return ErrTokenNotSet
|
||||||
|
}
|
||||||
|
// Validate format of the repository URL
|
||||||
|
repositoryURL, err := url.Parse(cfg.RepositoryURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
baseURL := repositoryURL.Scheme + "://" + repositoryURL.Host
|
||||||
|
pathParts := strings.Split(repositoryURL.Path, "/")
|
||||||
|
if len(pathParts) != 3 {
|
||||||
|
return ErrInvalidRepositoryURL
|
||||||
|
}
|
||||||
|
if cfg.repositoryOwner == pathParts[1] && cfg.repositoryName == pathParts[2] && cfg.giteaClient != nil {
|
||||||
|
// Already validated, let's skip the rest of the validation to avoid unnecessary API calls
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cfg.repositoryOwner = pathParts[1]
|
||||||
|
cfg.repositoryName = pathParts[2]
|
||||||
|
opts := []gitea.ClientOption{
|
||||||
|
gitea.SetToken(cfg.Token),
|
||||||
|
}
|
||||||
|
if cfg.ClientConfig != nil && cfg.ClientConfig.Insecure {
|
||||||
|
// add new http client for skip verify
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
opts = append(opts, gitea.SetHTTPClient(httpClient))
|
||||||
|
}
|
||||||
|
cfg.giteaClient, err = gitea.NewClient(baseURL, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
user, _, err := cfg.giteaClient.GetMyUserInfo()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg.username = user.UserName
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if override.ClientConfig != nil {
|
||||||
|
cfg.ClientConfig = override.ClientConfig
|
||||||
|
}
|
||||||
|
if len(override.RepositoryURL) > 0 {
|
||||||
|
cfg.RepositoryURL = override.RepositoryURL
|
||||||
|
}
|
||||||
|
if len(override.Token) > 0 {
|
||||||
|
cfg.Token = override.Token
|
||||||
|
}
|
||||||
|
if len(override.Assignees) > 0 {
|
||||||
|
cfg.Assignees = override.Assignees
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using Discord
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the provider's configuration
|
||||||
|
func (provider *AlertProvider) Validate() error {
|
||||||
|
return provider.DefaultConfig.Validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
|
||||||
|
// or closes the relevant issue(s) if the resolved parameter passed is true.
|
||||||
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
title := "alert(gatus): " + ep.DisplayName()
|
||||||
|
if !resolved {
|
||||||
|
_, _, err = cfg.giteaClient.CreateIssue(
|
||||||
|
cfg.repositoryOwner,
|
||||||
|
cfg.repositoryName,
|
||||||
|
gitea.CreateIssueOption{
|
||||||
|
Title: title,
|
||||||
|
Body: provider.buildIssueBody(ep, alert, result),
|
||||||
|
Assignees: cfg.Assignees,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create issue: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
issues, _, err := cfg.giteaClient.ListRepoIssues(
|
||||||
|
cfg.repositoryOwner,
|
||||||
|
cfg.repositoryName,
|
||||||
|
gitea.ListIssueOption{
|
||||||
|
State: gitea.StateOpen,
|
||||||
|
CreatedBy: cfg.username,
|
||||||
|
ListOptions: gitea.ListOptions{
|
||||||
|
Page: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list issues: %w", err)
|
||||||
|
}
|
||||||
|
for _, issue := range issues {
|
||||||
|
if issue.Title == title {
|
||||||
|
stateClosed := gitea.StateClosed
|
||||||
|
_, _, err = cfg.giteaClient.EditIssue(
|
||||||
|
cfg.repositoryOwner,
|
||||||
|
cfg.repositoryName,
|
||||||
|
issue.ID,
|
||||||
|
gitea.EditIssueOption{
|
||||||
|
State: &stateClosed,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to close issue: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildIssueBody builds the body of the issue
|
||||||
|
func (provider *AlertProvider) buildIssueBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result) string {
|
||||||
|
var formattedConditionResults string
|
||||||
|
if len(result.ConditionResults) > 0 {
|
||||||
|
formattedConditionResults = "\n\n## Condition results\n"
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
var prefix string
|
||||||
|
if conditionResult.Success {
|
||||||
|
prefix = ":white_check_mark:"
|
||||||
|
} else {
|
||||||
|
prefix = ":x:"
|
||||||
|
}
|
||||||
|
formattedConditionResults += fmt.Sprintf("- %s - `%s`\n", prefix, conditionResult.Condition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var description string
|
||||||
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
|
description = ":\n> " + alertDescription
|
||||||
|
}
|
||||||
|
message := fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||||
|
return message + description + formattedConditionResults
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
|
return provider.DefaultAlert
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration (we're returning the cfg here even if there's an error mostly for testing purposes)
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
229
alerting/provider/gitea/gitea_test.go
Normal file
229
alerting/provider/gitea/gitea_test.go
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.gitea.io/sdk/gitea"
|
||||||
|
"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 TestAlertProvider_Validate(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
ExpectedError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "invalid",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "", Token: ""}},
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "invalid-token",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"}},
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "missing-repository-name",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN", Token: "12345"}},
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "enterprise-client",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.example.com/TwiN/test", Token: "12345"}},
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "invalid-url",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "gitea.com/TwiN/test", Token: "12345"}},
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
err := scenario.Provider.Validate()
|
||||||
|
if scenario.ExpectedError && err == nil {
|
||||||
|
t.Error("expected error, got none")
|
||||||
|
}
|
||||||
|
if !scenario.ExpectedError && err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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-error",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-error",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"}},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
cfg, err := scenario.Provider.GetConfig("", &scenario.Alert)
|
||||||
|
if err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
|
cfg.giteaClient, _ = gitea.NewClient("https://gitea.com")
|
||||||
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
|
err = scenario.Provider.Send(
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||||
|
&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"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Endpoint endpoint.Endpoint
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
NoConditions bool
|
||||||
|
ExpectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3},
|
||||||
|
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\n> description-1\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-with-no-description",
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{FailureThreshold: 10},
|
||||||
|
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-with-no-conditions",
|
||||||
|
NoConditions: true,
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 10},
|
||||||
|
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row:\n> description-1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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: true},
|
||||||
|
{Condition: "[STATUS] == 200", Success: false},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body := scenario.Provider.buildIssueBody(
|
||||||
|
&scenario.Endpoint,
|
||||||
|
&scenario.Alert,
|
||||||
|
&endpoint.Result{ConditionResults: conditionResults},
|
||||||
|
)
|
||||||
|
if strings.TrimSpace(body) != strings.TrimSpace(scenario.ExpectedBody) {
|
||||||
|
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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_GetConfig(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"},
|
||||||
|
},
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-alert-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"},
|
||||||
|
},
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"repository-url": "https://gitea.com/TwiN/alert-test", "token": "54321", "assignees": []string{"TwiN"}}},
|
||||||
|
ExpectedOutput: Config{RepositoryURL: "https://gitea.com/TwiN/alert-test", Token: "54321", Assignees: []string{"TwiN"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
|
||||||
|
if err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.RepositoryURL != scenario.ExpectedOutput.RepositoryURL {
|
||||||
|
t.Errorf("expected repository URL %s, got %s", scenario.ExpectedOutput.RepositoryURL, got.RepositoryURL)
|
||||||
|
}
|
||||||
|
if got.Token != scenario.ExpectedOutput.Token {
|
||||||
|
t.Errorf("expected token %s, got %s", scenario.ExpectedOutput.Token, got.Token)
|
||||||
|
}
|
||||||
|
if len(got.Assignees) != len(scenario.ExpectedOutput.Assignees) {
|
||||||
|
t.Errorf("expected %d assignees, got %d", len(scenario.ExpectedOutput.Assignees), len(got.Assignees))
|
||||||
|
}
|
||||||
|
for i, assignee := range got.Assignees {
|
||||||
|
if assignee != scenario.ExpectedOutput.Assignees[i] {
|
||||||
|
t.Errorf("expected assignee %s, got %s", scenario.ExpectedOutput.Assignees[i], assignee)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil && !strings.Contains(err.Error(), "user does not exist") {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,88 +2,124 @@ package github
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/google/go-github/v48/github"
|
"github.com/google/go-github/v48/github"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Discord
|
var (
|
||||||
type AlertProvider struct {
|
ErrRepositoryURLNotSet = errors.New("repository-url not set")
|
||||||
|
ErrInvalidRepositoryURL = errors.New("invalid repository-url")
|
||||||
|
ErrTokenNotSet = errors.New("token not set")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
RepositoryURL string `yaml:"repository-url"` // The URL of the GitHub repository to create issues in
|
RepositoryURL string `yaml:"repository-url"` // The URL of the GitHub repository to create issues in
|
||||||
Token string `yaml:"token"` // Token requires at least RW on issues and RO on metadata
|
Token string `yaml:"token"` // Token requires at least RW on issues and RO on metadata
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
|
||||||
|
|
||||||
username string
|
username string
|
||||||
repositoryOwner string
|
repositoryOwner string
|
||||||
repositoryName string
|
repositoryName string
|
||||||
githubClient *github.Client
|
githubClient *github.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
func (cfg *Config) Validate() error {
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
if len(cfg.RepositoryURL) == 0 {
|
||||||
if len(provider.Token) == 0 || len(provider.RepositoryURL) == 0 {
|
return ErrRepositoryURLNotSet
|
||||||
return false
|
}
|
||||||
|
if len(cfg.Token) == 0 {
|
||||||
|
return ErrTokenNotSet
|
||||||
}
|
}
|
||||||
// Validate format of the repository URL
|
// Validate format of the repository URL
|
||||||
repositoryURL, err := url.Parse(provider.RepositoryURL)
|
repositoryURL, err := url.Parse(cfg.RepositoryURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return err
|
||||||
}
|
}
|
||||||
baseURL := repositoryURL.Scheme + "://" + repositoryURL.Host
|
baseURL := repositoryURL.Scheme + "://" + repositoryURL.Host
|
||||||
pathParts := strings.Split(repositoryURL.Path, "/")
|
pathParts := strings.Split(repositoryURL.Path, "/")
|
||||||
if len(pathParts) != 3 {
|
if len(pathParts) != 3 {
|
||||||
return false
|
return ErrInvalidRepositoryURL
|
||||||
}
|
}
|
||||||
provider.repositoryOwner = pathParts[1]
|
if cfg.repositoryOwner == pathParts[1] && cfg.repositoryName == pathParts[2] && cfg.githubClient != nil {
|
||||||
provider.repositoryName = pathParts[2]
|
// Already validated, let's skip the rest of the validation to avoid unnecessary API calls
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cfg.repositoryOwner = pathParts[1]
|
||||||
|
cfg.repositoryName = pathParts[2]
|
||||||
// Create oauth2 HTTP client with GitHub token
|
// Create oauth2 HTTP client with GitHub token
|
||||||
httpClientWithStaticTokenSource := oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(&oauth2.Token{
|
httpClientWithStaticTokenSource := oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(&oauth2.Token{
|
||||||
AccessToken: provider.Token,
|
AccessToken: cfg.Token,
|
||||||
}))
|
}))
|
||||||
// Create GitHub client
|
// Create GitHub client
|
||||||
if baseURL == "https://github.com" {
|
if baseURL == "https://github.com" {
|
||||||
provider.githubClient = github.NewClient(httpClientWithStaticTokenSource)
|
cfg.githubClient = github.NewClient(httpClientWithStaticTokenSource)
|
||||||
} else {
|
} else {
|
||||||
provider.githubClient, err = github.NewEnterpriseClient(baseURL, baseURL, httpClientWithStaticTokenSource)
|
cfg.githubClient, err = github.NewEnterpriseClient(baseURL, baseURL, httpClientWithStaticTokenSource)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return fmt.Errorf("failed to create enterprise GitHub client: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Retrieve the username once to validate that the token is valid
|
// Retrieve the username once to validate that the token is valid
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
user, _, err := provider.githubClient.Users.Get(ctx, "")
|
user, _, err := cfg.githubClient.Users.Get(ctx, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return fmt.Errorf("failed to retrieve GitHub user: %w", err)
|
||||||
}
|
}
|
||||||
provider.username = *user.Login
|
cfg.username = *user.Login
|
||||||
return true
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.RepositoryURL) > 0 {
|
||||||
|
cfg.RepositoryURL = override.RepositoryURL
|
||||||
|
}
|
||||||
|
if len(override.Token) > 0 {
|
||||||
|
cfg.Token = override.Token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using Discord
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the provider's configuration
|
||||||
|
func (provider *AlertProvider) Validate() error {
|
||||||
|
return provider.DefaultConfig.Validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
|
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
|
||||||
// or closes the relevant issue(s) if the resolved parameter passed is true.
|
// or closes the relevant issue(s) if the resolved parameter passed is true.
|
||||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
title := "alert(gatus): " + endpoint.DisplayName()
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
title := "alert(gatus): " + ep.DisplayName()
|
||||||
if !resolved {
|
if !resolved {
|
||||||
_, _, err := provider.githubClient.Issues.Create(context.Background(), provider.repositoryOwner, provider.repositoryName, &github.IssueRequest{
|
_, _, err := cfg.githubClient.Issues.Create(context.Background(), cfg.repositoryOwner, cfg.repositoryName, &github.IssueRequest{
|
||||||
Title: github.String(title),
|
Title: github.String(title),
|
||||||
Body: github.String(provider.buildIssueBody(endpoint, alert, result)),
|
Body: github.String(provider.buildIssueBody(ep, alert, result)),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create issue: %w", err)
|
return fmt.Errorf("failed to create issue: %w", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
issues, _, err := provider.githubClient.Issues.ListByRepo(context.Background(), provider.repositoryOwner, provider.repositoryName, &github.IssueListByRepoOptions{
|
issues, _, err := cfg.githubClient.Issues.ListByRepo(context.Background(), cfg.repositoryOwner, cfg.repositoryName, &github.IssueListByRepoOptions{
|
||||||
State: "open",
|
State: "open",
|
||||||
Creator: provider.username,
|
Creator: cfg.username,
|
||||||
ListOptions: github.ListOptions{PerPage: 100},
|
ListOptions: github.ListOptions{PerPage: 100},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -91,7 +127,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
|
|||||||
}
|
}
|
||||||
for _, issue := range issues {
|
for _, issue := range issues {
|
||||||
if *issue.Title == title {
|
if *issue.Title == title {
|
||||||
_, _, err = provider.githubClient.Issues.Edit(context.Background(), provider.repositoryOwner, provider.repositoryName, *issue.Number, &github.IssueRequest{
|
_, _, err = cfg.githubClient.Issues.Edit(context.Background(), cfg.repositoryOwner, cfg.repositoryName, *issue.Number, &github.IssueRequest{
|
||||||
State: github.String("closed"),
|
State: github.String("closed"),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -104,26 +140,51 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildIssueBody builds the body of the issue
|
// buildIssueBody builds the body of the issue
|
||||||
func (provider *AlertProvider) buildIssueBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result) string {
|
func (provider *AlertProvider) buildIssueBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result) string {
|
||||||
var results string
|
var formattedConditionResults string
|
||||||
for _, conditionResult := range result.ConditionResults {
|
if len(result.ConditionResults) > 0 {
|
||||||
var prefix string
|
formattedConditionResults = "\n\n## Condition results\n"
|
||||||
if conditionResult.Success {
|
for _, conditionResult := range result.ConditionResults {
|
||||||
prefix = ":white_check_mark:"
|
var prefix string
|
||||||
} else {
|
if conditionResult.Success {
|
||||||
prefix = ":x:"
|
prefix = ":white_check_mark:"
|
||||||
|
} else {
|
||||||
|
prefix = ":x:"
|
||||||
|
}
|
||||||
|
formattedConditionResults += fmt.Sprintf("- %s - `%s`\n", prefix, conditionResult.Condition)
|
||||||
}
|
}
|
||||||
results += fmt.Sprintf("- %s - `%s`\n", prefix, conditionResult.Condition)
|
|
||||||
}
|
}
|
||||||
var description string
|
var description string
|
||||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
description = ":\n> " + alertDescription
|
description = ":\n> " + alertDescription
|
||||||
}
|
}
|
||||||
message := fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
message := fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||||
return message + description + "\n\n## Condition results\n" + results
|
return message + description + formattedConditionResults
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration (we're returning the cfg here even if there's an error mostly for testing purposes)
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,47 +7,51 @@ import (
|
|||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/TwiN/gatus/v5/test"
|
"github.com/TwiN/gatus/v5/test"
|
||||||
"github.com/google/go-github/v48/github"
|
"github.com/google/go-github/v48/github"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
Name string
|
Name string
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
Expected bool
|
ExpectedError bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "invalid",
|
Name: "invalid",
|
||||||
Provider: AlertProvider{RepositoryURL: "", Token: ""},
|
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "", Token: ""}},
|
||||||
Expected: false,
|
ExpectedError: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "invalid-token",
|
Name: "invalid-token",
|
||||||
Provider: AlertProvider{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"},
|
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"}},
|
||||||
Expected: false,
|
ExpectedError: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "missing-repository-name",
|
Name: "missing-repository-name",
|
||||||
Provider: AlertProvider{RepositoryURL: "https://github.com/TwiN", Token: "12345"},
|
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN", Token: "12345"}},
|
||||||
Expected: false,
|
ExpectedError: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "enterprise-client",
|
Name: "enterprise-client",
|
||||||
Provider: AlertProvider{RepositoryURL: "https://github.example.com/TwiN/test", Token: "12345"},
|
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.example.com/TwiN/test", Token: "12345"}},
|
||||||
Expected: false,
|
ExpectedError: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "invalid-url",
|
Name: "invalid-url",
|
||||||
Provider: AlertProvider{RepositoryURL: "github.com/TwiN/test", Token: "12345"},
|
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "github.com/TwiN/test", Token: "12345"}},
|
||||||
Expected: false,
|
ExpectedError: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
if scenario.Provider.IsValid() != scenario.Expected {
|
err := scenario.Provider.Validate()
|
||||||
t.Errorf("expected %t, got %t", scenario.Expected, scenario.Provider.IsValid())
|
if scenario.ExpectedError && err == nil {
|
||||||
|
t.Error("expected error, got none")
|
||||||
|
}
|
||||||
|
if !scenario.ExpectedError && err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -67,14 +71,14 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered-error",
|
Name: "triggered-error",
|
||||||
Provider: AlertProvider{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"},
|
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedError: true,
|
ExpectedError: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved-error",
|
Name: "resolved-error",
|
||||||
Provider: AlertProvider{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"},
|
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedError: true,
|
ExpectedError: true,
|
||||||
@@ -82,13 +86,17 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
scenario.Provider.githubClient = github.NewClient(nil)
|
cfg, err := scenario.Provider.GetConfig("", &scenario.Alert)
|
||||||
|
if err != nil && !strings.Contains(err.Error(), "failed to retrieve GitHub user") && !strings.Contains(err.Error(), "no such host") {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
|
cfg.githubClient = github.NewClient(nil)
|
||||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
err := scenario.Provider.Send(
|
err = scenario.Provider.Send(
|
||||||
&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
},
|
},
|
||||||
@@ -109,37 +117,48 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
firstDescription := "description-1"
|
firstDescription := "description-1"
|
||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
Name string
|
Name string
|
||||||
Endpoint core.Endpoint
|
Endpoint endpoint.Endpoint
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
Alert alert.Alert
|
Alert alert.Alert
|
||||||
|
NoConditions bool
|
||||||
ExpectedBody string
|
ExpectedBody string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
|
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{},
|
||||||
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3},
|
||||||
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\n> description-1\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`",
|
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\n> description-1\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "no-description",
|
Name: "triggered-with-no-description",
|
||||||
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
|
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{},
|
||||||
Alert: alert.Alert{FailureThreshold: 10},
|
Alert: alert.Alert{FailureThreshold: 10},
|
||||||
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`",
|
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-with-no-conditions",
|
||||||
|
NoConditions: true,
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 10},
|
||||||
|
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row:\n> description-1",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
var conditionResults []*endpoint.ConditionResult
|
||||||
|
if !scenario.NoConditions {
|
||||||
|
conditionResults = []*endpoint.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: true},
|
||||||
|
{Condition: "[STATUS] == 200", Success: false},
|
||||||
|
}
|
||||||
|
}
|
||||||
body := scenario.Provider.buildIssueBody(
|
body := scenario.Provider.buildIssueBody(
|
||||||
&scenario.Endpoint,
|
&scenario.Endpoint,
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{ConditionResults: conditionResults},
|
||||||
ConditionResults: []*core.ConditionResult{
|
|
||||||
{Condition: "[CONNECTED] == true", Success: true},
|
|
||||||
{Condition: "[STATUS] == 200", Success: false},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
if strings.TrimSpace(body) != strings.TrimSpace(scenario.ExpectedBody) {
|
if strings.TrimSpace(body) != strings.TrimSpace(scenario.ExpectedBody) {
|
||||||
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
||||||
@@ -149,10 +168,54 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
t.Error("expected default alert to be not nil")
|
t.Error("expected default alert to be not nil")
|
||||||
}
|
}
|
||||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
t.Error("expected default alert to be nil")
|
t.Error("expected default alert to be nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"},
|
||||||
|
},
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-alert-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"},
|
||||||
|
},
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"repository-url": "https://github.com/TwiN/alert-test", "token": "54321"}},
|
||||||
|
ExpectedOutput: Config{RepositoryURL: "https://github.com/TwiN/alert-test", Token: "54321"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
|
||||||
|
if err != nil && !strings.Contains(err.Error(), "failed to retrieve GitHub user") && !strings.Contains(err.Error(), "no such host") {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.RepositoryURL != scenario.ExpectedOutput.RepositoryURL {
|
||||||
|
t.Errorf("expected repository URL %s, got %s", scenario.ExpectedOutput.RepositoryURL, got.RepositoryURL)
|
||||||
|
}
|
||||||
|
if got.Token != scenario.ExpectedOutput.Token {
|
||||||
|
t.Errorf("expected token %s, got %s", scenario.ExpectedOutput.Token, got.Token)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil && !strings.Contains(err.Error(), "failed to retrieve GitHub user") {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,55 +11,99 @@ import (
|
|||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultSeverity = "critical"
|
||||||
|
DefaultMonitoringTool = "gatus"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidWebhookURL = fmt.Errorf("invalid webhook-url")
|
||||||
|
ErrAuthorizationKeyNotSet = fmt.Errorf("authorization-key not set")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
WebhookURL string `yaml:"webhook-url"` // The webhook url provided by GitLab
|
||||||
|
AuthorizationKey string `yaml:"authorization-key"` // The authorization key provided by GitLab
|
||||||
|
Severity string `yaml:"severity,omitempty"` // Severity can be one of: critical, high, medium, low, info, unknown. Defaults to critical
|
||||||
|
MonitoringTool string `yaml:"monitoring-tool,omitempty"` // MonitoringTool overrides the name sent to gitlab. Defaults to gatus
|
||||||
|
EnvironmentName string `yaml:"environment-name,omitempty"` // EnvironmentName is the name of the associated GitLab environment. Required to display alerts on a dashboard.
|
||||||
|
Service string `yaml:"service,omitempty"` // Service affected. Defaults to the endpoint's display name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.WebhookURL) == 0 {
|
||||||
|
return ErrInvalidWebhookURL
|
||||||
|
} else if _, err := url.Parse(cfg.WebhookURL); err != nil {
|
||||||
|
return ErrInvalidWebhookURL
|
||||||
|
}
|
||||||
|
if len(cfg.AuthorizationKey) == 0 {
|
||||||
|
return ErrAuthorizationKeyNotSet
|
||||||
|
}
|
||||||
|
if len(cfg.Severity) == 0 {
|
||||||
|
cfg.Severity = DefaultSeverity
|
||||||
|
}
|
||||||
|
if len(cfg.MonitoringTool) == 0 {
|
||||||
|
cfg.MonitoringTool = DefaultMonitoringTool
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.WebhookURL) > 0 {
|
||||||
|
cfg.WebhookURL = override.WebhookURL
|
||||||
|
}
|
||||||
|
if len(override.AuthorizationKey) > 0 {
|
||||||
|
cfg.AuthorizationKey = override.AuthorizationKey
|
||||||
|
}
|
||||||
|
if len(override.Severity) > 0 {
|
||||||
|
cfg.Severity = override.Severity
|
||||||
|
}
|
||||||
|
if len(override.MonitoringTool) > 0 {
|
||||||
|
cfg.MonitoringTool = override.MonitoringTool
|
||||||
|
}
|
||||||
|
if len(override.EnvironmentName) > 0 {
|
||||||
|
cfg.EnvironmentName = override.EnvironmentName
|
||||||
|
}
|
||||||
|
if len(override.Service) > 0 {
|
||||||
|
cfg.Service = override.Service
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using GitLab
|
// AlertProvider is the configuration necessary for sending an alert using GitLab
|
||||||
type AlertProvider struct {
|
type AlertProvider struct {
|
||||||
WebhookURL string `yaml:"webhook-url"` // The webhook url provided by GitLab
|
DefaultConfig Config `yaml:",inline"`
|
||||||
AuthorizationKey string `yaml:"authorization-key"` // The authorization key provided by GitLab
|
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
// Severity can be one of: critical, high, medium, low, info, unknown. Defaults to critical
|
|
||||||
Severity string `yaml:"severity,omitempty"`
|
|
||||||
// MonitoringTool overrides the name sent to gitlab. Defaults to gatus
|
|
||||||
MonitoringTool string `yaml:"monitoring-tool,omitempty"`
|
|
||||||
// EnvironmentName is the name of the associated GitLab environment. Required to display alerts on a dashboard.
|
|
||||||
EnvironmentName string `yaml:"environment-name,omitempty"`
|
|
||||||
// Service affected. Defaults to endpoint display name
|
|
||||||
Service string `yaml:"service,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// Validate the provider's configuration
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
func (provider *AlertProvider) Validate() error {
|
||||||
if len(provider.AuthorizationKey) == 0 || len(provider.WebhookURL) == 0 {
|
return provider.DefaultConfig.Validate()
|
||||||
return false
|
|
||||||
}
|
|
||||||
// Validate format of the repository URL
|
|
||||||
_, err := url.Parse(provider.WebhookURL)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
|
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
|
||||||
// or closes the relevant issue(s) if the resolved parameter passed is true.
|
// or closes the relevant issue(s) if the resolved parameter passed is true.
|
||||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if len(alert.ResolveKey) == 0 {
|
if len(alert.ResolveKey) == 0 {
|
||||||
alert.ResolveKey = uuid.NewString()
|
alert.ResolveKey = uuid.NewString()
|
||||||
}
|
}
|
||||||
|
buffer := bytes.NewBuffer(provider.buildAlertBody(cfg, ep, alert, result, resolved))
|
||||||
buffer := bytes.NewBuffer(provider.buildAlertBody(endpoint, alert, result, resolved))
|
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
|
||||||
request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
request.Header.Set("Content-Type", "application/json")
|
request.Header.Set("Content-Type", "application/json")
|
||||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", provider.AuthorizationKey))
|
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.AuthorizationKey))
|
||||||
response, err := client.GetHTTPClient(nil).Do(request)
|
response, err := client.GetHTTPClient(nil).Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -85,45 +129,37 @@ type AlertBody struct {
|
|||||||
GitlabEnvironmentName string `json:"gitlab_environment_name,omitempty"` // The name of the associated GitLab environment. Required to display alerts on a dashboard.
|
GitlabEnvironmentName string `json:"gitlab_environment_name,omitempty"` // The name of the associated GitLab environment. Required to display alerts on a dashboard.
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *AlertProvider) monitoringTool() string {
|
|
||||||
if len(provider.MonitoringTool) > 0 {
|
|
||||||
return provider.MonitoringTool
|
|
||||||
}
|
|
||||||
return "gatus"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (provider *AlertProvider) service(endpoint *core.Endpoint) string {
|
|
||||||
if len(provider.Service) > 0 {
|
|
||||||
return provider.Service
|
|
||||||
}
|
|
||||||
return endpoint.DisplayName()
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildAlertBody builds the body of the alert
|
// buildAlertBody builds the body of the alert
|
||||||
func (provider *AlertProvider) buildAlertBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
|
func (provider *AlertProvider) buildAlertBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
|
service := cfg.Service
|
||||||
|
if len(service) == 0 {
|
||||||
|
service = ep.DisplayName()
|
||||||
|
}
|
||||||
body := AlertBody{
|
body := AlertBody{
|
||||||
Title: fmt.Sprintf("alert(%s): %s", provider.monitoringTool(), provider.service(endpoint)),
|
Title: fmt.Sprintf("alert(%s): %s", cfg.MonitoringTool, service),
|
||||||
StartTime: result.Timestamp.Format(time.RFC3339),
|
StartTime: result.Timestamp.Format(time.RFC3339),
|
||||||
Service: provider.service(endpoint),
|
Service: service,
|
||||||
MonitoringTool: provider.monitoringTool(),
|
MonitoringTool: cfg.MonitoringTool,
|
||||||
Hosts: endpoint.URL,
|
Hosts: ep.URL,
|
||||||
GitlabEnvironmentName: provider.EnvironmentName,
|
GitlabEnvironmentName: cfg.EnvironmentName,
|
||||||
Severity: provider.Severity,
|
Severity: cfg.Severity,
|
||||||
Fingerprint: alert.ResolveKey,
|
Fingerprint: alert.ResolveKey,
|
||||||
}
|
}
|
||||||
if resolved {
|
if resolved {
|
||||||
body.EndTime = result.Timestamp.Format(time.RFC3339)
|
body.EndTime = result.Timestamp.Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
|
var formattedConditionResults string
|
||||||
var results string
|
if len(result.ConditionResults) > 0 {
|
||||||
for _, conditionResult := range result.ConditionResults {
|
formattedConditionResults = "\n\n## Condition results\n"
|
||||||
var prefix string
|
for _, conditionResult := range result.ConditionResults {
|
||||||
if conditionResult.Success {
|
var prefix string
|
||||||
prefix = ":white_check_mark:"
|
if conditionResult.Success {
|
||||||
} else {
|
prefix = ":white_check_mark:"
|
||||||
prefix = ":x:"
|
} else {
|
||||||
|
prefix = ":x:"
|
||||||
|
}
|
||||||
|
formattedConditionResults += fmt.Sprintf("- %s - `%s`\n", prefix, conditionResult.Condition)
|
||||||
}
|
}
|
||||||
results += fmt.Sprintf("- %s - `%s`\n", prefix, conditionResult.Condition)
|
|
||||||
}
|
}
|
||||||
var description string
|
var description string
|
||||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
@@ -131,17 +167,38 @@ func (provider *AlertProvider) buildAlertBody(endpoint *core.Endpoint, alert *al
|
|||||||
}
|
}
|
||||||
var message string
|
var message string
|
||||||
if resolved {
|
if resolved {
|
||||||
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
|
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||||
} else {
|
} else {
|
||||||
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||||
}
|
}
|
||||||
body.Description = message + description + "\n\n## Condition results\n" + results
|
body.Description = message + description + formattedConditionResults
|
||||||
|
bodyAsJSON, _ := json.Marshal(body)
|
||||||
json, _ := json.Marshal(body)
|
return bodyAsJSON
|
||||||
return json
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration (we're returning the cfg here even if there's an error mostly for testing purposes)
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,41 +7,45 @@ import (
|
|||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/TwiN/gatus/v5/test"
|
"github.com/TwiN/gatus/v5/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
Name string
|
Name string
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
Expected bool
|
ExpectedError bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "invalid",
|
Name: "invalid",
|
||||||
Provider: AlertProvider{WebhookURL: "", AuthorizationKey: ""},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "", AuthorizationKey: ""}},
|
||||||
Expected: false,
|
ExpectedError: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "missing-webhook-url",
|
Name: "missing-webhook-url",
|
||||||
Provider: AlertProvider{WebhookURL: "", AuthorizationKey: "12345"},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "", AuthorizationKey: "12345"}},
|
||||||
Expected: false,
|
ExpectedError: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "missing-authorization-key",
|
Name: "missing-authorization-key",
|
||||||
Provider: AlertProvider{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: ""},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/whatever/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: ""}},
|
||||||
Expected: false,
|
ExpectedError: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "invalid-url",
|
Name: "invalid-url",
|
||||||
Provider: AlertProvider{WebhookURL: " http://foo.com", AuthorizationKey: "12345"},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: " http://foo.com", AuthorizationKey: "12345"}},
|
||||||
Expected: false,
|
ExpectedError: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
if scenario.Provider.IsValid() != scenario.Expected {
|
err := scenario.Provider.Validate()
|
||||||
t.Errorf("expected %t, got %t", scenario.Expected, scenario.Provider.IsValid())
|
if scenario.ExpectedError && err == nil {
|
||||||
|
t.Error("expected error, got none")
|
||||||
|
}
|
||||||
|
if !scenario.ExpectedError && err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -61,7 +65,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered-error",
|
Name: "triggered-error",
|
||||||
Provider: AlertProvider{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedError: false,
|
ExpectedError: false,
|
||||||
@@ -71,7 +75,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved-error",
|
Name: "resolved-error",
|
||||||
Provider: AlertProvider{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedError: false,
|
ExpectedError: false,
|
||||||
@@ -84,10 +88,10 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
err := scenario.Provider.Send(
|
err := scenario.Provider.Send(
|
||||||
&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
},
|
},
|
||||||
@@ -108,33 +112,38 @@ func TestAlertProvider_buildAlertBody(t *testing.T) {
|
|||||||
firstDescription := "description-1"
|
firstDescription := "description-1"
|
||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
Name string
|
Name string
|
||||||
Endpoint core.Endpoint
|
Endpoint endpoint.Endpoint
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
Alert alert.Alert
|
Alert alert.Alert
|
||||||
ExpectedBody string
|
ExpectedBody string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
|
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3},
|
||||||
ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\"}",
|
ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\",\"severity\":\"critical\"}",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "no-description",
|
Name: "no-description",
|
||||||
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
|
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"}},
|
||||||
Alert: alert.Alert{FailureThreshold: 10},
|
Alert: alert.Alert{FailureThreshold: 10},
|
||||||
ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 10 time(s) in a row\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\"}",
|
ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 10 time(s) in a row\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\",\"severity\":\"critical\"}",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
cfg, err := scenario.Provider.GetConfig("", &scenario.Alert)
|
||||||
|
if err != nil {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
body := scenario.Provider.buildAlertBody(
|
body := scenario.Provider.buildAlertBody(
|
||||||
|
cfg,
|
||||||
&scenario.Endpoint,
|
&scenario.Endpoint,
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[CONNECTED] == true", Success: true},
|
{Condition: "[CONNECTED] == true", Success: true},
|
||||||
{Condition: "[STATUS] == 200", Success: false},
|
{Condition: "[STATUS] == 200", Success: false},
|
||||||
},
|
},
|
||||||
@@ -149,10 +158,66 @@ func TestAlertProvider_buildAlertBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
t.Error("expected default alert to be not nil")
|
t.Error("expected default alert to be not nil")
|
||||||
}
|
}
|
||||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
t.Error("expected default alert to be nil")
|
t.Error("expected default alert to be nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{WebhookURL: "https://github.com/TwiN/test", AuthorizationKey: "12345"},
|
||||||
|
},
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "https://github.com/TwiN/test", AuthorizationKey: "12345", Severity: DefaultSeverity, MonitoringTool: DefaultMonitoringTool},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-alert-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{WebhookURL: "https://github.com/TwiN/test", AuthorizationKey: "12345"},
|
||||||
|
},
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"repository-url": "https://github.com/TwiN/alert-test", "authorization-key": "54321", "severity": "info", "monitoring-tool": "not-gatus", "environment-name": "prod", "service": "example"}},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "https://github.com/TwiN/test", AuthorizationKey: "54321", Severity: "info", MonitoringTool: "not-gatus", EnvironmentName: "prod", Service: "example"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
|
||||||
|
if err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
|
||||||
|
t.Errorf("expected repository URL %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
|
||||||
|
}
|
||||||
|
if got.AuthorizationKey != scenario.ExpectedOutput.AuthorizationKey {
|
||||||
|
t.Errorf("expected AuthorizationKey %s, got %s", scenario.ExpectedOutput.AuthorizationKey, got.AuthorizationKey)
|
||||||
|
}
|
||||||
|
if got.Severity != scenario.ExpectedOutput.Severity {
|
||||||
|
t.Errorf("expected Severity %s, got %s", scenario.ExpectedOutput.Severity, got.Severity)
|
||||||
|
}
|
||||||
|
if got.MonitoringTool != scenario.ExpectedOutput.MonitoringTool {
|
||||||
|
t.Errorf("expected MonitoringTool %s, got %s", scenario.ExpectedOutput.MonitoringTool, got.MonitoringTool)
|
||||||
|
}
|
||||||
|
if got.EnvironmentName != scenario.ExpectedOutput.EnvironmentName {
|
||||||
|
t.Errorf("expected EnvironmentName %s, got %s", scenario.ExpectedOutput.EnvironmentName, got.EnvironmentName)
|
||||||
|
}
|
||||||
|
if got.Service != scenario.ExpectedOutput.Service {
|
||||||
|
t.Errorf("expected Service %s, got %s", scenario.ExpectedOutput.Service, got.Service)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,21 +3,46 @@ package googlechat
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrWebhookURLNotSet = errors.New("webhook-url not set")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
WebhookURL string `yaml:"webhook-url"`
|
||||||
|
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.WebhookURL) == 0 {
|
||||||
|
return ErrWebhookURLNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if override.ClientConfig != nil {
|
||||||
|
cfg.ClientConfig = override.ClientConfig
|
||||||
|
}
|
||||||
|
if len(override.WebhookURL) > 0 {
|
||||||
|
cfg.WebhookURL = override.WebhookURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Google chat
|
// AlertProvider is the configuration necessary for sending an alert using Google chat
|
||||||
type AlertProvider struct {
|
type AlertProvider struct {
|
||||||
WebhookURL string `yaml:"webhook-url"`
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
|
||||||
ClientConfig *client.Config `yaml:"client,omitempty"`
|
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
@@ -28,36 +53,37 @@ type AlertProvider struct {
|
|||||||
|
|
||||||
// Override is a case under which the default integration is overridden
|
// Override is a case under which the default integration is overridden
|
||||||
type Override struct {
|
type Override struct {
|
||||||
Group string `yaml:"group"`
|
Group string `yaml:"group"`
|
||||||
WebhookURL string `yaml:"webhook-url"`
|
Config `yaml:",inline"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// Validate the provider's configuration
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
func (provider *AlertProvider) Validate() error {
|
||||||
if provider.ClientConfig == nil {
|
|
||||||
provider.ClientConfig = client.GetDefaultConfig()
|
|
||||||
}
|
|
||||||
registeredGroups := make(map[string]bool)
|
registeredGroups := make(map[string]bool)
|
||||||
if provider.Overrides != nil {
|
if provider.Overrides != nil {
|
||||||
for _, override := range provider.Overrides {
|
for _, override := range provider.Overrides {
|
||||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
|
||||||
return false
|
return ErrDuplicateGroupOverride
|
||||||
}
|
}
|
||||||
registeredGroups[override.Group] = true
|
registeredGroups[override.Group] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return len(provider.WebhookURL) > 0
|
return provider.DefaultConfig.Validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
||||||
|
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
request.Header.Set("Content-Type", "application/json")
|
request.Header.Set("Content-Type", "application/json")
|
||||||
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
|
response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -112,7 +138,7 @@ type OpenLink struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildRequestBody builds the request body for the provider
|
// buildRequestBody builds the request body for the provider
|
||||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
|
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
var message, color string
|
var message, color string
|
||||||
if resolved {
|
if resolved {
|
||||||
color = "#36A64F"
|
color = "#36A64F"
|
||||||
@@ -121,7 +147,7 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
|
|||||||
color = "#DD0000"
|
color = "#DD0000"
|
||||||
message = fmt.Sprintf("<font color='%s'>An alert has been triggered due to having failed %d time(s) in a row</font>", color, alert.FailureThreshold)
|
message = fmt.Sprintf("<font color='%s'>An alert has been triggered due to having failed %d time(s) in a row</font>", color, alert.FailureThreshold)
|
||||||
}
|
}
|
||||||
var results string
|
var formattedConditionResults string
|
||||||
for _, conditionResult := range result.ConditionResults {
|
for _, conditionResult := range result.ConditionResults {
|
||||||
var prefix string
|
var prefix string
|
||||||
if conditionResult.Success {
|
if conditionResult.Success {
|
||||||
@@ -129,7 +155,7 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
|
|||||||
} else {
|
} else {
|
||||||
prefix = "❌"
|
prefix = "❌"
|
||||||
}
|
}
|
||||||
results += fmt.Sprintf("%s %s<br>", prefix, conditionResult.Condition)
|
formattedConditionResults += fmt.Sprintf("%s %s<br>", prefix, conditionResult.Condition)
|
||||||
}
|
}
|
||||||
var description string
|
var description string
|
||||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
@@ -143,28 +169,30 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
|
|||||||
Widgets: []Widgets{
|
Widgets: []Widgets{
|
||||||
{
|
{
|
||||||
KeyValue: &KeyValue{
|
KeyValue: &KeyValue{
|
||||||
TopLabel: endpoint.DisplayName(),
|
TopLabel: ep.DisplayName(),
|
||||||
Content: message,
|
Content: message,
|
||||||
ContentMultiline: "true",
|
ContentMultiline: "true",
|
||||||
BottomLabel: description,
|
BottomLabel: description,
|
||||||
Icon: "BOOKMARK",
|
Icon: "BOOKMARK",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
KeyValue: &KeyValue{
|
|
||||||
TopLabel: "Condition results",
|
|
||||||
Content: results,
|
|
||||||
ContentMultiline: "true",
|
|
||||||
Icon: "DESCRIPTION",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if endpoint.Type() == core.EndpointTypeHTTP {
|
if len(formattedConditionResults) > 0 {
|
||||||
|
payload.Cards[0].Sections[0].Widgets = append(payload.Cards[0].Sections[0].Widgets, Widgets{
|
||||||
|
KeyValue: &KeyValue{
|
||||||
|
TopLabel: "Condition results",
|
||||||
|
Content: formattedConditionResults,
|
||||||
|
ContentMultiline: "true",
|
||||||
|
Icon: "DESCRIPTION",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if ep.Type() == endpoint.TypeHTTP {
|
||||||
// We only include a button targeting the URL if the endpoint is an HTTP endpoint
|
// We only include a button targeting the URL if the endpoint is an HTTP endpoint
|
||||||
// If the URL isn't prefixed with https://, Google Chat will just display a blank message aynways.
|
// If the URL isn't prefixed with https://, Google Chat will just display a blank message aynways.
|
||||||
// See https://github.com/TwiN/gatus/issues/362
|
// See https://github.com/TwiN/gatus/issues/362
|
||||||
@@ -173,29 +201,48 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
|
|||||||
{
|
{
|
||||||
TextButton: TextButton{
|
TextButton: TextButton{
|
||||||
Text: "URL",
|
Text: "URL",
|
||||||
OnClick: OnClick{OpenLink: OpenLink{URL: endpoint.URL}},
|
OnClick: OnClick{OpenLink: OpenLink{URL: ep.URL}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(payload)
|
bodyAsJSON, _ := json.Marshal(payload)
|
||||||
return body
|
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
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,54 +7,54 @@ import (
|
|||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/TwiN/gatus/v5/test"
|
"github.com/TwiN/gatus/v5/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
invalidProvider := AlertProvider{WebhookURL: ""}
|
invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
|
||||||
if invalidProvider.IsValid() {
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
t.Error("provider shouldn't have been valid")
|
t.Error("provider shouldn't have been valid")
|
||||||
}
|
}
|
||||||
validProvider := AlertProvider{WebhookURL: "http://example.com"}
|
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
|
||||||
if !validProvider.IsValid() {
|
if err := validProvider.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||||
providerWithInvalidOverrideGroup := AlertProvider{
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
WebhookURL: "http://example.com",
|
Config: Config{WebhookURL: "http://example.com"},
|
||||||
Group: "",
|
Group: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if providerWithInvalidOverrideGroup.IsValid() {
|
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||||
t.Error("provider Group shouldn't have been valid")
|
t.Error("provider Group shouldn't have been valid")
|
||||||
}
|
}
|
||||||
providerWithInvalidOverrideTo := AlertProvider{
|
providerWithInvalidOverrideTo := AlertProvider{
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
WebhookURL: "",
|
Config: Config{WebhookURL: ""},
|
||||||
Group: "group",
|
Group: "group",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if providerWithInvalidOverrideTo.IsValid() {
|
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
|
||||||
t.Error("provider integration key shouldn't have been valid")
|
t.Error("provider integration key shouldn't have been valid")
|
||||||
}
|
}
|
||||||
providerWithValidOverride := AlertProvider{
|
providerWithValidOverride := AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
WebhookURL: "http://example.com",
|
Config: Config{WebhookURL: "http://example.com"},
|
||||||
Group: "group",
|
Group: "group",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if !providerWithValidOverride.IsValid() {
|
if err := providerWithValidOverride.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -83,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "triggered-error",
|
Name: "triggered-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -93,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -103,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved-error",
|
Name: "resolved-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -116,10 +116,10 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
err := scenario.Provider.Send(
|
err := scenario.Provider.Send(
|
||||||
&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
},
|
},
|
||||||
@@ -141,7 +141,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
secondDescription := "description-2"
|
secondDescription := "description-2"
|
||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
Name string
|
Name string
|
||||||
Endpoint core.Endpoint
|
Endpoint endpoint.Endpoint
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
Alert alert.Alert
|
Alert alert.Alert
|
||||||
Resolved bool
|
Resolved bool
|
||||||
@@ -149,7 +149,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
|
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
@@ -157,7 +157,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
|
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
@@ -165,7 +165,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "icmp-should-not-include-url", // See https://github.com/TwiN/gatus/issues/362
|
Name: "icmp-should-not-include-url", // See https://github.com/TwiN/gatus/issues/362
|
||||||
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "icmp://example.org"},
|
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "icmp://example.org"},
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
@@ -173,7 +173,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "tcp-should-not-include-url", // See https://github.com/TwiN/gatus/issues/362
|
Name: "tcp-should-not-include-url", // See https://github.com/TwiN/gatus/issues/362
|
||||||
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "tcp://example.org"},
|
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "tcp://example.org"},
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
@@ -185,8 +185,8 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
body := scenario.Provider.buildRequestBody(
|
body := scenario.Provider.buildRequestBody(
|
||||||
&scenario.Endpoint,
|
&scenario.Endpoint,
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
},
|
},
|
||||||
@@ -205,72 +205,100 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
t.Error("expected default alert to be not nil")
|
t.Error("expected default alert to be not nil")
|
||||||
}
|
}
|
||||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
t.Error("expected default alert to be nil")
|
t.Error("expected default alert to be nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
tests := []struct {
|
scenarios := []struct {
|
||||||
Name string
|
Name string
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
InputGroup string
|
InputGroup string
|
||||||
ExpectedOutput string
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "provider-no-override-specify-no-group-should-default",
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: nil,
|
Overrides: nil,
|
||||||
},
|
},
|
||||||
InputGroup: "",
|
InputGroup: "",
|
||||||
ExpectedOutput: "http://example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-no-override-specify-group-should-default",
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: nil,
|
Overrides: nil,
|
||||||
},
|
},
|
||||||
InputGroup: "group",
|
InputGroup: "group",
|
||||||
ExpectedOutput: "http://example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-with-override-specify-no-group-should-default",
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
WebhookURL: "http://example01.com",
|
Config: Config{WebhookURL: "http://example01.com"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InputGroup: "",
|
InputGroup: "",
|
||||||
ExpectedOutput: "http://example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-with-override-specify-group-should-override",
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
WebhookURL: "http://example01.com",
|
Config: Config{WebhookURL: "http://group-example.com"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InputGroup: "group",
|
InputGroup: "group",
|
||||||
ExpectedOutput: "http://example01.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://group-example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{WebhookURL: "http://group-example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, scenario := range scenarios {
|
||||||
t.Run(tt.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
|
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||||
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
|
||||||
|
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
161
alerting/provider/gotify/gotify.go
Normal file
161
alerting/provider/gotify/gotify.go
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
package gotify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultPriority = 5
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrServerURLNotSet = errors.New("server URL not set")
|
||||||
|
ErrTokenNotSet = errors.New("token not set")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
ServerURL string `yaml:"server-url"` // URL of the Gotify server
|
||||||
|
Token string `yaml:"token"` // Token to use when sending a message to the Gotify server
|
||||||
|
Priority int `yaml:"priority,omitempty"` // Priority of the message. Defaults to DefaultPriority.
|
||||||
|
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if cfg.Priority == 0 {
|
||||||
|
cfg.Priority = DefaultPriority
|
||||||
|
}
|
||||||
|
if len(cfg.ServerURL) == 0 {
|
||||||
|
return ErrServerURLNotSet
|
||||||
|
}
|
||||||
|
if len(cfg.Token) == 0 {
|
||||||
|
return ErrTokenNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.ServerURL) > 0 {
|
||||||
|
cfg.ServerURL = override.ServerURL
|
||||||
|
}
|
||||||
|
if len(override.Token) > 0 {
|
||||||
|
cfg.Token = override.Token
|
||||||
|
}
|
||||||
|
if override.Priority != 0 {
|
||||||
|
cfg.Priority = override.Priority
|
||||||
|
}
|
||||||
|
if len(override.Title) > 0 {
|
||||||
|
cfg.Title = override.Title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using Gotify
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the provider's configuration
|
||||||
|
func (provider *AlertProvider) Validate() error {
|
||||||
|
return provider.DefaultConfig.Validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send an alert using the provider
|
||||||
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||||
|
request, err := http.NewRequest(http.MethodPost, cfg.ServerURL+"/message?token="+cfg.Token, 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("failed to send alert to Gotify: %s", string(body))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Body struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestBody builds the request body for the provider
|
||||||
|
func (provider *AlertProvider) buildRequestBody(cfg *Config, 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)
|
||||||
|
}
|
||||||
|
var formattedConditionResults string
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
var prefix string
|
||||||
|
if conditionResult.Success {
|
||||||
|
prefix = "✓"
|
||||||
|
} else {
|
||||||
|
prefix = "✕"
|
||||||
|
}
|
||||||
|
formattedConditionResults += fmt.Sprintf("\n%s - %s", prefix, conditionResult.Condition)
|
||||||
|
}
|
||||||
|
if len(alert.GetDescription()) > 0 {
|
||||||
|
message += " with the following description: " + alert.GetDescription()
|
||||||
|
}
|
||||||
|
message += formattedConditionResults
|
||||||
|
title := "Gatus: " + ep.DisplayName()
|
||||||
|
if cfg.Title != "" {
|
||||||
|
title = cfg.Title
|
||||||
|
}
|
||||||
|
bodyAsJSON, _ := json.Marshal(Body{
|
||||||
|
Message: message,
|
||||||
|
Title: title,
|
||||||
|
Priority: cfg.Priority,
|
||||||
|
})
|
||||||
|
return bodyAsJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
|
return provider.DefaultAlert
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
163
alerting/provider/gotify/gotify_test.go
Normal file
163
alerting/provider/gotify/gotify_test.go
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
package gotify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
provider AlertProvider
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "faketoken"}},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-server-url",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{ServerURL: "", Token: "faketoken"}},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-app-token",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: ""}},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no-priority-should-use-default-value",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "faketoken"}},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
|
if err := scenario.provider.Validate(); (err == nil) != scenario.expected {
|
||||||
|
t.Errorf("expected: %t, got: %t", scenario.expected, err == nil)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||||
|
var (
|
||||||
|
description = "custom-description"
|
||||||
|
//title = "custom-title"
|
||||||
|
endpointName = "custom-endpoint"
|
||||||
|
)
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
ExpectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "faketoken"}},
|
||||||
|
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been triggered due to having failed 3 time(s) in a row with the following description: %s\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpointName, description),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "faketoken"}},
|
||||||
|
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been resolved after passing successfully 5 time(s) in a row with the following description: %s\\n✓ - [CONNECTED] == true\\n✓ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpointName, description),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "custom-title",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "faketoken", Title: "custom-title"}},
|
||||||
|
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been triggered due to having failed 3 time(s) in a row with the following description: %s\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"title\":\"custom-title\",\"priority\":0}", endpointName, description),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
body := scenario.Provider.buildRequestBody(
|
||||||
|
&scenario.Provider.DefaultConfig,
|
||||||
|
&endpoint.Endpoint{Name: endpointName},
|
||||||
|
&scenario.Alert,
|
||||||
|
&endpoint.Result{
|
||||||
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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) {
|
||||||
|
provider := AlertProvider{DefaultAlert: &alert.Alert{}}
|
||||||
|
if provider.GetDefaultAlert() != provider.DefaultAlert {
|
||||||
|
t.Error("expected default alert to be returned")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "12345"},
|
||||||
|
},
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{ServerURL: "https://gotify.example.com", Token: "12345", Priority: DefaultPriority},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-alert-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "12345"},
|
||||||
|
},
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"server-url": "https://gotify.group-example.com", "token": "54321", "title": "alert-title", "priority": 3}},
|
||||||
|
ExpectedOutput: Config{ServerURL: "https://gotify.group-example.com", Token: "54321", Title: "alert-title", Priority: 3},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
|
||||||
|
if err != nil {
|
||||||
|
t.Error("expected no error, got:", err.Error())
|
||||||
|
}
|
||||||
|
if got.ServerURL != scenario.ExpectedOutput.ServerURL {
|
||||||
|
t.Errorf("expected server URL to be %s, got %s", scenario.ExpectedOutput.ServerURL, got.ServerURL)
|
||||||
|
}
|
||||||
|
if got.Token != scenario.ExpectedOutput.Token {
|
||||||
|
t.Errorf("expected token to be %s, got %s", scenario.ExpectedOutput.Token, got.Token)
|
||||||
|
}
|
||||||
|
if got.Title != scenario.ExpectedOutput.Title {
|
||||||
|
t.Errorf("expected title to be %s, got %s", scenario.ExpectedOutput.Title, got.Title)
|
||||||
|
}
|
||||||
|
if got.Priority != scenario.ExpectedOutput.Priority {
|
||||||
|
t.Errorf("expected priority to be %d, got %d", scenario.ExpectedOutput.Priority, got.Priority)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
223
alerting/provider/jetbrainsspace/jetbrainsspace.go
Normal file
223
alerting/provider/jetbrainsspace/jetbrainsspace.go
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
package jetbrainsspace
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrProjectNotSet = errors.New("project not set")
|
||||||
|
ErrChannelIDNotSet = errors.New("channel-id not set")
|
||||||
|
ErrTokenNotSet = errors.New("token not set")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Project string `yaml:"project"` // Project name
|
||||||
|
ChannelID string `yaml:"channel-id"` // Chat Channel ID
|
||||||
|
Token string `yaml:"token"` // Bearer Token
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.Project) == 0 {
|
||||||
|
return ErrProjectNotSet
|
||||||
|
}
|
||||||
|
if len(cfg.ChannelID) == 0 {
|
||||||
|
return ErrChannelIDNotSet
|
||||||
|
}
|
||||||
|
if len(cfg.Token) == 0 {
|
||||||
|
return ErrTokenNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.Project) > 0 {
|
||||||
|
cfg.Project = override.Project
|
||||||
|
}
|
||||||
|
if len(override.ChannelID) > 0 {
|
||||||
|
cfg.ChannelID = override.ChannelID
|
||||||
|
}
|
||||||
|
if len(override.Token) > 0 {
|
||||||
|
cfg.Token = override.Token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using JetBrains Space
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override is a case under which the default integration is overridden
|
||||||
|
type Override struct {
|
||||||
|
Group string `yaml:"group"`
|
||||||
|
Config `yaml:",inline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the provider's configuration
|
||||||
|
func (provider *AlertProvider) Validate() error {
|
||||||
|
registeredGroups := make(map[string]bool)
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||||
|
return ErrDuplicateGroupOverride
|
||||||
|
}
|
||||||
|
registeredGroups[override.Group] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return provider.DefaultConfig.Validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send an alert using the provider
|
||||||
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||||
|
url := fmt.Sprintf("https://%s.jetbrains.space/api/http/chats/messages/send-message", cfg.Project)
|
||||||
|
request, err := http.NewRequest(http.MethodPost, url, buffer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set("Authorization", "Bearer "+cfg.Token)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
type Body struct {
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
Content Content `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Content struct {
|
||||||
|
ClassName string `json:"className"`
|
||||||
|
Style string `json:"style"`
|
||||||
|
Sections []Section `json:"sections,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Section struct {
|
||||||
|
ClassName string `json:"className"`
|
||||||
|
Elements []Element `json:"elements"`
|
||||||
|
Header string `json:"header"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Element struct {
|
||||||
|
ClassName string `json:"className"`
|
||||||
|
Accessory Accessory `json:"accessory"`
|
||||||
|
Style string `json:"style"`
|
||||||
|
Size string `json:"size"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Accessory struct {
|
||||||
|
ClassName string `json:"className"`
|
||||||
|
Icon Icon `json:"icon"`
|
||||||
|
Style string `json:"style"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Icon struct {
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestBody builds the request body for the provider
|
||||||
|
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
|
body := Body{
|
||||||
|
Channel: "id:" + cfg.ChannelID,
|
||||||
|
Content: Content{
|
||||||
|
ClassName: "ChatMessage.Block",
|
||||||
|
Sections: []Section{{
|
||||||
|
ClassName: "MessageSection",
|
||||||
|
Elements: []Element{},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if resolved {
|
||||||
|
body.Content.Style = "SUCCESS"
|
||||||
|
body.Content.Sections[0].Header = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||||
|
} else {
|
||||||
|
body.Content.Style = "WARNING"
|
||||||
|
body.Content.Sections[0].Header = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||||
|
}
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
icon := "warning"
|
||||||
|
style := "WARNING"
|
||||||
|
if conditionResult.Success {
|
||||||
|
icon = "success"
|
||||||
|
style = "SUCCESS"
|
||||||
|
}
|
||||||
|
body.Content.Sections[0].Elements = append(body.Content.Sections[0].Elements, Element{
|
||||||
|
ClassName: "MessageText",
|
||||||
|
Accessory: Accessory{
|
||||||
|
ClassName: "MessageIcon",
|
||||||
|
Icon: Icon{Icon: icon},
|
||||||
|
Style: style,
|
||||||
|
},
|
||||||
|
Style: style,
|
||||||
|
Size: "REGULAR",
|
||||||
|
Content: conditionResult.Condition,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
bodyAsJSON, _ := json.Marshal(body)
|
||||||
|
return bodyAsJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
|
return provider.DefaultAlert
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
318
alerting/provider/jetbrainsspace/jetbrainsspace_test.go
Normal file
318
alerting/provider/jetbrainsspace/jetbrainsspace_test.go
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
package jetbrainsspace
|
||||||
|
|
||||||
|
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 TestAlertProvider_Validate(t *testing.T) {
|
||||||
|
invalidProvider := AlertProvider{DefaultConfig: Config{Project: ""}}
|
||||||
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
|
t.Error("provider shouldn't have been valid")
|
||||||
|
}
|
||||||
|
validProvider := AlertProvider{DefaultConfig: Config{Project: "foo", ChannelID: "bar", Token: "baz"}}
|
||||||
|
if err := validProvider.Validate(); err != nil {
|
||||||
|
t.Error("provider should've been valid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||||
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
|
DefaultConfig: Config{Project: "foobar"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Config: Config{ChannelID: "http://example.com"},
|
||||||
|
Group: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||||
|
t.Error("provider Group shouldn't have been valid")
|
||||||
|
}
|
||||||
|
providerWithInvalidOverrideTo := AlertProvider{
|
||||||
|
DefaultConfig: Config{Project: "foobar"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Config: Config{ChannelID: ""},
|
||||||
|
Group: "group",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
|
||||||
|
t.Error("provider integration key shouldn't have been valid")
|
||||||
|
}
|
||||||
|
providerWithValidOverride := AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
Project: "foo",
|
||||||
|
ChannelID: "bar",
|
||||||
|
Token: "baz",
|
||||||
|
},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Config: Config{ChannelID: "foobar"},
|
||||||
|
Group: "group",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := providerWithValidOverride.Validate(); err != nil {
|
||||||
|
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{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
|
||||||
|
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{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
|
||||||
|
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{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
|
||||||
|
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{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
|
||||||
|
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
|
||||||
|
Endpoint endpoint.Endpoint
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
ExpectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"WARNING","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *name* has been triggered due to having failed 3 time(s) in a row"}]}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-with-group",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"WARNING","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *group/name* has been triggered due to having failed 3 time(s) in a row"}]}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"SUCCESS","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *name* has been resolved after passing successfully 5 time(s) in a row"}]}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-with-group",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"SUCCESS","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row"}]}}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
body := scenario.Provider.buildRequestBody(
|
||||||
|
&scenario.Provider.DefaultConfig,
|
||||||
|
&scenario.Endpoint,
|
||||||
|
&scenario.Alert,
|
||||||
|
&endpoint.Result{
|
||||||
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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_GetConfig(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputGroup string
|
||||||
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{ChannelID: "group-channel"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{ChannelID: "group-channel"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{ChannelID: "group-channel", Project: "project", Token: "token"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{ChannelID: "group-channel"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"channel-id": "alert-channel", "project": "alert-project", "token": "alert-token"}},
|
||||||
|
ExpectedOutput: Config{ChannelID: "alert-channel", Project: "alert-project", Token: "alert-token"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.ChannelID != scenario.ExpectedOutput.ChannelID {
|
||||||
|
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.ChannelID, got.ChannelID)
|
||||||
|
}
|
||||||
|
if got.Project != scenario.ExpectedOutput.Project {
|
||||||
|
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.Project, got.Project)
|
||||||
|
}
|
||||||
|
if got.Token != scenario.ExpectedOutput.Token {
|
||||||
|
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.Token, got.Token)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package matrix
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
@@ -12,30 +13,19 @@ import (
|
|||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Matrix
|
const defaultServerURL = "https://matrix-client.matrix.org"
|
||||||
type AlertProvider struct {
|
|
||||||
MatrixProviderConfig `yaml:",inline"`
|
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
var (
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
ErrAccessTokenNotSet = errors.New("access-token not set")
|
||||||
|
ErrInternalRoomID = errors.New("internal-room-id not set")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
)
|
||||||
|
|
||||||
// Overrides is a list of Override that may be prioritized over the default configuration
|
type Config struct {
|
||||||
Overrides []Override `yaml:"overrides,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Override is a case under which the default integration is overridden
|
|
||||||
type Override struct {
|
|
||||||
Group string `yaml:"group"`
|
|
||||||
|
|
||||||
MatrixProviderConfig `yaml:",inline"`
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultHomeserverURL = "https://matrix-client.matrix.org"
|
|
||||||
|
|
||||||
type MatrixProviderConfig struct {
|
|
||||||
// ServerURL is the custom homeserver to use (optional)
|
// ServerURL is the custom homeserver to use (optional)
|
||||||
ServerURL string `yaml:"server-url"`
|
ServerURL string `yaml:"server-url"`
|
||||||
|
|
||||||
@@ -46,36 +36,78 @@ type MatrixProviderConfig struct {
|
|||||||
InternalRoomID string `yaml:"internal-room-id"`
|
InternalRoomID string `yaml:"internal-room-id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
func (cfg *Config) Validate() error {
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
if len(cfg.ServerURL) == 0 {
|
||||||
|
cfg.ServerURL = defaultServerURL
|
||||||
|
}
|
||||||
|
if len(cfg.AccessToken) == 0 {
|
||||||
|
return ErrAccessTokenNotSet
|
||||||
|
}
|
||||||
|
if len(cfg.InternalRoomID) == 0 {
|
||||||
|
return ErrInternalRoomID
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.ServerURL) > 0 {
|
||||||
|
cfg.ServerURL = override.ServerURL
|
||||||
|
}
|
||||||
|
if len(override.AccessToken) > 0 {
|
||||||
|
cfg.AccessToken = override.AccessToken
|
||||||
|
}
|
||||||
|
if len(override.InternalRoomID) > 0 {
|
||||||
|
cfg.InternalRoomID = override.InternalRoomID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using Matrix
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override is a case under which the default integration is overridden
|
||||||
|
type Override struct {
|
||||||
|
Group string `yaml:"group"`
|
||||||
|
Config `yaml:",inline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the provider's configuration
|
||||||
|
func (provider *AlertProvider) Validate() error {
|
||||||
registeredGroups := make(map[string]bool)
|
registeredGroups := make(map[string]bool)
|
||||||
if provider.Overrides != nil {
|
if provider.Overrides != nil {
|
||||||
for _, override := range provider.Overrides {
|
for _, override := range provider.Overrides {
|
||||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.AccessToken) == 0 || len(override.InternalRoomID) == 0 {
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.AccessToken) == 0 || len(override.InternalRoomID) == 0 {
|
||||||
return false
|
return ErrDuplicateGroupOverride
|
||||||
}
|
}
|
||||||
registeredGroups[override.Group] = true
|
registeredGroups[override.Group] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return len(provider.AccessToken) > 0 && len(provider.InternalRoomID) > 0
|
return provider.DefaultConfig.Validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
config := provider.getConfigForGroup(endpoint.Group)
|
if err != nil {
|
||||||
if config.ServerURL == "" {
|
return err
|
||||||
config.ServerURL = defaultHomeserverURL
|
|
||||||
}
|
}
|
||||||
|
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
||||||
// The Matrix endpoint requires a unique transaction ID for each event sent
|
// The Matrix endpoint requires a unique transaction ID for each event sent
|
||||||
txnId := randStringBytes(24)
|
txnId := randStringBytes(24)
|
||||||
request, err := http.NewRequest(
|
request, err := http.NewRequest(
|
||||||
http.MethodPut,
|
http.MethodPut,
|
||||||
fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/send/m.room.message/%s?access_token=%s",
|
fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/send/m.room.message/%s?access_token=%s",
|
||||||
config.ServerURL,
|
cfg.ServerURL,
|
||||||
url.PathEscape(config.InternalRoomID),
|
url.PathEscape(cfg.InternalRoomID),
|
||||||
txnId,
|
txnId,
|
||||||
url.QueryEscape(config.AccessToken),
|
url.QueryEscape(cfg.AccessToken),
|
||||||
),
|
),
|
||||||
buffer,
|
buffer,
|
||||||
)
|
)
|
||||||
@@ -103,24 +135,25 @@ type Body struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildRequestBody builds the request body for the provider
|
// buildRequestBody builds the request body for the provider
|
||||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
|
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
body, _ := json.Marshal(Body{
|
body, _ := json.Marshal(Body{
|
||||||
MsgType: "m.text",
|
MsgType: "m.text",
|
||||||
Format: "org.matrix.custom.html",
|
Format: "org.matrix.custom.html",
|
||||||
Body: buildPlaintextMessageBody(endpoint, alert, result, resolved),
|
Body: buildPlaintextMessageBody(ep, alert, result, resolved),
|
||||||
FormattedBody: buildHTMLMessageBody(endpoint, alert, result, resolved),
|
FormattedBody: buildHTMLMessageBody(ep, alert, result, resolved),
|
||||||
})
|
})
|
||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildPlaintextMessageBody builds the message body in plaintext to include in request
|
// buildPlaintextMessageBody builds the message body in plaintext to include in request
|
||||||
func buildPlaintextMessageBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
func buildPlaintextMessageBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
|
||||||
var message, results string
|
var message string
|
||||||
if resolved {
|
if resolved {
|
||||||
message = fmt.Sprintf("An alert for `%s` has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
|
message = fmt.Sprintf("An alert for `%s` has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||||
} else {
|
} else {
|
||||||
message = fmt.Sprintf("An alert for `%s` has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
message = fmt.Sprintf("An alert for `%s` has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||||
}
|
}
|
||||||
|
var formattedConditionResults string
|
||||||
for _, conditionResult := range result.ConditionResults {
|
for _, conditionResult := range result.ConditionResults {
|
||||||
var prefix string
|
var prefix string
|
||||||
if conditionResult.Success {
|
if conditionResult.Success {
|
||||||
@@ -128,49 +161,42 @@ func buildPlaintextMessageBody(endpoint *core.Endpoint, alert *alert.Alert, resu
|
|||||||
} else {
|
} else {
|
||||||
prefix = "✕"
|
prefix = "✕"
|
||||||
}
|
}
|
||||||
results += fmt.Sprintf("\n%s - %s", prefix, conditionResult.Condition)
|
formattedConditionResults += fmt.Sprintf("\n%s - %s", prefix, conditionResult.Condition)
|
||||||
}
|
}
|
||||||
var description string
|
var description string
|
||||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
description = "\n" + alertDescription
|
description = "\n" + alertDescription
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s%s\n%s", message, description, results)
|
return fmt.Sprintf("%s%s\n%s", message, description, formattedConditionResults)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildHTMLMessageBody builds the message body in HTML to include in request
|
// buildHTMLMessageBody builds the message body in HTML to include in request
|
||||||
func buildHTMLMessageBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
func buildHTMLMessageBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
|
||||||
var message, results string
|
var message string
|
||||||
if resolved {
|
if resolved {
|
||||||
message = fmt.Sprintf("An alert for <code>%s</code> has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
|
message = fmt.Sprintf("An alert for <code>%s</code> has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||||
} else {
|
} else {
|
||||||
message = fmt.Sprintf("An alert for <code>%s</code> has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
message = fmt.Sprintf("An alert for <code>%s</code> has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||||
}
|
}
|
||||||
for _, conditionResult := range result.ConditionResults {
|
var formattedConditionResults string
|
||||||
var prefix string
|
if len(result.ConditionResults) > 0 {
|
||||||
if conditionResult.Success {
|
formattedConditionResults = "\n<h5>Condition results</h5><ul>"
|
||||||
prefix = "✅"
|
for _, conditionResult := range result.ConditionResults {
|
||||||
} else {
|
var prefix string
|
||||||
prefix = "❌"
|
if conditionResult.Success {
|
||||||
|
prefix = "✅"
|
||||||
|
} else {
|
||||||
|
prefix = "❌"
|
||||||
|
}
|
||||||
|
formattedConditionResults += fmt.Sprintf("<li>%s - <code>%s</code></li>", prefix, conditionResult.Condition)
|
||||||
}
|
}
|
||||||
results += fmt.Sprintf("<li>%s - <code>%s</code></li>", prefix, conditionResult.Condition)
|
formattedConditionResults += "</ul>"
|
||||||
}
|
}
|
||||||
var description string
|
var description string
|
||||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
description = fmt.Sprintf("\n<blockquote>%s</blockquote>", alertDescription)
|
description = fmt.Sprintf("\n<blockquote>%s</blockquote>", alertDescription)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("<h3>%s</h3>%s\n<h5>Condition results</h5><ul>%s</ul>", message, description, results)
|
return fmt.Sprintf("<h3>%s</h3>%s%s", message, description, formattedConditionResults)
|
||||||
}
|
|
||||||
|
|
||||||
// getConfigForGroup returns the appropriate configuration for a given group
|
|
||||||
func (provider *AlertProvider) getConfigForGroup(group string) MatrixProviderConfig {
|
|
||||||
if provider.Overrides != nil {
|
|
||||||
for _, override := range provider.Overrides {
|
|
||||||
if group == override.Group {
|
|
||||||
return override.MatrixProviderConfig
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return provider.MatrixProviderConfig
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func randStringBytes(n int) string {
|
func randStringBytes(n int) string {
|
||||||
@@ -185,6 +211,37 @@ func randStringBytes(n int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,79 +7,79 @@ import (
|
|||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/TwiN/gatus/v5/test"
|
"github.com/TwiN/gatus/v5/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
invalidProvider := AlertProvider{
|
invalidProvider := AlertProvider{
|
||||||
MatrixProviderConfig: MatrixProviderConfig{
|
DefaultConfig: Config{
|
||||||
AccessToken: "",
|
AccessToken: "",
|
||||||
InternalRoomID: "",
|
InternalRoomID: "",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if invalidProvider.IsValid() {
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
t.Error("provider shouldn't have been valid")
|
t.Error("provider shouldn't have been valid")
|
||||||
}
|
}
|
||||||
validProvider := AlertProvider{
|
validProvider := AlertProvider{
|
||||||
MatrixProviderConfig: MatrixProviderConfig{
|
DefaultConfig: Config{
|
||||||
AccessToken: "1",
|
AccessToken: "1",
|
||||||
InternalRoomID: "!a:example.com",
|
InternalRoomID: "!a:example.com",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if !validProvider.IsValid() {
|
if err := validProvider.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
validProviderWithHomeserver := AlertProvider{
|
validProviderWithHomeserver := AlertProvider{
|
||||||
MatrixProviderConfig: MatrixProviderConfig{
|
DefaultConfig: Config{
|
||||||
ServerURL: "https://example.com",
|
ServerURL: "https://example.com",
|
||||||
AccessToken: "1",
|
AccessToken: "1",
|
||||||
InternalRoomID: "!a:example.com",
|
InternalRoomID: "!a:example.com",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if !validProviderWithHomeserver.IsValid() {
|
if err := validProviderWithHomeserver.Validate(); err != nil {
|
||||||
t.Error("provider with homeserver should've been valid")
|
t.Error("provider with homeserver should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||||
providerWithInvalidOverrideGroup := AlertProvider{
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "",
|
Group: "",
|
||||||
MatrixProviderConfig: MatrixProviderConfig{
|
Config: Config{
|
||||||
AccessToken: "",
|
AccessToken: "",
|
||||||
InternalRoomID: "",
|
InternalRoomID: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if providerWithInvalidOverrideGroup.IsValid() {
|
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||||
t.Error("provider Group shouldn't have been valid")
|
t.Error("provider Group shouldn't have been valid")
|
||||||
}
|
}
|
||||||
providerWithInvalidOverrideTo := AlertProvider{
|
providerWithInvalidOverrideTo := AlertProvider{
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
MatrixProviderConfig: MatrixProviderConfig{
|
Config: Config{
|
||||||
AccessToken: "",
|
AccessToken: "",
|
||||||
InternalRoomID: "",
|
InternalRoomID: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if providerWithInvalidOverrideTo.IsValid() {
|
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
|
||||||
t.Error("provider integration key shouldn't have been valid")
|
t.Error("provider integration key shouldn't have been valid")
|
||||||
}
|
}
|
||||||
providerWithValidOverride := AlertProvider{
|
providerWithValidOverride := AlertProvider{
|
||||||
MatrixProviderConfig: MatrixProviderConfig{
|
DefaultConfig: Config{
|
||||||
AccessToken: "1",
|
AccessToken: "1",
|
||||||
InternalRoomID: "!a:example.com",
|
InternalRoomID: "!a:example.com",
|
||||||
},
|
},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
MatrixProviderConfig: MatrixProviderConfig{
|
Config: Config{
|
||||||
ServerURL: "https://example.com",
|
ServerURL: "https://example.com",
|
||||||
AccessToken: "1",
|
AccessToken: "1",
|
||||||
InternalRoomID: "!a:example.com",
|
InternalRoomID: "!a:example.com",
|
||||||
@@ -87,7 +87,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if !providerWithValidOverride.IsValid() {
|
if err := providerWithValidOverride.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,18 +105,28 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
ExpectedError bool
|
ExpectedError bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered-with-bad-config",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
}),
|
}),
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{AccessToken: "1", InternalRoomID: "!a:example.com"}},
|
||||||
|
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,
|
ExpectedError: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "triggered-error",
|
Name: "triggered-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{AccessToken: "1", InternalRoomID: "!a:example.com"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -126,7 +136,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{AccessToken: "1", InternalRoomID: "!a:example.com"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -136,7 +146,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved-error",
|
Name: "resolved-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{AccessToken: "1", InternalRoomID: "!a:example.com"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -149,10 +159,10 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
err := scenario.Provider.Send(
|
err := scenario.Provider.Send(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
},
|
},
|
||||||
@@ -197,10 +207,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
body := scenario.Provider.buildRequestBody(
|
body := scenario.Provider.buildRequestBody(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
},
|
},
|
||||||
@@ -219,25 +229,26 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
t.Error("expected default alert to be not nil")
|
t.Error("expected default alert to be not nil")
|
||||||
}
|
}
|
||||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
t.Error("expected default alert to be nil")
|
t.Error("expected default alert to be nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
tests := []struct {
|
scenarios := []struct {
|
||||||
Name string
|
Name string
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
InputGroup string
|
InputGroup string
|
||||||
ExpectedOutput MatrixProviderConfig
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "provider-no-override-specify-no-group-should-default",
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
MatrixProviderConfig: MatrixProviderConfig{
|
DefaultConfig: Config{
|
||||||
ServerURL: "https://example.com",
|
ServerURL: "https://example.com",
|
||||||
AccessToken: "1",
|
AccessToken: "1",
|
||||||
InternalRoomID: "!a:example.com",
|
InternalRoomID: "!a:example.com",
|
||||||
@@ -245,7 +256,8 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
|||||||
Overrides: nil,
|
Overrides: nil,
|
||||||
},
|
},
|
||||||
InputGroup: "",
|
InputGroup: "",
|
||||||
ExpectedOutput: MatrixProviderConfig{
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{
|
||||||
ServerURL: "https://example.com",
|
ServerURL: "https://example.com",
|
||||||
AccessToken: "1",
|
AccessToken: "1",
|
||||||
InternalRoomID: "!a:example.com",
|
InternalRoomID: "!a:example.com",
|
||||||
@@ -254,7 +266,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
|||||||
{
|
{
|
||||||
Name: "provider-no-override-specify-group-should-default",
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
MatrixProviderConfig: MatrixProviderConfig{
|
DefaultConfig: Config{
|
||||||
ServerURL: "https://example.com",
|
ServerURL: "https://example.com",
|
||||||
AccessToken: "1",
|
AccessToken: "1",
|
||||||
InternalRoomID: "!a:example.com",
|
InternalRoomID: "!a:example.com",
|
||||||
@@ -262,7 +274,8 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
|||||||
Overrides: nil,
|
Overrides: nil,
|
||||||
},
|
},
|
||||||
InputGroup: "group",
|
InputGroup: "group",
|
||||||
ExpectedOutput: MatrixProviderConfig{
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{
|
||||||
ServerURL: "https://example.com",
|
ServerURL: "https://example.com",
|
||||||
AccessToken: "1",
|
AccessToken: "1",
|
||||||
InternalRoomID: "!a:example.com",
|
InternalRoomID: "!a:example.com",
|
||||||
@@ -271,7 +284,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
|||||||
{
|
{
|
||||||
Name: "provider-with-override-specify-no-group-should-default",
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
MatrixProviderConfig: MatrixProviderConfig{
|
DefaultConfig: Config{
|
||||||
ServerURL: "https://example.com",
|
ServerURL: "https://example.com",
|
||||||
AccessToken: "1",
|
AccessToken: "1",
|
||||||
InternalRoomID: "!a:example.com",
|
InternalRoomID: "!a:example.com",
|
||||||
@@ -279,16 +292,17 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
|||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
MatrixProviderConfig: MatrixProviderConfig{
|
Config: Config{
|
||||||
ServerURL: "https://example01.com",
|
ServerURL: "https://group-example.com",
|
||||||
AccessToken: "12",
|
AccessToken: "12",
|
||||||
InternalRoomID: "!a:example01.com",
|
InternalRoomID: "!a:group-example.com",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InputGroup: "",
|
InputGroup: "",
|
||||||
ExpectedOutput: MatrixProviderConfig{
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{
|
||||||
ServerURL: "https://example.com",
|
ServerURL: "https://example.com",
|
||||||
AccessToken: "1",
|
AccessToken: "1",
|
||||||
InternalRoomID: "!a:example.com",
|
InternalRoomID: "!a:example.com",
|
||||||
@@ -297,7 +311,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
|||||||
{
|
{
|
||||||
Name: "provider-with-override-specify-group-should-override",
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
MatrixProviderConfig: MatrixProviderConfig{
|
DefaultConfig: Config{
|
||||||
ServerURL: "https://example.com",
|
ServerURL: "https://example.com",
|
||||||
AccessToken: "1",
|
AccessToken: "1",
|
||||||
InternalRoomID: "!a:example.com",
|
InternalRoomID: "!a:example.com",
|
||||||
@@ -305,8 +319,35 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
|||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
MatrixProviderConfig: MatrixProviderConfig{
|
Config: Config{
|
||||||
ServerURL: "https://example01.com",
|
ServerURL: "https://group-example.com",
|
||||||
|
AccessToken: "12",
|
||||||
|
InternalRoomID: "!a:group-example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{
|
||||||
|
ServerURL: "https://group-example.com",
|
||||||
|
AccessToken: "12",
|
||||||
|
InternalRoomID: "!a:group-example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
ServerURL: "https://example.com",
|
||||||
|
AccessToken: "1",
|
||||||
|
InternalRoomID: "!a:example.com",
|
||||||
|
},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{
|
||||||
|
ServerURL: "https://group-example.com",
|
||||||
AccessToken: "12",
|
AccessToken: "12",
|
||||||
InternalRoomID: "!a:example01.com",
|
InternalRoomID: "!a:example01.com",
|
||||||
},
|
},
|
||||||
@@ -314,17 +355,32 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
InputGroup: "group",
|
InputGroup: "group",
|
||||||
ExpectedOutput: MatrixProviderConfig{
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"server-url": "https://alert-example.com", "access-token": "123", "internal-room-id": "!a:alert-example.com"}},
|
||||||
ServerURL: "https://example01.com",
|
ExpectedOutput: Config{
|
||||||
AccessToken: "12",
|
ServerURL: "https://alert-example.com",
|
||||||
InternalRoomID: "!a:example01.com",
|
AccessToken: "123",
|
||||||
|
InternalRoomID: "!a:alert-example.com",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, scenario := range scenarios {
|
||||||
t.Run(tt.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
if got := tt.Provider.getConfigForGroup(tt.InputGroup); got != tt.ExpectedOutput {
|
outputConfig, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||||
t.Errorf("AlertProvider.getConfigForGroup() = %v, want %v", got, tt.ExpectedOutput)
|
if err != nil {
|
||||||
|
t.Errorf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if outputConfig.ServerURL != scenario.ExpectedOutput.ServerURL {
|
||||||
|
t.Errorf("expected ServerURL to be %s, got %s", scenario.ExpectedOutput.ServerURL, outputConfig.ServerURL)
|
||||||
|
}
|
||||||
|
if outputConfig.AccessToken != scenario.ExpectedOutput.AccessToken {
|
||||||
|
t.Errorf("expected AccessToken to be %s, got %s", scenario.ExpectedOutput.AccessToken, outputConfig.AccessToken)
|
||||||
|
}
|
||||||
|
if outputConfig.InternalRoomID != scenario.ExpectedOutput.InternalRoomID {
|
||||||
|
t.Errorf("expected InternalRoomID to be %s, got %s", scenario.ExpectedOutput.InternalRoomID, outputConfig.InternalRoomID)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,21 +3,50 @@ package mattermost
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrWebhookURLNotSet = errors.New("webhook URL not set")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
WebhookURL string `yaml:"webhook-url"`
|
||||||
|
Channel string `yaml:"channel,omitempty"`
|
||||||
|
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.WebhookURL) == 0 {
|
||||||
|
return ErrWebhookURLNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if override.ClientConfig != nil {
|
||||||
|
cfg.ClientConfig = override.ClientConfig
|
||||||
|
}
|
||||||
|
if len(override.WebhookURL) > 0 {
|
||||||
|
cfg.WebhookURL = override.WebhookURL
|
||||||
|
}
|
||||||
|
if len(override.Channel) > 0 {
|
||||||
|
cfg.Channel = override.Channel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Mattermost
|
// AlertProvider is the configuration necessary for sending an alert using Mattermost
|
||||||
type AlertProvider struct {
|
type AlertProvider struct {
|
||||||
WebhookURL string `yaml:"webhook-url"`
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
|
||||||
ClientConfig *client.Config `yaml:"client,omitempty"`
|
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
@@ -28,36 +57,37 @@ type AlertProvider struct {
|
|||||||
|
|
||||||
// Override is a case under which the default integration is overridden
|
// Override is a case under which the default integration is overridden
|
||||||
type Override struct {
|
type Override struct {
|
||||||
Group string `yaml:"group"`
|
Group string `yaml:"group"`
|
||||||
WebhookURL string `yaml:"webhook-url"`
|
Config `yaml:",inline"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// Validate the provider's configuration
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
func (provider *AlertProvider) Validate() error {
|
||||||
if provider.ClientConfig == nil {
|
|
||||||
provider.ClientConfig = client.GetDefaultConfig()
|
|
||||||
}
|
|
||||||
if provider.Overrides != nil {
|
if provider.Overrides != nil {
|
||||||
registeredGroups := make(map[string]bool)
|
registeredGroups := make(map[string]bool)
|
||||||
for _, override := range provider.Overrides {
|
for _, override := range provider.Overrides {
|
||||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
|
||||||
return false
|
return ErrDuplicateGroupOverride
|
||||||
}
|
}
|
||||||
registeredGroups[override.Group] = true
|
registeredGroups[override.Group] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return len(provider.WebhookURL) > 0
|
return provider.DefaultConfig.Validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(cfg, ep, alert, result, resolved)))
|
||||||
|
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
request.Header.Set("Content-Type", "application/json")
|
request.Header.Set("Content-Type", "application/json")
|
||||||
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
|
response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -70,6 +100,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Body struct {
|
type Body struct {
|
||||||
|
Channel string `json:"channel,omitempty"` // Optional channel override
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
IconURL string `json:"icon_url"`
|
IconURL string `json:"icon_url"`
|
||||||
@@ -92,29 +123,33 @@ type Field struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildRequestBody builds the request body for the provider
|
// buildRequestBody builds the request body for the provider
|
||||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
|
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
var message, color, results string
|
var message, color string
|
||||||
if resolved {
|
if resolved {
|
||||||
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
|
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||||
color = "#36A64F"
|
color = "#36A64F"
|
||||||
} else {
|
} else {
|
||||||
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||||
color = "#DD0000"
|
color = "#DD0000"
|
||||||
}
|
}
|
||||||
for _, conditionResult := range result.ConditionResults {
|
var formattedConditionResults string
|
||||||
var prefix string
|
if len(result.ConditionResults) > 0 {
|
||||||
if conditionResult.Success {
|
for _, conditionResult := range result.ConditionResults {
|
||||||
prefix = ":white_check_mark:"
|
var prefix string
|
||||||
} else {
|
if conditionResult.Success {
|
||||||
prefix = ":x:"
|
prefix = ":white_check_mark:"
|
||||||
|
} else {
|
||||||
|
prefix = ":x:"
|
||||||
|
}
|
||||||
|
formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
||||||
}
|
}
|
||||||
results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
|
||||||
}
|
}
|
||||||
var description string
|
var description string
|
||||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
description = ":\n> " + alertDescription
|
description = ":\n> " + alertDescription
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(Body{
|
body := Body{
|
||||||
|
Channel: cfg.Channel,
|
||||||
Text: "",
|
Text: "",
|
||||||
Username: "gatus",
|
Username: "gatus",
|
||||||
IconURL: "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png",
|
IconURL: "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png",
|
||||||
@@ -125,32 +160,52 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
|
|||||||
Text: message + description,
|
Text: message + description,
|
||||||
Short: false,
|
Short: false,
|
||||||
Color: color,
|
Color: color,
|
||||||
Fields: []Field{
|
|
||||||
{
|
|
||||||
Title: "Condition results",
|
|
||||||
Value: results,
|
|
||||||
Short: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
if len(formattedConditionResults) > 0 {
|
||||||
|
body.Attachments[0].Fields = append(body.Attachments[0].Fields, Field{
|
||||||
|
Title: "Condition results",
|
||||||
|
Value: formattedConditionResults,
|
||||||
|
Short: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
bodyAsJSON, _ := json.Marshal(body)
|
||||||
|
return bodyAsJSON
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,58 +7,54 @@ import (
|
|||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/TwiN/gatus/v5/test"
|
"github.com/TwiN/gatus/v5/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
invalidProvider := AlertProvider{WebhookURL: ""}
|
invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
|
||||||
if invalidProvider.IsValid() {
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
t.Error("provider shouldn't have been valid")
|
t.Error("provider shouldn't have been valid")
|
||||||
}
|
}
|
||||||
validProvider := AlertProvider{WebhookURL: "http://example.com"}
|
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
|
||||||
if !validProvider.IsValid() {
|
if err := validProvider.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||||
providerWithInvalidOverrideGroup := AlertProvider{
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
WebhookURL: "http://example.com",
|
Config: Config{WebhookURL: "http://example.com"},
|
||||||
Group: "",
|
Group: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||||
if providerWithInvalidOverrideGroup.IsValid() {
|
|
||||||
t.Error("provider Group shouldn't have been valid")
|
t.Error("provider Group shouldn't have been valid")
|
||||||
}
|
}
|
||||||
|
|
||||||
providerWithInvalidOverrideWebHookUrl := AlertProvider{
|
providerWithInvalidOverrideWebHookUrl := AlertProvider{
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
|
Config: Config{WebhookURL: ""},
|
||||||
WebhookURL: "",
|
Group: "group",
|
||||||
Group: "group",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if providerWithInvalidOverrideWebHookUrl.IsValid() {
|
if err := providerWithInvalidOverrideWebHookUrl.Validate(); err == nil {
|
||||||
t.Error("provider WebHookURL shoudn't have been valid")
|
t.Error("provider WebHookURL shouldn't have been valid")
|
||||||
}
|
}
|
||||||
|
|
||||||
providerWithValidOverride := AlertProvider{
|
providerWithValidOverride := AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
WebhookURL: "http://example.com",
|
Config: Config{WebhookURL: "http://example.com"},
|
||||||
Group: "group",
|
Group: "group",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if !providerWithValidOverride.IsValid() {
|
if err := providerWithValidOverride.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -87,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "triggered-error",
|
Name: "triggered-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -97,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -107,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved-error",
|
Name: "resolved-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -120,10 +116,10 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
err := scenario.Provider.Send(
|
err := scenario.Provider.Send(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
},
|
},
|
||||||
@@ -168,10 +164,11 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
body := scenario.Provider.buildRequestBody(
|
body := scenario.Provider.buildRequestBody(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&scenario.Provider.DefaultConfig,
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
},
|
},
|
||||||
@@ -190,72 +187,100 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
t.Error("expected default alert to be not nil")
|
t.Error("expected default alert to be not nil")
|
||||||
}
|
}
|
||||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
t.Error("expected default alert to be nil")
|
t.Error("expected default alert to be nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
tests := []struct {
|
scenarios := []struct {
|
||||||
Name string
|
Name string
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
InputGroup string
|
InputGroup string
|
||||||
ExpectedOutput string
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "provider-no-override-specify-no-group-should-default",
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: nil,
|
Overrides: nil,
|
||||||
},
|
},
|
||||||
InputGroup: "",
|
InputGroup: "",
|
||||||
ExpectedOutput: "http://example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-no-override-specify-group-should-default",
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: nil,
|
Overrides: nil,
|
||||||
},
|
},
|
||||||
InputGroup: "group",
|
InputGroup: "group",
|
||||||
ExpectedOutput: "http://example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-with-override-specify-no-group-should-default",
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
WebhookURL: "http://example01.com",
|
Config: Config{WebhookURL: "http://example01.com"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InputGroup: "",
|
InputGroup: "",
|
||||||
ExpectedOutput: "http://example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-with-override-specify-group-should-override",
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
WebhookURL: "http://example01.com",
|
Config: Config{WebhookURL: "http://group-example.com"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InputGroup: "group",
|
InputGroup: "group",
|
||||||
ExpectedOutput: "http://example01.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://group-example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{WebhookURL: "http://group-example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, scenario := range scenarios {
|
||||||
t.Run(tt.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
|
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||||
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
|
||||||
|
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,44 +3,83 @@ package messagebird
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const restAPIURL = "https://rest.messagebird.com/messages"
|
||||||
restAPIURL = "https://rest.messagebird.com/messages"
|
|
||||||
|
var (
|
||||||
|
ErrorAccessKeyNotSet = errors.New("access-key not set")
|
||||||
|
ErrorOriginatorNotSet = errors.New("originator not set")
|
||||||
|
ErrorRecipientsNotSet = errors.New("recipients not set")
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Messagebird
|
type Config struct {
|
||||||
type AlertProvider struct {
|
|
||||||
AccessKey string `yaml:"access-key"`
|
AccessKey string `yaml:"access-key"`
|
||||||
Originator string `yaml:"originator"`
|
Originator string `yaml:"originator"`
|
||||||
Recipients string `yaml:"recipients"`
|
Recipients string `yaml:"recipients"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.AccessKey) == 0 {
|
||||||
|
return ErrorAccessKeyNotSet
|
||||||
|
}
|
||||||
|
if len(cfg.Originator) == 0 {
|
||||||
|
return ErrorOriginatorNotSet
|
||||||
|
}
|
||||||
|
if len(cfg.Recipients) == 0 {
|
||||||
|
return ErrorRecipientsNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.AccessKey) > 0 {
|
||||||
|
cfg.AccessKey = override.AccessKey
|
||||||
|
}
|
||||||
|
if len(override.Originator) > 0 {
|
||||||
|
cfg.Originator = override.Originator
|
||||||
|
}
|
||||||
|
if len(override.Recipients) > 0 {
|
||||||
|
cfg.Recipients = override.Recipients
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using Messagebird
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// Validate the provider's configuration
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
func (provider *AlertProvider) Validate() error {
|
||||||
return len(provider.AccessKey) > 0 && len(provider.Originator) > 0 && len(provider.Recipients) > 0
|
return provider.DefaultConfig.Validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
// Reference doc for messagebird: https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms
|
// Reference doc for messagebird: https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms
|
||||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||||
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
|
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
request.Header.Set("Content-Type", "application/json")
|
request.Header.Set("Content-Type", "application/json")
|
||||||
request.Header.Set("Authorization", fmt.Sprintf("AccessKey %s", provider.AccessKey))
|
request.Header.Set("Authorization", fmt.Sprintf("AccessKey %s", cfg.AccessKey))
|
||||||
response, err := client.GetHTTPClient(nil).Do(request)
|
response, err := client.GetHTTPClient(nil).Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -60,22 +99,44 @@ type Body struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildRequestBody builds the request body for the provider
|
// buildRequestBody builds the request body for the provider
|
||||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
|
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
var message string
|
var message string
|
||||||
if resolved {
|
if resolved {
|
||||||
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
|
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||||
} else {
|
} else {
|
||||||
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
|
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(Body{
|
body, _ := json.Marshal(Body{
|
||||||
Originator: provider.Originator,
|
Originator: cfg.Originator,
|
||||||
Recipients: provider.Recipients,
|
Recipients: cfg.Recipients,
|
||||||
Body: message,
|
Body: message,
|
||||||
})
|
})
|
||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,21 +7,23 @@ import (
|
|||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/TwiN/gatus/v5/test"
|
"github.com/TwiN/gatus/v5/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMessagebirdAlertProvider_IsValid(t *testing.T) {
|
func TestMessagebirdAlertProvider_IsValid(t *testing.T) {
|
||||||
invalidProvider := AlertProvider{}
|
invalidProvider := AlertProvider{}
|
||||||
if invalidProvider.IsValid() {
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
t.Error("provider shouldn't have been valid")
|
t.Error("provider shouldn't have been valid")
|
||||||
}
|
}
|
||||||
validProvider := AlertProvider{
|
validProvider := AlertProvider{
|
||||||
AccessKey: "1",
|
DefaultConfig: Config{
|
||||||
Originator: "1",
|
AccessKey: "1",
|
||||||
Recipients: "1",
|
Originator: "1",
|
||||||
|
Recipients: "1",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if !validProvider.IsValid() {
|
if err := validProvider.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,7 +42,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -50,7 +52,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "triggered-error",
|
Name: "triggered-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -60,7 +62,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -70,7 +72,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved-error",
|
Name: "resolved-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -83,10 +85,10 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
err := scenario.Provider.Send(
|
err := scenario.Provider.Send(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
},
|
},
|
||||||
@@ -115,14 +117,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{AccessKey: "1", Originator: "2", Recipients: "3"},
|
Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedBody: "{\"originator\":\"2\",\"recipients\":\"3\",\"body\":\"TRIGGERED: endpoint-name - description-1\"}",
|
ExpectedBody: "{\"originator\":\"2\",\"recipients\":\"3\",\"body\":\"TRIGGERED: endpoint-name - description-1\"}",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{AccessKey: "4", Originator: "5", Recipients: "6"},
|
Provider: AlertProvider{DefaultConfig: Config{AccessKey: "4", Originator: "5", Recipients: "6"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedBody: "{\"originator\":\"5\",\"recipients\":\"6\",\"body\":\"RESOLVED: endpoint-name - description-2\"}",
|
ExpectedBody: "{\"originator\":\"5\",\"recipients\":\"6\",\"body\":\"RESOLVED: endpoint-name - description-2\"}",
|
||||||
@@ -131,10 +133,11 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
body := scenario.Provider.buildRequestBody(
|
body := scenario.Provider.buildRequestBody(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&scenario.Provider.DefaultConfig,
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
},
|
},
|
||||||
@@ -145,7 +148,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
||||||
}
|
}
|
||||||
out := make(map[string]interface{})
|
out := make(map[string]interface{})
|
||||||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -153,10 +156,57 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
t.Error("expected default alert to be not nil")
|
t.Error("expected default alert to be not nil")
|
||||||
}
|
}
|
||||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
t.Error("expected default alert to be nil")
|
t.Error("expected default alert to be nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"},
|
||||||
|
},
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{AccessKey: "1", Originator: "2", Recipients: "3"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-alert-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"},
|
||||||
|
},
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"access-key": "4", "originator": "5", "recipients": "6"}},
|
||||||
|
ExpectedOutput: Config{AccessKey: "4", Originator: "5", Recipients: "6"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
|
||||||
|
if err != nil {
|
||||||
|
t.Error("expected no error, got:", err.Error())
|
||||||
|
}
|
||||||
|
if got.AccessKey != scenario.ExpectedOutput.AccessKey {
|
||||||
|
t.Errorf("expected access key to be %s, got %s", scenario.ExpectedOutput.AccessKey, got.AccessKey)
|
||||||
|
}
|
||||||
|
if got.Originator != scenario.ExpectedOutput.Originator {
|
||||||
|
t.Errorf("expected originator to be %s, got %s", scenario.ExpectedOutput.Originator, got.Originator)
|
||||||
|
}
|
||||||
|
if got.Recipients != scenario.ExpectedOutput.Recipients {
|
||||||
|
t.Errorf("expected recipients to be %s, got %s", scenario.ExpectedOutput.Recipients, got.Recipients)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package ntfy
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -11,50 +12,142 @@ import (
|
|||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DefaultURL = "https://ntfy.sh"
|
DefaultURL = "https://ntfy.sh"
|
||||||
DefaultPriority = 3
|
DefaultPriority = 3
|
||||||
|
TokenPrefix = "tk_"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidToken = errors.New("invalid token")
|
||||||
|
ErrTopicNotSet = errors.New("topic not set")
|
||||||
|
ErrInvalidPriority = errors.New("priority must between 1 and 5 inclusively")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Topic string `yaml:"topic"`
|
||||||
|
URL string `yaml:"url,omitempty"` // Defaults to DefaultURL
|
||||||
|
Priority int `yaml:"priority,omitempty"` // Defaults to DefaultPriority
|
||||||
|
Token string `yaml:"token,omitempty"` // Defaults to ""
|
||||||
|
Email string `yaml:"email,omitempty"` // Defaults to ""
|
||||||
|
Click string `yaml:"click,omitempty"` // Defaults to ""
|
||||||
|
DisableFirebase bool `yaml:"disable-firebase,omitempty"` // Defaults to false
|
||||||
|
DisableCache bool `yaml:"disable-cache,omitempty"` // Defaults to false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.URL) == 0 {
|
||||||
|
cfg.URL = DefaultURL
|
||||||
|
}
|
||||||
|
if cfg.Priority == 0 {
|
||||||
|
cfg.Priority = DefaultPriority
|
||||||
|
}
|
||||||
|
if len(cfg.Token) > 0 && !strings.HasPrefix(cfg.Token, TokenPrefix) {
|
||||||
|
return ErrInvalidToken
|
||||||
|
}
|
||||||
|
if len(cfg.Topic) == 0 {
|
||||||
|
return ErrTopicNotSet
|
||||||
|
}
|
||||||
|
if cfg.Priority < 1 || cfg.Priority > 5 {
|
||||||
|
return ErrInvalidPriority
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.Topic) > 0 {
|
||||||
|
cfg.Topic = override.Topic
|
||||||
|
}
|
||||||
|
if len(override.URL) > 0 {
|
||||||
|
cfg.URL = override.URL
|
||||||
|
}
|
||||||
|
if override.Priority > 0 {
|
||||||
|
cfg.Priority = override.Priority
|
||||||
|
}
|
||||||
|
if len(override.Token) > 0 {
|
||||||
|
cfg.Token = override.Token
|
||||||
|
}
|
||||||
|
if len(override.Email) > 0 {
|
||||||
|
cfg.Email = override.Email
|
||||||
|
}
|
||||||
|
if len(override.Click) > 0 {
|
||||||
|
cfg.Click = override.Click
|
||||||
|
}
|
||||||
|
if override.DisableFirebase {
|
||||||
|
cfg.DisableFirebase = true
|
||||||
|
}
|
||||||
|
if override.DisableCache {
|
||||||
|
cfg.DisableCache = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Slack
|
// AlertProvider is the configuration necessary for sending an alert using Slack
|
||||||
type AlertProvider struct {
|
type AlertProvider struct {
|
||||||
Topic string `yaml:"topic"`
|
DefaultConfig Config `yaml:",inline"`
|
||||||
URL string `yaml:"url,omitempty"` // Defaults to DefaultURL
|
|
||||||
Priority int `yaml:"priority,omitempty"` // Defaults to DefaultPriority
|
|
||||||
Token string `yaml:"token,omitempty"` // Defaults to ""
|
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// Override is a case under which the default integration is overridden
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
type Override struct {
|
||||||
if len(provider.URL) == 0 {
|
Group string `yaml:"group"`
|
||||||
provider.URL = DefaultURL
|
Config `yaml:",inline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the provider's configuration
|
||||||
|
func (provider *AlertProvider) Validate() error {
|
||||||
|
registeredGroups := make(map[string]bool)
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if len(override.Group) == 0 {
|
||||||
|
return ErrDuplicateGroupOverride
|
||||||
|
}
|
||||||
|
if _, ok := registeredGroups[override.Group]; ok {
|
||||||
|
return ErrDuplicateGroupOverride
|
||||||
|
}
|
||||||
|
if len(override.Token) > 0 && !strings.HasPrefix(override.Token, TokenPrefix) {
|
||||||
|
return ErrDuplicateGroupOverride
|
||||||
|
}
|
||||||
|
if override.Priority < 0 || override.Priority >= 6 {
|
||||||
|
return ErrDuplicateGroupOverride
|
||||||
|
}
|
||||||
|
registeredGroups[override.Group] = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if provider.Priority == 0 {
|
|
||||||
provider.Priority = DefaultPriority
|
return provider.DefaultConfig.Validate()
|
||||||
}
|
|
||||||
isTokenValid := true
|
|
||||||
if len(provider.Token) > 0 {
|
|
||||||
isTokenValid = strings.HasPrefix(provider.Token, "tk_")
|
|
||||||
}
|
|
||||||
return len(provider.URL) > 0 && len(provider.Topic) > 0 && provider.Priority > 0 && provider.Priority < 6 && isTokenValid
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
request, err := http.NewRequest(http.MethodPost, provider.URL, buffer)
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||||
|
url := cfg.URL
|
||||||
|
request, err := http.NewRequest(http.MethodPost, url, buffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
request.Header.Set("Content-Type", "application/json")
|
request.Header.Set("Content-Type", "application/json")
|
||||||
if len(provider.Token) > 0 {
|
if token := cfg.Token; len(token) > 0 {
|
||||||
request.Header.Set("Authorization", "Bearer "+provider.Token)
|
request.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
if cfg.DisableFirebase {
|
||||||
|
request.Header.Set("Firebase", "no")
|
||||||
|
}
|
||||||
|
if cfg.DisableCache {
|
||||||
|
request.Header.Set("Cache", "no")
|
||||||
}
|
}
|
||||||
response, err := client.GetHTTPClient(nil).Do(request)
|
response, err := client.GetHTTPClient(nil).Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -74,32 +167,77 @@ type Body struct {
|
|||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
Priority int `json:"priority"`
|
Priority int `json:"priority"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
Click string `json:"click,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildRequestBody builds the request body for the provider
|
// buildRequestBody builds the request body for the provider
|
||||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
|
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
var message, tag string
|
var message, formattedConditionResults, tag string
|
||||||
if resolved {
|
if resolved {
|
||||||
tag = "white_check_mark"
|
tag = "white_check_mark"
|
||||||
message = "An alert has been resolved after passing successfully " + strconv.Itoa(alert.SuccessThreshold) + " time(s) in a row"
|
message = "An alert has been resolved after passing successfully " + strconv.Itoa(alert.SuccessThreshold) + " time(s) in a row"
|
||||||
} else {
|
} else {
|
||||||
tag = "x"
|
tag = "rotating_light"
|
||||||
message = "An alert has been triggered due to having failed " + strconv.Itoa(alert.FailureThreshold) + " time(s) in a row"
|
message = "An alert has been triggered due to having failed " + strconv.Itoa(alert.FailureThreshold) + " time(s) in a row"
|
||||||
}
|
}
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
var prefix string
|
||||||
|
if conditionResult.Success {
|
||||||
|
prefix = "🟢"
|
||||||
|
} else {
|
||||||
|
prefix = "🔴"
|
||||||
|
}
|
||||||
|
formattedConditionResults += fmt.Sprintf("\n%s %s", prefix, conditionResult.Condition)
|
||||||
|
}
|
||||||
if len(alert.GetDescription()) > 0 {
|
if len(alert.GetDescription()) > 0 {
|
||||||
message += " with the following description: " + alert.GetDescription()
|
message += " with the following description: " + alert.GetDescription()
|
||||||
}
|
}
|
||||||
|
message += formattedConditionResults
|
||||||
body, _ := json.Marshal(Body{
|
body, _ := json.Marshal(Body{
|
||||||
Topic: provider.Topic,
|
Topic: cfg.Topic,
|
||||||
Title: "Gatus: " + endpoint.DisplayName(),
|
Title: "Gatus: " + ep.DisplayName(),
|
||||||
Message: message,
|
Message: message,
|
||||||
Tags: []string{tag},
|
Tags: []string{tag},
|
||||||
Priority: provider.Priority,
|
Priority: cfg.Priority,
|
||||||
|
Email: cfg.Email,
|
||||||
|
Click: cfg.Click,
|
||||||
})
|
})
|
||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ package ntfy
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
name string
|
name string
|
||||||
provider AlertProvider
|
provider AlertProvider
|
||||||
@@ -16,49 +19,78 @@ func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "valid",
|
name: "valid",
|
||||||
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
|
provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1}},
|
||||||
expected: true,
|
expected: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "no-url-should-use-default-value",
|
name: "no-url-should-use-default-value",
|
||||||
provider: AlertProvider{Topic: "example", Priority: 1},
|
provider: AlertProvider{DefaultConfig: Config{Topic: "example", Priority: 1}},
|
||||||
expected: true,
|
expected: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "valid-with-token",
|
name: "valid-with-token",
|
||||||
provider: AlertProvider{Topic: "example", Priority: 1, Token: "tk_faketoken"},
|
provider: AlertProvider{DefaultConfig: Config{Topic: "example", Priority: 1, Token: "tk_faketoken"}},
|
||||||
expected: true,
|
expected: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid-token",
|
name: "invalid-token",
|
||||||
provider: AlertProvider{Topic: "example", Priority: 1, Token: "xx_faketoken"},
|
provider: AlertProvider{DefaultConfig: Config{Topic: "example", Priority: 1, Token: "xx_faketoken"}},
|
||||||
expected: false,
|
expected: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid-topic",
|
name: "invalid-topic",
|
||||||
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "", Priority: 1},
|
provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "", Priority: 1}},
|
||||||
expected: false,
|
expected: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid-priority-too-high",
|
name: "invalid-priority-too-high",
|
||||||
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 6},
|
provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 6}},
|
||||||
expected: false,
|
expected: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid-priority-too-low",
|
name: "invalid-priority-too-low",
|
||||||
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: -1},
|
provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: -1}},
|
||||||
expected: false,
|
expected: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "no-priority-should-use-default-value",
|
name: "no-priority-should-use-default-value",
|
||||||
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example"},
|
provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example"}},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-override-token",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{Topic: "example"}, Overrides: []Override{{Group: "g", Config: Config{Token: "xx_faketoken"}}}},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-override-priority",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{Topic: "example"}, Overrides: []Override{{Group: "g", Config: Config{Priority: 8}}}},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no-override-group-name",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{Topic: "example"}, Overrides: []Override{{}}},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duplicate-override-group-names",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{Topic: "example"}, Overrides: []Override{{Group: "g"}, {Group: "g"}}},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid-override",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{Topic: "example"}, Overrides: []Override{{Group: "g1", Config: Config{Priority: 4, Click: "https://example.com"}}, {Group: "g2", Config: Config{Topic: "Example", Token: "tk_faketoken"}}}},
|
||||||
expected: true,
|
expected: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.name, func(t *testing.T) {
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
if scenario.provider.IsValid() != scenario.expected {
|
err := scenario.provider.Validate()
|
||||||
t.Errorf("expected %t, got %t", scenario.expected, scenario.provider.IsValid())
|
if scenario.expected && err != nil {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
|
if !scenario.expected && err == nil {
|
||||||
|
t.Error("expected error, got none")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -76,26 +108,59 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
|
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1","tags":["x"],"priority":1}`,
|
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 2},
|
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 2}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2","tags":["white_check_mark"],"priority":2}`,
|
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\n🟢 [CONNECTED] == true\n🟢 [STATUS] == 200","tags":["white_check_mark"],"priority":2}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-email",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com"}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-email",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 2, Email: "test@example.com", Click: "example.com"}},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\n🟢 [CONNECTED] == true\n🟢 [STATUS] == 200","tags":["white_check_mark"],"priority":2,"email":"test@example.com","click":"example.com"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "group-override",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 5, Email: "test@example.com", Click: "example.com"}, Overrides: []Override{{Group: "g", Config: Config{Topic: "group-topic", Priority: 4, Email: "override@test.com", Click: "test.com"}}}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: `{"topic":"group-topic","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":4,"email":"override@test.com","click":"test.com"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "alert-override",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 5, Email: "test@example.com", Click: "example.com"}, Overrides: []Override{{Group: "g", Config: Config{Topic: "group-topic", Priority: 4, Email: "override@test.com", Click: "test.com"}}}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3, ProviderOverride: map[string]any{"topic": "alert-topic"}},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: `{"topic":"alert-topic","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":4,"email":"override@test.com","click":"test.com"}`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
cfg, err := scenario.Provider.GetConfig("g", &scenario.Alert)
|
||||||
|
if err != nil {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
body := scenario.Provider.buildRequestBody(
|
body := scenario.Provider.buildRequestBody(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
cfg,
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
},
|
},
|
||||||
@@ -112,3 +177,238 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
|
description := "description-1"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
Group string
|
||||||
|
ExpectedBody string
|
||||||
|
ExpectedHeaders map[string]string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com"}},
|
||||||
|
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
Group: "",
|
||||||
|
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
|
||||||
|
ExpectedHeaders: map[string]string{
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "token",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", Token: "tk_mytoken"}},
|
||||||
|
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
Group: "",
|
||||||
|
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
|
||||||
|
ExpectedHeaders: map[string]string{
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": "Bearer tk_mytoken",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "no firebase",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableFirebase: true}},
|
||||||
|
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
Group: "",
|
||||||
|
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
|
||||||
|
ExpectedHeaders: map[string]string{
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Firebase": "no",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "no cache",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableCache: true}},
|
||||||
|
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
Group: "",
|
||||||
|
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
|
||||||
|
ExpectedHeaders: map[string]string{
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Cache": "no",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "neither firebase & cache",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableFirebase: true, DisableCache: true}},
|
||||||
|
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
Group: "",
|
||||||
|
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
|
||||||
|
ExpectedHeaders: map[string]string{
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Firebase": "no",
|
||||||
|
"Cache": "no",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "overrides",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", Token: "tk_mytoken"}, Overrides: []Override{Override{Group: "other-group", Config: Config{URL: "https://example.com", Token: "tk_othertoken"}}, Override{Group: "test-group", Config: Config{Token: "tk_test_token"}}}},
|
||||||
|
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
Group: "test-group",
|
||||||
|
ExpectedBody: `{"topic":"example","title":"Gatus: test-group/endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
|
||||||
|
ExpectedHeaders: map[string]string{
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": "Bearer tk_test_token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
// Start a local HTTP server
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
// Test request parameters
|
||||||
|
for header, value := range scenario.ExpectedHeaders {
|
||||||
|
if value != req.Header.Get(header) {
|
||||||
|
t.Errorf("expected: %s, got: %s", value, req.Header.Get(header))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(req.Body)
|
||||||
|
if string(body) != scenario.ExpectedBody {
|
||||||
|
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
||||||
|
}
|
||||||
|
// Send response to be tested
|
||||||
|
rw.Write([]byte(`OK`))
|
||||||
|
}))
|
||||||
|
// Close the server when test finishes
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
scenario.Provider.DefaultConfig.URL = server.URL
|
||||||
|
err := scenario.Provider.Send(
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name", Group: scenario.Group},
|
||||||
|
&scenario.Alert,
|
||||||
|
&endpoint.Result{
|
||||||
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Error("Encountered an error on Send: ", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputGroup string
|
||||||
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{URL: "https://group-example.com", Topic: "group-topic", Priority: 2},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{URL: "https://group-example.com", Topic: "group-topic", Priority: 2},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{URL: "https://group-example.com", Topic: "group-topic", Priority: 2},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{URL: "https://group-example.com", Topic: "group-topic", Priority: 2},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"url": "http://alert-example.com", "topic": "alert-topic", "priority": 3}},
|
||||||
|
ExpectedOutput: Config{URL: "http://alert-example.com", Topic: "alert-topic", Priority: 3},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-partial-overrides",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{Topic: "group-topic"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"priority": 3}},
|
||||||
|
ExpectedOutput: Config{URL: "https://ntfy.sh", Topic: "group-topic", Priority: 3},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.URL != scenario.ExpectedOutput.URL {
|
||||||
|
t.Errorf("expected url %s, got %s", scenario.ExpectedOutput.URL, got.URL)
|
||||||
|
}
|
||||||
|
if got.Topic != scenario.ExpectedOutput.Topic {
|
||||||
|
t.Errorf("expected topic %s, got %s", scenario.ExpectedOutput.Topic, got.Topic)
|
||||||
|
}
|
||||||
|
if got.Priority != scenario.ExpectedOutput.Priority {
|
||||||
|
t.Errorf("expected priority %d, got %d", scenario.ExpectedOutput.Priority, got.Priority)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package opsgenie
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -11,14 +12,19 @@ import (
|
|||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
restAPI = "https://api.opsgenie.com/v2/alerts"
|
restAPI = "https://api.opsgenie.com/v2/alerts"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AlertProvider struct {
|
var (
|
||||||
|
ErrAPIKeyNotSet = errors.New("api-key not set")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
// APIKey to use for
|
// APIKey to use for
|
||||||
APIKey string `yaml:"api-key"`
|
APIKey string `yaml:"api-key"`
|
||||||
|
|
||||||
@@ -46,26 +52,74 @@ type AlertProvider struct {
|
|||||||
//
|
//
|
||||||
// default: []
|
// default: []
|
||||||
Tags []string `yaml:"tags"`
|
Tags []string `yaml:"tags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.APIKey) == 0 {
|
||||||
|
return ErrAPIKeyNotSet
|
||||||
|
}
|
||||||
|
if len(cfg.Source) == 0 {
|
||||||
|
cfg.Source = "gatus"
|
||||||
|
}
|
||||||
|
if len(cfg.EntityPrefix) == 0 {
|
||||||
|
cfg.EntityPrefix = "gatus-"
|
||||||
|
}
|
||||||
|
if len(cfg.AliasPrefix) == 0 {
|
||||||
|
cfg.AliasPrefix = "gatus-healthcheck-"
|
||||||
|
}
|
||||||
|
if len(cfg.Priority) == 0 {
|
||||||
|
cfg.Priority = "P1"
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.APIKey) > 0 {
|
||||||
|
cfg.APIKey = override.APIKey
|
||||||
|
}
|
||||||
|
if len(override.Priority) > 0 {
|
||||||
|
cfg.Priority = override.Priority
|
||||||
|
}
|
||||||
|
if len(override.Source) > 0 {
|
||||||
|
cfg.Source = override.Source
|
||||||
|
}
|
||||||
|
if len(override.EntityPrefix) > 0 {
|
||||||
|
cfg.EntityPrefix = override.EntityPrefix
|
||||||
|
}
|
||||||
|
if len(override.AliasPrefix) > 0 {
|
||||||
|
cfg.AliasPrefix = override.AliasPrefix
|
||||||
|
}
|
||||||
|
if len(override.Tags) > 0 {
|
||||||
|
cfg.Tags = override.Tags
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// Validate the provider's configuration
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
func (provider *AlertProvider) Validate() error {
|
||||||
return len(provider.APIKey) > 0
|
return provider.DefaultConfig.Validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
//
|
//
|
||||||
// Relevant: https://docs.opsgenie.com/docs/alert-api
|
// Relevant: https://docs.opsgenie.com/docs/alert-api
|
||||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
err := provider.createAlert(endpoint, alert, result, resolved)
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = provider.sendAlertRequest(cfg, ep, alert, result, resolved)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if resolved {
|
if resolved {
|
||||||
err = provider.closeAlert(endpoint, alert)
|
err = provider.closeAlert(cfg, ep, alert)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -75,24 +129,24 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
|
|||||||
// The alert has been resolved and there's no error, so we can clear the alert's ResolveKey
|
// The alert has been resolved and there's no error, so we can clear the alert's ResolveKey
|
||||||
alert.ResolveKey = ""
|
alert.ResolveKey = ""
|
||||||
} else {
|
} else {
|
||||||
alert.ResolveKey = provider.alias(buildKey(endpoint))
|
alert.ResolveKey = cfg.AliasPrefix + buildKey(ep)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *AlertProvider) createAlert(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
func (provider *AlertProvider) sendAlertRequest(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
payload := provider.buildCreateRequestBody(endpoint, alert, result, resolved)
|
payload := provider.buildCreateRequestBody(cfg, ep, alert, result, resolved)
|
||||||
return provider.sendRequest(restAPI, http.MethodPost, payload)
|
return provider.sendRequest(cfg, restAPI, http.MethodPost, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *AlertProvider) closeAlert(endpoint *core.Endpoint, alert *alert.Alert) error {
|
func (provider *AlertProvider) closeAlert(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert) error {
|
||||||
payload := provider.buildCloseRequestBody(endpoint, alert)
|
payload := provider.buildCloseRequestBody(ep, alert)
|
||||||
url := restAPI + "/" + provider.alias(buildKey(endpoint)) + "/close?identifierType=alias"
|
url := restAPI + "/" + cfg.AliasPrefix + buildKey(ep) + "/close?identifierType=alias"
|
||||||
return provider.sendRequest(url, http.MethodPost, payload)
|
return provider.sendRequest(cfg, url, http.MethodPost, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *AlertProvider) sendRequest(url, method string, payload interface{}) error {
|
func (provider *AlertProvider) sendRequest(cfg *Config, url, method string, payload interface{}) error {
|
||||||
body, err := json.Marshal(payload)
|
body, err := json.Marshal(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error build alert with payload %v: %w", payload, err)
|
return fmt.Errorf("error build alert with payload %v: %w", payload, err)
|
||||||
@@ -102,7 +156,7 @@ func (provider *AlertProvider) sendRequest(url, method string, payload interface
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
request.Header.Set("Content-Type", "application/json")
|
request.Header.Set("Content-Type", "application/json")
|
||||||
request.Header.Set("Authorization", "GenieKey "+provider.APIKey)
|
request.Header.Set("Authorization", "GenieKey "+cfg.APIKey)
|
||||||
response, err := client.GetHTTPClient(nil).Do(request)
|
response, err := client.GetHTTPClient(nil).Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -115,18 +169,19 @@ func (provider *AlertProvider) sendRequest(url, method string, payload interface
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *AlertProvider) buildCreateRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) alertCreateRequest {
|
func (provider *AlertProvider) buildCreateRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) alertCreateRequest {
|
||||||
var message, description, results string
|
var message, description string
|
||||||
if resolved {
|
if resolved {
|
||||||
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription())
|
message = fmt.Sprintf("RESOLVED: %s - %s", ep.Name, alert.GetDescription())
|
||||||
description = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
|
description = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||||
} else {
|
} else {
|
||||||
message = fmt.Sprintf("%s - %s", endpoint.Name, alert.GetDescription())
|
message = fmt.Sprintf("%s - %s", ep.Name, alert.GetDescription())
|
||||||
description = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
description = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||||
}
|
}
|
||||||
if endpoint.Group != "" {
|
if ep.Group != "" {
|
||||||
message = fmt.Sprintf("[%s] %s", endpoint.Group, message)
|
message = fmt.Sprintf("[%s] %s", ep.Group, message)
|
||||||
}
|
}
|
||||||
|
var formattedConditionResults string
|
||||||
for _, conditionResult := range result.ConditionResults {
|
for _, conditionResult := range result.ConditionResults {
|
||||||
var prefix string
|
var prefix string
|
||||||
if conditionResult.Success {
|
if conditionResult.Success {
|
||||||
@@ -134,13 +189,13 @@ func (provider *AlertProvider) buildCreateRequestBody(endpoint *core.Endpoint, a
|
|||||||
} else {
|
} else {
|
||||||
prefix = "▢"
|
prefix = "▢"
|
||||||
}
|
}
|
||||||
results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
||||||
}
|
}
|
||||||
description = description + "\n" + results
|
description = description + "\n" + formattedConditionResults
|
||||||
key := buildKey(endpoint)
|
key := buildKey(ep)
|
||||||
details := map[string]string{
|
details := map[string]string{
|
||||||
"endpoint:url": endpoint.URL,
|
"endpoint:url": ep.URL,
|
||||||
"endpoint:group": endpoint.Group,
|
"endpoint:group": ep.Group,
|
||||||
"result:hostname": result.Hostname,
|
"result:hostname": result.Hostname,
|
||||||
"result:ip": result.IP,
|
"result:ip": result.IP,
|
||||||
"result:dns_code": result.DNSRCode,
|
"result:dns_code": result.DNSRCode,
|
||||||
@@ -157,65 +212,55 @@ func (provider *AlertProvider) buildCreateRequestBody(endpoint *core.Endpoint, a
|
|||||||
return alertCreateRequest{
|
return alertCreateRequest{
|
||||||
Message: message,
|
Message: message,
|
||||||
Description: description,
|
Description: description,
|
||||||
Source: provider.source(),
|
Source: cfg.Source,
|
||||||
Priority: provider.priority(),
|
Priority: cfg.Priority,
|
||||||
Alias: provider.alias(key),
|
Alias: cfg.AliasPrefix + key,
|
||||||
Entity: provider.entity(key),
|
Entity: cfg.EntityPrefix + key,
|
||||||
Tags: provider.Tags,
|
Tags: cfg.Tags,
|
||||||
Details: details,
|
Details: details,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *AlertProvider) buildCloseRequestBody(endpoint *core.Endpoint, alert *alert.Alert) alertCloseRequest {
|
func (provider *AlertProvider) buildCloseRequestBody(ep *endpoint.Endpoint, alert *alert.Alert) alertCloseRequest {
|
||||||
return alertCloseRequest{
|
return alertCloseRequest{
|
||||||
Source: buildKey(endpoint),
|
Source: buildKey(ep),
|
||||||
Note: fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription()),
|
Note: fmt.Sprintf("RESOLVED: %s - %s", ep.Name, alert.GetDescription()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *AlertProvider) source() string {
|
|
||||||
source := provider.Source
|
|
||||||
if source == "" {
|
|
||||||
return "gatus"
|
|
||||||
}
|
|
||||||
return source
|
|
||||||
}
|
|
||||||
|
|
||||||
func (provider *AlertProvider) alias(key string) string {
|
|
||||||
alias := provider.AliasPrefix
|
|
||||||
if alias == "" {
|
|
||||||
alias = "gatus-healthcheck-"
|
|
||||||
}
|
|
||||||
return alias + key
|
|
||||||
}
|
|
||||||
|
|
||||||
func (provider *AlertProvider) entity(key string) string {
|
|
||||||
alias := provider.EntityPrefix
|
|
||||||
if alias == "" {
|
|
||||||
alias = "gatus-"
|
|
||||||
}
|
|
||||||
return alias + key
|
|
||||||
}
|
|
||||||
|
|
||||||
func (provider *AlertProvider) priority() string {
|
|
||||||
priority := provider.Priority
|
|
||||||
if priority == "" {
|
|
||||||
return "P1"
|
|
||||||
}
|
|
||||||
return priority
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildKey(endpoint *core.Endpoint) string {
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
name := toKebabCase(endpoint.Name)
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
if endpoint.Group == "" {
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildKey(ep *endpoint.Endpoint) string {
|
||||||
|
name := toKebabCase(ep.Name)
|
||||||
|
if ep.Group == "" {
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
return toKebabCase(endpoint.Group) + "-" + name
|
return toKebabCase(ep.Group) + "-" + name
|
||||||
}
|
}
|
||||||
|
|
||||||
func toKebabCase(val string) string {
|
func toKebabCase(val string) string {
|
||||||
|
|||||||
@@ -7,17 +7,17 @@ import (
|
|||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/TwiN/gatus/v5/test"
|
"github.com/TwiN/gatus/v5/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
invalidProvider := AlertProvider{APIKey: ""}
|
invalidProvider := AlertProvider{DefaultConfig: Config{APIKey: ""}}
|
||||||
if invalidProvider.IsValid() {
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
t.Error("provider shouldn't have been valid")
|
t.Error("provider shouldn't have been valid")
|
||||||
}
|
}
|
||||||
validProvider := AlertProvider{APIKey: "00000000-0000-0000-0000-000000000000"}
|
validProvider := AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}}
|
||||||
if !validProvider.IsValid() {
|
if err := validProvider.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,7 +35,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
|
||||||
Alert: alert.Alert{Description: &description, SuccessThreshold: 1, FailureThreshold: 1},
|
Alert: alert.Alert{Description: &description, SuccessThreshold: 1, FailureThreshold: 1},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedError: false,
|
ExpectedError: false,
|
||||||
@@ -45,7 +45,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "triggered-error",
|
Name: "triggered-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
|
||||||
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedError: true,
|
ExpectedError: true,
|
||||||
@@ -55,7 +55,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
|
||||||
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedError: false,
|
ExpectedError: false,
|
||||||
@@ -65,7 +65,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved-error",
|
Name: "resolved-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
|
||||||
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedError: true,
|
ExpectedError: true,
|
||||||
@@ -74,15 +74,14 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
err := scenario.Provider.Send(
|
err := scenario.Provider.Send(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
},
|
},
|
||||||
@@ -106,17 +105,17 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
|
|||||||
Name string
|
Name string
|
||||||
Provider *AlertProvider
|
Provider *AlertProvider
|
||||||
Alert *alert.Alert
|
Alert *alert.Alert
|
||||||
Endpoint *core.Endpoint
|
Endpoint *endpoint.Endpoint
|
||||||
Result *core.Result
|
Result *endpoint.Result
|
||||||
Resolved bool
|
Resolved bool
|
||||||
want alertCreateRequest
|
want alertCreateRequest
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "missing all params (unresolved)",
|
Name: "missing all params (unresolved)",
|
||||||
Provider: &AlertProvider{},
|
Provider: &AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
|
||||||
Alert: &alert.Alert{},
|
Alert: &alert.Alert{},
|
||||||
Endpoint: &core.Endpoint{},
|
Endpoint: &endpoint.Endpoint{},
|
||||||
Result: &core.Result{},
|
Result: &endpoint.Result{},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
want: alertCreateRequest{
|
want: alertCreateRequest{
|
||||||
Message: " - ",
|
Message: " - ",
|
||||||
@@ -131,10 +130,10 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "missing all params (resolved)",
|
Name: "missing all params (resolved)",
|
||||||
Provider: &AlertProvider{},
|
Provider: &AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
|
||||||
Alert: &alert.Alert{},
|
Alert: &alert.Alert{},
|
||||||
Endpoint: &core.Endpoint{},
|
Endpoint: &endpoint.Endpoint{},
|
||||||
Result: &core.Result{},
|
Result: &endpoint.Result{},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
want: alertCreateRequest{
|
want: alertCreateRequest{
|
||||||
Message: "RESOLVED: - ",
|
Message: "RESOLVED: - ",
|
||||||
@@ -149,16 +148,16 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "with default options (unresolved)",
|
Name: "with default options (unresolved)",
|
||||||
Provider: &AlertProvider{},
|
Provider: &AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
|
||||||
Alert: &alert.Alert{
|
Alert: &alert.Alert{
|
||||||
Description: &description,
|
Description: &description,
|
||||||
FailureThreshold: 3,
|
FailureThreshold: 3,
|
||||||
},
|
},
|
||||||
Endpoint: &core.Endpoint{
|
Endpoint: &endpoint.Endpoint{
|
||||||
Name: "my super app",
|
Name: "my super app",
|
||||||
},
|
},
|
||||||
Result: &core.Result{
|
Result: &endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{
|
{
|
||||||
Condition: "[STATUS] == 200",
|
Condition: "[STATUS] == 200",
|
||||||
Success: true,
|
Success: true,
|
||||||
@@ -184,21 +183,23 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
|
|||||||
{
|
{
|
||||||
Name: "with custom options (resolved)",
|
Name: "with custom options (resolved)",
|
||||||
Provider: &AlertProvider{
|
Provider: &AlertProvider{
|
||||||
Priority: "P5",
|
DefaultConfig: Config{
|
||||||
EntityPrefix: "oompa-",
|
Priority: "P5",
|
||||||
AliasPrefix: "loompa-",
|
EntityPrefix: "oompa-",
|
||||||
Source: "gatus-hc",
|
AliasPrefix: "loompa-",
|
||||||
Tags: []string{"do-ba-dee-doo"},
|
Source: "gatus-hc",
|
||||||
|
Tags: []string{"do-ba-dee-doo"},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Alert: &alert.Alert{
|
Alert: &alert.Alert{
|
||||||
Description: &description,
|
Description: &description,
|
||||||
SuccessThreshold: 4,
|
SuccessThreshold: 4,
|
||||||
},
|
},
|
||||||
Endpoint: &core.Endpoint{
|
Endpoint: &endpoint.Endpoint{
|
||||||
Name: "my mega app",
|
Name: "my mega app",
|
||||||
},
|
},
|
||||||
Result: &core.Result{
|
Result: &endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{
|
{
|
||||||
Condition: "[STATUS] == 200",
|
Condition: "[STATUS] == 200",
|
||||||
Success: true,
|
Success: true,
|
||||||
@@ -220,23 +221,23 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
|
|||||||
{
|
{
|
||||||
Name: "with default options and details (unresolved)",
|
Name: "with default options and details (unresolved)",
|
||||||
Provider: &AlertProvider{
|
Provider: &AlertProvider{
|
||||||
Tags: []string{"foo"},
|
DefaultConfig: Config{Tags: []string{"foo"}, APIKey: "00000000-0000-0000-0000-000000000000"},
|
||||||
},
|
},
|
||||||
Alert: &alert.Alert{
|
Alert: &alert.Alert{
|
||||||
Description: &description,
|
Description: &description,
|
||||||
FailureThreshold: 6,
|
FailureThreshold: 6,
|
||||||
},
|
},
|
||||||
Endpoint: &core.Endpoint{
|
Endpoint: &endpoint.Endpoint{
|
||||||
Name: "my app",
|
Name: "my app",
|
||||||
Group: "end game",
|
Group: "end game",
|
||||||
URL: "https://my.go/app",
|
URL: "https://my.go/app",
|
||||||
},
|
},
|
||||||
Result: &core.Result{
|
Result: &endpoint.Result{
|
||||||
HTTPStatus: 400,
|
HTTPStatus: 400,
|
||||||
Hostname: "my.go",
|
Hostname: "my.go",
|
||||||
Errors: []string{"error 01", "error 02"},
|
Errors: []string{"error 01", "error 02"},
|
||||||
Success: false,
|
Success: false,
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{
|
{
|
||||||
Condition: "[STATUS] == 200",
|
Condition: "[STATUS] == 200",
|
||||||
Success: false,
|
Success: false,
|
||||||
@@ -265,8 +266,9 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
|
|||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
actual := scenario
|
actual := scenario
|
||||||
t.Run(actual.Name, func(t *testing.T) {
|
t.Run(actual.Name, func(t *testing.T) {
|
||||||
if got := actual.Provider.buildCreateRequestBody(actual.Endpoint, actual.Alert, actual.Result, actual.Resolved); !reflect.DeepEqual(got, actual.want) {
|
_ = scenario.Provider.Validate()
|
||||||
t.Errorf("buildCreateRequestBody() = %v, want %v", got, actual.want)
|
if got := actual.Provider.buildCreateRequestBody(&scenario.Provider.DefaultConfig, actual.Endpoint, actual.Alert, actual.Result, actual.Resolved); !reflect.DeepEqual(got, actual.want) {
|
||||||
|
t.Errorf("got:\n%v\nwant:\n%v", got, actual.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -279,14 +281,14 @@ func TestAlertProvider_buildCloseRequestBody(t *testing.T) {
|
|||||||
Name string
|
Name string
|
||||||
Provider *AlertProvider
|
Provider *AlertProvider
|
||||||
Alert *alert.Alert
|
Alert *alert.Alert
|
||||||
Endpoint *core.Endpoint
|
Endpoint *endpoint.Endpoint
|
||||||
want alertCloseRequest
|
want alertCloseRequest
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "Missing all values",
|
Name: "Missing all values",
|
||||||
Provider: &AlertProvider{},
|
Provider: &AlertProvider{},
|
||||||
Alert: &alert.Alert{},
|
Alert: &alert.Alert{},
|
||||||
Endpoint: &core.Endpoint{},
|
Endpoint: &endpoint.Endpoint{},
|
||||||
want: alertCloseRequest{
|
want: alertCloseRequest{
|
||||||
Source: "",
|
Source: "",
|
||||||
Note: "RESOLVED: - ",
|
Note: "RESOLVED: - ",
|
||||||
@@ -298,7 +300,7 @@ func TestAlertProvider_buildCloseRequestBody(t *testing.T) {
|
|||||||
Alert: &alert.Alert{
|
Alert: &alert.Alert{
|
||||||
Description: &description,
|
Description: &description,
|
||||||
},
|
},
|
||||||
Endpoint: &core.Endpoint{
|
Endpoint: &endpoint.Endpoint{
|
||||||
Name: "endpoint name",
|
Name: "endpoint name",
|
||||||
},
|
},
|
||||||
want: alertCloseRequest{
|
want: alertCloseRequest{
|
||||||
@@ -307,7 +309,6 @@ func TestAlertProvider_buildCloseRequestBody(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
actual := scenario
|
actual := scenario
|
||||||
t.Run(actual.Name, func(t *testing.T) {
|
t.Run(actual.Name, func(t *testing.T) {
|
||||||
@@ -317,3 +318,44 @@ func TestAlertProvider_buildCloseRequestBody(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"},
|
||||||
|
},
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{APIKey: "00000000-0000-0000-0000-000000000000"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-alert-override--alert-override-should-take-precedence",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"},
|
||||||
|
},
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"api-key": "00000000-0000-0000-0000-000000000001"}},
|
||||||
|
ExpectedOutput: Config{APIKey: "00000000-0000-0000-0000-000000000001"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.APIKey != scenario.ExpectedOutput.APIKey {
|
||||||
|
t.Errorf("expected APIKey to be %s, got %s", scenario.ExpectedOutput.APIKey, got.APIKey)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,23 +3,47 @@ package pagerduty
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/TwiN/logr"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
restAPIURL = "https://events.pagerduty.com/v2/enqueue"
|
restAPIURL = "https://events.pagerduty.com/v2/enqueue"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrIntegrationKeyNotSet = errors.New("integration-key must have exactly 32 characters")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
IntegrationKey string `yaml:"integration-key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.IntegrationKey) != 32 {
|
||||||
|
return ErrIntegrationKeyNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.IntegrationKey) > 0 {
|
||||||
|
cfg.IntegrationKey = override.IntegrationKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using PagerDuty
|
// AlertProvider is the configuration necessary for sending an alert using PagerDuty
|
||||||
type AlertProvider struct {
|
type AlertProvider struct {
|
||||||
IntegrationKey string `yaml:"integration-key"`
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
@@ -30,30 +54,34 @@ type AlertProvider struct {
|
|||||||
|
|
||||||
// Override is a case under which the default integration is overridden
|
// Override is a case under which the default integration is overridden
|
||||||
type Override struct {
|
type Override struct {
|
||||||
Group string `yaml:"group"`
|
Group string `yaml:"group"`
|
||||||
IntegrationKey string `yaml:"integration-key"`
|
Config `yaml:",inline"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// Validate the provider's configuration
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
func (provider *AlertProvider) Validate() error {
|
||||||
registeredGroups := make(map[string]bool)
|
registeredGroups := make(map[string]bool)
|
||||||
if provider.Overrides != nil {
|
if provider.Overrides != nil {
|
||||||
for _, override := range provider.Overrides {
|
for _, override := range provider.Overrides {
|
||||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.IntegrationKey) != 32 {
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||||
return false
|
return ErrDuplicateGroupOverride
|
||||||
}
|
}
|
||||||
registeredGroups[override.Group] = true
|
registeredGroups[override.Group] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Either the default integration key has the right length, or there are overrides who are properly configured.
|
// Either the default integration key has the right length, or there are overrides who are properly configured.
|
||||||
return len(provider.IntegrationKey) == 32 || len(provider.Overrides) != 0
|
return provider.DefaultConfig.Validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
//
|
//
|
||||||
// Relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/
|
// Relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/
|
||||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||||
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
|
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -74,11 +102,10 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
|
|||||||
alert.ResolveKey = ""
|
alert.ResolveKey = ""
|
||||||
} else {
|
} else {
|
||||||
// We need to retrieve the resolve key from the response
|
// We need to retrieve the resolve key from the response
|
||||||
body, err := io.ReadAll(response.Body)
|
|
||||||
var payload pagerDutyResponsePayload
|
var payload pagerDutyResponsePayload
|
||||||
if err = json.Unmarshal(body, &payload); err != nil {
|
if err = json.NewDecoder(response.Body).Decode(&payload); err != nil {
|
||||||
// Silently fail. We don't want to create tons of alerts just because we failed to parse the body.
|
// Silently fail. We don't want to create tons of alerts just because we failed to parse the body.
|
||||||
log.Printf("[pagerduty][Send] Ran into error unmarshaling pagerduty response: %s", err.Error())
|
logr.Errorf("[pagerduty.Send] Ran into error decoding pagerduty response: %s", err.Error())
|
||||||
} else {
|
} else {
|
||||||
alert.ResolveKey = payload.DedupKey
|
alert.ResolveKey = payload.DedupKey
|
||||||
}
|
}
|
||||||
@@ -101,19 +128,19 @@ type Payload struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildRequestBody builds the request body for the provider
|
// buildRequestBody builds the request body for the provider
|
||||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
|
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
var message, eventAction, resolveKey string
|
var message, eventAction, resolveKey string
|
||||||
if resolved {
|
if resolved {
|
||||||
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
|
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||||
eventAction = "resolve"
|
eventAction = "resolve"
|
||||||
resolveKey = alert.ResolveKey
|
resolveKey = alert.ResolveKey
|
||||||
} else {
|
} else {
|
||||||
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
|
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||||
eventAction = "trigger"
|
eventAction = "trigger"
|
||||||
resolveKey = ""
|
resolveKey = ""
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(Body{
|
body, _ := json.Marshal(Body{
|
||||||
RoutingKey: provider.getIntegrationKeyForGroup(endpoint.Group),
|
RoutingKey: cfg.IntegrationKey,
|
||||||
DedupKey: resolveKey,
|
DedupKey: resolveKey,
|
||||||
EventAction: eventAction,
|
EventAction: eventAction,
|
||||||
Payload: Payload{
|
Payload: Payload{
|
||||||
@@ -125,21 +152,40 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
|
|||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
// getIntegrationKeyForGroup returns the appropriate pagerduty integration key for a given group
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
func (provider *AlertProvider) getIntegrationKeyForGroup(group string) string {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
|
return provider.DefaultAlert
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
if provider.Overrides != nil {
|
if provider.Overrides != nil {
|
||||||
for _, override := range provider.Overrides {
|
for _, override := range provider.Overrides {
|
||||||
if group == override.Group {
|
if group == override.Group {
|
||||||
return override.IntegrationKey
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return provider.IntegrationKey
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
return provider.DefaultAlert
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
type pagerDutyResponsePayload struct {
|
type pagerDutyResponsePayload struct {
|
||||||
|
|||||||
@@ -7,54 +7,45 @@ import (
|
|||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/TwiN/gatus/v5/test"
|
"github.com/TwiN/gatus/v5/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
invalidProvider := AlertProvider{IntegrationKey: ""}
|
invalidProvider := AlertProvider{DefaultConfig: Config{IntegrationKey: ""}}
|
||||||
if invalidProvider.IsValid() {
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
t.Error("provider shouldn't have been valid")
|
t.Error("provider shouldn't have been valid")
|
||||||
}
|
}
|
||||||
validProvider := AlertProvider{IntegrationKey: "00000000000000000000000000000000"}
|
validProvider := AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}}
|
||||||
if !validProvider.IsValid() {
|
if err := validProvider.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||||
providerWithInvalidOverrideGroup := AlertProvider{
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
|
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
IntegrationKey: "00000000000000000000000000000000",
|
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
|
||||||
Group: "",
|
Group: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if providerWithInvalidOverrideGroup.IsValid() {
|
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||||
t.Error("provider Group shouldn't have been valid")
|
t.Error("provider Group shouldn't have been valid")
|
||||||
}
|
}
|
||||||
providerWithInvalidOverrideIntegrationKey := AlertProvider{
|
|
||||||
Overrides: []Override{
|
|
||||||
{
|
|
||||||
IntegrationKey: "",
|
|
||||||
Group: "group",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if providerWithInvalidOverrideIntegrationKey.IsValid() {
|
|
||||||
t.Error("provider integration key shouldn't have been valid")
|
|
||||||
}
|
|
||||||
providerWithValidOverride := AlertProvider{
|
providerWithValidOverride := AlertProvider{
|
||||||
|
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
IntegrationKey: "00000000000000000000000000000000",
|
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
|
||||||
Group: "group",
|
Group: "group",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if !providerWithValidOverride.IsValid() {
|
if err := providerWithValidOverride.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid, got error:", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +63,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -82,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "triggered-error",
|
Name: "triggered-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -92,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -102,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved-error",
|
Name: "resolved-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -115,10 +106,10 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
err := scenario.Provider.Send(
|
err := scenario.Provider.Send(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
},
|
},
|
||||||
@@ -146,14 +137,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"},
|
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
|
||||||
Alert: alert.Alert{Description: &description},
|
Alert: alert.Alert{Description: &description},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedBody: "{\"routing_key\":\"00000000000000000000000000000000\",\"dedup_key\":\"\",\"event_action\":\"trigger\",\"payload\":{\"summary\":\"TRIGGERED: endpoint-name - test\",\"source\":\"Gatus\",\"severity\":\"critical\"}}",
|
ExpectedBody: "{\"routing_key\":\"00000000000000000000000000000000\",\"dedup_key\":\"\",\"event_action\":\"trigger\",\"payload\":{\"summary\":\"TRIGGERED: endpoint-name - test\",\"source\":\"Gatus\",\"severity\":\"critical\"}}",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"},
|
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
|
||||||
Alert: alert.Alert{Description: &description, ResolveKey: "key"},
|
Alert: alert.Alert{Description: &description, ResolveKey: "key"},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedBody: "{\"routing_key\":\"00000000000000000000000000000000\",\"dedup_key\":\"key\",\"event_action\":\"resolve\",\"payload\":{\"summary\":\"RESOLVED: endpoint-name - test\",\"source\":\"Gatus\",\"severity\":\"critical\"}}",
|
ExpectedBody: "{\"routing_key\":\"00000000000000000000000000000000\",\"dedup_key\":\"key\",\"event_action\":\"resolve\",\"payload\":{\"summary\":\"RESOLVED: endpoint-name - test\",\"source\":\"Gatus\",\"severity\":\"critical\"}}",
|
||||||
@@ -161,7 +152,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
body := scenario.Provider.buildRequestBody(&core.Endpoint{Name: "endpoint-name"}, &scenario.Alert, &core.Result{}, scenario.Resolved)
|
body := scenario.Provider.buildRequestBody(&scenario.Provider.DefaultConfig, &endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, &endpoint.Result{}, scenario.Resolved)
|
||||||
if string(body) != scenario.ExpectedBody {
|
if string(body) != scenario.ExpectedBody {
|
||||||
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
||||||
}
|
}
|
||||||
@@ -173,74 +164,102 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_getIntegrationKeyForGroup(t *testing.T) {
|
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_GetConfig(t *testing.T) {
|
||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
Name string
|
Name string
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
InputGroup string
|
InputGroup string
|
||||||
ExpectedOutput string
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "provider-no-override-specify-no-group-should-default",
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
IntegrationKey: "00000000000000000000000000000001",
|
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||||
Overrides: nil,
|
Overrides: nil,
|
||||||
},
|
},
|
||||||
InputGroup: "",
|
InputGroup: "",
|
||||||
ExpectedOutput: "00000000000000000000000000000001",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-no-override-specify-group-should-default",
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
IntegrationKey: "00000000000000000000000000000001",
|
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||||
Overrides: nil,
|
Overrides: nil,
|
||||||
},
|
},
|
||||||
InputGroup: "group",
|
InputGroup: "group",
|
||||||
ExpectedOutput: "00000000000000000000000000000001",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-with-override-specify-no-group-should-default",
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
IntegrationKey: "00000000000000000000000000000001",
|
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
IntegrationKey: "00000000000000000000000000000002",
|
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InputGroup: "",
|
InputGroup: "",
|
||||||
ExpectedOutput: "00000000000000000000000000000001",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-with-override-specify-group-should-override",
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
IntegrationKey: "00000000000000000000000000000001",
|
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
IntegrationKey: "00000000000000000000000000000002",
|
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InputGroup: "group",
|
InputGroup: "group",
|
||||||
ExpectedOutput: "00000000000000000000000000000002",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000002"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"integration-key": "00000000000000000000000000000003"}},
|
||||||
|
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000003"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
if output := scenario.Provider.getIntegrationKeyForGroup(scenario.InputGroup); output != scenario.ExpectedOutput {
|
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||||
t.Errorf("expected %s, got %s", scenario.ExpectedOutput, output)
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.IntegrationKey != scenario.ExpectedOutput.IntegrationKey {
|
||||||
|
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.IntegrationKey, got.IntegrationKey)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ package provider
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/awsses"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/custom"
|
"github.com/TwiN/gatus/v5/alerting/provider/custom"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/discord"
|
"github.com/TwiN/gatus/v5/alerting/provider/discord"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/email"
|
"github.com/TwiN/gatus/v5/alerting/provider/email"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/gitea"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/github"
|
"github.com/TwiN/gatus/v5/alerting/provider/github"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
|
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
|
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
|
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
|
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
|
||||||
@@ -17,25 +20,35 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
|
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/slack"
|
"github.com/TwiN/gatus/v5/alerting/provider/slack"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/teams"
|
"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/telegram"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
|
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/alerting/provider/zulip"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertProvider is the interface that each providers should implement
|
// AlertProvider is the interface that each provider should implement
|
||||||
type AlertProvider interface {
|
type AlertProvider interface {
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// Validate the provider's configuration
|
||||||
IsValid() bool
|
Validate() error
|
||||||
|
|
||||||
|
// Send an alert using the provider
|
||||||
|
Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
GetDefaultAlert() *alert.Alert
|
GetDefaultAlert() *alert.Alert
|
||||||
|
|
||||||
// Send an alert using the provider
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error
|
ValidateOverrides(group string, alert *alert.Alert) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseWithDefaultAlert parses an Endpoint alert by using the provider's default alert as a baseline
|
type Config[T any] interface {
|
||||||
func ParseWithDefaultAlert(providerDefaultAlert, endpointAlert *alert.Alert) {
|
Validate() error
|
||||||
|
Merge(override *T)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeProviderDefaultAlertIntoEndpointAlert parses an Endpoint alert by using the provider's default alert as a baseline
|
||||||
|
func MergeProviderDefaultAlertIntoEndpointAlert(providerDefaultAlert, endpointAlert *alert.Alert) {
|
||||||
if providerDefaultAlert == nil || endpointAlert == nil {
|
if providerDefaultAlert == nil || endpointAlert == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -57,13 +70,16 @@ func ParseWithDefaultAlert(providerDefaultAlert, endpointAlert *alert.Alert) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// Validate interface implementation on compile
|
// Validate provider interface implementation on compile
|
||||||
|
_ AlertProvider = (*awsses.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*custom.AlertProvider)(nil)
|
_ AlertProvider = (*custom.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*discord.AlertProvider)(nil)
|
_ AlertProvider = (*discord.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*email.AlertProvider)(nil)
|
_ AlertProvider = (*email.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*gitea.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*github.AlertProvider)(nil)
|
_ AlertProvider = (*github.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*gitlab.AlertProvider)(nil)
|
_ AlertProvider = (*gitlab.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*googlechat.AlertProvider)(nil)
|
_ AlertProvider = (*googlechat.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*jetbrainsspace.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*matrix.AlertProvider)(nil)
|
_ AlertProvider = (*matrix.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*mattermost.AlertProvider)(nil)
|
_ AlertProvider = (*mattermost.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*messagebird.AlertProvider)(nil)
|
_ AlertProvider = (*messagebird.AlertProvider)(nil)
|
||||||
@@ -73,6 +89,32 @@ var (
|
|||||||
_ AlertProvider = (*pushover.AlertProvider)(nil)
|
_ AlertProvider = (*pushover.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*slack.AlertProvider)(nil)
|
_ AlertProvider = (*slack.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*teams.AlertProvider)(nil)
|
_ AlertProvider = (*teams.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*teamsworkflows.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*telegram.AlertProvider)(nil)
|
_ AlertProvider = (*telegram.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*twilio.AlertProvider)(nil)
|
_ AlertProvider = (*twilio.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*zulip.AlertProvider)(nil)
|
||||||
|
|
||||||
|
// Validate config interface implementation on compile
|
||||||
|
_ Config[awsses.Config] = (*awsses.Config)(nil)
|
||||||
|
_ Config[custom.Config] = (*custom.Config)(nil)
|
||||||
|
_ Config[discord.Config] = (*discord.Config)(nil)
|
||||||
|
_ Config[email.Config] = (*email.Config)(nil)
|
||||||
|
_ Config[gitea.Config] = (*gitea.Config)(nil)
|
||||||
|
_ Config[github.Config] = (*github.Config)(nil)
|
||||||
|
_ Config[gitlab.Config] = (*gitlab.Config)(nil)
|
||||||
|
_ Config[googlechat.Config] = (*googlechat.Config)(nil)
|
||||||
|
_ Config[jetbrainsspace.Config] = (*jetbrainsspace.Config)(nil)
|
||||||
|
_ Config[matrix.Config] = (*matrix.Config)(nil)
|
||||||
|
_ Config[mattermost.Config] = (*mattermost.Config)(nil)
|
||||||
|
_ Config[messagebird.Config] = (*messagebird.Config)(nil)
|
||||||
|
_ Config[ntfy.Config] = (*ntfy.Config)(nil)
|
||||||
|
_ Config[opsgenie.Config] = (*opsgenie.Config)(nil)
|
||||||
|
_ Config[pagerduty.Config] = (*pagerduty.Config)(nil)
|
||||||
|
_ Config[pushover.Config] = (*pushover.Config)(nil)
|
||||||
|
_ Config[slack.Config] = (*slack.Config)(nil)
|
||||||
|
_ Config[teams.Config] = (*teams.Config)(nil)
|
||||||
|
_ Config[teamsworkflows.Config] = (*teamsworkflows.Config)(nil)
|
||||||
|
_ Config[telegram.Config] = (*telegram.Config)(nil)
|
||||||
|
_ Config[twilio.Config] = (*twilio.Config)(nil)
|
||||||
|
_ Config[zulip.Config] = (*zulip.Config)(nil)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ func TestParseWithDefaultAlert(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
ParseWithDefaultAlert(scenario.DefaultAlert, scenario.EndpointAlert)
|
MergeProviderDefaultAlertIntoEndpointAlert(scenario.DefaultAlert, scenario.EndpointAlert)
|
||||||
if scenario.ExpectedOutputAlert == nil {
|
if scenario.ExpectedOutputAlert == nil {
|
||||||
if scenario.EndpointAlert != nil {
|
if scenario.EndpointAlert != nil {
|
||||||
t.Fail()
|
t.Fail()
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ package pushover
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -17,8 +19,13 @@ const (
|
|||||||
defaultPriority = 0
|
defaultPriority = 0
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Pushover
|
var (
|
||||||
type AlertProvider struct {
|
ErrInvalidApplicationToken = errors.New("application-token must be 30 characters long")
|
||||||
|
ErrInvalidUserKey = errors.New("user-key must be 30 characters long")
|
||||||
|
ErrInvalidPriority = errors.New("priority and resolved-priority must be between -2 and 2")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
// Key used to authenticate the application sending
|
// Key used to authenticate the application sending
|
||||||
// See "Your Applications" on the dashboard, or add a new one: https://pushover.net/apps/build
|
// See "Your Applications" on the dashboard, or add a new one: https://pushover.net/apps/build
|
||||||
ApplicationToken string `yaml:"application-token"`
|
ApplicationToken string `yaml:"application-token"`
|
||||||
@@ -34,26 +41,76 @@ type AlertProvider struct {
|
|||||||
// default: 0
|
// default: 0
|
||||||
Priority int `yaml:"priority,omitempty"`
|
Priority int `yaml:"priority,omitempty"`
|
||||||
|
|
||||||
|
// Priority of resolved messages, ranging from -2 (very low) to 2 (Emergency)
|
||||||
|
// default: 0
|
||||||
|
ResolvedPriority int `yaml:"resolved-priority,omitempty"`
|
||||||
|
|
||||||
// Sound of the messages (see: https://pushover.net/api#sounds)
|
// Sound of the messages (see: https://pushover.net/api#sounds)
|
||||||
// default: "" (pushover)
|
// default: "" (pushover)
|
||||||
Sound string `yaml:"sound,omitempty"`
|
Sound string `yaml:"sound,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if cfg.Priority == 0 {
|
||||||
|
cfg.Priority = defaultPriority
|
||||||
|
}
|
||||||
|
if cfg.ResolvedPriority == 0 {
|
||||||
|
cfg.ResolvedPriority = defaultPriority
|
||||||
|
}
|
||||||
|
if len(cfg.ApplicationToken) != 30 {
|
||||||
|
return ErrInvalidApplicationToken
|
||||||
|
}
|
||||||
|
if len(cfg.UserKey) != 30 {
|
||||||
|
return ErrInvalidUserKey
|
||||||
|
}
|
||||||
|
if cfg.Priority < -2 || cfg.Priority > 2 || cfg.ResolvedPriority < -2 || cfg.ResolvedPriority > 2 {
|
||||||
|
return ErrInvalidPriority
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.ApplicationToken) > 0 {
|
||||||
|
cfg.ApplicationToken = override.ApplicationToken
|
||||||
|
}
|
||||||
|
if len(override.UserKey) > 0 {
|
||||||
|
cfg.UserKey = override.UserKey
|
||||||
|
}
|
||||||
|
if len(override.Title) > 0 {
|
||||||
|
cfg.Title = override.Title
|
||||||
|
}
|
||||||
|
if override.Priority != 0 {
|
||||||
|
cfg.Priority = override.Priority
|
||||||
|
}
|
||||||
|
if override.ResolvedPriority != 0 {
|
||||||
|
cfg.ResolvedPriority = override.ResolvedPriority
|
||||||
|
}
|
||||||
|
if len(override.Sound) > 0 {
|
||||||
|
cfg.Sound = override.Sound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using Pushover
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// Validate the provider's configuration
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
func (provider *AlertProvider) Validate() error {
|
||||||
if provider.Priority == 0 {
|
return provider.DefaultConfig.Validate()
|
||||||
provider.Priority = defaultPriority
|
|
||||||
}
|
|
||||||
return len(provider.ApplicationToken) == 30 && len(provider.UserKey) == 30 && provider.Priority >= -2 && provider.Priority <= 2
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
// Reference doc for pushover: https://pushover.net/api
|
// Reference doc for pushover: https://pushover.net/api
|
||||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||||
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
|
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -81,32 +138,51 @@ type Body struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildRequestBody builds the request body for the provider
|
// buildRequestBody builds the request body for the provider
|
||||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
|
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
var message string
|
var message string
|
||||||
if resolved {
|
if resolved {
|
||||||
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
|
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||||
} else {
|
} else {
|
||||||
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
|
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||||
|
}
|
||||||
|
priority := cfg.Priority
|
||||||
|
if resolved {
|
||||||
|
priority = cfg.ResolvedPriority
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(Body{
|
body, _ := json.Marshal(Body{
|
||||||
Token: provider.ApplicationToken,
|
Token: cfg.ApplicationToken,
|
||||||
User: provider.UserKey,
|
User: cfg.UserKey,
|
||||||
Title: provider.Title,
|
Title: cfg.Title,
|
||||||
Message: message,
|
Message: message,
|
||||||
Priority: provider.priority(),
|
Priority: priority,
|
||||||
Sound: provider.Sound,
|
Sound: cfg.Sound,
|
||||||
})
|
})
|
||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *AlertProvider) priority() int {
|
|
||||||
if provider.Priority == 0 {
|
|
||||||
return defaultPriority
|
|
||||||
}
|
|
||||||
return provider.Priority
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,35 +7,43 @@ import (
|
|||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/TwiN/gatus/v5/test"
|
"github.com/TwiN/gatus/v5/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPushoverAlertProvider_IsValid(t *testing.T) {
|
func TestPushoverAlertProvider_IsValid(t *testing.T) {
|
||||||
invalidProvider := AlertProvider{}
|
t.Run("empty-invalid-provider", func(t *testing.T) {
|
||||||
if invalidProvider.IsValid() {
|
invalidProvider := AlertProvider{}
|
||||||
t.Error("provider shouldn't have been valid")
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
}
|
t.Error("provider shouldn't have been valid")
|
||||||
validProvider := AlertProvider{
|
}
|
||||||
ApplicationToken: "aTokenWithLengthOf30characters",
|
})
|
||||||
UserKey: "aTokenWithLengthOf30characters",
|
t.Run("valid-provider", func(t *testing.T) {
|
||||||
Title: "Gatus Notification",
|
validProvider := AlertProvider{
|
||||||
Priority: 1,
|
DefaultConfig: Config{
|
||||||
}
|
ApplicationToken: "aTokenWithLengthOf30characters",
|
||||||
if !validProvider.IsValid() {
|
UserKey: "aTokenWithLengthOf30characters",
|
||||||
t.Error("provider should've been valid")
|
Title: "Gatus Notification",
|
||||||
}
|
Priority: 1,
|
||||||
}
|
ResolvedPriority: 1,
|
||||||
|
},
|
||||||
func TestPushoverAlertProvider_IsInvalid(t *testing.T) {
|
}
|
||||||
invalidProvider := AlertProvider{
|
if err := validProvider.Validate(); err != nil {
|
||||||
ApplicationToken: "aTokenWithLengthOfMoreThan30characters",
|
t.Error("provider should've been valid")
|
||||||
UserKey: "aTokenWithLengthOfMoreThan30characters",
|
}
|
||||||
Priority: 5,
|
})
|
||||||
}
|
t.Run("invalid-provider", func(t *testing.T) {
|
||||||
if invalidProvider.IsValid() {
|
invalidProvider := AlertProvider{
|
||||||
t.Error("provider should've been invalid")
|
DefaultConfig: Config{
|
||||||
}
|
ApplicationToken: "aTokenWithLengthOfMoreThan30characters",
|
||||||
|
UserKey: "aTokenWithLengthOfMoreThan30characters",
|
||||||
|
Priority: 5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
|
t.Error("provider should've been invalid")
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_Send(t *testing.T) {
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
@@ -52,7 +60,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -62,7 +70,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "triggered-error",
|
Name: "triggered-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -72,7 +80,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -82,7 +90,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved-error",
|
Name: "resolved-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -95,10 +103,10 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
err := scenario.Provider.Send(
|
err := scenario.Provider.Send(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
},
|
},
|
||||||
@@ -119,29 +127,37 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
firstDescription := "description-1"
|
firstDescription := "description-1"
|
||||||
secondDescription := "description-2"
|
secondDescription := "description-2"
|
||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
Name string
|
Name string
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
Alert alert.Alert
|
Alert alert.Alert
|
||||||
Resolved bool
|
Resolved bool
|
||||||
ExpectedBody string
|
ResolvedPriority bool
|
||||||
|
ExpectedBody string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{ApplicationToken: "TokenWithLengthOf30Characters1", UserKey: "TokenWithLengthOf30Characters4"},
|
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters1", UserKey: "TokenWithLengthOf30Characters4"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters1\",\"user\":\"TokenWithLengthOf30Characters4\",\"message\":\"TRIGGERED: endpoint-name - description-1\",\"priority\":0}",
|
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters1\",\"user\":\"TokenWithLengthOf30Characters4\",\"message\":\"TRIGGERED: endpoint-name - description-1\",\"priority\":0}",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2},
|
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, ResolvedPriority: 2}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"RESOLVED: endpoint-name - description-2\",\"priority\":2}",
|
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"RESOLVED: endpoint-name - description-2\",\"priority\":2}",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-priority",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, ResolvedPriority: 0}},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"RESOLVED: endpoint-name - description-2\",\"priority\":0}",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "with-sound",
|
Name: "with-sound",
|
||||||
Provider: AlertProvider{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, Sound: "falling"},
|
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, ResolvedPriority: 2, Sound: "falling"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"RESOLVED: endpoint-name - description-2\",\"priority\":2,\"sound\":\"falling\"}",
|
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"RESOLVED: endpoint-name - description-2\",\"priority\":2,\"sound\":\"falling\"}",
|
||||||
@@ -150,10 +166,11 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
body := scenario.Provider.buildRequestBody(
|
body := scenario.Provider.buildRequestBody(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&scenario.Provider.DefaultConfig,
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
},
|
},
|
||||||
@@ -172,10 +189,57 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
t.Error("expected default alert to be not nil")
|
t.Error("expected default alert to be not nil")
|
||||||
}
|
}
|
||||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
t.Error("expected default alert to be nil")
|
t.Error("expected default alert to be nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputGroup string
|
||||||
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-alert-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"application-token": "TokenWithLengthOf30Characters2", "user-key": "TokenWithLengthOf30Characters3"}},
|
||||||
|
ExpectedOutput: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters3"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.ApplicationToken != scenario.ExpectedOutput.ApplicationToken {
|
||||||
|
t.Errorf("expected application token to be %s, got %s", scenario.ExpectedOutput.ApplicationToken, got.ApplicationToken)
|
||||||
|
}
|
||||||
|
if got.UserKey != scenario.ExpectedOutput.UserKey {
|
||||||
|
t.Errorf("expected user key to be %s, got %s", scenario.ExpectedOutput.UserKey, got.UserKey)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,48 +3,78 @@ package slack
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrWebhookURLNotSet = errors.New("webhook-url not set")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
WebhookURL string `yaml:"webhook-url"` // Slack webhook URL
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.WebhookURL) == 0 {
|
||||||
|
return ErrWebhookURLNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.WebhookURL) > 0 {
|
||||||
|
cfg.WebhookURL = override.WebhookURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Slack
|
// AlertProvider is the configuration necessary for sending an alert using Slack
|
||||||
type AlertProvider struct {
|
type AlertProvider struct {
|
||||||
WebhookURL string `yaml:"webhook-url"` // Slack webhook URL
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
// Overrides is a list of Override that may be prioritized over the default configuration
|
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||||
Overrides []Override `yaml:"overrides,omitempty"`
|
Overrides []Override `yaml:"overrides,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override is a case under which the default integration is overridden
|
// Override is a case under which the default integration is overridden
|
||||||
type Override struct {
|
type Override struct {
|
||||||
Group string `yaml:"group"`
|
Group string `yaml:"group"`
|
||||||
WebhookURL string `yaml:"webhook-url"`
|
Config `yaml:",inline"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// Validate the provider's configuration
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
func (provider *AlertProvider) Validate() error {
|
||||||
registeredGroups := make(map[string]bool)
|
registeredGroups := make(map[string]bool)
|
||||||
if provider.Overrides != nil {
|
if provider.Overrides != nil {
|
||||||
for _, override := range provider.Overrides {
|
for _, override := range provider.Overrides {
|
||||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||||
return false
|
return ErrDuplicateGroupOverride
|
||||||
}
|
}
|
||||||
registeredGroups[override.Group] = true
|
registeredGroups[override.Group] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return len(provider.WebhookURL) > 0
|
return provider.DefaultConfig.Validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
||||||
|
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -71,7 +101,7 @@ type Attachment struct {
|
|||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
Short bool `json:"short"`
|
Short bool `json:"short"`
|
||||||
Color string `json:"color"`
|
Color string `json:"color"`
|
||||||
Fields []Field `json:"fields"`
|
Fields []Field `json:"fields,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Field struct {
|
type Field struct {
|
||||||
@@ -81,15 +111,16 @@ type Field struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildRequestBody builds the request body for the provider
|
// buildRequestBody builds the request body for the provider
|
||||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
|
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
var message, color, results string
|
var message, color string
|
||||||
if resolved {
|
if resolved {
|
||||||
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
|
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||||
color = "#36A64F"
|
color = "#36A64F"
|
||||||
} else {
|
} else {
|
||||||
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||||
color = "#DD0000"
|
color = "#DD0000"
|
||||||
}
|
}
|
||||||
|
var formattedConditionResults string
|
||||||
for _, conditionResult := range result.ConditionResults {
|
for _, conditionResult := range result.ConditionResults {
|
||||||
var prefix string
|
var prefix string
|
||||||
if conditionResult.Success {
|
if conditionResult.Success {
|
||||||
@@ -97,13 +128,13 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
|
|||||||
} else {
|
} else {
|
||||||
prefix = ":x:"
|
prefix = ":x:"
|
||||||
}
|
}
|
||||||
results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
||||||
}
|
}
|
||||||
var description string
|
var description string
|
||||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
description = ":\n> " + alertDescription
|
description = ":\n> " + alertDescription
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(Body{
|
body := Body{
|
||||||
Text: "",
|
Text: "",
|
||||||
Attachments: []Attachment{
|
Attachments: []Attachment{
|
||||||
{
|
{
|
||||||
@@ -111,32 +142,52 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
|
|||||||
Text: message + description,
|
Text: message + description,
|
||||||
Short: false,
|
Short: false,
|
||||||
Color: color,
|
Color: color,
|
||||||
Fields: []Field{
|
|
||||||
{
|
|
||||||
Title: "Condition results",
|
|
||||||
Value: results,
|
|
||||||
Short: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
if len(formattedConditionResults) > 0 {
|
||||||
|
body.Attachments[0].Fields = append(body.Attachments[0].Fields, Field{
|
||||||
|
Title: "Condition results",
|
||||||
|
Value: formattedConditionResults,
|
||||||
|
Short: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
bodyAsJSON, _ := json.Marshal(body)
|
||||||
|
return bodyAsJSON
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,54 +7,54 @@ import (
|
|||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/TwiN/gatus/v5/test"
|
"github.com/TwiN/gatus/v5/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
invalidProvider := AlertProvider{WebhookURL: ""}
|
invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
|
||||||
if invalidProvider.IsValid() {
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
t.Error("provider shouldn't have been valid")
|
t.Error("provider shouldn't have been valid")
|
||||||
}
|
}
|
||||||
validProvider := AlertProvider{WebhookURL: "https://example.com"}
|
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "https://example.com"}}
|
||||||
if !validProvider.IsValid() {
|
if err := validProvider.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||||
providerWithInvalidOverrideGroup := AlertProvider{
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
WebhookURL: "http://example.com",
|
Config: Config{WebhookURL: "http://example.com"},
|
||||||
Group: "",
|
Group: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if providerWithInvalidOverrideGroup.IsValid() {
|
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||||
t.Error("provider Group shouldn't have been valid")
|
t.Error("provider Group shouldn't have been valid")
|
||||||
}
|
}
|
||||||
providerWithInvalidOverrideTo := AlertProvider{
|
providerWithInvalidOverrideTo := AlertProvider{
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
WebhookURL: "",
|
Config: Config{WebhookURL: ""},
|
||||||
Group: "group",
|
Group: "group",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if providerWithInvalidOverrideTo.IsValid() {
|
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
|
||||||
t.Error("provider integration key shouldn't have been valid")
|
t.Error("provider integration key shouldn't have been valid")
|
||||||
}
|
}
|
||||||
providerWithValidOverride := AlertProvider{
|
providerWithValidOverride := AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
WebhookURL: "http://example.com",
|
Config: Config{WebhookURL: "http://example.com"},
|
||||||
Group: "group",
|
Group: "group",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if !providerWithValidOverride.IsValid() {
|
if err := providerWithValidOverride.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -83,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "triggered-error",
|
Name: "triggered-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -93,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -103,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved-error",
|
Name: "resolved-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -116,10 +116,10 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
err := scenario.Provider.Send(
|
err := scenario.Provider.Send(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
},
|
},
|
||||||
@@ -142,15 +142,16 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
Name string
|
Name string
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
Endpoint core.Endpoint
|
Endpoint endpoint.Endpoint
|
||||||
Alert alert.Alert
|
Alert alert.Alert
|
||||||
|
NoConditions bool
|
||||||
Resolved bool
|
Resolved bool
|
||||||
ExpectedBody string
|
ExpectedBody string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{},
|
||||||
Endpoint: core.Endpoint{Name: "name"},
|
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
|
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
|
||||||
@@ -158,15 +159,24 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
{
|
{
|
||||||
Name: "triggered-with-group",
|
Name: "triggered-with-group",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{},
|
||||||
Endpoint: core.Endpoint{Name: "name", Group: "group"},
|
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *group/name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
|
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *group/name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-with-no-conditions",
|
||||||
|
NoConditions: true,
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\"}]}",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{},
|
||||||
Endpoint: core.Endpoint{Name: "name"},
|
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
|
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
|
||||||
@@ -174,7 +184,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
{
|
{
|
||||||
Name: "resolved-with-group",
|
Name: "resolved-with-group",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{},
|
||||||
Endpoint: core.Endpoint{Name: "name", Group: "group"},
|
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
|
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
|
||||||
@@ -182,14 +192,18 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
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(
|
body := scenario.Provider.buildRequestBody(
|
||||||
&scenario.Endpoint,
|
&scenario.Endpoint,
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: conditionResults,
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
scenario.Resolved,
|
scenario.Resolved,
|
||||||
)
|
)
|
||||||
@@ -205,72 +219,100 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
t.Error("expected default alert to be not nil")
|
t.Error("expected default alert to be not nil")
|
||||||
}
|
}
|
||||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
t.Error("expected default alert to be nil")
|
t.Error("expected default alert to be nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
tests := []struct {
|
scenarios := []struct {
|
||||||
Name string
|
Name string
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
InputGroup string
|
InputGroup string
|
||||||
ExpectedOutput string
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "provider-no-override-specify-no-group-should-default",
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: nil,
|
Overrides: nil,
|
||||||
},
|
},
|
||||||
InputGroup: "",
|
InputGroup: "",
|
||||||
ExpectedOutput: "http://example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-no-override-specify-group-should-default",
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: nil,
|
Overrides: nil,
|
||||||
},
|
},
|
||||||
InputGroup: "group",
|
InputGroup: "group",
|
||||||
ExpectedOutput: "http://example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-with-override-specify-no-group-should-default",
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
WebhookURL: "http://example01.com",
|
Config: Config{WebhookURL: "http://example01.com"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InputGroup: "",
|
InputGroup: "",
|
||||||
ExpectedOutput: "http://example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-with-override-specify-group-should-override",
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
WebhookURL: "http://example01.com",
|
Config: Config{WebhookURL: "http://group-example.com"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InputGroup: "group",
|
InputGroup: "group",
|
||||||
ExpectedOutput: "http://example01.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://group-example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{WebhookURL: "http://group-example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, scenario := range scenarios {
|
||||||
t.Run(tt.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
|
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||||
t.Errorf("AlertProvider.getWebhookURLForGroup() = %v, want %v", got, tt.ExpectedOutput)
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
|
||||||
|
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,18 +3,52 @@ package teams
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrWebhookURLNotSet = errors.New("webhook-url not set")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
WebhookURL string `yaml:"webhook-url"`
|
||||||
|
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
|
||||||
|
|
||||||
|
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
||||||
|
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.WebhookURL) == 0 {
|
||||||
|
return ErrWebhookURLNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if override.ClientConfig != nil {
|
||||||
|
cfg.ClientConfig = override.ClientConfig
|
||||||
|
}
|
||||||
|
if len(override.WebhookURL) > 0 {
|
||||||
|
cfg.WebhookURL = override.WebhookURL
|
||||||
|
}
|
||||||
|
if len(override.Title) > 0 {
|
||||||
|
cfg.Title = override.Title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Teams
|
// AlertProvider is the configuration necessary for sending an alert using Teams
|
||||||
type AlertProvider struct {
|
type AlertProvider struct {
|
||||||
WebhookURL string `yaml:"webhook-url"`
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
@@ -25,33 +59,37 @@ type AlertProvider struct {
|
|||||||
|
|
||||||
// Override is a case under which the default integration is overridden
|
// Override is a case under which the default integration is overridden
|
||||||
type Override struct {
|
type Override struct {
|
||||||
Group string `yaml:"group"`
|
Group string `yaml:"group"`
|
||||||
WebhookURL string `yaml:"webhook-url"`
|
Config `yaml:",inline"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// Validate the provider's configuration
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
func (provider *AlertProvider) Validate() error {
|
||||||
registeredGroups := make(map[string]bool)
|
registeredGroups := make(map[string]bool)
|
||||||
if provider.Overrides != nil {
|
if provider.Overrides != nil {
|
||||||
for _, override := range provider.Overrides {
|
for _, override := range provider.Overrides {
|
||||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||||
return false
|
return ErrDuplicateGroupOverride
|
||||||
}
|
}
|
||||||
registeredGroups[override.Group] = true
|
registeredGroups[override.Group] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return len(provider.WebhookURL) > 0
|
return provider.DefaultConfig.Validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer)
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||||
|
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
request.Header.Set("Content-Type", "application/json")
|
request.Header.Set("Content-Type", "application/json")
|
||||||
response, err := client.GetHTTPClient(nil).Do(request)
|
response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -69,7 +107,7 @@ type Body struct {
|
|||||||
ThemeColor string `json:"themeColor"`
|
ThemeColor string `json:"themeColor"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
Sections []Section `json:"sections"`
|
Sections []Section `json:"sections,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Section struct {
|
type Section struct {
|
||||||
@@ -78,16 +116,16 @@ type Section struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildRequestBody builds the request body for the provider
|
// buildRequestBody builds the request body for the provider
|
||||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
|
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
var message, color string
|
var message, color string
|
||||||
if resolved {
|
if resolved {
|
||||||
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
|
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||||
color = "#36A64F"
|
color = "#36A64F"
|
||||||
} else {
|
} else {
|
||||||
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||||
color = "#DD0000"
|
color = "#DD0000"
|
||||||
}
|
}
|
||||||
var results string
|
var formattedConditionResults string
|
||||||
for _, conditionResult := range result.ConditionResults {
|
for _, conditionResult := range result.ConditionResults {
|
||||||
var prefix string
|
var prefix string
|
||||||
if conditionResult.Success {
|
if conditionResult.Success {
|
||||||
@@ -95,41 +133,64 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
|
|||||||
} else {
|
} else {
|
||||||
prefix = "❌"
|
prefix = "❌"
|
||||||
}
|
}
|
||||||
results += fmt.Sprintf("%s - `%s`<br/>", prefix, conditionResult.Condition)
|
formattedConditionResults += fmt.Sprintf("%s - `%s`<br/>", prefix, conditionResult.Condition)
|
||||||
}
|
}
|
||||||
var description string
|
var description string
|
||||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
description = ": " + alertDescription
|
description = ": " + alertDescription
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(Body{
|
body := Body{
|
||||||
Type: "MessageCard",
|
Type: "MessageCard",
|
||||||
Context: "http://schema.org/extensions",
|
Context: "http://schema.org/extensions",
|
||||||
ThemeColor: color,
|
ThemeColor: color,
|
||||||
Title: "🚨 Gatus",
|
Title: cfg.Title,
|
||||||
Text: message + description,
|
Text: message + description,
|
||||||
Sections: []Section{
|
|
||||||
{
|
|
||||||
ActivityTitle: "Condition results",
|
|
||||||
Text: results,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
if len(body.Title) == 0 {
|
||||||
|
body.Title = "🚨 Gatus"
|
||||||
|
}
|
||||||
|
if len(formattedConditionResults) > 0 {
|
||||||
|
body.Sections = append(body.Sections, Section{
|
||||||
|
ActivityTitle: "Condition results",
|
||||||
|
Text: formattedConditionResults,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
bodyAsJSON, _ := json.Marshal(body)
|
||||||
|
return bodyAsJSON
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,54 +7,54 @@ import (
|
|||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/TwiN/gatus/v5/test"
|
"github.com/TwiN/gatus/v5/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
invalidProvider := AlertProvider{WebhookURL: ""}
|
invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
|
||||||
if invalidProvider.IsValid() {
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
t.Error("provider shouldn't have been valid")
|
t.Error("provider shouldn't have been valid")
|
||||||
}
|
}
|
||||||
validProvider := AlertProvider{WebhookURL: "http://example.com"}
|
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
|
||||||
if !validProvider.IsValid() {
|
if err := validProvider.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||||
providerWithInvalidOverrideGroup := AlertProvider{
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
WebhookURL: "http://example.com",
|
Config: Config{WebhookURL: "http://example.com"},
|
||||||
Group: "",
|
Group: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if providerWithInvalidOverrideGroup.IsValid() {
|
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||||
t.Error("provider Group shouldn't have been valid")
|
t.Error("provider Group shouldn't have been valid")
|
||||||
}
|
}
|
||||||
providerWithInvalidOverrideTo := AlertProvider{
|
providerWithInvalidOverrideTo := AlertProvider{
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
WebhookURL: "",
|
Config: Config{WebhookURL: ""},
|
||||||
Group: "group",
|
Group: "group",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if providerWithInvalidOverrideTo.IsValid() {
|
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
|
||||||
t.Error("provider integration key shouldn't have been valid")
|
t.Error("provider integration key shouldn't have been valid")
|
||||||
}
|
}
|
||||||
providerWithValidOverride := AlertProvider{
|
providerWithValidOverride := AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
WebhookURL: "http://example.com",
|
Config: Config{WebhookURL: "http://example.com"},
|
||||||
Group: "group",
|
Group: "group",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if !providerWithValidOverride.IsValid() {
|
if err := providerWithValidOverride.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -83,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "triggered-error",
|
Name: "triggered-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -93,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -103,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved-error",
|
Name: "resolved-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -116,10 +116,10 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
err := scenario.Provider.Send(
|
err := scenario.Provider.Send(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
},
|
},
|
||||||
@@ -143,6 +143,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
Name string
|
Name string
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
Alert alert.Alert
|
Alert alert.Alert
|
||||||
|
NoConditions bool
|
||||||
Resolved bool
|
Resolved bool
|
||||||
ExpectedBody string
|
ExpectedBody string
|
||||||
}{
|
}{
|
||||||
@@ -160,18 +161,29 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedBody: "{\"@type\":\"MessageCard\",\"@context\":\"http://schema.org/extensions\",\"themeColor\":\"#36A64F\",\"title\":\"\\u0026#x1F6A8; Gatus\",\"text\":\"An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row: description-2\",\"sections\":[{\"activityTitle\":\"Condition results\",\"text\":\"\\u0026#x2705; - `[CONNECTED] == true`\\u003cbr/\\u003e\\u0026#x2705; - `[STATUS] == 200`\\u003cbr/\\u003e\"}]}",
|
ExpectedBody: "{\"@type\":\"MessageCard\",\"@context\":\"http://schema.org/extensions\",\"themeColor\":\"#36A64F\",\"title\":\"\\u0026#x1F6A8; Gatus\",\"text\":\"An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row: description-2\",\"sections\":[{\"activityTitle\":\"Condition results\",\"text\":\"\\u0026#x2705; - `[CONNECTED] == true`\\u003cbr/\\u003e\\u0026#x2705; - `[STATUS] == 200`\\u003cbr/\\u003e\"}]}",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-with-no-conditions",
|
||||||
|
NoConditions: true,
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: "{\"@type\":\"MessageCard\",\"@context\":\"http://schema.org/extensions\",\"themeColor\":\"#36A64F\",\"title\":\"\\u0026#x1F6A8; Gatus\",\"text\":\"An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row: description-2\"}",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
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(
|
body := scenario.Provider.buildRequestBody(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&scenario.Provider.DefaultConfig,
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{ConditionResults: conditionResults},
|
||||||
ConditionResults: []*core.ConditionResult{
|
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
scenario.Resolved,
|
scenario.Resolved,
|
||||||
)
|
)
|
||||||
if string(body) != scenario.ExpectedBody {
|
if string(body) != scenario.ExpectedBody {
|
||||||
@@ -186,72 +198,100 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
t.Error("expected default alert to be not nil")
|
t.Error("expected default alert to be not nil")
|
||||||
}
|
}
|
||||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
t.Error("expected default alert to be nil")
|
t.Error("expected default alert to be nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
tests := []struct {
|
scenarios := []struct {
|
||||||
Name string
|
Name string
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
InputGroup string
|
InputGroup string
|
||||||
ExpectedOutput string
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "provider-no-override-specify-no-group-should-default",
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: nil,
|
Overrides: nil,
|
||||||
},
|
},
|
||||||
InputGroup: "",
|
InputGroup: "",
|
||||||
ExpectedOutput: "http://example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-no-override-specify-group-should-default",
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: nil,
|
Overrides: nil,
|
||||||
},
|
},
|
||||||
InputGroup: "group",
|
InputGroup: "group",
|
||||||
ExpectedOutput: "http://example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-with-override-specify-no-group-should-default",
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
WebhookURL: "http://example01.com",
|
Config: Config{WebhookURL: "http://example01.com"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InputGroup: "",
|
InputGroup: "",
|
||||||
ExpectedOutput: "http://example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-with-override-specify-group-should-override",
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
WebhookURL: "http://example01.com",
|
Config: Config{WebhookURL: "http://group-example.com"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InputGroup: "group",
|
InputGroup: "group",
|
||||||
ExpectedOutput: "http://example01.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://group-example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{WebhookURL: "http://group-example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, scenario := range scenarios {
|
||||||
t.Run(tt.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
|
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||||
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
|
||||||
|
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
255
alerting/provider/teamsworkflows/teamsworkflows.go
Normal file
255
alerting/provider/teamsworkflows/teamsworkflows.go
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
package teamsworkflows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrWebhookURLNotSet = errors.New("webhook-url not set")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
WebhookURL string `yaml:"webhook-url"`
|
||||||
|
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.WebhookURL) == 0 {
|
||||||
|
return ErrWebhookURLNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.WebhookURL) > 0 {
|
||||||
|
cfg.WebhookURL = override.WebhookURL
|
||||||
|
}
|
||||||
|
if len(override.Title) > 0 {
|
||||||
|
cfg.Title = override.Title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using Teams
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override is a case under which the default integration is overridden
|
||||||
|
type Override struct {
|
||||||
|
Group string `yaml:"group"`
|
||||||
|
Config `yaml:",inline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the provider's configuration
|
||||||
|
func (provider *AlertProvider) Validate() error {
|
||||||
|
registeredGroups := make(map[string]bool)
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||||
|
return ErrDuplicateGroupOverride
|
||||||
|
}
|
||||||
|
registeredGroups[override.Group] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return provider.DefaultConfig.Validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send an alert using the provider
|
||||||
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||||
|
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, 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"`
|
||||||
|
MSTeams MSTeamsBody `json:"msteams"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
Style string `json:"style,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MSTeamsBody represents the msteams options
|
||||||
|
type MSTeamsBody struct {
|
||||||
|
Width string `json:"width"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
|
var message string
|
||||||
|
var themeColor 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)
|
||||||
|
themeColor = "Good" // green
|
||||||
|
} 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)
|
||||||
|
themeColor = "Attention" // red
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure default title if it's not provided
|
||||||
|
title := "⛑ Gatus"
|
||||||
|
if cfg.Title != "" {
|
||||||
|
title = cfg.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: "Container",
|
||||||
|
Style: themeColor,
|
||||||
|
Items: []CardBody{
|
||||||
|
{
|
||||||
|
Type: "Container",
|
||||||
|
Style: "Default",
|
||||||
|
Items: []CardBody{
|
||||||
|
{
|
||||||
|
Type: "TextBlock",
|
||||||
|
Text: title,
|
||||||
|
Size: "Medium",
|
||||||
|
Weight: "Bolder",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: "TextBlock",
|
||||||
|
Text: message,
|
||||||
|
Wrap: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: "FactSet",
|
||||||
|
Facts: facts,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MSTeams: MSTeamsBody{
|
||||||
|
Width: "Full",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
|
return provider.DefaultAlert
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
298
alerting/provider/teamsworkflows/teamsworkflows_test.go
Normal file
298
alerting/provider/teamsworkflows/teamsworkflows_test.go
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
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 TestAlertProvider_Validate(t *testing.T) {
|
||||||
|
invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
|
||||||
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
|
t.Error("provider shouldn't have been valid")
|
||||||
|
}
|
||||||
|
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
|
||||||
|
if err := validProvider.Validate(); err != nil {
|
||||||
|
t.Error("provider should've been valid, got", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||||
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Config: Config{WebhookURL: "http://example.com"},
|
||||||
|
Group: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||||
|
t.Error("provider Group shouldn't have been valid")
|
||||||
|
}
|
||||||
|
providerWithInvalidOverrideTo := AlertProvider{
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Config: Config{WebhookURL: ""},
|
||||||
|
Group: "group",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
|
||||||
|
t.Error("provider integration key shouldn't have been valid")
|
||||||
|
}
|
||||||
|
providerWithValidOverride := AlertProvider{
|
||||||
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Config: Config{WebhookURL: "http://example.com"},
|
||||||
|
Group: "group",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := providerWithValidOverride.Validate(); err != nil {
|
||||||
|
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{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
|
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{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
|
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{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
|
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{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
|
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\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"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\"}]}],\"style\":\"Default\"}],\"style\":\"Attention\"}],\"msteams\":{\"width\":\"Full\"}},\"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\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"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\"}]}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"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\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"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}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"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(
|
||||||
|
&scenario.Provider.DefaultConfig,
|
||||||
|
&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_GetConfig(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputGroup string
|
||||||
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{WebhookURL: "http://example01.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{WebhookURL: "http://group-example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://group-example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{WebhookURL: "http://group-example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
|
||||||
|
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,51 +3,105 @@ package telegram
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultAPIURL = "https://api.telegram.org"
|
const defaultApiUrl = "https://api.telegram.org"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrTokenNotSet = errors.New("token not set")
|
||||||
|
ErrIDNotSet = errors.New("id not set")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Token string `yaml:"token"`
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
ApiUrl string `yaml:"api-url"`
|
||||||
|
|
||||||
|
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.ApiUrl) == 0 {
|
||||||
|
cfg.ApiUrl = defaultApiUrl
|
||||||
|
}
|
||||||
|
if len(cfg.Token) == 0 {
|
||||||
|
return ErrTokenNotSet
|
||||||
|
}
|
||||||
|
if len(cfg.ID) == 0 {
|
||||||
|
return ErrIDNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if override.ClientConfig != nil {
|
||||||
|
cfg.ClientConfig = override.ClientConfig
|
||||||
|
}
|
||||||
|
if len(override.Token) > 0 {
|
||||||
|
cfg.Token = override.Token
|
||||||
|
}
|
||||||
|
if len(override.ID) > 0 {
|
||||||
|
cfg.ID = override.ID
|
||||||
|
}
|
||||||
|
if len(override.ApiUrl) > 0 {
|
||||||
|
cfg.ApiUrl = override.ApiUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Telegram
|
// AlertProvider is the configuration necessary for sending an alert using Telegram
|
||||||
type AlertProvider struct {
|
type AlertProvider struct {
|
||||||
Token string `yaml:"token"`
|
DefaultConfig Config `yaml:",inline"`
|
||||||
ID string `yaml:"id"`
|
|
||||||
APIURL string `yaml:"api-url"`
|
|
||||||
|
|
||||||
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
|
||||||
ClientConfig *client.Config `yaml:"client,omitempty"`
|
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
|
// Overrides is a list of overrides that may be prioritized over the default configuration
|
||||||
|
Overrides []*Override `yaml:"overrides,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// Override is a configuration that may be prioritized over the default configuration
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
type Override struct {
|
||||||
if provider.ClientConfig == nil {
|
Group string `yaml:"group"`
|
||||||
provider.ClientConfig = client.GetDefaultConfig()
|
Config `yaml:",inline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the provider's configuration
|
||||||
|
func (provider *AlertProvider) Validate() error {
|
||||||
|
registeredGroups := make(map[string]bool)
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||||
|
return ErrDuplicateGroupOverride
|
||||||
|
}
|
||||||
|
registeredGroups[override.Group] = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return len(provider.Token) > 0 && len(provider.ID) > 0
|
return provider.DefaultConfig.Validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
apiURL := provider.APIURL
|
if err != nil {
|
||||||
if apiURL == "" {
|
return err
|
||||||
apiURL = defaultAPIURL
|
|
||||||
}
|
}
|
||||||
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/bot%s/sendMessage", apiURL, provider.Token), buffer)
|
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||||
|
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/bot%s/sendMessage", cfg.ApiUrl, cfg.Token), buffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
request.Header.Set("Content-Type", "application/json")
|
request.Header.Set("Content-Type", "application/json")
|
||||||
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
|
response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -66,37 +120,72 @@ type Body struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildRequestBody builds the request body for the provider
|
// buildRequestBody builds the request body for the provider
|
||||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
|
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
var message, results string
|
var message string
|
||||||
if resolved {
|
if resolved {
|
||||||
message = fmt.Sprintf("An alert for *%s* has been resolved:\n—\n _healthcheck passing successfully %d time(s) in a row_\n— ", endpoint.DisplayName(), alert.FailureThreshold)
|
message = fmt.Sprintf("An alert for *%s* has been resolved:\n—\n _healthcheck passing successfully %d time(s) in a row_\n— ", ep.DisplayName(), alert.SuccessThreshold)
|
||||||
} else {
|
} else {
|
||||||
message = fmt.Sprintf("An alert for *%s* has been triggered:\n—\n _healthcheck failed %d time(s) in a row_\n— ", endpoint.DisplayName(), alert.FailureThreshold)
|
message = fmt.Sprintf("An alert for *%s* has been triggered:\n—\n _healthcheck failed %d time(s) in a row_\n— ", ep.DisplayName(), alert.FailureThreshold)
|
||||||
}
|
}
|
||||||
for _, conditionResult := range result.ConditionResults {
|
var formattedConditionResults string
|
||||||
var prefix string
|
if len(result.ConditionResults) > 0 {
|
||||||
if conditionResult.Success {
|
formattedConditionResults = "\n*Condition results*\n"
|
||||||
prefix = "✅"
|
for _, conditionResult := range result.ConditionResults {
|
||||||
} else {
|
var prefix string
|
||||||
prefix = "❌"
|
if conditionResult.Success {
|
||||||
|
prefix = "✅"
|
||||||
|
} else {
|
||||||
|
prefix = "❌"
|
||||||
|
}
|
||||||
|
formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
||||||
}
|
}
|
||||||
results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
|
||||||
}
|
}
|
||||||
var text string
|
var text string
|
||||||
if len(alert.GetDescription()) > 0 {
|
if len(alert.GetDescription()) > 0 {
|
||||||
text = fmt.Sprintf("⛑ *Gatus* \n%s \n*Description* \n_%s_ \n\n*Condition results*\n%s", message, alert.GetDescription(), results)
|
text = fmt.Sprintf("⛑ *Gatus* \n%s \n*Description* \n_%s_ \n%s", message, alert.GetDescription(), formattedConditionResults)
|
||||||
} else {
|
} else {
|
||||||
text = fmt.Sprintf("⛑ *Gatus* \n%s \n*Condition results*\n%s", message, results)
|
text = fmt.Sprintf("⛑ *Gatus* \n%s%s", message, formattedConditionResults)
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(Body{
|
bodyAsJSON, _ := json.Marshal(Body{
|
||||||
ChatID: provider.ID,
|
ChatID: cfg.ID,
|
||||||
Text: text,
|
Text: text,
|
||||||
ParseMode: "MARKDOWN",
|
ParseMode: "MARKDOWN",
|
||||||
})
|
})
|
||||||
return body
|
return bodyAsJSON
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,27 +7,39 @@ import (
|
|||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/TwiN/gatus/v5/test"
|
"github.com/TwiN/gatus/v5/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
t.Run("invalid-provider", func(t *testing.T) {
|
t.Run("invalid-provider", func(t *testing.T) {
|
||||||
invalidProvider := AlertProvider{Token: "", ID: ""}
|
invalidProvider := AlertProvider{DefaultConfig: Config{Token: "", ID: ""}}
|
||||||
if invalidProvider.IsValid() {
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
t.Error("provider shouldn't have been valid")
|
t.Error("provider shouldn't have been valid")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
t.Run("valid-provider", func(t *testing.T) {
|
t.Run("valid-provider", func(t *testing.T) {
|
||||||
validProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}
|
validProvider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}}
|
||||||
if validProvider.ClientConfig != nil {
|
if err := validProvider.Validate(); err != nil {
|
||||||
t.Error("provider client config should have been nil prior to IsValid() being executed")
|
|
||||||
}
|
|
||||||
if !validProvider.IsValid() {
|
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
if validProvider.ClientConfig == nil {
|
})
|
||||||
t.Error("provider client config should have been set after IsValid() was executed")
|
t.Run("invalid-provider-override-nonexist-group", func(t *testing.T) {
|
||||||
|
invalidProvider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Config: Config{Token: "token", ID: "id"}}}}
|
||||||
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
|
t.Error("provider shouldn't have been valid")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("invalid-provider-override-duplicate-group", func(t *testing.T) {
|
||||||
|
invalidProvider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group1", Config: Config{Token: "token", ID: "id"}}, {Group: "group1", Config: Config{ID: "id2"}}}}
|
||||||
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
|
t.Error("provider shouldn't have been valid")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("valid-provider-with-overrides", func(t *testing.T) {
|
||||||
|
validProvider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group", Config: Config{Token: "token", ID: "id"}}}}
|
||||||
|
if err := validProvider.Validate(); err != nil {
|
||||||
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -46,7 +58,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{ID: "123", Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -56,7 +68,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "triggered-error",
|
Name: "triggered-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{ID: "123", Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -66,7 +78,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{ID: "123", Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -76,7 +88,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved-error",
|
Name: "resolved-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{ID: "123", Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -89,10 +101,10 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
err := scenario.Provider.Send(
|
err := scenario.Provider.Send(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
},
|
},
|
||||||
@@ -116,35 +128,47 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
Name string
|
Name string
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
Alert alert.Alert
|
Alert alert.Alert
|
||||||
|
NoConditions bool
|
||||||
Resolved bool
|
Resolved bool
|
||||||
ExpectedBody string
|
ExpectedBody string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{ID: "123"},
|
Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\n_description-1_ \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
|
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\n_description-1_ \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{ID: "123"},
|
Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 3 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\\n*Condition results*\\n✅ - `[CONNECTED] == true`\\n✅ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
|
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 5 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\\n*Condition results*\\n✅ - `[CONNECTED] == true`\\n✅ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-with-no-conditions",
|
||||||
|
NoConditions: true,
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 5 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\",\"parse_mode\":\"MARKDOWN\"}",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
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(
|
body := scenario.Provider.buildRequestBody(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&scenario.Provider.DefaultConfig,
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{ConditionResults: conditionResults},
|
||||||
ConditionResults: []*core.ConditionResult{
|
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
scenario.Resolved,
|
scenario.Resolved,
|
||||||
)
|
)
|
||||||
if string(body) != scenario.ExpectedBody {
|
if string(body) != scenario.ExpectedBody {
|
||||||
@@ -159,10 +183,70 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
t.Error("expected default alert to be not nil")
|
t.Error("expected default alert to be not nil")
|
||||||
}
|
}
|
||||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
t.Error("expected default alert to be nil")
|
t.Error("expected default alert to be nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
|
t.Run("get-token-with-override", func(t *testing.T) {
|
||||||
|
provider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group", Config: Config{Token: "groupToken", ID: "overrideID"}}}}
|
||||||
|
cfg, err := provider.GetConfig("group", &alert.Alert{})
|
||||||
|
if err != nil {
|
||||||
|
t.Error("expected no error, got", err)
|
||||||
|
}
|
||||||
|
if cfg.Token != "groupToken" {
|
||||||
|
t.Error("token should have been 'groupToken'")
|
||||||
|
}
|
||||||
|
if cfg.ID != "overrideID" {
|
||||||
|
t.Error("id should have been 'overrideID'")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("get-default-token-with-overridden-id", func(t *testing.T) {
|
||||||
|
provider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group", Config: Config{ID: "overrideID"}}}}
|
||||||
|
cfg, err := provider.GetConfig("group", &alert.Alert{})
|
||||||
|
if err != nil {
|
||||||
|
t.Error("expected no error, got", err)
|
||||||
|
}
|
||||||
|
if cfg.Token != provider.DefaultConfig.Token {
|
||||||
|
t.Error("token should have been the default token")
|
||||||
|
}
|
||||||
|
if cfg.ID != "overrideID" {
|
||||||
|
t.Error("id should have been 'overrideID'")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("get-default-token-with-overridden-token", func(t *testing.T) {
|
||||||
|
provider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group", Config: Config{Token: "groupToken"}}}}
|
||||||
|
cfg, err := provider.GetConfig("group", &alert.Alert{})
|
||||||
|
if err != nil {
|
||||||
|
t.Error("expected no error, got", err)
|
||||||
|
}
|
||||||
|
if cfg.Token != "groupToken" {
|
||||||
|
t.Error("token should have been 'groupToken'")
|
||||||
|
}
|
||||||
|
if cfg.ID != provider.DefaultConfig.ID {
|
||||||
|
t.Error("id should have been the default id")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("get-default-token-with-overridden-token-and-alert-token-override", func(t *testing.T) {
|
||||||
|
provider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group", Config: Config{Token: "groupToken"}}}}
|
||||||
|
alert := &alert.Alert{ProviderOverride: map[string]any{"token": "alertToken"}}
|
||||||
|
cfg, err := provider.GetConfig("group", alert)
|
||||||
|
if err != nil {
|
||||||
|
t.Error("expected no error, got", err)
|
||||||
|
}
|
||||||
|
if cfg.Token != "alertToken" {
|
||||||
|
t.Error("token should have been 'alertToken'")
|
||||||
|
}
|
||||||
|
if cfg.ID != provider.DefaultConfig.ID {
|
||||||
|
t.Error("id should have been the default id")
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = provider.ValidateOverrides("group", alert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package twilio
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -10,34 +11,81 @@ import (
|
|||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Twilio
|
var (
|
||||||
type AlertProvider struct {
|
ErrSIDNotSet = errors.New("sid not set")
|
||||||
|
ErrTokenNotSet = errors.New("token not set")
|
||||||
|
ErrFromNotSet = errors.New("from not set")
|
||||||
|
ErrToNotSet = errors.New("to not set")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
SID string `yaml:"sid"`
|
SID string `yaml:"sid"`
|
||||||
Token string `yaml:"token"`
|
Token string `yaml:"token"`
|
||||||
From string `yaml:"from"`
|
From string `yaml:"from"`
|
||||||
To string `yaml:"to"`
|
To string `yaml:"to"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.SID) == 0 {
|
||||||
|
return ErrSIDNotSet
|
||||||
|
}
|
||||||
|
if len(cfg.Token) == 0 {
|
||||||
|
return ErrTokenNotSet
|
||||||
|
}
|
||||||
|
if len(cfg.From) == 0 {
|
||||||
|
return ErrFromNotSet
|
||||||
|
}
|
||||||
|
if len(cfg.To) == 0 {
|
||||||
|
return ErrToNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.SID) > 0 {
|
||||||
|
cfg.SID = override.SID
|
||||||
|
}
|
||||||
|
if len(override.Token) > 0 {
|
||||||
|
cfg.Token = override.Token
|
||||||
|
}
|
||||||
|
if len(override.From) > 0 {
|
||||||
|
cfg.From = override.From
|
||||||
|
}
|
||||||
|
if len(override.To) > 0 {
|
||||||
|
cfg.To = override.To
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using Twilio
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// Validate the provider's configuration
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
func (provider *AlertProvider) Validate() error {
|
||||||
return len(provider.Token) > 0 && len(provider.SID) > 0 && len(provider.From) > 0 && len(provider.To) > 0
|
return provider.DefaultConfig.Validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved)))
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", provider.SID), buffer)
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(cfg, ep, alert, result, resolved)))
|
||||||
|
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", cfg.SID), buffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
request.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(provider.SID+":"+provider.Token))))
|
request.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(cfg.SID+":"+cfg.Token))))
|
||||||
response, err := client.GetHTTPClient(nil).Do(request)
|
response, err := client.GetHTTPClient(nil).Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -51,21 +99,43 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildRequestBody builds the request body for the provider
|
// buildRequestBody builds the request body for the provider
|
||||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
|
||||||
var message string
|
var message string
|
||||||
if resolved {
|
if resolved {
|
||||||
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
|
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||||
} else {
|
} else {
|
||||||
message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription())
|
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||||
}
|
}
|
||||||
return url.Values{
|
return url.Values{
|
||||||
"To": {provider.To},
|
"To": {cfg.To},
|
||||||
"From": {provider.From},
|
"From": {cfg.From},
|
||||||
"Body": {message},
|
"Body": {message},
|
||||||
}.Encode()
|
}.Encode()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,28 +1,110 @@
|
|||||||
package twilio
|
package twilio
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/TwiN/gatus/v5/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTwilioAlertProvider_IsValid(t *testing.T) {
|
func TestTwilioAlertProvider_IsValid(t *testing.T) {
|
||||||
invalidProvider := AlertProvider{}
|
invalidProvider := AlertProvider{}
|
||||||
if invalidProvider.IsValid() {
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
t.Error("provider shouldn't have been valid")
|
t.Error("provider shouldn't have been valid")
|
||||||
}
|
}
|
||||||
validProvider := AlertProvider{
|
validProvider := AlertProvider{
|
||||||
SID: "1",
|
DefaultConfig: Config{
|
||||||
Token: "1",
|
SID: "1",
|
||||||
From: "1",
|
Token: "1",
|
||||||
To: "1",
|
From: "1",
|
||||||
|
To: "1",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if !validProvider.IsValid() {
|
if err := validProvider.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
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{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"}},
|
||||||
|
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{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"}},
|
||||||
|
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{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"}},
|
||||||
|
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{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"}},
|
||||||
|
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) {
|
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||||
firstDescription := "description-1"
|
firstDescription := "description-1"
|
||||||
secondDescription := "description-2"
|
secondDescription := "description-2"
|
||||||
@@ -35,14 +117,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{SID: "1", Token: "2", From: "3", To: "4"},
|
Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedBody: "Body=TRIGGERED%3A+endpoint-name+-+description-1&From=3&To=4",
|
ExpectedBody: "Body=TRIGGERED%3A+endpoint-name+-+description-1&From=3&To=4",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{SID: "1", Token: "2", From: "3", To: "4"},
|
Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedBody: "Body=RESOLVED%3A+endpoint-name+-+description-2&From=3&To=4",
|
ExpectedBody: "Body=RESOLVED%3A+endpoint-name+-+description-2&From=3&To=4",
|
||||||
@@ -51,10 +133,11 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
body := scenario.Provider.buildRequestBody(
|
body := scenario.Provider.buildRequestBody(
|
||||||
&core.Endpoint{Name: "endpoint-name"},
|
&scenario.Provider.DefaultConfig,
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&core.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
},
|
},
|
||||||
@@ -69,10 +152,60 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
t.Error("expected default alert to be not nil")
|
t.Error("expected default alert to be not nil")
|
||||||
}
|
}
|
||||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
t.Error("expected default alert to be nil")
|
t.Error("expected default alert to be nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"},
|
||||||
|
},
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{SID: "1", Token: "2", From: "3", To: "4"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-alert-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"},
|
||||||
|
},
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"sid": "5", "token": "6", "from": "7", "to": "8"}},
|
||||||
|
ExpectedOutput: Config{SID: "5", Token: "6", From: "7", To: "8"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
|
||||||
|
if err != nil {
|
||||||
|
t.Error("expected no error, got:", err.Error())
|
||||||
|
}
|
||||||
|
if got.SID != scenario.ExpectedOutput.SID {
|
||||||
|
t.Errorf("expected SID to be %s, got %s", scenario.ExpectedOutput.SID, got.SID)
|
||||||
|
}
|
||||||
|
if got.Token != scenario.ExpectedOutput.Token {
|
||||||
|
t.Errorf("expected token to be %s, got %s", scenario.ExpectedOutput.Token, got.Token)
|
||||||
|
}
|
||||||
|
if got.From != scenario.ExpectedOutput.From {
|
||||||
|
t.Errorf("expected from to be %s, got %s", scenario.ExpectedOutput.From, got.From)
|
||||||
|
}
|
||||||
|
if got.To != scenario.ExpectedOutput.To {
|
||||||
|
t.Errorf("expected to to be %s, got %s", scenario.ExpectedOutput.To, got.To)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
183
alerting/provider/zulip/zulip.go
Normal file
183
alerting/provider/zulip/zulip.go
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
package zulip
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrBotEmailNotSet = errors.New("bot-email not set")
|
||||||
|
ErrBotAPIKeyNotSet = errors.New("bot-api-key not set")
|
||||||
|
ErrDomainNotSet = errors.New("domain not set")
|
||||||
|
ErrChannelIDNotSet = errors.New("channel-id not set")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
BotEmail string `yaml:"bot-email"` // Email of the bot user
|
||||||
|
BotAPIKey string `yaml:"bot-api-key"` // API key of the bot user
|
||||||
|
Domain string `yaml:"domain"` // Domain of the Zulip server
|
||||||
|
ChannelID string `yaml:"channel-id"` // ID of the channel to send the message to
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.BotEmail) == 0 {
|
||||||
|
return ErrBotEmailNotSet
|
||||||
|
}
|
||||||
|
if len(cfg.BotAPIKey) == 0 {
|
||||||
|
return ErrBotAPIKeyNotSet
|
||||||
|
}
|
||||||
|
if len(cfg.Domain) == 0 {
|
||||||
|
return ErrDomainNotSet
|
||||||
|
}
|
||||||
|
if len(cfg.ChannelID) == 0 {
|
||||||
|
return ErrChannelIDNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.BotEmail) > 0 {
|
||||||
|
cfg.BotEmail = override.BotEmail
|
||||||
|
}
|
||||||
|
if len(override.BotAPIKey) > 0 {
|
||||||
|
cfg.BotAPIKey = override.BotAPIKey
|
||||||
|
}
|
||||||
|
if len(override.Domain) > 0 {
|
||||||
|
cfg.Domain = override.Domain
|
||||||
|
}
|
||||||
|
if len(override.ChannelID) > 0 {
|
||||||
|
cfg.ChannelID = override.ChannelID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using Zulip
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override is a case under which the default integration is overridden
|
||||||
|
type Override struct {
|
||||||
|
Group string `yaml:"group"`
|
||||||
|
Config `yaml:",inline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the provider's configuration
|
||||||
|
func (provider *AlertProvider) Validate() error {
|
||||||
|
registeredGroups := make(map[string]bool)
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||||
|
return ErrDuplicateGroupOverride
|
||||||
|
}
|
||||||
|
registeredGroups[override.Group] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return provider.DefaultConfig.Validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send an alert using the provider
|
||||||
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBufferString(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||||
|
zulipEndpoint := fmt.Sprintf("https://%s/api/v1/messages", cfg.Domain)
|
||||||
|
request, err := http.NewRequest(http.MethodPost, zulipEndpoint, buffer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.SetBasicAuth(cfg.BotEmail, cfg.BotAPIKey)
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
request.Header.Set("User-Agent", "Gatus")
|
||||||
|
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 nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestBody builds the request body for the provider
|
||||||
|
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
|
message += "\n> " + alertDescription + "\n"
|
||||||
|
}
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
var prefix string
|
||||||
|
if conditionResult.Success {
|
||||||
|
prefix = ":check:"
|
||||||
|
} else {
|
||||||
|
prefix = ":cross_mark:"
|
||||||
|
}
|
||||||
|
message += fmt.Sprintf("\n%s - `%s`", prefix, conditionResult.Condition)
|
||||||
|
}
|
||||||
|
return url.Values{
|
||||||
|
"type": {"channel"},
|
||||||
|
"to": {cfg.ChannelID},
|
||||||
|
"topic": {"Gatus"},
|
||||||
|
"content": {message},
|
||||||
|
}.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
|
return provider.DefaultAlert
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
482
alerting/provider/zulip/zulip_test.go
Normal file
482
alerting/provider/zulip/zulip_test.go
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
package zulip
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"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 TestAlertProvider_Validate(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
AlertProvider AlertProvider
|
||||||
|
ExpectedError error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "Empty provider",
|
||||||
|
AlertProvider: AlertProvider{},
|
||||||
|
ExpectedError: ErrBotEmailNotSet,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Empty channel id",
|
||||||
|
AlertProvider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
BotEmail: "something",
|
||||||
|
BotAPIKey: "something",
|
||||||
|
Domain: "something",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ExpectedError: ErrChannelIDNotSet,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Empty domain",
|
||||||
|
AlertProvider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
BotEmail: "something",
|
||||||
|
BotAPIKey: "something",
|
||||||
|
ChannelID: "something",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ExpectedError: ErrDomainNotSet,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Empty bot api key",
|
||||||
|
AlertProvider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
BotEmail: "something",
|
||||||
|
Domain: "something",
|
||||||
|
ChannelID: "something",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ExpectedError: ErrBotAPIKeyNotSet,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Empty bot email",
|
||||||
|
AlertProvider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
BotAPIKey: "something",
|
||||||
|
Domain: "something",
|
||||||
|
ChannelID: "something",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ExpectedError: ErrBotEmailNotSet,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Valid provider",
|
||||||
|
AlertProvider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
BotEmail: "something",
|
||||||
|
BotAPIKey: "something",
|
||||||
|
Domain: "something",
|
||||||
|
ChannelID: "something",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ExpectedError: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
if err := scenario.AlertProvider.Validate(); !errors.Is(err, scenario.ExpectedError) {
|
||||||
|
t.Errorf("ExpectedError error %v, got %v", scenario.ExpectedError, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||||
|
basicConfig := Config{
|
||||||
|
BotEmail: "bot-email",
|
||||||
|
BotAPIKey: "bot-api-key",
|
||||||
|
Domain: "domain",
|
||||||
|
ChannelID: "channel-id",
|
||||||
|
}
|
||||||
|
alertDesc := "Description"
|
||||||
|
basicAlert := alert.Alert{
|
||||||
|
SuccessThreshold: 2,
|
||||||
|
FailureThreshold: 3,
|
||||||
|
Description: &alertDesc,
|
||||||
|
}
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
provider AlertProvider
|
||||||
|
alert alert.Alert
|
||||||
|
resolved bool
|
||||||
|
hasConditions bool
|
||||||
|
expectedBody url.Values
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Resolved alert with no conditions",
|
||||||
|
provider: AlertProvider{
|
||||||
|
DefaultConfig: basicConfig,
|
||||||
|
},
|
||||||
|
alert: basicAlert,
|
||||||
|
resolved: true,
|
||||||
|
hasConditions: false,
|
||||||
|
expectedBody: url.Values{
|
||||||
|
"content": {`An alert for **endpoint-Name** has been resolved after passing successfully 2 time(s) in a row
|
||||||
|
> Description
|
||||||
|
`},
|
||||||
|
"to": {"channel-id"},
|
||||||
|
"topic": {"Gatus"},
|
||||||
|
"type": {"channel"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Resolved alert with conditions",
|
||||||
|
provider: AlertProvider{
|
||||||
|
DefaultConfig: basicConfig,
|
||||||
|
},
|
||||||
|
alert: basicAlert,
|
||||||
|
resolved: true,
|
||||||
|
hasConditions: true,
|
||||||
|
expectedBody: url.Values{
|
||||||
|
"content": {`An alert for **endpoint-Name** has been resolved after passing successfully 2 time(s) in a row
|
||||||
|
> Description
|
||||||
|
|
||||||
|
:check: - ` + "`[CONNECTED] == true`" + `
|
||||||
|
:check: - ` + "`[STATUS] == 200`" + `
|
||||||
|
:check: - ` + "`[BODY] != \"\"`"},
|
||||||
|
"to": {"channel-id"},
|
||||||
|
"topic": {"Gatus"},
|
||||||
|
"type": {"channel"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Failed alert with no conditions",
|
||||||
|
provider: AlertProvider{
|
||||||
|
DefaultConfig: basicConfig,
|
||||||
|
},
|
||||||
|
alert: basicAlert,
|
||||||
|
resolved: false,
|
||||||
|
hasConditions: false,
|
||||||
|
expectedBody: url.Values{
|
||||||
|
"content": {`An alert for **endpoint-Name** has been triggered due to having failed 3 time(s) in a row
|
||||||
|
> Description
|
||||||
|
`},
|
||||||
|
"to": {"channel-id"},
|
||||||
|
"topic": {"Gatus"},
|
||||||
|
"type": {"channel"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Failed alert with conditions",
|
||||||
|
provider: AlertProvider{
|
||||||
|
DefaultConfig: basicConfig,
|
||||||
|
},
|
||||||
|
alert: basicAlert,
|
||||||
|
resolved: false,
|
||||||
|
hasConditions: true,
|
||||||
|
expectedBody: url.Values{
|
||||||
|
"content": {`An alert for **endpoint-Name** has been triggered due to having failed 3 time(s) in a row
|
||||||
|
> Description
|
||||||
|
|
||||||
|
:cross_mark: - ` + "`[CONNECTED] == true`" + `
|
||||||
|
:cross_mark: - ` + "`[STATUS] == 200`" + `
|
||||||
|
:cross_mark: - ` + "`[BODY] != \"\"`"},
|
||||||
|
"to": {"channel-id"},
|
||||||
|
"topic": {"Gatus"},
|
||||||
|
"type": {"channel"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var conditionResults []*endpoint.ConditionResult
|
||||||
|
if tc.hasConditions {
|
||||||
|
conditionResults = []*endpoint.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: tc.resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: tc.resolved},
|
||||||
|
{Condition: "[BODY] != \"\"", Success: tc.resolved},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body := tc.provider.buildRequestBody(
|
||||||
|
&tc.provider.DefaultConfig,
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-Name"},
|
||||||
|
&tc.alert,
|
||||||
|
&endpoint.Result{
|
||||||
|
ConditionResults: conditionResults,
|
||||||
|
},
|
||||||
|
tc.resolved,
|
||||||
|
)
|
||||||
|
valuesResult, err := url.ParseQuery(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
if fmt.Sprintf("%v", valuesResult) != fmt.Sprintf("%v", tc.expectedBody) {
|
||||||
|
t.Errorf("Expected body:\n%v\ngot:\n%v", tc.expectedBody, valuesResult)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
|
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
|
t.Error("ExpectedError default alert to be not nil")
|
||||||
|
}
|
||||||
|
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
|
t.Error("ExpectedError default alert to be nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
|
defer client.InjectHTTPClient(nil)
|
||||||
|
validateRequest := func(req *http.Request) {
|
||||||
|
if req.URL.String() != "https://custom-domain/api/v1/messages" {
|
||||||
|
t.Errorf("ExpectedError url https://custom-domain.zulipchat.com/api/v1/messages, got %s", req.URL.String())
|
||||||
|
}
|
||||||
|
if req.Method != http.MethodPost {
|
||||||
|
t.Errorf("ExpectedError POST request, got %s", req.Method)
|
||||||
|
}
|
||||||
|
if req.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
|
||||||
|
t.Errorf("ExpectedError Content-Type header to be application/x-www-form-urlencoded, got %s", req.Header.Get("Content-Type"))
|
||||||
|
}
|
||||||
|
if req.Header.Get("User-Agent") != "Gatus" {
|
||||||
|
t.Errorf("ExpectedError User-Agent header to be Gatus, got %s", req.Header.Get("User-Agent"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
basicConfig := Config{
|
||||||
|
BotEmail: "bot-email",
|
||||||
|
BotAPIKey: "bot-api-key",
|
||||||
|
Domain: "custom-domain",
|
||||||
|
ChannelID: "channel-id",
|
||||||
|
}
|
||||||
|
basicAlert := alert.Alert{
|
||||||
|
SuccessThreshold: 2,
|
||||||
|
FailureThreshold: 3,
|
||||||
|
}
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
provider AlertProvider
|
||||||
|
alert alert.Alert
|
||||||
|
resolved bool
|
||||||
|
mockRoundTripper test.MockRoundTripper
|
||||||
|
expectedError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "resolved",
|
||||||
|
provider: AlertProvider{
|
||||||
|
DefaultConfig: basicConfig,
|
||||||
|
},
|
||||||
|
alert: basicAlert,
|
||||||
|
resolved: true,
|
||||||
|
mockRoundTripper: test.MockRoundTripper(func(req *http.Request) *http.Response {
|
||||||
|
validateRequest(req)
|
||||||
|
return &http.Response{StatusCode: http.StatusOK}
|
||||||
|
}),
|
||||||
|
expectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "resolved error",
|
||||||
|
provider: AlertProvider{
|
||||||
|
DefaultConfig: basicConfig,
|
||||||
|
},
|
||||||
|
alert: basicAlert,
|
||||||
|
resolved: true,
|
||||||
|
mockRoundTripper: test.MockRoundTripper(func(req *http.Request) *http.Response {
|
||||||
|
validateRequest(req)
|
||||||
|
return &http.Response{StatusCode: http.StatusInternalServerError}
|
||||||
|
}),
|
||||||
|
expectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "triggered",
|
||||||
|
provider: AlertProvider{
|
||||||
|
DefaultConfig: basicConfig,
|
||||||
|
},
|
||||||
|
alert: basicAlert,
|
||||||
|
resolved: false,
|
||||||
|
mockRoundTripper: test.MockRoundTripper(func(req *http.Request) *http.Response {
|
||||||
|
validateRequest(req)
|
||||||
|
return &http.Response{StatusCode: http.StatusOK}
|
||||||
|
}),
|
||||||
|
expectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "triggered error",
|
||||||
|
provider: AlertProvider{
|
||||||
|
DefaultConfig: basicConfig,
|
||||||
|
},
|
||||||
|
alert: basicAlert,
|
||||||
|
resolved: false,
|
||||||
|
mockRoundTripper: test.MockRoundTripper(func(req *http.Request) *http.Response {
|
||||||
|
validateRequest(req)
|
||||||
|
return &http.Response{StatusCode: http.StatusInternalServerError}
|
||||||
|
}),
|
||||||
|
expectedError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
client.InjectHTTPClient(&http.Client{Transport: tc.mockRoundTripper})
|
||||||
|
err := tc.provider.Send(
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-Name"},
|
||||||
|
&tc.alert,
|
||||||
|
&endpoint.Result{
|
||||||
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: tc.resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: tc.resolved},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tc.resolved,
|
||||||
|
)
|
||||||
|
if tc.expectedError && err == nil {
|
||||||
|
t.Error("ExpectedError error, got none")
|
||||||
|
}
|
||||||
|
if !tc.expectedError && err != nil {
|
||||||
|
t.Errorf("ExpectedError no error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputGroup string
|
||||||
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-overrides",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
BotEmail: "default-bot-email",
|
||||||
|
BotAPIKey: "default-bot-api-key",
|
||||||
|
Domain: "default-domain",
|
||||||
|
ChannelID: "default-channel-id",
|
||||||
|
},
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{
|
||||||
|
BotEmail: "default-bot-email",
|
||||||
|
BotAPIKey: "default-bot-api-key",
|
||||||
|
Domain: "default-domain",
|
||||||
|
ChannelID: "default-channel-id",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
BotEmail: "default-bot-email",
|
||||||
|
BotAPIKey: "default-bot-api-key",
|
||||||
|
Domain: "default-domain",
|
||||||
|
ChannelID: "default-channel-id",
|
||||||
|
},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{ChannelID: "group-channel-id"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{
|
||||||
|
BotEmail: "default-bot-email",
|
||||||
|
BotAPIKey: "default-bot-api-key",
|
||||||
|
Domain: "default-domain",
|
||||||
|
ChannelID: "default-channel-id",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
BotEmail: "default-bot-email",
|
||||||
|
BotAPIKey: "default-bot-api-key",
|
||||||
|
Domain: "default-domain",
|
||||||
|
ChannelID: "default-channel-id",
|
||||||
|
},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{
|
||||||
|
BotEmail: "group-bot-email",
|
||||||
|
BotAPIKey: "group-bot-api-key",
|
||||||
|
Domain: "group-domain",
|
||||||
|
ChannelID: "group-channel-id",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{
|
||||||
|
BotEmail: "group-bot-email",
|
||||||
|
BotAPIKey: "group-bot-api-key",
|
||||||
|
Domain: "group-domain",
|
||||||
|
ChannelID: "group-channel-id",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
BotEmail: "default-bot-email",
|
||||||
|
BotAPIKey: "default-bot-api-key",
|
||||||
|
Domain: "default-domain",
|
||||||
|
ChannelID: "default-channel-id",
|
||||||
|
},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{
|
||||||
|
BotEmail: "group-bot-email",
|
||||||
|
BotAPIKey: "group-bot-api-key",
|
||||||
|
Domain: "group-domain",
|
||||||
|
ChannelID: "group-channel-id",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{
|
||||||
|
"bot-email": "alert-bot-email",
|
||||||
|
"bot-api-key": "alert-bot-api-key",
|
||||||
|
"domain": "alert-domain",
|
||||||
|
"channel-id": "alert-channel-id",
|
||||||
|
}},
|
||||||
|
ExpectedOutput: Config{
|
||||||
|
BotEmail: "alert-bot-email",
|
||||||
|
BotAPIKey: "alert-bot-api-key",
|
||||||
|
Domain: "alert-domain",
|
||||||
|
ChannelID: "alert-channel-id",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.BotEmail != scenario.ExpectedOutput.BotEmail {
|
||||||
|
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.BotEmail, got.BotEmail)
|
||||||
|
}
|
||||||
|
if got.BotAPIKey != scenario.ExpectedOutput.BotAPIKey {
|
||||||
|
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.BotAPIKey, got.BotAPIKey)
|
||||||
|
}
|
||||||
|
if got.Domain != scenario.ExpectedOutput.Domain {
|
||||||
|
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.Domain, got.Domain)
|
||||||
|
}
|
||||||
|
if got.ChannelID != scenario.ExpectedOutput.ChannelID {
|
||||||
|
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.ChannelID, got.ChannelID)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
14
api/api.go
14
api/api.go
@@ -2,13 +2,14 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v5/config"
|
"github.com/TwiN/gatus/v5/config"
|
||||||
|
"github.com/TwiN/gatus/v5/config/web"
|
||||||
static "github.com/TwiN/gatus/v5/web"
|
static "github.com/TwiN/gatus/v5/web"
|
||||||
"github.com/TwiN/health"
|
"github.com/TwiN/health"
|
||||||
|
"github.com/TwiN/logr"
|
||||||
fiber "github.com/gofiber/fiber/v2"
|
fiber "github.com/gofiber/fiber/v2"
|
||||||
"github.com/gofiber/fiber/v2/middleware/adaptor"
|
"github.com/gofiber/fiber/v2/middleware/adaptor"
|
||||||
"github.com/gofiber/fiber/v2/middleware/compress"
|
"github.com/gofiber/fiber/v2/middleware/compress"
|
||||||
@@ -26,6 +27,10 @@ type API struct {
|
|||||||
|
|
||||||
func New(cfg *config.Config) *API {
|
func New(cfg *config.Config) *API {
|
||||||
api := &API{}
|
api := &API{}
|
||||||
|
if cfg.Web == nil {
|
||||||
|
logr.Warnf("[api.New] nil web config passed as parameter. This should only happen in tests. Using default web configuration")
|
||||||
|
cfg.Web = web.GetDefaultConfig()
|
||||||
|
}
|
||||||
api.router = api.createRouter(cfg)
|
api.router = api.createRouter(cfg)
|
||||||
return api
|
return api
|
||||||
}
|
}
|
||||||
@@ -37,9 +42,11 @@ func (a *API) Router() *fiber.App {
|
|||||||
func (a *API) createRouter(cfg *config.Config) *fiber.App {
|
func (a *API) createRouter(cfg *config.Config) *fiber.App {
|
||||||
app := fiber.New(fiber.Config{
|
app := fiber.New(fiber.Config{
|
||||||
ErrorHandler: func(c *fiber.Ctx, err error) error {
|
ErrorHandler: func(c *fiber.Ctx, err error) error {
|
||||||
log.Printf("[api.ErrorHandler] %s", err.Error())
|
logr.Errorf("[api.ErrorHandler] %s", err.Error())
|
||||||
return fiber.DefaultErrorHandler(c, err)
|
return fiber.DefaultErrorHandler(c, err)
|
||||||
},
|
},
|
||||||
|
ReadBufferSize: cfg.Web.ReadBufferSize,
|
||||||
|
Network: fiber.NetworkTCP,
|
||||||
})
|
})
|
||||||
if os.Getenv("ENVIRONMENT") == "dev" {
|
if os.Getenv("ENVIRONMENT") == "dev" {
|
||||||
app.Use(cors.New(cors.Config{
|
app.Use(cors.New(cors.Config{
|
||||||
@@ -65,9 +72,12 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
|
|||||||
unprotectedAPIRouter := apiRouter.Group("/")
|
unprotectedAPIRouter := apiRouter.Group("/")
|
||||||
unprotectedAPIRouter.Get("/v1/config", ConfigHandler{securityConfig: cfg.Security}.GetConfig)
|
unprotectedAPIRouter.Get("/v1/config", ConfigHandler{securityConfig: cfg.Security}.GetConfig)
|
||||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.svg", HealthBadge)
|
unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.svg", HealthBadge)
|
||||||
|
unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.shields", HealthBadgeShields)
|
||||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/uptimes/:duration/badge.svg", UptimeBadge)
|
unprotectedAPIRouter.Get("/v1/endpoints/:key/uptimes/:duration/badge.svg", UptimeBadge)
|
||||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/badge.svg", ResponseTimeBadge(cfg))
|
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/badge.svg", ResponseTimeBadge(cfg))
|
||||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/chart.svg", ResponseTimeChart)
|
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/chart.svg", ResponseTimeChart)
|
||||||
|
// This endpoint requires authz with bearer token, so technically it is protected
|
||||||
|
unprotectedAPIRouter.Post("/v1/endpoints/:key/external", CreateExternalEndpointResult(cfg))
|
||||||
// SPA
|
// SPA
|
||||||
app.Get("/", SinglePageApplication(cfg.UI))
|
app.Get("/", SinglePageApplication(cfg.UI))
|
||||||
app.Get("/endpoints/:name", SinglePageApplication(cfg.UI))
|
app.Get("/endpoints/:name", SinglePageApplication(cfg.UI))
|
||||||
|
|||||||
92
api/badge.go
92
api/badge.go
@@ -1,12 +1,15 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v5/config"
|
"github.com/TwiN/gatus/v5/config"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint/ui"
|
||||||
"github.com/TwiN/gatus/v5/storage/store"
|
"github.com/TwiN/gatus/v5/storage/store"
|
||||||
"github.com/TwiN/gatus/v5/storage/store/common"
|
"github.com/TwiN/gatus/v5/storage/store/common"
|
||||||
"github.com/TwiN/gatus/v5/storage/store/common/paging"
|
"github.com/TwiN/gatus/v5/storage/store/common/paging"
|
||||||
@@ -34,11 +37,13 @@ var (
|
|||||||
|
|
||||||
// UptimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
|
// UptimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
|
||||||
//
|
//
|
||||||
// Valid values for :duration -> 7d, 24h, 1h
|
// Valid values for :duration -> 30d, 7d, 24h, 1h
|
||||||
func UptimeBadge(c *fiber.Ctx) error {
|
func UptimeBadge(c *fiber.Ctx) error {
|
||||||
duration := c.Params("duration")
|
duration := c.Params("duration")
|
||||||
var from time.Time
|
var from time.Time
|
||||||
switch duration {
|
switch duration {
|
||||||
|
case "30d":
|
||||||
|
from = time.Now().Add(-30 * 24 * time.Hour)
|
||||||
case "7d":
|
case "7d":
|
||||||
from = time.Now().Add(-7 * 24 * time.Hour)
|
from = time.Now().Add(-7 * 24 * time.Hour)
|
||||||
case "24h":
|
case "24h":
|
||||||
@@ -46,14 +51,14 @@ func UptimeBadge(c *fiber.Ctx) error {
|
|||||||
case "1h":
|
case "1h":
|
||||||
from = time.Now().Add(-2 * time.Hour) // Because uptime metrics are stored by hour, we have to cheat a little
|
from = time.Now().Add(-2 * time.Hour) // Because uptime metrics are stored by hour, we have to cheat a little
|
||||||
default:
|
default:
|
||||||
return c.Status(400).SendString("Durations supported: 7d, 24h, 1h")
|
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
|
||||||
}
|
}
|
||||||
key := c.Params("key")
|
key := c.Params("key")
|
||||||
uptime, err := store.Get().GetUptimeByKey(key, from, time.Now())
|
uptime, err := store.Get().GetUptimeByKey(key, from, time.Now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == common.ErrEndpointNotFound {
|
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||||
return c.Status(404).SendString(err.Error())
|
return c.Status(404).SendString(err.Error())
|
||||||
} else if err == common.ErrInvalidTimeRange {
|
} else if errors.Is(err, common.ErrInvalidTimeRange) {
|
||||||
return c.Status(400).SendString(err.Error())
|
return c.Status(400).SendString(err.Error())
|
||||||
}
|
}
|
||||||
return c.Status(500).SendString(err.Error())
|
return c.Status(500).SendString(err.Error())
|
||||||
@@ -66,12 +71,14 @@ func UptimeBadge(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
// ResponseTimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
|
// ResponseTimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
|
||||||
//
|
//
|
||||||
// Valid values for :duration -> 7d, 24h, 1h
|
// Valid values for :duration -> 30d, 7d, 24h, 1h
|
||||||
func ResponseTimeBadge(config *config.Config) fiber.Handler {
|
func ResponseTimeBadge(cfg *config.Config) fiber.Handler {
|
||||||
return func(c *fiber.Ctx) error {
|
return func(c *fiber.Ctx) error {
|
||||||
duration := c.Params("duration")
|
duration := c.Params("duration")
|
||||||
var from time.Time
|
var from time.Time
|
||||||
switch duration {
|
switch duration {
|
||||||
|
case "30d":
|
||||||
|
from = time.Now().Add(-30 * 24 * time.Hour)
|
||||||
case "7d":
|
case "7d":
|
||||||
from = time.Now().Add(-7 * 24 * time.Hour)
|
from = time.Now().Add(-7 * 24 * time.Hour)
|
||||||
case "24h":
|
case "24h":
|
||||||
@@ -79,14 +86,14 @@ func ResponseTimeBadge(config *config.Config) fiber.Handler {
|
|||||||
case "1h":
|
case "1h":
|
||||||
from = time.Now().Add(-2 * time.Hour) // Because response time metrics are stored by hour, we have to cheat a little
|
from = time.Now().Add(-2 * time.Hour) // Because response time metrics are stored by hour, we have to cheat a little
|
||||||
default:
|
default:
|
||||||
return c.Status(400).SendString("Durations supported: 7d, 24h, 1h")
|
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
|
||||||
}
|
}
|
||||||
key := c.Params("key")
|
key := c.Params("key")
|
||||||
averageResponseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
|
averageResponseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == common.ErrEndpointNotFound {
|
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||||
return c.Status(404).SendString(err.Error())
|
return c.Status(404).SendString(err.Error())
|
||||||
} else if err == common.ErrInvalidTimeRange {
|
} else if errors.Is(err, common.ErrInvalidTimeRange) {
|
||||||
return c.Status(400).SendString(err.Error())
|
return c.Status(400).SendString(err.Error())
|
||||||
}
|
}
|
||||||
return c.Status(500).SendString(err.Error())
|
return c.Status(500).SendString(err.Error())
|
||||||
@@ -94,7 +101,7 @@ func ResponseTimeBadge(config *config.Config) fiber.Handler {
|
|||||||
c.Set("Content-Type", "image/svg+xml")
|
c.Set("Content-Type", "image/svg+xml")
|
||||||
c.Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
c.Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
c.Set("Expires", "0")
|
c.Set("Expires", "0")
|
||||||
return c.Status(200).Send(generateResponseTimeBadgeSVG(duration, averageResponseTime, key, config))
|
return c.Status(200).Send(generateResponseTimeBadgeSVG(duration, averageResponseTime, key, cfg))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,9 +111,9 @@ func HealthBadge(c *fiber.Ctx) error {
|
|||||||
pagingConfig := paging.NewEndpointStatusParams()
|
pagingConfig := paging.NewEndpointStatusParams()
|
||||||
status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1))
|
status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == common.ErrEndpointNotFound {
|
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||||
return c.Status(404).SendString(err.Error())
|
return c.Status(404).SendString(err.Error())
|
||||||
} else if err == common.ErrInvalidTimeRange {
|
} else if errors.Is(err, common.ErrInvalidTimeRange) {
|
||||||
return c.Status(400).SendString(err.Error())
|
return c.Status(400).SendString(err.Error())
|
||||||
}
|
}
|
||||||
return c.Status(500).SendString(err.Error())
|
return c.Status(500).SendString(err.Error())
|
||||||
@@ -125,9 +132,41 @@ func HealthBadge(c *fiber.Ctx) error {
|
|||||||
return c.Status(200).Send(generateHealthBadgeSVG(healthStatus))
|
return c.Status(200).Send(generateHealthBadgeSVG(healthStatus))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func HealthBadgeShields(c *fiber.Ctx) error {
|
||||||
|
key := c.Params("key")
|
||||||
|
pagingConfig := paging.NewEndpointStatusParams()
|
||||||
|
status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||||
|
return c.Status(404).SendString(err.Error())
|
||||||
|
} else if errors.Is(err, common.ErrInvalidTimeRange) {
|
||||||
|
return c.Status(400).SendString(err.Error())
|
||||||
|
}
|
||||||
|
return c.Status(500).SendString(err.Error())
|
||||||
|
}
|
||||||
|
healthStatus := HealthStatusUnknown
|
||||||
|
if len(status.Results) > 0 {
|
||||||
|
if status.Results[0].Success {
|
||||||
|
healthStatus = HealthStatusUp
|
||||||
|
} else {
|
||||||
|
healthStatus = HealthStatusDown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.Set("Content-Type", "application/json")
|
||||||
|
c.Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
|
c.Set("Expires", "0")
|
||||||
|
jsonData, err := generateHealthBadgeShields(healthStatus)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(500).SendString(err.Error())
|
||||||
|
}
|
||||||
|
return c.Status(200).Send(jsonData)
|
||||||
|
}
|
||||||
|
|
||||||
func generateUptimeBadgeSVG(duration string, uptime float64) []byte {
|
func generateUptimeBadgeSVG(duration string, uptime float64) []byte {
|
||||||
var labelWidth, valueWidth, valueWidthAdjustment int
|
var labelWidth, valueWidth, valueWidthAdjustment int
|
||||||
switch duration {
|
switch duration {
|
||||||
|
case "30d":
|
||||||
|
labelWidth = 70
|
||||||
case "7d":
|
case "7d":
|
||||||
labelWidth = 65
|
labelWidth = 65
|
||||||
case "24h":
|
case "24h":
|
||||||
@@ -194,6 +233,8 @@ func getBadgeColorFromUptime(uptime float64) string {
|
|||||||
func generateResponseTimeBadgeSVG(duration string, averageResponseTime int, key string, cfg *config.Config) []byte {
|
func generateResponseTimeBadgeSVG(duration string, averageResponseTime int, key string, cfg *config.Config) []byte {
|
||||||
var labelWidth, valueWidth int
|
var labelWidth, valueWidth int
|
||||||
switch duration {
|
switch duration {
|
||||||
|
case "30d":
|
||||||
|
labelWidth = 110
|
||||||
case "7d":
|
case "7d":
|
||||||
labelWidth = 105
|
labelWidth = 105
|
||||||
case "24h":
|
case "24h":
|
||||||
@@ -240,10 +281,13 @@ func generateResponseTimeBadgeSVG(duration string, averageResponseTime int, key
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getBadgeColorFromResponseTime(responseTime int, key string, cfg *config.Config) string {
|
func getBadgeColorFromResponseTime(responseTime int, key string, cfg *config.Config) string {
|
||||||
endpoint := cfg.GetEndpointByKey(key)
|
thresholds := ui.GetDefaultConfig().Badge.ResponseTime.Thresholds
|
||||||
|
if endpoint := cfg.GetEndpointByKey(key); endpoint != nil {
|
||||||
|
thresholds = endpoint.UIConfig.Badge.ResponseTime.Thresholds
|
||||||
|
}
|
||||||
// the threshold config requires 5 values, so we can be sure it's set here
|
// the threshold config requires 5 values, so we can be sure it's set here
|
||||||
for i := 0; i < 5; i++ {
|
for i := 0; i < 5; i++ {
|
||||||
if responseTime <= endpoint.UIConfig.Badge.ResponseTime.Thresholds[i] {
|
if responseTime <= thresholds[i] {
|
||||||
return badgeColors[i]
|
return badgeColors[i]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,6 +343,17 @@ func generateHealthBadgeSVG(healthStatus string) []byte {
|
|||||||
return svg
|
return svg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func generateHealthBadgeShields(healthStatus string) ([]byte, error) {
|
||||||
|
color := getBadgeShieldsColorFromHealth(healthStatus)
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"label": "gatus",
|
||||||
|
"message": healthStatus,
|
||||||
|
"color": color,
|
||||||
|
}
|
||||||
|
return json.Marshal(data)
|
||||||
|
}
|
||||||
|
|
||||||
func getBadgeColorFromHealth(healthStatus string) string {
|
func getBadgeColorFromHealth(healthStatus string) string {
|
||||||
if healthStatus == HealthStatusUp {
|
if healthStatus == HealthStatusUp {
|
||||||
return badgeColorHexAwesome
|
return badgeColorHexAwesome
|
||||||
@@ -307,3 +362,12 @@ func getBadgeColorFromHealth(healthStatus string) string {
|
|||||||
}
|
}
|
||||||
return badgeColorHexPassable
|
return badgeColorHexPassable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getBadgeShieldsColorFromHealth(healthStatus string) string {
|
||||||
|
if healthStatus == HealthStatusUp {
|
||||||
|
return "brightgreen"
|
||||||
|
} else if healthStatus == HealthStatusDown {
|
||||||
|
return "red"
|
||||||
|
}
|
||||||
|
return "yellow"
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v5/config"
|
"github.com/TwiN/gatus/v5/config"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/TwiN/gatus/v5/core/ui"
|
"github.com/TwiN/gatus/v5/config/endpoint/ui"
|
||||||
"github.com/TwiN/gatus/v5/storage/store"
|
"github.com/TwiN/gatus/v5/storage/store"
|
||||||
"github.com/TwiN/gatus/v5/watchdog"
|
"github.com/TwiN/gatus/v5/watchdog"
|
||||||
)
|
)
|
||||||
@@ -19,7 +19,7 @@ func TestBadge(t *testing.T) {
|
|||||||
defer cache.Clear()
|
defer cache.Clear()
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
Metrics: true,
|
Metrics: true,
|
||||||
Endpoints: []*core.Endpoint{
|
Endpoints: []*endpoint.Endpoint{
|
||||||
{
|
{
|
||||||
Name: "frontend",
|
Name: "frontend",
|
||||||
Group: "core",
|
Group: "core",
|
||||||
@@ -34,8 +34,8 @@ func TestBadge(t *testing.T) {
|
|||||||
cfg.Endpoints[0].UIConfig = ui.GetDefaultConfig()
|
cfg.Endpoints[0].UIConfig = ui.GetDefaultConfig()
|
||||||
cfg.Endpoints[1].UIConfig = ui.GetDefaultConfig()
|
cfg.Endpoints[1].UIConfig = ui.GetDefaultConfig()
|
||||||
|
|
||||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()})
|
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()})
|
||||||
api := New(cfg)
|
api := New(cfg)
|
||||||
router := api.Router()
|
router := api.Router()
|
||||||
type Scenario struct {
|
type Scenario struct {
|
||||||
@@ -110,6 +110,21 @@ func TestBadge(t *testing.T) {
|
|||||||
Path: "/api/v1/endpoints/invalid_key/health/badge.svg",
|
Path: "/api/v1/endpoints/invalid_key/health/badge.svg",
|
||||||
ExpectedCode: http.StatusNotFound,
|
ExpectedCode: http.StatusNotFound,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "badge-shields-health-up",
|
||||||
|
Path: "/api/v1/endpoints/core_frontend/health/badge.shields",
|
||||||
|
ExpectedCode: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "badge-shields-health-down",
|
||||||
|
Path: "/api/v1/endpoints/core_backend/health/badge.shields",
|
||||||
|
ExpectedCode: http.StatusOK,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "badge-shields-health-for-invalid-key",
|
||||||
|
Path: "/api/v1/endpoints/invalid_key/health/badge.shields",
|
||||||
|
ExpectedCode: http.StatusNotFound,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "chart-response-time-24h",
|
Name: "chart-response-time-24h",
|
||||||
Path: "/api/v1/endpoints/core_backend/response-times/24h/chart.svg",
|
Path: "/api/v1/endpoints/core_backend/response-times/24h/chart.svg",
|
||||||
@@ -203,30 +218,30 @@ func TestGetBadgeColorFromResponseTime(t *testing.T) {
|
|||||||
defer cache.Clear()
|
defer cache.Clear()
|
||||||
|
|
||||||
var (
|
var (
|
||||||
firstCondition = core.Condition("[STATUS] == 200")
|
firstCondition = endpoint.Condition("[STATUS] == 200")
|
||||||
secondCondition = core.Condition("[RESPONSE_TIME] < 500")
|
secondCondition = endpoint.Condition("[RESPONSE_TIME] < 500")
|
||||||
thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h")
|
thirdCondition = endpoint.Condition("[CERTIFICATE_EXPIRATION] < 72h")
|
||||||
)
|
)
|
||||||
|
|
||||||
firstTestEndpoint := core.Endpoint{
|
firstTestEndpoint := endpoint.Endpoint{
|
||||||
Name: "a",
|
Name: "a",
|
||||||
URL: "https://example.org/what/ever",
|
URL: "https://example.org/what/ever",
|
||||||
Method: "GET",
|
Method: "GET",
|
||||||
Body: "body",
|
Body: "body",
|
||||||
Interval: 30 * time.Second,
|
Interval: 30 * time.Second,
|
||||||
Conditions: []core.Condition{firstCondition, secondCondition, thirdCondition},
|
Conditions: []endpoint.Condition{firstCondition, secondCondition, thirdCondition},
|
||||||
Alerts: nil,
|
Alerts: nil,
|
||||||
NumberOfFailuresInARow: 0,
|
NumberOfFailuresInARow: 0,
|
||||||
NumberOfSuccessesInARow: 0,
|
NumberOfSuccessesInARow: 0,
|
||||||
UIConfig: ui.GetDefaultConfig(),
|
UIConfig: ui.GetDefaultConfig(),
|
||||||
}
|
}
|
||||||
secondTestEndpoint := core.Endpoint{
|
secondTestEndpoint := endpoint.Endpoint{
|
||||||
Name: "b",
|
Name: "b",
|
||||||
URL: "https://example.org/what/ever",
|
URL: "https://example.org/what/ever",
|
||||||
Method: "GET",
|
Method: "GET",
|
||||||
Body: "body",
|
Body: "body",
|
||||||
Interval: 30 * time.Second,
|
Interval: 30 * time.Second,
|
||||||
Conditions: []core.Condition{firstCondition, secondCondition, thirdCondition},
|
Conditions: []endpoint.Condition{firstCondition, secondCondition, thirdCondition},
|
||||||
Alerts: nil,
|
Alerts: nil,
|
||||||
NumberOfFailuresInARow: 0,
|
NumberOfFailuresInARow: 0,
|
||||||
NumberOfSuccessesInARow: 0,
|
NumberOfSuccessesInARow: 0,
|
||||||
@@ -240,10 +255,10 @@ func TestGetBadgeColorFromResponseTime(t *testing.T) {
|
|||||||
}
|
}
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
Metrics: true,
|
Metrics: true,
|
||||||
Endpoints: []*core.Endpoint{&firstTestEndpoint, &secondTestEndpoint},
|
Endpoints: []*endpoint.Endpoint{&firstTestEndpoint, &secondTestEndpoint},
|
||||||
}
|
}
|
||||||
|
|
||||||
testSuccessfulResult := core.Result{
|
testSuccessfulResult := endpoint.Result{
|
||||||
Hostname: "example.org",
|
Hostname: "example.org",
|
||||||
IP: "127.0.0.1",
|
IP: "127.0.0.1",
|
||||||
HTTPStatus: 200,
|
HTTPStatus: 200,
|
||||||
@@ -253,7 +268,7 @@ func TestGetBadgeColorFromResponseTime(t *testing.T) {
|
|||||||
Timestamp: time.Now(),
|
Timestamp: time.Now(),
|
||||||
Duration: 150 * time.Millisecond,
|
Duration: 150 * time.Millisecond,
|
||||||
CertificateExpiration: 10 * time.Hour,
|
CertificateExpiration: 10 * time.Hour,
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{
|
{
|
||||||
Condition: "[STATUS] == 200",
|
Condition: "[STATUS] == 200",
|
||||||
Success: true,
|
Success: true,
|
||||||
|
|||||||
19
api/chart.go
19
api/chart.go
@@ -1,7 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"errors"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/TwiN/gatus/v5/storage/store"
|
"github.com/TwiN/gatus/v5/storage/store"
|
||||||
"github.com/TwiN/gatus/v5/storage/store/common"
|
"github.com/TwiN/gatus/v5/storage/store/common"
|
||||||
|
"github.com/TwiN/logr"
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
"github.com/wcharczuk/go-chart/v2"
|
||||||
"github.com/wcharczuk/go-chart/v2/drawing"
|
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||||
@@ -31,20 +32,24 @@ var (
|
|||||||
|
|
||||||
func ResponseTimeChart(c *fiber.Ctx) error {
|
func ResponseTimeChart(c *fiber.Ctx) error {
|
||||||
duration := c.Params("duration")
|
duration := c.Params("duration")
|
||||||
|
chartTimestampFormatter := chart.TimeValueFormatterWithFormat(timeFormat)
|
||||||
var from time.Time
|
var from time.Time
|
||||||
switch duration {
|
switch duration {
|
||||||
|
case "30d":
|
||||||
|
from = time.Now().Truncate(time.Hour).Add(-30 * 24 * time.Hour)
|
||||||
|
chartTimestampFormatter = chart.TimeDateValueFormatter
|
||||||
case "7d":
|
case "7d":
|
||||||
from = time.Now().Truncate(time.Hour).Add(-24 * 7 * time.Hour)
|
from = time.Now().Truncate(time.Hour).Add(-7 * 24 * time.Hour)
|
||||||
case "24h":
|
case "24h":
|
||||||
from = time.Now().Truncate(time.Hour).Add(-24 * time.Hour)
|
from = time.Now().Truncate(time.Hour).Add(-24 * time.Hour)
|
||||||
default:
|
default:
|
||||||
return c.Status(400).SendString("Durations supported: 7d, 24h")
|
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h")
|
||||||
}
|
}
|
||||||
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(c.Params("key"), from, time.Now())
|
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(c.Params("key"), from, time.Now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == common.ErrEndpointNotFound {
|
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||||
return c.Status(404).SendString(err.Error())
|
return c.Status(404).SendString(err.Error())
|
||||||
} else if err == common.ErrInvalidTimeRange {
|
} else if errors.Is(err, common.ErrInvalidTimeRange) {
|
||||||
return c.Status(400).SendString(err.Error())
|
return c.Status(400).SendString(err.Error())
|
||||||
}
|
}
|
||||||
return c.Status(500).SendString(err.Error())
|
return c.Status(500).SendString(err.Error())
|
||||||
@@ -87,7 +92,7 @@ func ResponseTimeChart(c *fiber.Ctx) error {
|
|||||||
Width: 1280,
|
Width: 1280,
|
||||||
Height: 300,
|
Height: 300,
|
||||||
XAxis: chart.XAxis{
|
XAxis: chart.XAxis{
|
||||||
ValueFormatter: chart.TimeValueFormatterWithFormat(timeFormat),
|
ValueFormatter: chartTimestampFormatter,
|
||||||
GridMajorStyle: gridStyle,
|
GridMajorStyle: gridStyle,
|
||||||
GridMinorStyle: gridStyle,
|
GridMinorStyle: gridStyle,
|
||||||
Style: axisStyle,
|
Style: axisStyle,
|
||||||
@@ -111,7 +116,7 @@ func ResponseTimeChart(c *fiber.Ctx) error {
|
|||||||
c.Set("Expires", "0")
|
c.Set("Expires", "0")
|
||||||
c.Status(http.StatusOK)
|
c.Status(http.StatusOK)
|
||||||
if err := graph.Render(chart.SVG, c); err != nil {
|
if err := graph.Render(chart.SVG, c); err != nil {
|
||||||
log.Println("[api][ResponseTimeChart] Failed to render response time chart:", err.Error())
|
logr.Errorf("[api.ResponseTimeChart] Failed to render response time chart: %s", err.Error())
|
||||||
return c.Status(500).SendString(err.Error())
|
return c.Status(500).SendString(err.Error())
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v5/config"
|
"github.com/TwiN/gatus/v5/config"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/TwiN/gatus/v5/storage/store"
|
"github.com/TwiN/gatus/v5/storage/store"
|
||||||
"github.com/TwiN/gatus/v5/watchdog"
|
"github.com/TwiN/gatus/v5/watchdog"
|
||||||
)
|
)
|
||||||
@@ -17,7 +17,7 @@ func TestResponseTimeChart(t *testing.T) {
|
|||||||
defer cache.Clear()
|
defer cache.Clear()
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
Metrics: true,
|
Metrics: true,
|
||||||
Endpoints: []*core.Endpoint{
|
Endpoints: []*endpoint.Endpoint{
|
||||||
{
|
{
|
||||||
Name: "frontend",
|
Name: "frontend",
|
||||||
Group: "core",
|
Group: "core",
|
||||||
@@ -28,8 +28,8 @@ func TestResponseTimeChart(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||||
api := New(cfg)
|
api := New(cfg)
|
||||||
router := api.Router()
|
router := api.Router()
|
||||||
type Scenario struct {
|
type Scenario struct {
|
||||||
@@ -49,6 +49,11 @@ func TestResponseTimeChart(t *testing.T) {
|
|||||||
Path: "/api/v1/endpoints/core_frontend/response-times/7d/chart.svg",
|
Path: "/api/v1/endpoints/core_frontend/response-times/7d/chart.svg",
|
||||||
ExpectedCode: http.StatusOK,
|
ExpectedCode: http.StatusOK,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "chart-response-time-30d",
|
||||||
|
Path: "/api/v1/endpoints/core_frontend/response-times/30d/chart.svg",
|
||||||
|
ExpectedCode: http.StatusOK,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "chart-response-time-with-invalid-duration",
|
Name: "chart-response-time-with-invalid-duration",
|
||||||
Path: "/api/v1/endpoints/core_backend/response-times/3d/chart.svg",
|
Path: "/api/v1/endpoints/core_backend/response-times/3d/chart.svg",
|
||||||
|
|||||||
@@ -2,17 +2,17 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/config"
|
"github.com/TwiN/gatus/v5/config"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/TwiN/gatus/v5/config/remote"
|
"github.com/TwiN/gatus/v5/config/remote"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
|
||||||
"github.com/TwiN/gatus/v5/storage/store"
|
"github.com/TwiN/gatus/v5/storage/store"
|
||||||
"github.com/TwiN/gatus/v5/storage/store/common"
|
"github.com/TwiN/gatus/v5/storage/store/common"
|
||||||
"github.com/TwiN/gatus/v5/storage/store/common/paging"
|
"github.com/TwiN/gatus/v5/storage/store/common/paging"
|
||||||
|
"github.com/TwiN/logr"
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,19 +26,19 @@ func EndpointStatuses(cfg *config.Config) fiber.Handler {
|
|||||||
if !exists {
|
if !exists {
|
||||||
endpointStatuses, err := store.Get().GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(page, pageSize))
|
endpointStatuses, err := store.Get().GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(page, pageSize))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[api][EndpointStatuses] Failed to retrieve endpoint statuses: %s", err.Error())
|
logr.Errorf("[api.EndpointStatuses] Failed to retrieve endpoint statuses: %s", err.Error())
|
||||||
return c.Status(500).SendString(err.Error())
|
return c.Status(500).SendString(err.Error())
|
||||||
}
|
}
|
||||||
// ALPHA: Retrieve endpoint statuses from remote instances
|
// ALPHA: Retrieve endpoint statuses from remote instances
|
||||||
if endpointStatusesFromRemote, err := getEndpointStatusesFromRemoteInstances(cfg.Remote); err != nil {
|
if endpointStatusesFromRemote, err := getEndpointStatusesFromRemoteInstances(cfg.Remote); err != nil {
|
||||||
log.Printf("[handler][EndpointStatuses] Silently failed to retrieve endpoint statuses from remote: %s", err.Error())
|
logr.Errorf("[handler.EndpointStatuses] Silently failed to retrieve endpoint statuses from remote: %s", err.Error())
|
||||||
} else if endpointStatusesFromRemote != nil {
|
} else if endpointStatusesFromRemote != nil {
|
||||||
endpointStatuses = append(endpointStatuses, endpointStatusesFromRemote...)
|
endpointStatuses = append(endpointStatuses, endpointStatusesFromRemote...)
|
||||||
}
|
}
|
||||||
// Marshal endpoint statuses to JSON
|
// Marshal endpoint statuses to JSON
|
||||||
data, err = json.Marshal(endpointStatuses)
|
data, err = json.Marshal(endpointStatuses)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[api][EndpointStatuses] Unable to marshal object to JSON: %s", err.Error())
|
logr.Errorf("[api.EndpointStatuses] Unable to marshal object to JSON: %s", err.Error())
|
||||||
return c.Status(500).SendString("unable to marshal object to JSON")
|
return c.Status(500).SendString("unable to marshal object to JSON")
|
||||||
}
|
}
|
||||||
cache.SetWithTTL(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize), data, cacheTTL)
|
cache.SetWithTTL(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize), data, cacheTTL)
|
||||||
@@ -50,27 +50,21 @@ func EndpointStatuses(cfg *config.Config) fiber.Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*core.EndpointStatus, error) {
|
func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*endpoint.Status, error) {
|
||||||
if remoteConfig == nil || len(remoteConfig.Instances) == 0 {
|
if remoteConfig == nil || len(remoteConfig.Instances) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
var endpointStatusesFromAllRemotes []*core.EndpointStatus
|
var endpointStatusesFromAllRemotes []*endpoint.Status
|
||||||
httpClient := client.GetHTTPClient(remoteConfig.ClientConfig)
|
httpClient := client.GetHTTPClient(remoteConfig.ClientConfig)
|
||||||
for _, instance := range remoteConfig.Instances {
|
for _, instance := range remoteConfig.Instances {
|
||||||
response, err := httpClient.Get(instance.URL)
|
response, err := httpClient.Get(instance.URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
body, err := io.ReadAll(response.Body)
|
var endpointStatuses []*endpoint.Status
|
||||||
if err != nil {
|
if err = json.NewDecoder(response.Body).Decode(&endpointStatuses); err != nil {
|
||||||
_ = response.Body.Close()
|
_ = response.Body.Close()
|
||||||
log.Printf("[handler][getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error())
|
logr.Errorf("[api.getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error())
|
||||||
continue
|
|
||||||
}
|
|
||||||
var endpointStatuses []*core.EndpointStatus
|
|
||||||
if err = json.Unmarshal(body, &endpointStatuses); err != nil {
|
|
||||||
_ = response.Body.Close()
|
|
||||||
log.Printf("[handler][getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error())
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
_ = response.Body.Close()
|
_ = response.Body.Close()
|
||||||
@@ -82,24 +76,24 @@ func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*cor
|
|||||||
return endpointStatusesFromAllRemotes, nil
|
return endpointStatusesFromAllRemotes, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EndpointStatus retrieves a single core.EndpointStatus by group and endpoint name
|
// EndpointStatus retrieves a single endpoint.Status by group and endpoint name
|
||||||
func EndpointStatus(c *fiber.Ctx) error {
|
func EndpointStatus(c *fiber.Ctx) error {
|
||||||
page, pageSize := extractPageAndPageSizeFromRequest(c)
|
page, pageSize := extractPageAndPageSizeFromRequest(c)
|
||||||
endpointStatus, err := store.Get().GetEndpointStatusByKey(c.Params("key"), paging.NewEndpointStatusParams().WithResults(page, pageSize).WithEvents(1, common.MaximumNumberOfEvents))
|
endpointStatus, err := store.Get().GetEndpointStatusByKey(c.Params("key"), paging.NewEndpointStatusParams().WithResults(page, pageSize).WithEvents(1, common.MaximumNumberOfEvents))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == common.ErrEndpointNotFound {
|
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||||
return c.Status(404).SendString(err.Error())
|
return c.Status(404).SendString(err.Error())
|
||||||
}
|
}
|
||||||
log.Printf("[api][EndpointStatus] Failed to retrieve endpoint status: %s", err.Error())
|
logr.Errorf("[api.EndpointStatus] Failed to retrieve endpoint status: %s", err.Error())
|
||||||
return c.Status(500).SendString(err.Error())
|
return c.Status(500).SendString(err.Error())
|
||||||
}
|
}
|
||||||
if endpointStatus == nil { // XXX: is this check necessary?
|
if endpointStatus == nil { // XXX: is this check necessary?
|
||||||
log.Printf("[api][EndpointStatus] Endpoint with key=%s not found", c.Params("key"))
|
logr.Errorf("[api.EndpointStatus] Endpoint with key=%s not found", c.Params("key"))
|
||||||
return c.Status(404).SendString("not found")
|
return c.Status(404).SendString("not found")
|
||||||
}
|
}
|
||||||
output, err := json.Marshal(endpointStatus)
|
output, err := json.Marshal(endpointStatus)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[api][EndpointStatus] Unable to marshal object to JSON: %s", err.Error())
|
logr.Errorf("[api.EndpointStatus] Unable to marshal object to JSON: %s", err.Error())
|
||||||
return c.Status(500).SendString("unable to marshal object to JSON")
|
return c.Status(500).SendString("unable to marshal object to JSON")
|
||||||
}
|
}
|
||||||
c.Set("Content-Type", "application/json")
|
c.Set("Content-Type", "application/json")
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v5/config"
|
"github.com/TwiN/gatus/v5/config"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/TwiN/gatus/v5/storage/store"
|
"github.com/TwiN/gatus/v5/storage/store"
|
||||||
"github.com/TwiN/gatus/v5/watchdog"
|
"github.com/TwiN/gatus/v5/watchdog"
|
||||||
)
|
)
|
||||||
@@ -16,19 +16,19 @@ import (
|
|||||||
var (
|
var (
|
||||||
timestamp = time.Now()
|
timestamp = time.Now()
|
||||||
|
|
||||||
testEndpoint = core.Endpoint{
|
testEndpoint = endpoint.Endpoint{
|
||||||
Name: "name",
|
Name: "name",
|
||||||
Group: "group",
|
Group: "group",
|
||||||
URL: "https://example.org/what/ever",
|
URL: "https://example.org/what/ever",
|
||||||
Method: "GET",
|
Method: "GET",
|
||||||
Body: "body",
|
Body: "body",
|
||||||
Interval: 30 * time.Second,
|
Interval: 30 * time.Second,
|
||||||
Conditions: []core.Condition{core.Condition("[STATUS] == 200"), core.Condition("[RESPONSE_TIME] < 500"), core.Condition("[CERTIFICATE_EXPIRATION] < 72h")},
|
Conditions: []endpoint.Condition{endpoint.Condition("[STATUS] == 200"), endpoint.Condition("[RESPONSE_TIME] < 500"), endpoint.Condition("[CERTIFICATE_EXPIRATION] < 72h")},
|
||||||
Alerts: nil,
|
Alerts: nil,
|
||||||
NumberOfFailuresInARow: 0,
|
NumberOfFailuresInARow: 0,
|
||||||
NumberOfSuccessesInARow: 0,
|
NumberOfSuccessesInARow: 0,
|
||||||
}
|
}
|
||||||
testSuccessfulResult = core.Result{
|
testSuccessfulResult = endpoint.Result{
|
||||||
Hostname: "example.org",
|
Hostname: "example.org",
|
||||||
IP: "127.0.0.1",
|
IP: "127.0.0.1",
|
||||||
HTTPStatus: 200,
|
HTTPStatus: 200,
|
||||||
@@ -38,7 +38,7 @@ var (
|
|||||||
Timestamp: timestamp,
|
Timestamp: timestamp,
|
||||||
Duration: 150 * time.Millisecond,
|
Duration: 150 * time.Millisecond,
|
||||||
CertificateExpiration: 10 * time.Hour,
|
CertificateExpiration: 10 * time.Hour,
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{
|
{
|
||||||
Condition: "[STATUS] == 200",
|
Condition: "[STATUS] == 200",
|
||||||
Success: true,
|
Success: true,
|
||||||
@@ -53,7 +53,7 @@ var (
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
testUnsuccessfulResult = core.Result{
|
testUnsuccessfulResult = endpoint.Result{
|
||||||
Hostname: "example.org",
|
Hostname: "example.org",
|
||||||
IP: "127.0.0.1",
|
IP: "127.0.0.1",
|
||||||
HTTPStatus: 200,
|
HTTPStatus: 200,
|
||||||
@@ -63,7 +63,7 @@ var (
|
|||||||
Timestamp: timestamp,
|
Timestamp: timestamp,
|
||||||
Duration: 750 * time.Millisecond,
|
Duration: 750 * time.Millisecond,
|
||||||
CertificateExpiration: 10 * time.Hour,
|
CertificateExpiration: 10 * time.Hour,
|
||||||
ConditionResults: []*core.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
{
|
{
|
||||||
Condition: "[STATUS] == 200",
|
Condition: "[STATUS] == 200",
|
||||||
Success: true,
|
Success: true,
|
||||||
@@ -85,7 +85,7 @@ func TestEndpointStatus(t *testing.T) {
|
|||||||
defer cache.Clear()
|
defer cache.Clear()
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
Metrics: true,
|
Metrics: true,
|
||||||
Endpoints: []*core.Endpoint{
|
Endpoints: []*endpoint.Endpoint{
|
||||||
{
|
{
|
||||||
Name: "frontend",
|
Name: "frontend",
|
||||||
Group: "core",
|
Group: "core",
|
||||||
@@ -96,8 +96,8 @@ func TestEndpointStatus(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||||
api := New(cfg)
|
api := New(cfg)
|
||||||
router := api.Router()
|
router := api.Router()
|
||||||
type Scenario struct {
|
type Scenario struct {
|
||||||
|
|||||||
70
api/external_endpoint.go
Normal file
70
api/external_endpoint.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/config"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/TwiN/gatus/v5/storage/store"
|
||||||
|
"github.com/TwiN/gatus/v5/storage/store/common"
|
||||||
|
"github.com/TwiN/gatus/v5/watchdog"
|
||||||
|
"github.com/TwiN/logr"
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
// Check if the success query parameter is present
|
||||||
|
success, exists := c.Queries()["success"]
|
||||||
|
if !exists || (success != "true" && success != "false") {
|
||||||
|
return c.Status(400).SendString("missing or invalid success query parameter")
|
||||||
|
}
|
||||||
|
// Check if the authorization bearer token header is correct
|
||||||
|
authorizationHeader := string(c.Request().Header.Peek("Authorization"))
|
||||||
|
if !strings.HasPrefix(authorizationHeader, "Bearer ") {
|
||||||
|
return c.Status(401).SendString("invalid Authorization header")
|
||||||
|
}
|
||||||
|
token := strings.TrimSpace(strings.TrimPrefix(authorizationHeader, "Bearer "))
|
||||||
|
if len(token) == 0 {
|
||||||
|
return c.Status(401).SendString("bearer token must not be empty")
|
||||||
|
}
|
||||||
|
key := c.Params("key")
|
||||||
|
externalEndpoint := cfg.GetExternalEndpointByKey(key)
|
||||||
|
if externalEndpoint == nil {
|
||||||
|
logr.Errorf("[api.CreateExternalEndpointResult] External endpoint with key=%s not found", key)
|
||||||
|
return c.Status(404).SendString("not found")
|
||||||
|
}
|
||||||
|
if externalEndpoint.Token != token {
|
||||||
|
logr.Errorf("[api.CreateExternalEndpointResult] Invalid token for external endpoint with key=%s", key)
|
||||||
|
return c.Status(401).SendString("invalid token")
|
||||||
|
}
|
||||||
|
// Persist the result in the storage
|
||||||
|
result := &endpoint.Result{
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
Success: c.QueryBool("success"),
|
||||||
|
Errors: []string{},
|
||||||
|
}
|
||||||
|
if !result.Success && c.Query("error") != "" {
|
||||||
|
result.Errors = append(result.Errors, c.Query("error"))
|
||||||
|
}
|
||||||
|
convertedEndpoint := externalEndpoint.ToEndpoint()
|
||||||
|
if err := store.Get().Insert(convertedEndpoint, result); err != nil {
|
||||||
|
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||||
|
return c.Status(404).SendString(err.Error())
|
||||||
|
}
|
||||||
|
logr.Errorf("[api.CreateExternalEndpointResult] Failed to insert result in storage: %s", err.Error())
|
||||||
|
return c.Status(500).SendString(err.Error())
|
||||||
|
}
|
||||||
|
logr.Infof("[api.CreateExternalEndpointResult] Successfully inserted result for external endpoint with key=%s and success=%s", c.Params("key"), success)
|
||||||
|
// Check if an alert should be triggered or resolved
|
||||||
|
if !cfg.Maintenance.IsUnderMaintenance() {
|
||||||
|
watchdog.HandleAlerting(convertedEndpoint, result, cfg.Alerting)
|
||||||
|
externalEndpoint.NumberOfSuccessesInARow = convertedEndpoint.NumberOfSuccessesInARow
|
||||||
|
externalEndpoint.NumberOfFailuresInARow = convertedEndpoint.NumberOfFailuresInARow
|
||||||
|
}
|
||||||
|
// Return the result
|
||||||
|
return c.Status(200).SendString("")
|
||||||
|
}
|
||||||
|
}
|
||||||
161
api/external_endpoint_test.go
Normal file
161
api/external_endpoint_test.go
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/discord"
|
||||||
|
"github.com/TwiN/gatus/v5/config"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/TwiN/gatus/v5/config/maintenance"
|
||||||
|
"github.com/TwiN/gatus/v5/storage/store"
|
||||||
|
"github.com/TwiN/gatus/v5/storage/store/common/paging"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateExternalEndpointResult(t *testing.T) {
|
||||||
|
defer store.Get().Clear()
|
||||||
|
defer cache.Clear()
|
||||||
|
cfg := &config.Config{
|
||||||
|
Alerting: &alerting.Config{
|
||||||
|
Discord: &discord.AlertProvider{},
|
||||||
|
},
|
||||||
|
ExternalEndpoints: []*endpoint.ExternalEndpoint{
|
||||||
|
{
|
||||||
|
Name: "n",
|
||||||
|
Group: "g",
|
||||||
|
Token: "token",
|
||||||
|
Alerts: []*alert.Alert{
|
||||||
|
{
|
||||||
|
Type: alert.TypeDiscord,
|
||||||
|
FailureThreshold: 2,
|
||||||
|
SuccessThreshold: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Maintenance: &maintenance.Config{},
|
||||||
|
}
|
||||||
|
api := New(cfg)
|
||||||
|
router := api.Router()
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Path string
|
||||||
|
AuthorizationHeaderBearerToken string
|
||||||
|
ExpectedCode int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "no-token",
|
||||||
|
Path: "/api/v1/endpoints/g_n/external?success=true",
|
||||||
|
AuthorizationHeaderBearerToken: "",
|
||||||
|
ExpectedCode: 401,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "bad-token",
|
||||||
|
Path: "/api/v1/endpoints/g_n/external?success=true",
|
||||||
|
AuthorizationHeaderBearerToken: "Bearer bad-token",
|
||||||
|
ExpectedCode: 401,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "bad-key",
|
||||||
|
Path: "/api/v1/endpoints/bad_key/external?success=true",
|
||||||
|
AuthorizationHeaderBearerToken: "Bearer token",
|
||||||
|
ExpectedCode: 404,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "bad-success-value",
|
||||||
|
Path: "/api/v1/endpoints/g_n/external?success=invalid",
|
||||||
|
AuthorizationHeaderBearerToken: "Bearer token",
|
||||||
|
ExpectedCode: 400,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "good-token-success-true",
|
||||||
|
Path: "/api/v1/endpoints/g_n/external?success=true",
|
||||||
|
AuthorizationHeaderBearerToken: "Bearer token",
|
||||||
|
ExpectedCode: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "good-token-success-true-with-ignored-error-because-success-true",
|
||||||
|
Path: "/api/v1/endpoints/g_n/external?success=true&error=failed",
|
||||||
|
AuthorizationHeaderBearerToken: "Bearer token",
|
||||||
|
ExpectedCode: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "good-token-success-false",
|
||||||
|
Path: "/api/v1/endpoints/g_n/external?success=false",
|
||||||
|
AuthorizationHeaderBearerToken: "Bearer token",
|
||||||
|
ExpectedCode: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "good-token-success-false-again",
|
||||||
|
Path: "/api/v1/endpoints/g_n/external?success=false",
|
||||||
|
AuthorizationHeaderBearerToken: "Bearer token",
|
||||||
|
ExpectedCode: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "good-token-success-false-with-error",
|
||||||
|
Path: "/api/v1/endpoints/g_n/external?success=false&error=failed",
|
||||||
|
AuthorizationHeaderBearerToken: "Bearer token",
|
||||||
|
ExpectedCode: 200,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
request := httptest.NewRequest("POST", scenario.Path, http.NoBody)
|
||||||
|
if len(scenario.AuthorizationHeaderBearerToken) > 0 {
|
||||||
|
request.Header.Set("Authorization", scenario.AuthorizationHeaderBearerToken)
|
||||||
|
}
|
||||||
|
response, err := router.Test(request)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode != scenario.ExpectedCode {
|
||||||
|
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
t.Run("verify-end-results", func(t *testing.T) {
|
||||||
|
endpointStatus, err := store.Get().GetEndpointStatus("g", "n", paging.NewEndpointStatusParams().WithResults(1, 10))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("failed to get endpoint status: %s", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if endpointStatus.Key != "g_n" {
|
||||||
|
t.Errorf("expected key to be g_n but got %s", endpointStatus.Key)
|
||||||
|
}
|
||||||
|
if len(endpointStatus.Results) != 5 {
|
||||||
|
t.Errorf("expected 3 results but got %d", len(endpointStatus.Results))
|
||||||
|
}
|
||||||
|
if !endpointStatus.Results[0].Success {
|
||||||
|
t.Errorf("expected first result to be successful")
|
||||||
|
}
|
||||||
|
if !endpointStatus.Results[1].Success {
|
||||||
|
t.Errorf("expected second result to be successful")
|
||||||
|
}
|
||||||
|
if len(endpointStatus.Results[1].Errors) > 0 {
|
||||||
|
t.Errorf("expected second result to have no errors")
|
||||||
|
}
|
||||||
|
if endpointStatus.Results[2].Success {
|
||||||
|
t.Errorf("expected third result to be unsuccessful")
|
||||||
|
}
|
||||||
|
if endpointStatus.Results[3].Success {
|
||||||
|
t.Errorf("expected fourth result to be unsuccessful")
|
||||||
|
}
|
||||||
|
if endpointStatus.Results[4].Success {
|
||||||
|
t.Errorf("expected fifth result to be unsuccessful")
|
||||||
|
}
|
||||||
|
if len(endpointStatus.Results[4].Errors) == 0 || endpointStatus.Results[4].Errors[0] != "failed" {
|
||||||
|
t.Errorf("expected fifth result to have errors: failed")
|
||||||
|
}
|
||||||
|
externalEndpointFromConfig := cfg.GetExternalEndpointByKey("g_n")
|
||||||
|
if externalEndpointFromConfig.NumberOfFailuresInARow != 3 {
|
||||||
|
t.Errorf("expected 3 failures in a row but got %d", externalEndpointFromConfig.NumberOfFailuresInARow)
|
||||||
|
}
|
||||||
|
if externalEndpointFromConfig.NumberOfSuccessesInARow != 0 {
|
||||||
|
t.Errorf("expected 0 successes in a row but got %d", externalEndpointFromConfig.NumberOfSuccessesInARow)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,10 +3,10 @@ package api
|
|||||||
import (
|
import (
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log"
|
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v5/config/ui"
|
"github.com/TwiN/gatus/v5/config/ui"
|
||||||
static "github.com/TwiN/gatus/v5/web"
|
static "github.com/TwiN/gatus/v5/web"
|
||||||
|
"github.com/TwiN/logr"
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,14 +15,14 @@ func SinglePageApplication(ui *ui.Config) fiber.Handler {
|
|||||||
t, err := template.ParseFS(static.FileSystem, static.IndexPath)
|
t, err := template.ParseFS(static.FileSystem, static.IndexPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// This should never happen, because ui.ValidateAndSetDefaults validates that the template works.
|
// This should never happen, because ui.ValidateAndSetDefaults validates that the template works.
|
||||||
log.Println("[api][SinglePageApplication] Failed to parse template. This should never happen, because the template is validated on start. Error:", err.Error())
|
logr.Errorf("[api.SinglePageApplication] Failed to parse template. This should never happen, because the template is validated on start. Error: %s", err.Error())
|
||||||
return c.Status(500).SendString("Failed to parse template. This should never happen, because the template is validated on start.")
|
return c.Status(500).SendString("Failed to parse template. This should never happen, because the template is validated on start.")
|
||||||
}
|
}
|
||||||
c.Set("Content-Type", "text/html")
|
c.Set("Content-Type", "text/html")
|
||||||
err = t.Execute(c, ui)
|
err = t.Execute(c, ui)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// This should never happen, because ui.ValidateAndSetDefaults validates that the template works.
|
// This should never happen, because ui.ValidateAndSetDefaults validates that the template works.
|
||||||
log.Println("[api][SinglePageApplication] Failed to execute template. This should never happen, because the template is validated on start. Error:", err.Error())
|
logr.Errorf("[api.SinglePageApplication] Failed to execute template. This should never happen, because the template is validated on start. Error: %s", err.Error())
|
||||||
return c.Status(500).SendString("Failed to parse template. This should never happen, because the template is validated on start.")
|
return c.Status(500).SendString("Failed to parse template. This should never happen, because the template is validated on start.")
|
||||||
}
|
}
|
||||||
return c.SendStatus(200)
|
return c.SendStatus(200)
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v5/config"
|
"github.com/TwiN/gatus/v5/config"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/TwiN/gatus/v5/config/ui"
|
"github.com/TwiN/gatus/v5/config/ui"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
|
||||||
"github.com/TwiN/gatus/v5/storage/store"
|
"github.com/TwiN/gatus/v5/storage/store"
|
||||||
"github.com/TwiN/gatus/v5/watchdog"
|
"github.com/TwiN/gatus/v5/watchdog"
|
||||||
)
|
)
|
||||||
@@ -20,7 +20,7 @@ func TestSinglePageApplication(t *testing.T) {
|
|||||||
defer cache.Clear()
|
defer cache.Clear()
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
Metrics: true,
|
Metrics: true,
|
||||||
Endpoints: []*core.Endpoint{
|
Endpoints: []*endpoint.Endpoint{
|
||||||
{
|
{
|
||||||
Name: "frontend",
|
Name: "frontend",
|
||||||
Group: "core",
|
Group: "core",
|
||||||
@@ -34,8 +34,8 @@ func TestSinglePageApplication(t *testing.T) {
|
|||||||
Title: "example-title",
|
Title: "example-title",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||||
api := New(cfg)
|
api := New(cfg)
|
||||||
router := api.Router()
|
router := api.Router()
|
||||||
type Scenario struct {
|
type Scenario struct {
|
||||||
|
|||||||
164
client/client.go
164
client/client.go
@@ -3,6 +3,7 @@ package client
|
|||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
@@ -15,7 +16,14 @@ import (
|
|||||||
"github.com/TwiN/gocache/v2"
|
"github.com/TwiN/gocache/v2"
|
||||||
"github.com/TwiN/whois"
|
"github.com/TwiN/whois"
|
||||||
"github.com/ishidawataru/sctp"
|
"github.com/ishidawataru/sctp"
|
||||||
|
"github.com/miekg/dns"
|
||||||
ping "github.com/prometheus-community/pro-bing"
|
ping "github.com/prometheus-community/pro-bing"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
"golang.org/x/net/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
dnsPort = 53
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -102,7 +110,6 @@ func CanCreateSCTPConnection(address string, config *Config) bool {
|
|||||||
_ = conn.Close()
|
_ = conn.Close()
|
||||||
res <- true
|
res <- true
|
||||||
})(ch)
|
})(ch)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case result := <-ch:
|
case result := <-ch:
|
||||||
return result
|
return result
|
||||||
@@ -142,26 +149,87 @@ func CanPerformStartTLS(address string, config *Config) (connected bool, certifi
|
|||||||
|
|
||||||
// CanPerformTLS checks whether a connection can be established to an address using the TLS protocol
|
// CanPerformTLS checks whether a connection can be established to an address using the TLS protocol
|
||||||
func CanPerformTLS(address string, config *Config) (connected bool, certificate *x509.Certificate, err error) {
|
func CanPerformTLS(address string, config *Config) (connected bool, certificate *x509.Certificate, err error) {
|
||||||
connection, err := tls.DialWithDialer(&net.Dialer{Timeout: config.Timeout}, "tcp", address, nil)
|
connection, err := tls.DialWithDialer(&net.Dialer{Timeout: config.Timeout}, "tcp", address, &tls.Config{
|
||||||
|
InsecureSkipVerify: config.Insecure,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer connection.Close()
|
defer connection.Close()
|
||||||
verifiedChains := connection.ConnectionState().VerifiedChains
|
verifiedChains := connection.ConnectionState().VerifiedChains
|
||||||
|
// If config.Insecure is set to true, verifiedChains will be an empty list []
|
||||||
|
// We should get the parsed certificates from PeerCertificates, it can't be empty on the client side
|
||||||
|
// Reference: https://pkg.go.dev/crypto/tls#PeerCertificates
|
||||||
if len(verifiedChains) == 0 || len(verifiedChains[0]) == 0 {
|
if len(verifiedChains) == 0 || len(verifiedChains[0]) == 0 {
|
||||||
return
|
peerCertificates := connection.ConnectionState().PeerCertificates
|
||||||
|
return true, peerCertificates[0], nil
|
||||||
}
|
}
|
||||||
return true, verifiedChains[0][0], nil
|
return true, verifiedChains[0][0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CanCreateSSHConnection checks whether a connection can be established and a command can be executed to an address
|
||||||
|
// using the SSH protocol.
|
||||||
|
func CanCreateSSHConnection(address, username, password string, config *Config) (bool, *ssh.Client, error) {
|
||||||
|
var port string
|
||||||
|
if strings.Contains(address, ":") {
|
||||||
|
addressAndPort := strings.Split(address, ":")
|
||||||
|
if len(addressAndPort) != 2 {
|
||||||
|
return false, nil, errors.New("invalid address for ssh, format must be host:port")
|
||||||
|
}
|
||||||
|
address = addressAndPort[0]
|
||||||
|
port = addressAndPort[1]
|
||||||
|
} else {
|
||||||
|
port = "22"
|
||||||
|
}
|
||||||
|
cli, err := ssh.Dial("tcp", strings.Join([]string{address, port}, ":"), &ssh.ClientConfig{
|
||||||
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||||
|
User: username,
|
||||||
|
Auth: []ssh.AuthMethod{
|
||||||
|
ssh.Password(password),
|
||||||
|
},
|
||||||
|
Timeout: config.Timeout,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false, nil, err
|
||||||
|
}
|
||||||
|
return true, cli, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteSSHCommand executes a command to an address using the SSH protocol.
|
||||||
|
func ExecuteSSHCommand(sshClient *ssh.Client, body string, config *Config) (bool, int, error) {
|
||||||
|
type Body struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
}
|
||||||
|
defer sshClient.Close()
|
||||||
|
var b Body
|
||||||
|
if err := json.Unmarshal([]byte(body), &b); err != nil {
|
||||||
|
return false, 0, err
|
||||||
|
}
|
||||||
|
sess, err := sshClient.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return false, 0, err
|
||||||
|
}
|
||||||
|
err = sess.Start(b.Command)
|
||||||
|
if err != nil {
|
||||||
|
return false, 0, err
|
||||||
|
}
|
||||||
|
defer sess.Close()
|
||||||
|
err = sess.Wait()
|
||||||
|
if err == nil {
|
||||||
|
return true, 0, nil
|
||||||
|
}
|
||||||
|
var exitErr *ssh.ExitError
|
||||||
|
if ok := errors.As(err, &exitErr); !ok {
|
||||||
|
return false, 0, err
|
||||||
|
}
|
||||||
|
return true, exitErr.ExitStatus(), nil
|
||||||
|
}
|
||||||
|
|
||||||
// Ping checks if an address can be pinged and returns the round-trip time if the address can be pinged
|
// Ping checks if an address can be pinged and returns the round-trip time if the address can be pinged
|
||||||
//
|
//
|
||||||
// Note that this function takes at least 100ms, even if the address is 127.0.0.1
|
// Note that this function takes at least 100ms, even if the address is 127.0.0.1
|
||||||
func Ping(address string, config *Config) (bool, time.Duration) {
|
func Ping(address string, config *Config) (bool, time.Duration) {
|
||||||
pinger, err := ping.NewPinger(address)
|
pinger := ping.New(address)
|
||||||
if err != nil {
|
|
||||||
return false, 0
|
|
||||||
}
|
|
||||||
pinger.Count = 1
|
pinger.Count = 1
|
||||||
pinger.Timeout = config.Timeout
|
pinger.Timeout = config.Timeout
|
||||||
// Set the pinger's privileged mode to true for every GOOS except darwin
|
// Set the pinger's privileged mode to true for every GOOS except darwin
|
||||||
@@ -170,7 +238,8 @@ func Ping(address string, config *Config) (bool, time.Duration) {
|
|||||||
// Note that for this to work on Linux, Gatus must run with sudo privileges.
|
// Note that for this to work on Linux, Gatus must run with sudo privileges.
|
||||||
// See https://github.com/prometheus-community/pro-bing#linux
|
// See https://github.com/prometheus-community/pro-bing#linux
|
||||||
pinger.SetPrivileged(runtime.GOOS != "darwin")
|
pinger.SetPrivileged(runtime.GOOS != "darwin")
|
||||||
err = pinger.Run()
|
pinger.SetNetwork(config.Network)
|
||||||
|
err := pinger.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, 0
|
return false, 0
|
||||||
}
|
}
|
||||||
@@ -184,6 +253,85 @@ func Ping(address string, config *Config) (bool, time.Duration) {
|
|||||||
return true, 0
|
return true, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QueryWebSocket opens a websocket connection, write `body` and return a message from the server
|
||||||
|
func QueryWebSocket(address, body string, config *Config) (bool, []byte, error) {
|
||||||
|
const (
|
||||||
|
Origin = "http://localhost/"
|
||||||
|
MaximumMessageSize = 1024 // in bytes
|
||||||
|
)
|
||||||
|
wsConfig, err := websocket.NewConfig(address, Origin)
|
||||||
|
if err != nil {
|
||||||
|
return false, nil, fmt.Errorf("error configuring websocket connection: %w", err)
|
||||||
|
}
|
||||||
|
if config != nil {
|
||||||
|
wsConfig.Dialer = &net.Dialer{Timeout: config.Timeout}
|
||||||
|
}
|
||||||
|
// Dial URL
|
||||||
|
ws, err := websocket.DialConfig(wsConfig)
|
||||||
|
if err != nil {
|
||||||
|
return false, nil, fmt.Errorf("error dialing websocket: %w", err)
|
||||||
|
}
|
||||||
|
defer ws.Close()
|
||||||
|
// Write message
|
||||||
|
if _, err := ws.Write([]byte(body)); err != nil {
|
||||||
|
return false, nil, fmt.Errorf("error writing websocket body: %w", err)
|
||||||
|
}
|
||||||
|
// Read message
|
||||||
|
var n int
|
||||||
|
msg := make([]byte, MaximumMessageSize)
|
||||||
|
if n, err = ws.Read(msg); err != nil {
|
||||||
|
return false, nil, fmt.Errorf("error reading websocket message: %w", err)
|
||||||
|
}
|
||||||
|
return true, msg[:n], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func QueryDNS(queryType, queryName, url string) (connected bool, dnsRcode string, body []byte, err error) {
|
||||||
|
if !strings.Contains(url, ":") {
|
||||||
|
url = fmt.Sprintf("%s:%d", url, dnsPort)
|
||||||
|
}
|
||||||
|
queryTypeAsUint16 := dns.StringToType[queryType]
|
||||||
|
c := new(dns.Client)
|
||||||
|
m := new(dns.Msg)
|
||||||
|
m.SetQuestion(queryName, queryTypeAsUint16)
|
||||||
|
r, _, err := c.Exchange(m, url)
|
||||||
|
if err != nil {
|
||||||
|
return false, "", nil, err
|
||||||
|
}
|
||||||
|
connected = true
|
||||||
|
dnsRcode = dns.RcodeToString[r.Rcode]
|
||||||
|
for _, rr := range r.Answer {
|
||||||
|
switch rr.Header().Rrtype {
|
||||||
|
case dns.TypeA:
|
||||||
|
if a, ok := rr.(*dns.A); ok {
|
||||||
|
body = []byte(a.A.String())
|
||||||
|
}
|
||||||
|
case dns.TypeAAAA:
|
||||||
|
if aaaa, ok := rr.(*dns.AAAA); ok {
|
||||||
|
body = []byte(aaaa.AAAA.String())
|
||||||
|
}
|
||||||
|
case dns.TypeCNAME:
|
||||||
|
if cname, ok := rr.(*dns.CNAME); ok {
|
||||||
|
body = []byte(cname.Target)
|
||||||
|
}
|
||||||
|
case dns.TypeMX:
|
||||||
|
if mx, ok := rr.(*dns.MX); ok {
|
||||||
|
body = []byte(mx.Mx)
|
||||||
|
}
|
||||||
|
case dns.TypeNS:
|
||||||
|
if ns, ok := rr.(*dns.NS); ok {
|
||||||
|
body = []byte(ns.Ns)
|
||||||
|
}
|
||||||
|
case dns.TypePTR:
|
||||||
|
if ptr, ok := rr.(*dns.PTR); ok {
|
||||||
|
body = []byte(ptr.Ptr)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
body = []byte("query type is not supported yet")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return connected, dnsRcode, body, nil
|
||||||
|
}
|
||||||
|
|
||||||
// InjectHTTPClient is used to inject a custom HTTP client for testing purposes
|
// InjectHTTPClient is used to inject a custom HTTP client for testing purposes
|
||||||
func InjectHTTPClient(httpClient *http.Client) {
|
func InjectHTTPClient(httpClient *http.Client) {
|
||||||
injectedHTTPClient = httpClient
|
injectedHTTPClient = httpClient
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/tls"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint/dns"
|
||||||
|
"github.com/TwiN/gatus/v5/pattern"
|
||||||
"github.com/TwiN/gatus/v5/test"
|
"github.com/TwiN/gatus/v5/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -83,6 +86,32 @@ func TestPing(t *testing.T) {
|
|||||||
t.Error("Round-trip time returned on failure should've been 0")
|
t.Error("Round-trip time returned on failure should've been 0")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Can't perform integration tests (e.g. pinging public targets by single-stacked hostname) here,
|
||||||
|
// because ICMP is blocked in the network of GitHub-hosted runners.
|
||||||
|
if success, rtt := Ping("127.0.0.1", &Config{Timeout: 500 * time.Millisecond, Network: "ip"}); !success {
|
||||||
|
t.Error("expected true")
|
||||||
|
if rtt == 0 {
|
||||||
|
t.Error("Round-trip time returned on failure should've been 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if success, rtt := Ping("::1", &Config{Timeout: 500 * time.Millisecond, Network: "ip"}); !success {
|
||||||
|
t.Error("expected true")
|
||||||
|
if rtt == 0 {
|
||||||
|
t.Error("Round-trip time returned on failure should've been 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if success, rtt := Ping("::1", &Config{Timeout: 500 * time.Millisecond, Network: "ip4"}); success {
|
||||||
|
t.Error("expected false, because the IP isn't an IPv4 address")
|
||||||
|
if rtt != 0 {
|
||||||
|
t.Error("Round-trip time returned on failure should've been 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if success, rtt := Ping("127.0.0.1", &Config{Timeout: 500 * time.Millisecond, Network: "ip6"}); success {
|
||||||
|
t.Error("expected false, because the IP isn't an IPv6 address")
|
||||||
|
if rtt != 0 {
|
||||||
|
t.Error("Round-trip time returned on failure should've been 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCanPerformStartTLS(t *testing.T) {
|
func TestCanPerformStartTLS(t *testing.T) {
|
||||||
@@ -250,6 +279,164 @@ func TestHttpClientProvidesOAuth2BearerToken(t *testing.T) {
|
|||||||
// to us as `X-Org-Authorization` header, we check here if the value matches
|
// to us as `X-Org-Authorization` header, we check here if the value matches
|
||||||
// our expected token `secret-token`
|
// our expected token `secret-token`
|
||||||
if response.Header.Get("X-Org-Authorization") != "Bearer secret-token" {
|
if response.Header.Get("X-Org-Authorization") != "Bearer secret-token" {
|
||||||
t.Error("exptected `secret-token` as Bearer token in the mocked response header `X-Org-Authorization`, but got", response.Header.Get("X-Org-Authorization"))
|
t.Error("expected `secret-token` as Bearer token in the mocked response header `X-Org-Authorization`, but got", response.Header.Get("X-Org-Authorization"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryWebSocket(t *testing.T) {
|
||||||
|
_, _, err := QueryWebSocket("", "body", &Config{Timeout: 2 * time.Second})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected an error due to the address being invalid")
|
||||||
|
}
|
||||||
|
_, _, err = QueryWebSocket("ws://example.org", "body", &Config{Timeout: 2 * time.Second})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected an error due to the target not being websocket-friendly")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTlsRenegotiation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cfg TLSConfig
|
||||||
|
expectedConfig tls.RenegotiationSupport
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "default",
|
||||||
|
cfg: TLSConfig{CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/cert.key"},
|
||||||
|
expectedConfig: tls.RenegotiateNever,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "never",
|
||||||
|
cfg: TLSConfig{RenegotiationSupport: "never", CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/cert.key"},
|
||||||
|
expectedConfig: tls.RenegotiateNever,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "once",
|
||||||
|
cfg: TLSConfig{RenegotiationSupport: "once", CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/cert.key"},
|
||||||
|
expectedConfig: tls.RenegotiateOnceAsClient,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "freely",
|
||||||
|
cfg: TLSConfig{RenegotiationSupport: "freely", CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/cert.key"},
|
||||||
|
expectedConfig: tls.RenegotiateFreelyAsClient,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "not-valid-and-broken",
|
||||||
|
cfg: TLSConfig{RenegotiationSupport: "invalid", CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/cert.key"},
|
||||||
|
expectedConfig: tls.RenegotiateNever,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
tls := &tls.Config{}
|
||||||
|
tlsConfig := configureTLS(tls, test.cfg)
|
||||||
|
if tlsConfig.Renegotiation != test.expectedConfig {
|
||||||
|
t.Errorf("expected tls renegotiation to be %v, but got %v", test.expectedConfig, tls.Renegotiation)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryDNS(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
inputDNS dns.Config
|
||||||
|
inputURL string
|
||||||
|
expectedDNSCode string
|
||||||
|
expectedBody string
|
||||||
|
isErrExpected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "test Config with type A",
|
||||||
|
inputDNS: dns.Config{
|
||||||
|
QueryType: "A",
|
||||||
|
QueryName: "example.com.",
|
||||||
|
},
|
||||||
|
inputURL: "8.8.8.8",
|
||||||
|
expectedDNSCode: "NOERROR",
|
||||||
|
expectedBody: "93.184.215.14",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "test Config with type AAAA",
|
||||||
|
inputDNS: dns.Config{
|
||||||
|
QueryType: "AAAA",
|
||||||
|
QueryName: "example.com.",
|
||||||
|
},
|
||||||
|
inputURL: "8.8.8.8",
|
||||||
|
expectedDNSCode: "NOERROR",
|
||||||
|
expectedBody: "2606:2800:21f:cb07:6820:80da:af6b:8b2c",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "test Config with type CNAME",
|
||||||
|
inputDNS: dns.Config{
|
||||||
|
QueryType: "CNAME",
|
||||||
|
QueryName: "en.wikipedia.org.",
|
||||||
|
},
|
||||||
|
inputURL: "8.8.8.8",
|
||||||
|
expectedDNSCode: "NOERROR",
|
||||||
|
expectedBody: "dyna.wikimedia.org.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "test Config with type MX",
|
||||||
|
inputDNS: dns.Config{
|
||||||
|
QueryType: "MX",
|
||||||
|
QueryName: "example.com.",
|
||||||
|
},
|
||||||
|
inputURL: "8.8.8.8",
|
||||||
|
expectedDNSCode: "NOERROR",
|
||||||
|
expectedBody: ".",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "test Config with type NS",
|
||||||
|
inputDNS: dns.Config{
|
||||||
|
QueryType: "NS",
|
||||||
|
QueryName: "example.com.",
|
||||||
|
},
|
||||||
|
inputURL: "8.8.8.8",
|
||||||
|
expectedDNSCode: "NOERROR",
|
||||||
|
expectedBody: "*.iana-servers.net.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "test Config with type PTR",
|
||||||
|
inputDNS: dns.Config{
|
||||||
|
QueryType: "PTR",
|
||||||
|
QueryName: "8.8.8.8.in-addr.arpa.",
|
||||||
|
},
|
||||||
|
inputURL: "8.8.8.8",
|
||||||
|
expectedDNSCode: "NOERROR",
|
||||||
|
expectedBody: "dns.google.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "test Config with fake type and retrieve error",
|
||||||
|
inputDNS: dns.Config{
|
||||||
|
QueryType: "B",
|
||||||
|
QueryName: "example",
|
||||||
|
},
|
||||||
|
inputURL: "8.8.8.8",
|
||||||
|
isErrExpected: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, dnsRCode, body, err := QueryDNS(test.inputDNS.QueryType, test.inputDNS.QueryName, test.inputURL)
|
||||||
|
if test.isErrExpected && err == nil {
|
||||||
|
t.Errorf("there should be an error")
|
||||||
|
}
|
||||||
|
if dnsRCode != test.expectedDNSCode {
|
||||||
|
t.Errorf("expected DNSRCode to be %s, got %s", test.expectedDNSCode, dnsRCode)
|
||||||
|
}
|
||||||
|
if test.inputDNS.QueryType == "NS" {
|
||||||
|
// Because there are often multiple nameservers backing a single domain, we'll only look at the suffix
|
||||||
|
if !pattern.Match(test.expectedBody, string(body)) {
|
||||||
|
t.Errorf("got %s, expected result %s,", string(body), test.expectedBody)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if string(body) != test.expectedBody {
|
||||||
|
t.Errorf("got %s, expected result %s,", string(body), test.expectedBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
164
client/config.go
164
client/config.go
@@ -4,30 +4,35 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"errors"
|
"errors"
|
||||||
"log"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/TwiN/logr"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
"golang.org/x/oauth2/clientcredentials"
|
"golang.org/x/oauth2/clientcredentials"
|
||||||
|
"google.golang.org/api/idtoken"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultHTTPTimeout = 10 * time.Second
|
defaultTimeout = 10 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrInvalidDNSResolver = errors.New("invalid DNS resolver specified. Required format is {proto}://{ip}:{port}")
|
ErrInvalidDNSResolver = errors.New("invalid DNS resolver specified. Required format is {proto}://{ip}:{port}")
|
||||||
ErrInvalidDNSResolverPort = errors.New("invalid DNS resolver port")
|
ErrInvalidDNSResolverPort = errors.New("invalid DNS resolver port")
|
||||||
ErrInvalidClientOAuth2Config = errors.New("invalid oauth2 configuration: must define all fields for client credentials flow (token-url, client-id, client-secret, scopes)")
|
ErrInvalidClientOAuth2Config = errors.New("invalid oauth2 configuration: must define all fields for client credentials flow (token-url, client-id, client-secret, scopes)")
|
||||||
|
ErrInvalidClientIAPConfig = errors.New("invalid Identity-Aware-Proxy configuration: must define all fields for Google Identity-Aware-Proxy programmatic authentication (audience)")
|
||||||
|
ErrInvalidClientTLSConfig = errors.New("invalid TLS configuration: certificate-file and private-key-file must be specified")
|
||||||
|
|
||||||
defaultConfig = Config{
|
defaultConfig = Config{
|
||||||
Insecure: false,
|
Insecure: false,
|
||||||
IgnoreRedirect: false,
|
IgnoreRedirect: false,
|
||||||
Timeout: defaultHTTPTimeout,
|
Timeout: defaultTimeout,
|
||||||
|
Network: "ip",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,6 +44,9 @@ func GetDefaultConfig() *Config {
|
|||||||
|
|
||||||
// Config is the configuration for clients
|
// Config is the configuration for clients
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
// ProxyURL is the URL of the proxy to use for the client
|
||||||
|
ProxyURL string `yaml:"proxy-url,omitempty"`
|
||||||
|
|
||||||
// Insecure determines whether to skip verifying the server's certificate chain and host name
|
// Insecure determines whether to skip verifying the server's certificate chain and host name
|
||||||
Insecure bool `yaml:"insecure,omitempty"`
|
Insecure bool `yaml:"insecure,omitempty"`
|
||||||
|
|
||||||
@@ -58,7 +66,16 @@ type Config struct {
|
|||||||
// See configureOAuth2 for more details.
|
// See configureOAuth2 for more details.
|
||||||
OAuth2Config *OAuth2Config `yaml:"oauth2,omitempty"`
|
OAuth2Config *OAuth2Config `yaml:"oauth2,omitempty"`
|
||||||
|
|
||||||
|
// IAPConfig is the Google Cloud Identity-Aware-Proxy configuration used for the client. (e.g. audience)
|
||||||
|
IAPConfig *IAPConfig `yaml:"identity-aware-proxy,omitempty"`
|
||||||
|
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
|
||||||
|
// Network (ip, ip4 or ip6) for the ICMP client
|
||||||
|
Network string `yaml:"network"`
|
||||||
|
|
||||||
|
// TLS configuration (optional)
|
||||||
|
TLS *TLSConfig `yaml:"tls,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DNSResolverConfig is the parsed configuration from the DNSResolver config string.
|
// DNSResolverConfig is the parsed configuration from the DNSResolver config string.
|
||||||
@@ -76,6 +93,22 @@ type OAuth2Config struct {
|
|||||||
Scopes []string `yaml:"scopes"` // e.g. ["openid"]
|
Scopes []string `yaml:"scopes"` // e.g. ["openid"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IAPConfig is the configuration for the Google Cloud Identity-Aware-Proxy
|
||||||
|
type IAPConfig struct {
|
||||||
|
Audience string `yaml:"audience"` // e.g. "toto.apps.googleusercontent.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLSConfig is the configuration for mTLS configurations
|
||||||
|
type TLSConfig struct {
|
||||||
|
// CertificateFile is the public certificate for TLS in PEM format.
|
||||||
|
CertificateFile string `yaml:"certificate-file,omitempty"`
|
||||||
|
|
||||||
|
// PrivateKeyFile is the private key file for TLS in PEM format.
|
||||||
|
PrivateKeyFile string `yaml:"private-key-file,omitempty"`
|
||||||
|
|
||||||
|
RenegotiationSupport string `yaml:"renegotiation,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// ValidateAndSetDefaults validates the client configuration and sets the default values if necessary
|
// ValidateAndSetDefaults validates the client configuration and sets the default values if necessary
|
||||||
func (c *Config) ValidateAndSetDefaults() error {
|
func (c *Config) ValidateAndSetDefaults() error {
|
||||||
if c.Timeout < time.Millisecond {
|
if c.Timeout < time.Millisecond {
|
||||||
@@ -90,6 +123,14 @@ func (c *Config) ValidateAndSetDefaults() error {
|
|||||||
if c.HasOAuth2Config() && !c.OAuth2Config.isValid() {
|
if c.HasOAuth2Config() && !c.OAuth2Config.isValid() {
|
||||||
return ErrInvalidClientOAuth2Config
|
return ErrInvalidClientOAuth2Config
|
||||||
}
|
}
|
||||||
|
if c.HasIAPConfig() && !c.IAPConfig.isValid() {
|
||||||
|
return ErrInvalidClientIAPConfig
|
||||||
|
}
|
||||||
|
if c.HasTLSConfig() {
|
||||||
|
if err := c.TLS.isValid(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,13 +171,46 @@ func (c *Config) HasOAuth2Config() bool {
|
|||||||
return c.OAuth2Config != nil
|
return c.OAuth2Config != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasIAPConfig returns true if the client has IAP configuration parameters
|
||||||
|
func (c *Config) HasIAPConfig() bool {
|
||||||
|
return c.IAPConfig != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasTLSConfig returns true if the client has client certificate parameters
|
||||||
|
func (c *Config) HasTLSConfig() bool {
|
||||||
|
return c.TLS != nil && len(c.TLS.CertificateFile) > 0 && len(c.TLS.PrivateKeyFile) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValid() returns true if the IAP configuration is valid
|
||||||
|
func (c *IAPConfig) isValid() bool {
|
||||||
|
return len(c.Audience) > 0
|
||||||
|
}
|
||||||
|
|
||||||
// isValid() returns true if the OAuth2 configuration is valid
|
// isValid() returns true if the OAuth2 configuration is valid
|
||||||
func (c *OAuth2Config) isValid() bool {
|
func (c *OAuth2Config) isValid() bool {
|
||||||
return len(c.TokenURL) > 0 && len(c.ClientID) > 0 && len(c.ClientSecret) > 0 && len(c.Scopes) > 0
|
return len(c.TokenURL) > 0 && len(c.ClientID) > 0 && len(c.ClientSecret) > 0 && len(c.Scopes) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetHTTPClient return an HTTP client matching the Config's parameters.
|
// isValid() returns nil if the client tls certificates are valid, otherwise returns an error
|
||||||
|
func (t *TLSConfig) isValid() error {
|
||||||
|
if len(t.CertificateFile) > 0 && len(t.PrivateKeyFile) > 0 {
|
||||||
|
_, err := tls.LoadX509KeyPair(t.CertificateFile, t.PrivateKeyFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ErrInvalidClientTLSConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// getHTTPClient return an HTTP client matching the Config's parameters.
|
||||||
func (c *Config) getHTTPClient() *http.Client {
|
func (c *Config) getHTTPClient() *http.Client {
|
||||||
|
tlsConfig := &tls.Config{
|
||||||
|
InsecureSkipVerify: c.Insecure,
|
||||||
|
}
|
||||||
|
if c.HasTLSConfig() && c.TLS.isValid() == nil {
|
||||||
|
tlsConfig = configureTLS(tlsConfig, *c.TLS)
|
||||||
|
}
|
||||||
if c.httpClient == nil {
|
if c.httpClient == nil {
|
||||||
c.httpClient = &http.Client{
|
c.httpClient = &http.Client{
|
||||||
Timeout: c.Timeout,
|
Timeout: c.Timeout,
|
||||||
@@ -144,9 +218,7 @@ func (c *Config) getHTTPClient() *http.Client {
|
|||||||
MaxIdleConns: 100,
|
MaxIdleConns: 100,
|
||||||
MaxIdleConnsPerHost: 20,
|
MaxIdleConnsPerHost: 20,
|
||||||
Proxy: http.ProxyFromEnvironment,
|
Proxy: http.ProxyFromEnvironment,
|
||||||
TLSClientConfig: &tls.Config{
|
TLSClientConfig: tlsConfig,
|
||||||
InsecureSkipVerify: c.Insecure,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
if c.IgnoreRedirect {
|
if c.IgnoreRedirect {
|
||||||
@@ -157,12 +229,20 @@ func (c *Config) getHTTPClient() *http.Client {
|
|||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if c.ProxyURL != "" {
|
||||||
|
proxyURL, err := url.Parse(c.ProxyURL)
|
||||||
|
if err != nil {
|
||||||
|
logr.Errorf("[client.getHTTPClient] THIS SHOULD NOT HAPPEN. Silently ignoring custom proxy due to error: %s", err.Error())
|
||||||
|
} else {
|
||||||
|
c.httpClient.Transport.(*http.Transport).Proxy = http.ProxyURL(proxyURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
if c.HasCustomDNSResolver() {
|
if c.HasCustomDNSResolver() {
|
||||||
dnsResolver, err := c.parseDNSResolver()
|
dnsResolver, err := c.parseDNSResolver()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// We're ignoring the error, because it should have been validated on startup ValidateAndSetDefaults.
|
// We're ignoring the error, because it should have been validated on startup ValidateAndSetDefaults.
|
||||||
// It shouldn't happen, but if it does, we'll log it... Better safe than sorry ;)
|
// It shouldn't happen, but if it does, we'll log it... Better safe than sorry ;)
|
||||||
log.Println("[client][getHTTPClient] THIS SHOULD NOT HAPPEN. Silently ignoring invalid DNS resolver due to error:", err.Error())
|
logr.Errorf("[client.getHTTPClient] THIS SHOULD NOT HAPPEN. Silently ignoring invalid DNS resolver due to error: %s", err.Error())
|
||||||
} else {
|
} else {
|
||||||
dialer := &net.Dialer{
|
dialer := &net.Dialer{
|
||||||
Resolver: &net.Resolver{
|
Resolver: &net.Resolver{
|
||||||
@@ -178,13 +258,55 @@ func (c *Config) getHTTPClient() *http.Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if c.HasOAuth2Config() {
|
if c.HasOAuth2Config() && c.HasIAPConfig() {
|
||||||
|
logr.Errorf("[client.getHTTPClient] Error: Both Identity-Aware-Proxy and Oauth2 configuration are present.")
|
||||||
|
} else if c.HasOAuth2Config() {
|
||||||
c.httpClient = configureOAuth2(c.httpClient, *c.OAuth2Config)
|
c.httpClient = configureOAuth2(c.httpClient, *c.OAuth2Config)
|
||||||
|
} else if c.HasIAPConfig() {
|
||||||
|
c.httpClient = configureIAP(c.httpClient, *c.IAPConfig)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return c.httpClient
|
return c.httpClient
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateIAPToken returns a boolean that will define if the Google identity-aware-proxy token can be fetched
|
||||||
|
// and if is it valid.
|
||||||
|
func validateIAPToken(ctx context.Context, c IAPConfig) bool {
|
||||||
|
ts, err := idtoken.NewTokenSource(ctx, c.Audience)
|
||||||
|
if err != nil {
|
||||||
|
logr.Errorf("[client.ValidateIAPToken] Claiming Identity token failed: %s", err.Error())
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
tok, err := ts.Token()
|
||||||
|
if err != nil {
|
||||||
|
logr.Errorf("[client.ValidateIAPToken] Get Identity-Aware-Proxy token failed: %s", err.Error())
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err = idtoken.Validate(ctx, tok.AccessToken, c.Audience)
|
||||||
|
if err != nil {
|
||||||
|
logr.Errorf("[client.ValidateIAPToken] Token Validation failed: %s", err.Error())
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// configureIAP returns an HTTP client that will obtain and refresh Identity-Aware-Proxy tokens as necessary.
|
||||||
|
// The returned Client and its Transport should not be modified.
|
||||||
|
func configureIAP(httpClient *http.Client, c IAPConfig) *http.Client {
|
||||||
|
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient)
|
||||||
|
if validateIAPToken(ctx, c) {
|
||||||
|
ts, err := idtoken.NewTokenSource(ctx, c.Audience)
|
||||||
|
if err != nil {
|
||||||
|
logr.Errorf("[client.configureIAP] Claiming Token Source failed: %s", err.Error())
|
||||||
|
return httpClient
|
||||||
|
}
|
||||||
|
client := oauth2.NewClient(ctx, ts)
|
||||||
|
client.Timeout = httpClient.Timeout
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
return httpClient
|
||||||
|
}
|
||||||
|
|
||||||
// configureOAuth2 returns an HTTP client that will obtain and refresh tokens as necessary.
|
// configureOAuth2 returns an HTTP client that will obtain and refresh tokens as necessary.
|
||||||
// The returned Client and its Transport should not be modified.
|
// The returned Client and its Transport should not be modified.
|
||||||
func configureOAuth2(httpClient *http.Client, c OAuth2Config) *http.Client {
|
func configureOAuth2(httpClient *http.Client, c OAuth2Config) *http.Client {
|
||||||
@@ -195,5 +317,27 @@ func configureOAuth2(httpClient *http.Client, c OAuth2Config) *http.Client {
|
|||||||
TokenURL: c.TokenURL,
|
TokenURL: c.TokenURL,
|
||||||
}
|
}
|
||||||
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient)
|
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient)
|
||||||
return oauth2cfg.Client(ctx)
|
client := oauth2cfg.Client(ctx)
|
||||||
|
client.Timeout = httpClient.Timeout
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
// configureTLS returns a TLS Config that will enable mTLS
|
||||||
|
func configureTLS(tlsConfig *tls.Config, c TLSConfig) *tls.Config {
|
||||||
|
clientTLSCert, err := tls.LoadX509KeyPair(c.CertificateFile, c.PrivateKeyFile)
|
||||||
|
if err != nil {
|
||||||
|
logr.Errorf("[client.configureTLS] Failed to load certificate: %s", err.Error())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tlsConfig.Certificates = []tls.Certificate{clientTLSCert}
|
||||||
|
tlsConfig.Renegotiation = tls.RenegotiateNever
|
||||||
|
renegotiationSupport := map[string]tls.RenegotiationSupport{
|
||||||
|
"once": tls.RenegotiateOnceAsClient,
|
||||||
|
"freely": tls.RenegotiateFreelyAsClient,
|
||||||
|
"never": tls.RenegotiateNever,
|
||||||
|
}
|
||||||
|
if val, ok := renegotiationSupport[c.RenegotiationSupport]; ok {
|
||||||
|
tlsConfig.Renegotiation = val
|
||||||
|
}
|
||||||
|
return tlsConfig
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -13,7 +14,7 @@ func TestConfig_getHTTPClient(t *testing.T) {
|
|||||||
if !(insecureClient.Transport).(*http.Transport).TLSClientConfig.InsecureSkipVerify {
|
if !(insecureClient.Transport).(*http.Transport).TLSClientConfig.InsecureSkipVerify {
|
||||||
t.Error("expected Config.Insecure set to true to cause the HTTP client to skip certificate verification")
|
t.Error("expected Config.Insecure set to true to cause the HTTP client to skip certificate verification")
|
||||||
}
|
}
|
||||||
if insecureClient.Timeout != defaultHTTPTimeout {
|
if insecureClient.Timeout != defaultTimeout {
|
||||||
t.Error("expected Config.Timeout to default the HTTP client to a timeout of 10s")
|
t.Error("expected Config.Timeout to default the HTTP client to a timeout of 10s")
|
||||||
}
|
}
|
||||||
request, _ := http.NewRequest("GET", "", nil)
|
request, _ := http.NewRequest("GET", "", nil)
|
||||||
@@ -79,3 +80,92 @@ func TestConfig_ValidateAndSetDefaults_withCustomDNSResolver(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConfig_getHTTPClient_withCustomProxyURL(t *testing.T) {
|
||||||
|
proxyURL := "http://proxy.example.com:8080"
|
||||||
|
cfg := &Config{
|
||||||
|
ProxyURL: proxyURL,
|
||||||
|
}
|
||||||
|
cfg.ValidateAndSetDefaults()
|
||||||
|
client := cfg.getHTTPClient()
|
||||||
|
transport := client.Transport.(*http.Transport)
|
||||||
|
if transport.Proxy == nil {
|
||||||
|
t.Errorf("expected Config.ProxyURL to set the HTTP client's proxy to %s", proxyURL)
|
||||||
|
}
|
||||||
|
req := &http.Request{
|
||||||
|
URL: &url.URL{
|
||||||
|
Scheme: "http",
|
||||||
|
Host: "www.example.com",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
expectProxyURL, err := transport.Proxy(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("can't proxy the request %s", proxyURL)
|
||||||
|
}
|
||||||
|
if proxyURL != expectProxyURL.String() {
|
||||||
|
t.Errorf("expected Config.ProxyURL to set the HTTP client's proxy to %s", proxyURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfig_TlsIsValid(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cfg *Config
|
||||||
|
expectedErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "good-tls-config",
|
||||||
|
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/cert.key"}},
|
||||||
|
expectedErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing-certificate-file",
|
||||||
|
cfg: &Config{TLS: &TLSConfig{CertificateFile: "doesnotexist", PrivateKeyFile: "../testdata/cert.key"}},
|
||||||
|
expectedErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bad-certificate-file",
|
||||||
|
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../testdata/badcert.pem", PrivateKeyFile: "../testdata/cert.key"}},
|
||||||
|
expectedErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no-certificate-file",
|
||||||
|
cfg: &Config{TLS: &TLSConfig{CertificateFile: "", PrivateKeyFile: "../testdata/cert.key"}},
|
||||||
|
expectedErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing-private-key-file",
|
||||||
|
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "doesnotexist"}},
|
||||||
|
expectedErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no-private-key-file",
|
||||||
|
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../testdata/cert.pem", PrivateKeyFile: ""}},
|
||||||
|
expectedErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bad-private-key-file",
|
||||||
|
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/badcert.key"}},
|
||||||
|
expectedErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bad-certificate-and-private-key-file",
|
||||||
|
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../testdata/badcert.pem", PrivateKeyFile: "../testdata/badcert.key"}},
|
||||||
|
expectedErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
err := test.cfg.TLS.isValid()
|
||||||
|
if (err != nil) != test.expectedErr {
|
||||||
|
t.Errorf("expected the existence of an error to be %v, got %v", test.expectedErr, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !test.expectedErr {
|
||||||
|
if test.cfg.TLS.isValid() != nil {
|
||||||
|
t.Error("cfg.TLS.isValid() returned an error even though no error was expected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ endpoints:
|
|||||||
query-name: "example.com"
|
query-name: "example.com"
|
||||||
query-type: "A"
|
query-type: "A"
|
||||||
conditions:
|
conditions:
|
||||||
- "[BODY] == 93.184.216.34"
|
- "[BODY] == 93.184.215.14"
|
||||||
- "[DNS_RCODE] == NOERROR"
|
- "[DNS_RCODE] == NOERROR"
|
||||||
|
|
||||||
- name: icmp-ping
|
- name: icmp-ping
|
||||||
|
|||||||
143
config/config.go
143
config/config.go
@@ -4,7 +4,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -15,14 +14,15 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider"
|
"github.com/TwiN/gatus/v5/alerting/provider"
|
||||||
"github.com/TwiN/gatus/v5/config/connectivity"
|
"github.com/TwiN/gatus/v5/config/connectivity"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/TwiN/gatus/v5/config/maintenance"
|
"github.com/TwiN/gatus/v5/config/maintenance"
|
||||||
"github.com/TwiN/gatus/v5/config/remote"
|
"github.com/TwiN/gatus/v5/config/remote"
|
||||||
"github.com/TwiN/gatus/v5/config/ui"
|
"github.com/TwiN/gatus/v5/config/ui"
|
||||||
"github.com/TwiN/gatus/v5/config/web"
|
"github.com/TwiN/gatus/v5/config/web"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
|
||||||
"github.com/TwiN/gatus/v5/security"
|
"github.com/TwiN/gatus/v5/security"
|
||||||
"github.com/TwiN/gatus/v5/storage"
|
"github.com/TwiN/gatus/v5/storage"
|
||||||
"github.com/TwiN/gatus/v5/util"
|
"github.com/TwiN/logr"
|
||||||
|
"github.com/gofiber/fiber/v2/log"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -53,6 +53,7 @@ var (
|
|||||||
// Config is the main configuration structure
|
// Config is the main configuration structure
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// Debug Whether to enable debug logs
|
// Debug Whether to enable debug logs
|
||||||
|
// Deprecated: Use the GATUS_LOG_LEVEL environment variable instead
|
||||||
Debug bool `yaml:"debug,omitempty"`
|
Debug bool `yaml:"debug,omitempty"`
|
||||||
|
|
||||||
// Metrics Whether to expose metrics at /metrics
|
// Metrics Whether to expose metrics at /metrics
|
||||||
@@ -67,14 +68,17 @@ type Config struct {
|
|||||||
// Disabling this may lead to inaccurate response times
|
// Disabling this may lead to inaccurate response times
|
||||||
DisableMonitoringLock bool `yaml:"disable-monitoring-lock,omitempty"`
|
DisableMonitoringLock bool `yaml:"disable-monitoring-lock,omitempty"`
|
||||||
|
|
||||||
// Security Configuration for securing access to Gatus
|
// Security is the configuration for securing access to Gatus
|
||||||
Security *security.Config `yaml:"security,omitempty"`
|
Security *security.Config `yaml:"security,omitempty"`
|
||||||
|
|
||||||
// Alerting Configuration for alerting
|
// Alerting is the configuration for alerting providers
|
||||||
Alerting *alerting.Config `yaml:"alerting,omitempty"`
|
Alerting *alerting.Config `yaml:"alerting,omitempty"`
|
||||||
|
|
||||||
// Endpoints List of endpoints to monitor
|
// Endpoints is the list of endpoints to monitor
|
||||||
Endpoints []*core.Endpoint `yaml:"endpoints,omitempty"`
|
Endpoints []*endpoint.Endpoint `yaml:"endpoints,omitempty"`
|
||||||
|
|
||||||
|
// ExternalEndpoints is the list of all external endpoints
|
||||||
|
ExternalEndpoints []*endpoint.ExternalEndpoint `yaml:"external-endpoints,omitempty"`
|
||||||
|
|
||||||
// Storage is the configuration for how the data is stored
|
// Storage is the configuration for how the data is stored
|
||||||
Storage *storage.Config `yaml:"storage,omitempty"`
|
Storage *storage.Config `yaml:"storage,omitempty"`
|
||||||
@@ -99,20 +103,29 @@ type Config struct {
|
|||||||
lastFileModTime time.Time // last modification time
|
lastFileModTime time.Time // last modification time
|
||||||
}
|
}
|
||||||
|
|
||||||
func (config *Config) GetEndpointByKey(key string) *core.Endpoint {
|
func (config *Config) GetEndpointByKey(key string) *endpoint.Endpoint {
|
||||||
// TODO: Should probably add a mutex here to prevent concurrent access
|
|
||||||
for i := 0; i < len(config.Endpoints); i++ {
|
for i := 0; i < len(config.Endpoints); i++ {
|
||||||
ep := config.Endpoints[i]
|
ep := config.Endpoints[i]
|
||||||
if util.ConvertGroupAndEndpointNameToKey(ep.Group, ep.Name) == key {
|
if ep.Key() == key {
|
||||||
return ep
|
return ep
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (config *Config) GetExternalEndpointByKey(key string) *endpoint.ExternalEndpoint {
|
||||||
|
for i := 0; i < len(config.ExternalEndpoints); i++ {
|
||||||
|
ee := config.ExternalEndpoints[i]
|
||||||
|
if ee.Key() == key {
|
||||||
|
return ee
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// HasLoadedConfigurationBeenModified returns whether one of the file that the
|
// HasLoadedConfigurationBeenModified returns whether one of the file that the
|
||||||
// configuration has been loaded from has been modified since it was last read
|
// configuration has been loaded from has been modified since it was last read
|
||||||
func (config Config) HasLoadedConfigurationBeenModified() bool {
|
func (config *Config) HasLoadedConfigurationBeenModified() bool {
|
||||||
lastMod := config.lastFileModTime.Unix()
|
lastMod := config.lastFileModTime.Unix()
|
||||||
fileInfo, err := os.Stat(config.configPath)
|
fileInfo, err := os.Stat(config.configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -125,7 +138,7 @@ func (config Config) HasLoadedConfigurationBeenModified() bool {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
return err == errEarlyReturn
|
return errors.Is(err, errEarlyReturn)
|
||||||
}
|
}
|
||||||
return !fileInfo.ModTime().IsZero() && config.lastFileModTime.Unix() < fileInfo.ModTime().Unix()
|
return !fileInfo.ModTime().IsZero() && config.lastFileModTime.Unix() < fileInfo.ModTime().Unix()
|
||||||
}
|
}
|
||||||
@@ -135,7 +148,7 @@ func (config *Config) UpdateLastFileModTime() {
|
|||||||
config.lastFileModTime = time.Now()
|
config.lastFileModTime = time.Now()
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadConfiguration loads the full configuration composed from the main configuration file
|
// LoadConfiguration loads the full configuration composed of the main configuration file
|
||||||
// and all composed configuration files
|
// and all composed configuration files
|
||||||
func LoadConfiguration(configPath string) (*Config, error) {
|
func LoadConfiguration(configPath string) (*Config, error) {
|
||||||
var configBytes []byte
|
var configBytes []byte
|
||||||
@@ -161,13 +174,11 @@ func LoadConfiguration(configPath string) (*Config, error) {
|
|||||||
if fileInfo.IsDir() {
|
if fileInfo.IsDir() {
|
||||||
err := walkConfigDir(configPath, func(path string, d fs.DirEntry, err error) error {
|
err := walkConfigDir(configPath, func(path string, d fs.DirEntry, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[config][LoadConfiguration] Error walking path=%s: %s", path, err)
|
return fmt.Errorf("error walking path %s: %w", path, err)
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
log.Printf("[config][LoadConfiguration] Reading configuration from %s", path)
|
logr.Infof("[config.LoadConfiguration] Reading configuration from %s", path)
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[config][LoadConfiguration] Error reading configuration from %s: %s", path, err)
|
|
||||||
return fmt.Errorf("error reading configuration from file %s: %w", path, err)
|
return fmt.Errorf("error reading configuration from file %s: %w", path, err)
|
||||||
}
|
}
|
||||||
configBytes, err = deepmerge.YAML(configBytes, data)
|
configBytes, err = deepmerge.YAML(configBytes, data)
|
||||||
@@ -177,9 +188,9 @@ func LoadConfiguration(configPath string) (*Config, error) {
|
|||||||
return nil, fmt.Errorf("error reading configuration from directory %s: %w", usedConfigPath, err)
|
return nil, fmt.Errorf("error reading configuration from directory %s: %w", usedConfigPath, err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.Printf("[config][LoadConfiguration] Reading configuration from configFile=%s", configPath)
|
logr.Infof("[config.LoadConfiguration] Reading configuration from configFile=%s", usedConfigPath)
|
||||||
if data, err := os.ReadFile(usedConfigPath); err != nil {
|
if data, err := os.ReadFile(usedConfigPath); err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("error reading configuration from directory %s: %w", usedConfigPath, err)
|
||||||
} else {
|
} else {
|
||||||
configBytes = data
|
configBytes = data
|
||||||
}
|
}
|
||||||
@@ -189,11 +200,11 @@ func LoadConfiguration(configPath string) (*Config, error) {
|
|||||||
}
|
}
|
||||||
config, err := parseAndValidateConfigBytes(configBytes)
|
config, err := parseAndValidateConfigBytes(configBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("error parsing config: %w", err)
|
||||||
}
|
}
|
||||||
config.configPath = usedConfigPath
|
config.configPath = usedConfigPath
|
||||||
config.UpdateLastFileModTime()
|
config.UpdateLastFileModTime()
|
||||||
return config, err
|
return config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// walkConfigDir is a wrapper for filepath.WalkDir that strips directories and non-config files
|
// walkConfigDir is a wrapper for filepath.WalkDir that strips directories and non-config files
|
||||||
@@ -234,7 +245,13 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
|
|||||||
if config == nil || config.Endpoints == nil || len(config.Endpoints) == 0 {
|
if config == nil || config.Endpoints == nil || len(config.Endpoints) == 0 {
|
||||||
err = ErrNoEndpointInConfig
|
err = ErrNoEndpointInConfig
|
||||||
} else {
|
} else {
|
||||||
validateAlertingConfig(config.Alerting, config.Endpoints, config.Debug)
|
// XXX: Remove this in v6.0.0
|
||||||
|
if config.Debug {
|
||||||
|
logr.Warn("WARNING: The 'debug' configuration has been deprecated and will be removed in v6.0.0")
|
||||||
|
logr.Warn("WARNING: Please use the GATUS_LOG_LEVEL environment variable instead")
|
||||||
|
}
|
||||||
|
// XXX: End of v6.0.0 removals
|
||||||
|
validateAlertingConfig(config.Alerting, config.Endpoints, config.ExternalEndpoints)
|
||||||
if err := validateSecurityConfig(config); err != nil {
|
if err := validateSecurityConfig(config); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -324,24 +341,40 @@ func validateWebConfig(config *Config) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateEndpointsConfig(config *Config) error {
|
func validateEndpointsConfig(config *Config) error {
|
||||||
for _, endpoint := range config.Endpoints {
|
duplicateValidationMap := make(map[string]bool)
|
||||||
if config.Debug {
|
// Validate endpoints
|
||||||
log.Printf("[config][validateEndpointsConfig] Validating endpoint '%s'", endpoint.Name)
|
for _, ep := range config.Endpoints {
|
||||||
|
logr.Debugf("[config.validateEndpointsConfig] Validating endpoint with key %s", ep.Key())
|
||||||
|
if endpointKey := ep.Key(); duplicateValidationMap[endpointKey] {
|
||||||
|
return fmt.Errorf("invalid endpoint %s: name and group combination must be unique", ep.Key())
|
||||||
|
} else {
|
||||||
|
duplicateValidationMap[endpointKey] = true
|
||||||
}
|
}
|
||||||
if err := endpoint.ValidateAndSetDefaults(); err != nil {
|
if err := ep.ValidateAndSetDefaults(); err != nil {
|
||||||
return fmt.Errorf("invalid endpoint %s: %w", endpoint.DisplayName(), err)
|
return fmt.Errorf("invalid endpoint %s: %w", ep.Key(), err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.Printf("[config][validateEndpointsConfig] Validated %d endpoints", len(config.Endpoints))
|
logr.Infof("[config.validateEndpointsConfig] Validated %d endpoints", len(config.Endpoints))
|
||||||
|
// Validate external endpoints
|
||||||
|
for _, ee := range config.ExternalEndpoints {
|
||||||
|
logr.Debugf("[config.validateEndpointsConfig] Validating external endpoint '%s'", ee.Name)
|
||||||
|
if endpointKey := ee.Key(); duplicateValidationMap[endpointKey] {
|
||||||
|
return fmt.Errorf("invalid external endpoint %s: name and group combination must be unique", ee.Key())
|
||||||
|
} else {
|
||||||
|
duplicateValidationMap[endpointKey] = true
|
||||||
|
}
|
||||||
|
if err := ee.ValidateAndSetDefaults(); err != nil {
|
||||||
|
return fmt.Errorf("invalid external endpoint %s: %w", ee.Key(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logr.Infof("[config.validateEndpointsConfig] Validated %d external endpoints", len(config.ExternalEndpoints))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateSecurityConfig(config *Config) error {
|
func validateSecurityConfig(config *Config) error {
|
||||||
if config.Security != nil {
|
if config.Security != nil {
|
||||||
if config.Security.IsValid() {
|
if config.Security.IsValid() {
|
||||||
if config.Debug {
|
logr.Debug("[config.validateSecurityConfig] Basic security configuration has been validated")
|
||||||
log.Printf("[config][validateSecurityConfig] Basic security configuration has been validated")
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// If there was an attempt to configure security, then it must mean that some confidential or private
|
// If there was an attempt to configure security, then it must mean that some confidential or private
|
||||||
// data are exposed. As a result, we'll force a panic because it's better to be safe than sorry.
|
// data are exposed. As a result, we'll force a panic because it's better to be safe than sorry.
|
||||||
@@ -353,20 +386,24 @@ func validateSecurityConfig(config *Config) error {
|
|||||||
|
|
||||||
// validateAlertingConfig validates the alerting configuration
|
// validateAlertingConfig validates the alerting configuration
|
||||||
// Note that the alerting configuration has to be validated before the endpoint configuration, because the default alert
|
// Note that the alerting configuration has to be validated before the endpoint configuration, because the default alert
|
||||||
// returned by provider.AlertProvider.GetDefaultAlert() must be parsed before core.Endpoint.ValidateAndSetDefaults()
|
// returned by provider.AlertProvider.GetDefaultAlert() must be parsed before endpoint.Endpoint.ValidateAndSetDefaults()
|
||||||
// sets the default alert values when none are set.
|
// sets the default alert values when none are set.
|
||||||
func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.Endpoint, debug bool) {
|
func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoint.Endpoint, externalEndpoints []*endpoint.ExternalEndpoint) {
|
||||||
if alertingConfig == nil {
|
if alertingConfig == nil {
|
||||||
log.Printf("[config][validateAlertingConfig] Alerting is not configured")
|
logr.Info("[config.validateAlertingConfig] Alerting is not configured")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
alertTypes := []alert.Type{
|
alertTypes := []alert.Type{
|
||||||
|
alert.TypeAWSSES,
|
||||||
alert.TypeCustom,
|
alert.TypeCustom,
|
||||||
alert.TypeDiscord,
|
alert.TypeDiscord,
|
||||||
|
alert.TypeEmail,
|
||||||
alert.TypeGitHub,
|
alert.TypeGitHub,
|
||||||
alert.TypeGitLab,
|
alert.TypeGitLab,
|
||||||
|
alert.TypeGitea,
|
||||||
alert.TypeGoogleChat,
|
alert.TypeGoogleChat,
|
||||||
alert.TypeEmail,
|
alert.TypeGotify,
|
||||||
|
alert.TypeJetBrainsSpace,
|
||||||
alert.TypeMatrix,
|
alert.TypeMatrix,
|
||||||
alert.TypeMattermost,
|
alert.TypeMattermost,
|
||||||
alert.TypeMessagebird,
|
alert.TypeMessagebird,
|
||||||
@@ -376,30 +413,50 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.E
|
|||||||
alert.TypePushover,
|
alert.TypePushover,
|
||||||
alert.TypeSlack,
|
alert.TypeSlack,
|
||||||
alert.TypeTeams,
|
alert.TypeTeams,
|
||||||
|
alert.TypeTeamsWorkflows,
|
||||||
alert.TypeTelegram,
|
alert.TypeTelegram,
|
||||||
alert.TypeTwilio,
|
alert.TypeTwilio,
|
||||||
|
alert.TypeZulip,
|
||||||
}
|
}
|
||||||
var validProviders, invalidProviders []alert.Type
|
var validProviders, invalidProviders []alert.Type
|
||||||
for _, alertType := range alertTypes {
|
for _, alertType := range alertTypes {
|
||||||
alertProvider := alertingConfig.GetAlertingProviderByAlertType(alertType)
|
alertProvider := alertingConfig.GetAlertingProviderByAlertType(alertType)
|
||||||
if alertProvider != nil {
|
if alertProvider != nil {
|
||||||
if alertProvider.IsValid() {
|
if err := alertProvider.Validate(); err == nil {
|
||||||
// Parse alerts with the provider's default alert
|
// Parse alerts with the provider's default alert
|
||||||
if alertProvider.GetDefaultAlert() != nil {
|
if alertProvider.GetDefaultAlert() != nil {
|
||||||
for _, endpoint := range endpoints {
|
for _, ep := range endpoints {
|
||||||
for alertIndex, endpointAlert := range endpoint.Alerts {
|
for alertIndex, endpointAlert := range ep.Alerts {
|
||||||
if alertType == endpointAlert.Type {
|
if alertType == endpointAlert.Type {
|
||||||
if debug {
|
logr.Debugf("[config.validateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ep.Key())
|
||||||
log.Printf("[config][validateAlertingConfig] Parsing alert %d with provider's default alert for provider=%s in endpoint=%s", alertIndex, alertType, endpoint.Name)
|
provider.MergeProviderDefaultAlertIntoEndpointAlert(alertProvider.GetDefaultAlert(), endpointAlert)
|
||||||
|
// Validate the endpoint alert's overrides, if applicable
|
||||||
|
if len(endpointAlert.ProviderOverride) > 0 {
|
||||||
|
if err = alertProvider.ValidateOverrides(ep.Group, endpointAlert); err != nil {
|
||||||
|
log.Warnf("[config.validateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ep.Key(), alertType, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, ee := range externalEndpoints {
|
||||||
|
for alertIndex, endpointAlert := range ee.Alerts {
|
||||||
|
if alertType == endpointAlert.Type {
|
||||||
|
logr.Debugf("[config.validateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ee.Key())
|
||||||
|
provider.MergeProviderDefaultAlertIntoEndpointAlert(alertProvider.GetDefaultAlert(), endpointAlert)
|
||||||
|
// Validate the endpoint alert's overrides, if applicable
|
||||||
|
if len(endpointAlert.ProviderOverride) > 0 {
|
||||||
|
if err = alertProvider.ValidateOverrides(ee.Group, endpointAlert); err != nil {
|
||||||
|
log.Warnf("[config.validateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ee.Key(), alertType, err.Error())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
provider.ParseWithDefaultAlert(alertProvider.GetDefaultAlert(), endpointAlert)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
validProviders = append(validProviders, alertType)
|
validProviders = append(validProviders, alertType)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("[config][validateAlertingConfig] Ignoring provider=%s because configuration is invalid", alertType)
|
logr.Warnf("[config.validateAlertingConfig] Ignoring provider=%s due to error=%s", alertType, err.Error())
|
||||||
invalidProviders = append(invalidProviders, alertType)
|
invalidProviders = append(invalidProviders, alertType)
|
||||||
alertingConfig.SetAlertingProviderToNil(alertProvider)
|
alertingConfig.SetAlertingProviderToNil(alertProvider)
|
||||||
}
|
}
|
||||||
@@ -407,5 +464,5 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.E
|
|||||||
invalidProviders = append(invalidProviders, alertType)
|
invalidProviders = append(invalidProviders, alertType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.Printf("[config][validateAlertingConfig] configuredProviders=%s; ignoredProviders=%s", validProviders, invalidProviders)
|
logr.Infof("[config.validateAlertingConfig] configuredProviders=%s; ignoredProviders=%s", validProviders, invalidProviders)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,16 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/alerting"
|
"github.com/TwiN/gatus/v5/alerting"
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider"
|
"github.com/TwiN/gatus/v5/alerting/provider"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/awsses"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/custom"
|
"github.com/TwiN/gatus/v5/alerting/provider/custom"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/discord"
|
"github.com/TwiN/gatus/v5/alerting/provider/discord"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/email"
|
"github.com/TwiN/gatus/v5/alerting/provider/email"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/gitea"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/github"
|
"github.com/TwiN/gatus/v5/alerting/provider/github"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
|
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/gotify"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
|
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
|
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
|
||||||
@@ -25,16 +30,19 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
|
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/slack"
|
"github.com/TwiN/gatus/v5/alerting/provider/slack"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/teams"
|
"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/telegram"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
|
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/zulip"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/TwiN/gatus/v5/config/web"
|
"github.com/TwiN/gatus/v5/config/web"
|
||||||
"github.com/TwiN/gatus/v5/core"
|
|
||||||
"github.com/TwiN/gatus/v5/storage"
|
"github.com/TwiN/gatus/v5/storage"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLoadConfiguration(t *testing.T) {
|
func TestLoadConfiguration(t *testing.T) {
|
||||||
|
yes := true
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -64,7 +72,7 @@ func TestLoadConfiguration(t *testing.T) {
|
|||||||
endpoints:
|
endpoints:
|
||||||
- name: website`,
|
- name: website`,
|
||||||
},
|
},
|
||||||
expectedError: core.ErrEndpointWithNoURL,
|
expectedError: endpoint.ErrEndpointWithNoURL,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "config-file-with-endpoint-that-has-no-conditions",
|
name: "config-file-with-endpoint-that-has-no-conditions",
|
||||||
@@ -75,7 +83,7 @@ endpoints:
|
|||||||
- name: website
|
- name: website
|
||||||
url: https://twin.sh/health`,
|
url: https://twin.sh/health`,
|
||||||
},
|
},
|
||||||
expectedError: core.ErrEndpointWithNoCondition,
|
expectedError: endpoint.ErrEndpointWithNoCondition,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "config-file",
|
name: "config-file",
|
||||||
@@ -89,11 +97,11 @@ endpoints:
|
|||||||
- "[STATUS] == 200"`,
|
- "[STATUS] == 200"`,
|
||||||
},
|
},
|
||||||
expectedConfig: &Config{
|
expectedConfig: &Config{
|
||||||
Endpoints: []*core.Endpoint{
|
Endpoints: []*endpoint.Endpoint{
|
||||||
{
|
{
|
||||||
Name: "website",
|
Name: "website",
|
||||||
URL: "https://twin.sh/health",
|
URL: "https://twin.sh/health",
|
||||||
Conditions: []core.Condition{"[STATUS] == 200"},
|
Conditions: []endpoint.Condition{"[STATUS] == 200"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -135,21 +143,21 @@ endpoints:
|
|||||||
- "[BODY].status == UP"`,
|
- "[BODY].status == UP"`,
|
||||||
},
|
},
|
||||||
expectedConfig: &Config{
|
expectedConfig: &Config{
|
||||||
Endpoints: []*core.Endpoint{
|
Endpoints: []*endpoint.Endpoint{
|
||||||
{
|
{
|
||||||
Name: "one",
|
Name: "one",
|
||||||
URL: "https://example.com",
|
URL: "https://example.com",
|
||||||
Conditions: []core.Condition{"[CONNECTED] == true", "[STATUS] == 200"},
|
Conditions: []endpoint.Condition{"[CONNECTED] == true", "[STATUS] == 200"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "two",
|
Name: "two",
|
||||||
URL: "https://example.org",
|
URL: "https://example.org",
|
||||||
Conditions: []core.Condition{"len([BODY]) > 0"},
|
Conditions: []endpoint.Condition{"len([BODY]) > 0"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "three",
|
Name: "three",
|
||||||
URL: "https://twin.sh/health",
|
URL: "https://twin.sh/health",
|
||||||
Conditions: []core.Condition{"[STATUS] == 200", "[BODY].status == UP"},
|
Conditions: []endpoint.Condition{"[STATUS] == 200", "[BODY].status == UP"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -164,6 +172,8 @@ metrics: true
|
|||||||
alerting:
|
alerting:
|
||||||
slack:
|
slack:
|
||||||
webhook-url: https://hooks.slack.com/services/xxx/yyy/zzz
|
webhook-url: https://hooks.slack.com/services/xxx/yyy/zzz
|
||||||
|
default-alert:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
endpoints:
|
endpoints:
|
||||||
- name: example
|
- name: example
|
||||||
@@ -172,12 +182,17 @@ endpoints:
|
|||||||
conditions:
|
conditions:
|
||||||
- "[STATUS] == 200"`,
|
- "[STATUS] == 200"`,
|
||||||
"b.yaml": `
|
"b.yaml": `
|
||||||
debug: true
|
|
||||||
|
|
||||||
alerting:
|
alerting:
|
||||||
discord:
|
discord:
|
||||||
webhook-url: https://discord.com/api/webhooks/xxx/yyy
|
webhook-url: https://discord.com/api/webhooks/xxx/yyy
|
||||||
|
|
||||||
|
external-endpoints:
|
||||||
|
- name: ext-ep-test
|
||||||
|
token: "potato"
|
||||||
|
alerts:
|
||||||
|
- type: slack
|
||||||
|
|
||||||
endpoints:
|
endpoints:
|
||||||
- name: frontend
|
- name: frontend
|
||||||
url: https://example.com
|
url: https://example.com
|
||||||
@@ -185,23 +200,35 @@ endpoints:
|
|||||||
- "[STATUS] == 200"`,
|
- "[STATUS] == 200"`,
|
||||||
},
|
},
|
||||||
expectedConfig: &Config{
|
expectedConfig: &Config{
|
||||||
Debug: true,
|
|
||||||
Metrics: true,
|
Metrics: true,
|
||||||
Alerting: &alerting.Config{
|
Alerting: &alerting.Config{
|
||||||
Discord: &discord.AlertProvider{WebhookURL: "https://discord.com/api/webhooks/xxx/yyy"},
|
Discord: &discord.AlertProvider{DefaultConfig: discord.Config{WebhookURL: "https://discord.com/api/webhooks/xxx/yyy"}},
|
||||||
Slack: &slack.AlertProvider{WebhookURL: "https://hooks.slack.com/services/xxx/yyy/zzz"},
|
Slack: &slack.AlertProvider{DefaultConfig: slack.Config{WebhookURL: "https://hooks.slack.com/services/xxx/yyy/zzz"}, DefaultAlert: &alert.Alert{Enabled: &yes}},
|
||||||
},
|
},
|
||||||
Endpoints: []*core.Endpoint{
|
ExternalEndpoints: []*endpoint.ExternalEndpoint{
|
||||||
|
{
|
||||||
|
Name: "ext-ep-test",
|
||||||
|
Token: "potato",
|
||||||
|
Alerts: []*alert.Alert{
|
||||||
|
{
|
||||||
|
Type: alert.TypeSlack,
|
||||||
|
FailureThreshold: 3,
|
||||||
|
SuccessThreshold: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Endpoints: []*endpoint.Endpoint{
|
||||||
{
|
{
|
||||||
Name: "example",
|
Name: "example",
|
||||||
URL: "https://example.org",
|
URL: "https://example.org",
|
||||||
Interval: 5 * time.Second,
|
Interval: 5 * time.Second,
|
||||||
Conditions: []core.Condition{"[STATUS] == 200"},
|
Conditions: []endpoint.Condition{"[STATUS] == 200"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "frontend",
|
Name: "frontend",
|
||||||
URL: "https://example.com",
|
URL: "https://example.com",
|
||||||
Conditions: []core.Condition{"[STATUS] == 200"},
|
Conditions: []endpoint.Condition{"[STATUS] == 200"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -303,11 +330,13 @@ func TestParseAndValidateConfigBytes(t *testing.T) {
|
|||||||
storage:
|
storage:
|
||||||
type: sqlite
|
type: sqlite
|
||||||
path: %s
|
path: %s
|
||||||
|
|
||||||
maintenance:
|
maintenance:
|
||||||
enabled: true
|
enabled: true
|
||||||
start: 00:00
|
start: 00:00
|
||||||
duration: 4h
|
duration: 4h
|
||||||
every: [Monday, Thursday]
|
every: [Monday, Thursday]
|
||||||
|
|
||||||
ui:
|
ui:
|
||||||
title: T
|
title: T
|
||||||
header: H
|
header: H
|
||||||
@@ -317,6 +346,12 @@ ui:
|
|||||||
link: "https://example.org"
|
link: "https://example.org"
|
||||||
- name: "Status page"
|
- name: "Status page"
|
||||||
link: "https://status.example.org"
|
link: "https://status.example.org"
|
||||||
|
|
||||||
|
external-endpoints:
|
||||||
|
- name: ext-ep-test
|
||||||
|
group: core
|
||||||
|
token: "potato"
|
||||||
|
|
||||||
endpoints:
|
endpoints:
|
||||||
- name: website
|
- name: website
|
||||||
url: https://twin.sh/health
|
url: https://twin.sh/health
|
||||||
@@ -357,10 +392,22 @@ endpoints:
|
|||||||
if mc := config.Maintenance; mc == nil || mc.Start != "00:00" || !mc.IsEnabled() || mc.Duration != 4*time.Hour || len(mc.Every) != 2 {
|
if mc := config.Maintenance; mc == nil || mc.Start != "00:00" || !mc.IsEnabled() || mc.Duration != 4*time.Hour || len(mc.Every) != 2 {
|
||||||
t.Error("Expected Config.Maintenance to be configured properly")
|
t.Error("Expected Config.Maintenance to be configured properly")
|
||||||
}
|
}
|
||||||
|
if len(config.ExternalEndpoints) != 1 {
|
||||||
|
t.Error("Should have returned one external endpoint")
|
||||||
|
}
|
||||||
|
if config.ExternalEndpoints[0].Name != "ext-ep-test" {
|
||||||
|
t.Errorf("Name should have been %s", "ext-ep-test")
|
||||||
|
}
|
||||||
|
if config.ExternalEndpoints[0].Group != "core" {
|
||||||
|
t.Errorf("Group should have been %s", "core")
|
||||||
|
}
|
||||||
|
if config.ExternalEndpoints[0].Token != "potato" {
|
||||||
|
t.Errorf("Token should have been %s", "potato")
|
||||||
|
}
|
||||||
|
|
||||||
if len(config.Endpoints) != 3 {
|
if len(config.Endpoints) != 3 {
|
||||||
t.Error("Should have returned two endpoints")
|
t.Error("Should have returned two endpoints")
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Endpoints[0].URL != "https://twin.sh/health" {
|
if config.Endpoints[0].URL != "https://twin.sh/health" {
|
||||||
t.Errorf("URL should have been %s", "https://twin.sh/health")
|
t.Errorf("URL should have been %s", "https://twin.sh/health")
|
||||||
}
|
}
|
||||||
@@ -382,7 +429,6 @@ endpoints:
|
|||||||
if len(config.Endpoints[0].Conditions) != 1 {
|
if len(config.Endpoints[0].Conditions) != 1 {
|
||||||
t.Errorf("There should have been %d conditions", 1)
|
t.Errorf("There should have been %d conditions", 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Endpoints[1].URL != "https://api.github.com/healthz" {
|
if config.Endpoints[1].URL != "https://api.github.com/healthz" {
|
||||||
t.Errorf("URL should have been %s", "https://api.github.com/healthz")
|
t.Errorf("URL should have been %s", "https://api.github.com/healthz")
|
||||||
}
|
}
|
||||||
@@ -404,7 +450,6 @@ endpoints:
|
|||||||
if len(config.Endpoints[1].Conditions) != 2 {
|
if len(config.Endpoints[1].Conditions) != 2 {
|
||||||
t.Errorf("There should have been %d conditions", 2)
|
t.Errorf("There should have been %d conditions", 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Endpoints[2].URL != "https://example.com/" {
|
if config.Endpoints[2].URL != "https://example.com/" {
|
||||||
t.Errorf("URL should have been %s", "https://example.com/")
|
t.Errorf("URL should have been %s", "https://example.com/")
|
||||||
}
|
}
|
||||||
@@ -440,7 +485,7 @@ endpoints:
|
|||||||
t.Error("expected no error, got", err.Error())
|
t.Error("expected no error, got", err.Error())
|
||||||
}
|
}
|
||||||
if config == nil {
|
if config == nil {
|
||||||
t.Fatal("Config shouldn't have been nil")
|
t.Fatal("DefaultConfig shouldn't have been nil")
|
||||||
}
|
}
|
||||||
if config.Metrics {
|
if config.Metrics {
|
||||||
t.Error("Metrics should've been false by default")
|
t.Error("Metrics should've been false by default")
|
||||||
@@ -654,8 +699,8 @@ endpoints:
|
|||||||
if config.Endpoints[0].Interval != 60*time.Second {
|
if config.Endpoints[0].Interval != 60*time.Second {
|
||||||
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
|
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
|
||||||
}
|
}
|
||||||
if userAgent := config.Endpoints[0].Headers["User-Agent"]; userAgent != core.GatusUserAgent {
|
if userAgent := config.Endpoints[0].Headers["User-Agent"]; userAgent != endpoint.GatusUserAgent {
|
||||||
t.Errorf("User-Agent should've been %s because it's the default value, got %s", core.GatusUserAgent, userAgent)
|
t.Errorf("User-Agent should've been %s because it's the default value, got %s", endpoint.GatusUserAgent, userAgent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -677,7 +722,6 @@ badconfig:
|
|||||||
|
|
||||||
func TestParseAndValidateConfigBytesWithAlerting(t *testing.T) {
|
func TestParseAndValidateConfigBytesWithAlerting(t *testing.T) {
|
||||||
config, err := parseAndValidateConfigBytes([]byte(`
|
config, err := parseAndValidateConfigBytes([]byte(`
|
||||||
debug: true
|
|
||||||
alerting:
|
alerting:
|
||||||
slack:
|
slack:
|
||||||
webhook-url: "http://example.com"
|
webhook-url: "http://example.com"
|
||||||
@@ -706,6 +750,10 @@ alerting:
|
|||||||
to: "+1-234-567-8901"
|
to: "+1-234-567-8901"
|
||||||
teams:
|
teams:
|
||||||
webhook-url: "http://example.com"
|
webhook-url: "http://example.com"
|
||||||
|
jetbrainsspace:
|
||||||
|
project: "foo"
|
||||||
|
channel-id: "bar"
|
||||||
|
token: "baz"
|
||||||
|
|
||||||
endpoints:
|
endpoints:
|
||||||
- name: website
|
- name: website
|
||||||
@@ -728,6 +776,7 @@ endpoints:
|
|||||||
success-threshold: 15
|
success-threshold: 15
|
||||||
- type: teams
|
- type: teams
|
||||||
- type: pushover
|
- type: pushover
|
||||||
|
- type: jetbrainsspace
|
||||||
conditions:
|
conditions:
|
||||||
- "[STATUS] == 200"
|
- "[STATUS] == 200"
|
||||||
`))
|
`))
|
||||||
@@ -741,7 +790,7 @@ endpoints:
|
|||||||
if config.Alerting == nil {
|
if config.Alerting == nil {
|
||||||
t.Fatal("config.Alerting shouldn't have been nil")
|
t.Fatal("config.Alerting shouldn't have been nil")
|
||||||
}
|
}
|
||||||
if config.Alerting.Slack == nil || !config.Alerting.Slack.IsValid() {
|
if config.Alerting.Slack == nil || config.Alerting.Slack.Validate() != nil {
|
||||||
t.Fatal("Slack alerting config should've been valid")
|
t.Fatal("Slack alerting config should've been valid")
|
||||||
}
|
}
|
||||||
// Endpoints
|
// Endpoints
|
||||||
@@ -754,8 +803,8 @@ endpoints:
|
|||||||
if config.Endpoints[0].Interval != 60*time.Second {
|
if config.Endpoints[0].Interval != 60*time.Second {
|
||||||
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
|
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
|
||||||
}
|
}
|
||||||
if len(config.Endpoints[0].Alerts) != 9 {
|
if len(config.Endpoints[0].Alerts) != 10 {
|
||||||
t.Fatal("There should've been 9 alerts configured")
|
t.Fatal("There should've been 10 alerts configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Endpoints[0].Alerts[0].Type != alert.TypeSlack {
|
if config.Endpoints[0].Alerts[0].Type != alert.TypeSlack {
|
||||||
@@ -862,12 +911,16 @@ endpoints:
|
|||||||
if !config.Endpoints[0].Alerts[8].IsEnabled() {
|
if !config.Endpoints[0].Alerts[8].IsEnabled() {
|
||||||
t.Error("The alert should've been enabled")
|
t.Error("The alert should've been enabled")
|
||||||
}
|
}
|
||||||
|
if config.Endpoints[0].Alerts[9].Type != alert.TypeJetBrainsSpace {
|
||||||
|
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeJetBrainsSpace, config.Endpoints[0].Alerts[9].Type)
|
||||||
|
}
|
||||||
|
if !config.Endpoints[0].Alerts[9].IsEnabled() {
|
||||||
|
t.Error("The alert should've been enabled")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlert(t *testing.T) {
|
func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlert(t *testing.T) {
|
||||||
config, err := parseAndValidateConfigBytes([]byte(`
|
config, err := parseAndValidateConfigBytes([]byte(`
|
||||||
debug: true
|
|
||||||
|
|
||||||
alerting:
|
alerting:
|
||||||
slack:
|
slack:
|
||||||
webhook-url: "http://example.com"
|
webhook-url: "http://example.com"
|
||||||
@@ -878,7 +931,7 @@ alerting:
|
|||||||
default-alert:
|
default-alert:
|
||||||
enabled: true
|
enabled: true
|
||||||
failure-threshold: 10
|
failure-threshold: 10
|
||||||
success-threshold: 1
|
success-threshold: 15
|
||||||
pagerduty:
|
pagerduty:
|
||||||
integration-key: "00000000000000000000000000000000"
|
integration-key: "00000000000000000000000000000000"
|
||||||
default-alert:
|
default-alert:
|
||||||
@@ -923,23 +976,57 @@ alerting:
|
|||||||
webhook-url: "http://example.com"
|
webhook-url: "http://example.com"
|
||||||
default-alert:
|
default-alert:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
jetbrainsspace:
|
||||||
|
project: "foo"
|
||||||
|
channel-id: "bar"
|
||||||
|
token: "baz"
|
||||||
|
default-alert:
|
||||||
|
enabled: true
|
||||||
|
failure-threshold: 5
|
||||||
|
success-threshold: 3
|
||||||
|
email:
|
||||||
|
from: "from@example.com"
|
||||||
|
username: "from@example.com"
|
||||||
|
password: "hunter2"
|
||||||
|
host: "mail.example.com"
|
||||||
|
port: 587
|
||||||
|
to: "recipient1@example.com,recipient2@example.com"
|
||||||
|
client:
|
||||||
|
insecure: false
|
||||||
|
default-alert:
|
||||||
|
enabled: true
|
||||||
|
gotify:
|
||||||
|
server-url: "https://gotify.example"
|
||||||
|
token: "**************"
|
||||||
|
default-alert:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
external-endpoints:
|
||||||
|
- name: ext-ep-test
|
||||||
|
group: core
|
||||||
|
token: potato
|
||||||
|
alerts:
|
||||||
|
- type: discord
|
||||||
|
|
||||||
endpoints:
|
endpoints:
|
||||||
- name: website
|
- name: website
|
||||||
url: https://twin.sh/health
|
url: https://twin.sh/health
|
||||||
alerts:
|
alerts:
|
||||||
- type: slack
|
- type: slack
|
||||||
- type: pagerduty
|
- type: pagerduty
|
||||||
- type: mattermost
|
- type: mattermost
|
||||||
- type: messagebird
|
- type: messagebird
|
||||||
- type: discord
|
- type: discord
|
||||||
success-threshold: 2 # test endpoint alert override
|
success-threshold: 8 # test endpoint alert override
|
||||||
- type: telegram
|
- type: telegram
|
||||||
- type: twilio
|
- type: twilio
|
||||||
- type: teams
|
- type: teams
|
||||||
- type: pushover
|
- type: pushover
|
||||||
conditions:
|
- type: jetbrainsspace
|
||||||
- "[STATUS] == 200"
|
- type: email
|
||||||
|
- type: gotify
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"
|
||||||
`))
|
`))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Error("expected no error, got", err.Error())
|
t.Error("expected no error, got", err.Error())
|
||||||
@@ -954,102 +1041,186 @@ endpoints:
|
|||||||
if config.Alerting == nil {
|
if config.Alerting == nil {
|
||||||
t.Fatal("config.Alerting shouldn't have been nil")
|
t.Fatal("config.Alerting shouldn't have been nil")
|
||||||
}
|
}
|
||||||
if config.Alerting.Slack == nil || !config.Alerting.Slack.IsValid() {
|
|
||||||
|
if config.Alerting.Slack == nil || config.Alerting.Slack.Validate() != nil {
|
||||||
t.Fatal("Slack alerting config should've been valid")
|
t.Fatal("Slack alerting config should've been valid")
|
||||||
}
|
}
|
||||||
if config.Alerting.Slack.GetDefaultAlert() == nil {
|
if config.Alerting.Slack.GetDefaultAlert() == nil {
|
||||||
t.Fatal("Slack.GetDefaultAlert() shouldn't have returned nil")
|
t.Fatal("Slack.GetDefaultAlert() shouldn't have returned nil")
|
||||||
}
|
}
|
||||||
if config.Alerting.Slack.WebhookURL != "http://example.com" {
|
if config.Alerting.Slack.DefaultConfig.WebhookURL != "http://example.com" {
|
||||||
t.Errorf("Slack webhook should've been %s, but was %s", "http://example.com", config.Alerting.Slack.WebhookURL)
|
t.Errorf("Slack webhook should've been %s, but was %s", "http://example.com", config.Alerting.Slack.DefaultConfig.WebhookURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Alerting.PagerDuty == nil || !config.Alerting.PagerDuty.IsValid() {
|
if config.Alerting.PagerDuty == nil || config.Alerting.PagerDuty.Validate() != nil {
|
||||||
t.Fatal("PagerDuty alerting config should've been valid")
|
t.Fatal("PagerDuty alerting config should've been valid")
|
||||||
}
|
}
|
||||||
if config.Alerting.PagerDuty.GetDefaultAlert() == nil {
|
if config.Alerting.PagerDuty.GetDefaultAlert() == nil {
|
||||||
t.Fatal("PagerDuty.GetDefaultAlert() shouldn't have returned nil")
|
t.Fatal("PagerDuty.GetDefaultAlert() shouldn't have returned nil")
|
||||||
}
|
}
|
||||||
if config.Alerting.PagerDuty.IntegrationKey != "00000000000000000000000000000000" {
|
if config.Alerting.PagerDuty.DefaultConfig.IntegrationKey != "00000000000000000000000000000000" {
|
||||||
t.Errorf("PagerDuty integration key should've been %s, but was %s", "00000000000000000000000000000000", config.Alerting.PagerDuty.IntegrationKey)
|
t.Errorf("PagerDuty integration key should've been %s, but was %s", "00000000000000000000000000000000", config.Alerting.PagerDuty.DefaultConfig.IntegrationKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Alerting.Pushover == nil || !config.Alerting.Pushover.IsValid() {
|
if config.Alerting.Pushover == nil || config.Alerting.Pushover.Validate() != nil {
|
||||||
t.Fatal("Pushover alerting config should've been valid")
|
t.Fatal("Pushover alerting config should've been valid")
|
||||||
}
|
}
|
||||||
if config.Alerting.Pushover.GetDefaultAlert() == nil {
|
if config.Alerting.Pushover.GetDefaultAlert() == nil {
|
||||||
t.Fatal("Pushover.GetDefaultAlert() shouldn't have returned nil")
|
t.Fatal("Pushover.GetDefaultAlert() shouldn't have returned nil")
|
||||||
}
|
}
|
||||||
if config.Alerting.Pushover.ApplicationToken != "000000000000000000000000000000" {
|
if config.Alerting.Pushover.DefaultConfig.ApplicationToken != "000000000000000000000000000000" {
|
||||||
t.Errorf("Pushover application token should've been %s, but was %s", "000000000000000000000000000000", config.Alerting.Pushover.ApplicationToken)
|
t.Errorf("Pushover application token should've been %s, but was %s", "000000000000000000000000000000", config.Alerting.Pushover.DefaultConfig.ApplicationToken)
|
||||||
}
|
}
|
||||||
if config.Alerting.Pushover.UserKey != "000000000000000000000000000000" {
|
if config.Alerting.Pushover.DefaultConfig.UserKey != "000000000000000000000000000000" {
|
||||||
t.Errorf("Pushover user key should've been %s, but was %s", "000000000000000000000000000000", config.Alerting.Pushover.UserKey)
|
t.Errorf("Pushover user key should've been %s, but was %s", "000000000000000000000000000000", config.Alerting.Pushover.DefaultConfig.UserKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Alerting.Mattermost == nil || !config.Alerting.Mattermost.IsValid() {
|
if config.Alerting.Mattermost == nil || config.Alerting.Mattermost.Validate() != nil {
|
||||||
t.Fatal("Mattermost alerting config should've been valid")
|
t.Fatal("Mattermost alerting config should've been valid")
|
||||||
}
|
}
|
||||||
if config.Alerting.Mattermost.GetDefaultAlert() == nil {
|
if config.Alerting.Mattermost.GetDefaultAlert() == nil {
|
||||||
t.Fatal("Mattermost.GetDefaultAlert() shouldn't have returned nil")
|
t.Fatal("Mattermost.GetDefaultAlert() shouldn't have returned nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Alerting.Messagebird == nil || !config.Alerting.Messagebird.IsValid() {
|
if config.Alerting.Messagebird == nil || config.Alerting.Messagebird.Validate() != nil {
|
||||||
t.Fatal("Messagebird alerting config should've been valid")
|
t.Fatal("Messagebird alerting config should've been valid")
|
||||||
}
|
}
|
||||||
if config.Alerting.Messagebird.GetDefaultAlert() == nil {
|
if config.Alerting.Messagebird.GetDefaultAlert() == nil {
|
||||||
t.Fatal("Messagebird.GetDefaultAlert() shouldn't have returned nil")
|
t.Fatal("Messagebird.GetDefaultAlert() shouldn't have returned nil")
|
||||||
}
|
}
|
||||||
if config.Alerting.Messagebird.AccessKey != "1" {
|
if config.Alerting.Messagebird.DefaultConfig.AccessKey != "1" {
|
||||||
t.Errorf("Messagebird access key should've been %s, but was %s", "1", config.Alerting.Messagebird.AccessKey)
|
t.Errorf("Messagebird access key should've been %s, but was %s", "1", config.Alerting.Messagebird.DefaultConfig.AccessKey)
|
||||||
}
|
}
|
||||||
if config.Alerting.Messagebird.Originator != "31619191918" {
|
if config.Alerting.Messagebird.DefaultConfig.Originator != "31619191918" {
|
||||||
t.Errorf("Messagebird originator field should've been %s, but was %s", "31619191918", config.Alerting.Messagebird.Originator)
|
t.Errorf("Messagebird originator field should've been %s, but was %s", "31619191918", config.Alerting.Messagebird.DefaultConfig.Originator)
|
||||||
}
|
}
|
||||||
if config.Alerting.Messagebird.Recipients != "31619191919" {
|
if config.Alerting.Messagebird.DefaultConfig.Recipients != "31619191919" {
|
||||||
t.Errorf("Messagebird to recipients should've been %s, but was %s", "31619191919", config.Alerting.Messagebird.Recipients)
|
t.Errorf("Messagebird to recipients should've been %s, but was %s", "31619191919", config.Alerting.Messagebird.DefaultConfig.Recipients)
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Alerting.Discord == nil || !config.Alerting.Discord.IsValid() {
|
if config.Alerting.Discord == nil || config.Alerting.Discord.Validate() != nil {
|
||||||
t.Fatal("Discord alerting config should've been valid")
|
t.Fatal("Discord alerting config should've been valid")
|
||||||
}
|
}
|
||||||
if config.Alerting.Discord.GetDefaultAlert() == nil {
|
if config.Alerting.Discord.GetDefaultAlert() == nil {
|
||||||
t.Fatal("Discord.GetDefaultAlert() shouldn't have returned nil")
|
t.Fatal("Discord.GetDefaultAlert() shouldn't have returned nil")
|
||||||
}
|
}
|
||||||
if config.Alerting.Discord.WebhookURL != "http://example.org" {
|
if config.Alerting.Discord.GetDefaultAlert().FailureThreshold != 10 {
|
||||||
t.Errorf("Discord webhook should've been %s, but was %s", "http://example.org", config.Alerting.Discord.WebhookURL)
|
t.Errorf("Discord default alert failure threshold should've been %d, but was %d", 10, config.Alerting.Discord.GetDefaultAlert().FailureThreshold)
|
||||||
|
}
|
||||||
|
if config.Alerting.Discord.GetDefaultAlert().SuccessThreshold != 15 {
|
||||||
|
t.Errorf("Discord default alert success threshold should've been %d, but was %d", 15, config.Alerting.Discord.GetDefaultAlert().SuccessThreshold)
|
||||||
|
}
|
||||||
|
if config.Alerting.Discord.DefaultConfig.WebhookURL != "http://example.org" {
|
||||||
|
t.Errorf("Discord webhook should've been %s, but was %s", "http://example.org", config.Alerting.Discord.DefaultConfig.WebhookURL)
|
||||||
}
|
}
|
||||||
if config.Alerting.GetAlertingProviderByAlertType(alert.TypeDiscord) != config.Alerting.Discord {
|
if config.Alerting.GetAlertingProviderByAlertType(alert.TypeDiscord) != config.Alerting.Discord {
|
||||||
t.Error("expected discord configuration")
|
t.Error("expected discord configuration")
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Alerting.Telegram == nil || !config.Alerting.Telegram.IsValid() {
|
if config.Alerting.Telegram == nil || config.Alerting.Telegram.Validate() != nil {
|
||||||
t.Fatal("Telegram alerting config should've been valid")
|
t.Fatal("Telegram alerting config should've been valid")
|
||||||
}
|
}
|
||||||
if config.Alerting.Telegram.GetDefaultAlert() == nil {
|
if config.Alerting.Telegram.GetDefaultAlert() == nil {
|
||||||
t.Fatal("Telegram.GetDefaultAlert() shouldn't have returned nil")
|
t.Fatal("Telegram.GetDefaultAlert() shouldn't have returned nil")
|
||||||
}
|
}
|
||||||
if config.Alerting.Telegram.Token != "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" {
|
if config.Alerting.Telegram.DefaultConfig.Token != "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" {
|
||||||
t.Errorf("Telegram token should've been %s, but was %s", "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", config.Alerting.Telegram.Token)
|
t.Errorf("Telegram token should've been %s, but was %s", "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", config.Alerting.Telegram.DefaultConfig.Token)
|
||||||
}
|
}
|
||||||
if config.Alerting.Telegram.ID != "0123456789" {
|
if config.Alerting.Telegram.DefaultConfig.ID != "0123456789" {
|
||||||
t.Errorf("Telegram ID should've been %s, but was %s", "012345689", config.Alerting.Telegram.ID)
|
t.Errorf("Telegram ID should've been %s, but was %s", "012345689", config.Alerting.Telegram.DefaultConfig.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Alerting.Twilio == nil || !config.Alerting.Twilio.IsValid() {
|
if config.Alerting.Twilio == nil || config.Alerting.Twilio.Validate() != nil {
|
||||||
t.Fatal("Twilio alerting config should've been valid")
|
t.Fatal("Twilio alerting config should've been valid")
|
||||||
}
|
}
|
||||||
if config.Alerting.Twilio.GetDefaultAlert() == nil {
|
if config.Alerting.Twilio.GetDefaultAlert() == nil {
|
||||||
t.Fatal("Twilio.GetDefaultAlert() shouldn't have returned nil")
|
t.Fatal("Twilio.GetDefaultAlert() shouldn't have returned nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Alerting.Teams == nil || !config.Alerting.Teams.IsValid() {
|
if config.Alerting.Teams == nil || config.Alerting.Teams.Validate() != nil {
|
||||||
t.Fatal("Teams alerting config should've been valid")
|
t.Fatal("Teams alerting config should've been valid")
|
||||||
}
|
}
|
||||||
if config.Alerting.Teams.GetDefaultAlert() == nil {
|
if config.Alerting.Teams.GetDefaultAlert() == nil {
|
||||||
t.Fatal("Teams.GetDefaultAlert() shouldn't have returned nil")
|
t.Fatal("Teams.GetDefaultAlert() shouldn't have returned nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.Alerting.JetBrainsSpace == nil || config.Alerting.JetBrainsSpace.Validate() != nil {
|
||||||
|
t.Fatal("JetBrainsSpace alerting config should've been valid")
|
||||||
|
}
|
||||||
|
if config.Alerting.JetBrainsSpace.GetDefaultAlert() == nil {
|
||||||
|
t.Fatal("JetBrainsSpace.GetDefaultAlert() shouldn't have returned nil")
|
||||||
|
}
|
||||||
|
if config.Alerting.JetBrainsSpace.DefaultConfig.Project != "foo" {
|
||||||
|
t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "foo", config.Alerting.JetBrainsSpace.DefaultConfig.Project)
|
||||||
|
}
|
||||||
|
if config.Alerting.JetBrainsSpace.DefaultConfig.ChannelID != "bar" {
|
||||||
|
t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "bar", config.Alerting.JetBrainsSpace.DefaultConfig.ChannelID)
|
||||||
|
}
|
||||||
|
if config.Alerting.JetBrainsSpace.DefaultConfig.Token != "baz" {
|
||||||
|
t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "baz", config.Alerting.JetBrainsSpace.DefaultConfig.Token)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Alerting.Email == nil || config.Alerting.Email.Validate() != nil {
|
||||||
|
t.Fatal("Email alerting config should've been valid")
|
||||||
|
}
|
||||||
|
if config.Alerting.Email.GetDefaultAlert() == nil {
|
||||||
|
t.Fatal("Email.GetDefaultAlert() shouldn't have returned nil")
|
||||||
|
}
|
||||||
|
if config.Alerting.Email.DefaultConfig.From != "from@example.com" {
|
||||||
|
t.Errorf("Email from should've been %s, but was %s", "from@example.com", config.Alerting.Email.DefaultConfig.From)
|
||||||
|
}
|
||||||
|
if config.Alerting.Email.DefaultConfig.Username != "from@example.com" {
|
||||||
|
t.Errorf("Email username should've been %s, but was %s", "from@example.com", config.Alerting.Email.DefaultConfig.Username)
|
||||||
|
}
|
||||||
|
if config.Alerting.Email.DefaultConfig.Password != "hunter2" {
|
||||||
|
t.Errorf("Email password should've been %s, but was %s", "hunter2", config.Alerting.Email.DefaultConfig.Password)
|
||||||
|
}
|
||||||
|
if config.Alerting.Email.DefaultConfig.Host != "mail.example.com" {
|
||||||
|
t.Errorf("Email host should've been %s, but was %s", "mail.example.com", config.Alerting.Email.DefaultConfig.Host)
|
||||||
|
}
|
||||||
|
if config.Alerting.Email.DefaultConfig.Port != 587 {
|
||||||
|
t.Errorf("Email port should've been %d, but was %d", 587, config.Alerting.Email.DefaultConfig.Port)
|
||||||
|
}
|
||||||
|
if config.Alerting.Email.DefaultConfig.To != "recipient1@example.com,recipient2@example.com" {
|
||||||
|
t.Errorf("Email to should've been %s, but was %s", "recipient1@example.com,recipient2@example.com", config.Alerting.Email.DefaultConfig.To)
|
||||||
|
}
|
||||||
|
if config.Alerting.Email.DefaultConfig.ClientConfig == nil {
|
||||||
|
t.Fatal("Email client config should've been set")
|
||||||
|
}
|
||||||
|
if config.Alerting.Email.DefaultConfig.ClientConfig.Insecure {
|
||||||
|
t.Error("Email client config should've been secure")
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Alerting.Gotify == nil || config.Alerting.Gotify.Validate() != nil {
|
||||||
|
t.Fatal("Gotify alerting config should've been valid")
|
||||||
|
}
|
||||||
|
if config.Alerting.Gotify.GetDefaultAlert() == nil {
|
||||||
|
t.Fatal("Gotify.GetDefaultAlert() shouldn't have returned nil")
|
||||||
|
}
|
||||||
|
if config.Alerting.Gotify.DefaultConfig.ServerURL != "https://gotify.example" {
|
||||||
|
t.Errorf("Gotify server URL should've been %s, but was %s", "https://gotify.example", config.Alerting.Gotify.DefaultConfig.ServerURL)
|
||||||
|
}
|
||||||
|
if config.Alerting.Gotify.DefaultConfig.Token != "**************" {
|
||||||
|
t.Errorf("Gotify token should've been %s, but was %s", "**************", config.Alerting.Gotify.DefaultConfig.Token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// External endpoints
|
||||||
|
if len(config.ExternalEndpoints) != 1 {
|
||||||
|
t.Error("There should've been 1 external endpoint")
|
||||||
|
}
|
||||||
|
if config.ExternalEndpoints[0].Alerts[0].Type != alert.TypeDiscord {
|
||||||
|
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeDiscord, config.ExternalEndpoints[0].Alerts[0].Type)
|
||||||
|
}
|
||||||
|
if !config.ExternalEndpoints[0].Alerts[0].IsEnabled() {
|
||||||
|
t.Error("The alert should've been enabled")
|
||||||
|
}
|
||||||
|
if config.ExternalEndpoints[0].Alerts[0].FailureThreshold != 10 {
|
||||||
|
t.Errorf("The failure threshold of the alert should've been %d, but it was %d", 10, config.ExternalEndpoints[0].Alerts[0].FailureThreshold)
|
||||||
|
}
|
||||||
|
if config.ExternalEndpoints[0].Alerts[0].SuccessThreshold != 15 {
|
||||||
|
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 15, config.ExternalEndpoints[0].Alerts[0].SuccessThreshold)
|
||||||
|
}
|
||||||
|
|
||||||
// Endpoints
|
// Endpoints
|
||||||
if len(config.Endpoints) != 1 {
|
if len(config.Endpoints) != 1 {
|
||||||
t.Error("There should've been 1 endpoint")
|
t.Error("There should've been 1 endpoint")
|
||||||
@@ -1060,8 +1231,8 @@ endpoints:
|
|||||||
if config.Endpoints[0].Interval != 60*time.Second {
|
if config.Endpoints[0].Interval != 60*time.Second {
|
||||||
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
|
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
|
||||||
}
|
}
|
||||||
if len(config.Endpoints[0].Alerts) != 9 {
|
if len(config.Endpoints[0].Alerts) != 12 {
|
||||||
t.Fatal("There should've been 9 alerts configured")
|
t.Fatalf("There should've been 12 alerts configured, got %d", len(config.Endpoints[0].Alerts))
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Endpoints[0].Alerts[0].Type != alert.TypeSlack {
|
if config.Endpoints[0].Alerts[0].Type != alert.TypeSlack {
|
||||||
@@ -1122,8 +1293,8 @@ endpoints:
|
|||||||
if config.Endpoints[0].Alerts[4].FailureThreshold != 10 {
|
if config.Endpoints[0].Alerts[4].FailureThreshold != 10 {
|
||||||
t.Errorf("The failure threshold of the alert should've been %d, but it was %d", 10, config.Endpoints[0].Alerts[4].FailureThreshold)
|
t.Errorf("The failure threshold of the alert should've been %d, but it was %d", 10, config.Endpoints[0].Alerts[4].FailureThreshold)
|
||||||
}
|
}
|
||||||
if config.Endpoints[0].Alerts[4].SuccessThreshold != 2 {
|
if config.Endpoints[0].Alerts[4].SuccessThreshold != 8 {
|
||||||
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[4].SuccessThreshold)
|
t.Errorf("The default success threshold of the alert should've been %d because it was explicitly overriden, but it was %d", 8, config.Endpoints[0].Alerts[4].SuccessThreshold)
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Endpoints[0].Alerts[5].Type != alert.TypeTelegram {
|
if config.Endpoints[0].Alerts[5].Type != alert.TypeTelegram {
|
||||||
@@ -1178,6 +1349,44 @@ endpoints:
|
|||||||
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[8].SuccessThreshold)
|
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[8].SuccessThreshold)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.Endpoints[0].Alerts[9].Type != alert.TypeJetBrainsSpace {
|
||||||
|
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeJetBrainsSpace, config.Endpoints[0].Alerts[9].Type)
|
||||||
|
}
|
||||||
|
if !config.Endpoints[0].Alerts[9].IsEnabled() {
|
||||||
|
t.Error("The alert should've been enabled")
|
||||||
|
}
|
||||||
|
if config.Endpoints[0].Alerts[9].FailureThreshold != 5 {
|
||||||
|
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 5, config.Endpoints[0].Alerts[9].FailureThreshold)
|
||||||
|
}
|
||||||
|
if config.Endpoints[0].Alerts[9].SuccessThreshold != 3 {
|
||||||
|
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[9].SuccessThreshold)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Endpoints[0].Alerts[10].Type != alert.TypeEmail {
|
||||||
|
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeEmail, config.Endpoints[0].Alerts[10].Type)
|
||||||
|
}
|
||||||
|
if !config.Endpoints[0].Alerts[10].IsEnabled() {
|
||||||
|
t.Error("The alert should've been enabled")
|
||||||
|
}
|
||||||
|
if config.Endpoints[0].Alerts[10].FailureThreshold != 3 {
|
||||||
|
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[10].FailureThreshold)
|
||||||
|
}
|
||||||
|
if config.Endpoints[0].Alerts[10].SuccessThreshold != 2 {
|
||||||
|
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[10].SuccessThreshold)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Endpoints[0].Alerts[11].Type != alert.TypeGotify {
|
||||||
|
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeGotify, config.Endpoints[0].Alerts[11].Type)
|
||||||
|
}
|
||||||
|
if !config.Endpoints[0].Alerts[11].IsEnabled() {
|
||||||
|
t.Error("The alert should've been enabled")
|
||||||
|
}
|
||||||
|
if config.Endpoints[0].Alerts[11].FailureThreshold != 3 {
|
||||||
|
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[11].FailureThreshold)
|
||||||
|
}
|
||||||
|
if config.Endpoints[0].Alerts[11].SuccessThreshold != 2 {
|
||||||
|
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[11].SuccessThreshold)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlertAndMultipleAlertsOfSameTypeWithOverriddenParameters(t *testing.T) {
|
func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlertAndMultipleAlertsOfSameTypeWithOverriddenParameters(t *testing.T) {
|
||||||
@@ -1201,6 +1410,8 @@ endpoints:
|
|||||||
- type: slack
|
- type: slack
|
||||||
enabled: false
|
enabled: false
|
||||||
failure-threshold: 30
|
failure-threshold: 30
|
||||||
|
provider-override:
|
||||||
|
webhook-url: https://example.com
|
||||||
conditions:
|
conditions:
|
||||||
- "[STATUS] == 200"
|
- "[STATUS] == 200"
|
||||||
`))
|
`))
|
||||||
@@ -1214,7 +1425,7 @@ endpoints:
|
|||||||
if config.Alerting == nil {
|
if config.Alerting == nil {
|
||||||
t.Fatal("config.Alerting shouldn't have been nil")
|
t.Fatal("config.Alerting shouldn't have been nil")
|
||||||
}
|
}
|
||||||
if config.Alerting.Slack == nil || !config.Alerting.Slack.IsValid() {
|
if config.Alerting.Slack == nil || config.Alerting.Slack.Validate() != nil {
|
||||||
t.Fatal("Slack alerting config should've been valid")
|
t.Fatal("Slack alerting config should've been valid")
|
||||||
}
|
}
|
||||||
// Endpoints
|
// Endpoints
|
||||||
@@ -1285,6 +1496,7 @@ endpoints:
|
|||||||
t.Fatal("PagerDuty alerting config should've been set to nil, because its IsValid() method returned false and therefore alerting.Config.SetAlertingProviderToNil() should've been called")
|
t.Fatal("PagerDuty alerting config should've been set to nil, because its IsValid() method returned false and therefore alerting.Config.SetAlertingProviderToNil() should've been called")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseAndValidateConfigBytesWithInvalidPushoverAlertingConfig(t *testing.T) {
|
func TestParseAndValidateConfigBytesWithInvalidPushoverAlertingConfig(t *testing.T) {
|
||||||
config, err := parseAndValidateConfigBytes([]byte(`
|
config, err := parseAndValidateConfigBytes([]byte(`
|
||||||
alerting:
|
alerting:
|
||||||
@@ -1341,17 +1553,18 @@ endpoints:
|
|||||||
if config.Alerting.Custom == nil {
|
if config.Alerting.Custom == nil {
|
||||||
t.Fatal("Custom alerting config shouldn't have been nil")
|
t.Fatal("Custom alerting config shouldn't have been nil")
|
||||||
}
|
}
|
||||||
if !config.Alerting.Custom.IsValid() {
|
if err = config.Alerting.Custom.Validate(); err != nil {
|
||||||
t.Fatal("Custom alerting config should've been valid")
|
t.Fatal("Custom alerting config should've been valid")
|
||||||
}
|
}
|
||||||
if config.Alerting.Custom.GetAlertStatePlaceholderValue(true) != "RESOLVED" {
|
cfg, _ := config.Alerting.Custom.GetConfig("", &alert.Alert{ProviderOverride: map[string]any{"client": map[string]any{"insecure": true}}})
|
||||||
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for RESOLVED should've been 'RESOLVED', got", config.Alerting.Custom.GetAlertStatePlaceholderValue(true))
|
if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, true) != "RESOLVED" {
|
||||||
|
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for RESOLVED should've been 'RESOLVED', got", config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, true))
|
||||||
}
|
}
|
||||||
if config.Alerting.Custom.GetAlertStatePlaceholderValue(false) != "TRIGGERED" {
|
if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, false) != "TRIGGERED" {
|
||||||
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'TRIGGERED', got", config.Alerting.Custom.GetAlertStatePlaceholderValue(false))
|
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'TRIGGERED', got", config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, false))
|
||||||
}
|
}
|
||||||
if config.Alerting.Custom.ClientConfig.Insecure {
|
if !cfg.ClientConfig.Insecure {
|
||||||
t.Errorf("ClientConfig.Insecure should have been %v, got %v", false, config.Alerting.Custom.ClientConfig.Insecure)
|
t.Errorf("ClientConfig.Insecure should have been %v, got %v", true, cfg.ClientConfig.Insecure)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1378,7 +1591,7 @@ endpoints:
|
|||||||
t.Error("expected no error, got", err.Error())
|
t.Error("expected no error, got", err.Error())
|
||||||
}
|
}
|
||||||
if config == nil {
|
if config == nil {
|
||||||
t.Fatal("Config shouldn't have been nil")
|
t.Fatal("DefaultConfig shouldn't have been nil")
|
||||||
}
|
}
|
||||||
if config.Alerting == nil {
|
if config.Alerting == nil {
|
||||||
t.Fatal("config.Alerting shouldn't have been nil")
|
t.Fatal("config.Alerting shouldn't have been nil")
|
||||||
@@ -1386,13 +1599,14 @@ endpoints:
|
|||||||
if config.Alerting.Custom == nil {
|
if config.Alerting.Custom == nil {
|
||||||
t.Fatal("Custom alerting config shouldn't have been nil")
|
t.Fatal("Custom alerting config shouldn't have been nil")
|
||||||
}
|
}
|
||||||
if !config.Alerting.Custom.IsValid() {
|
if err = config.Alerting.Custom.Validate(); err != nil {
|
||||||
t.Fatal("Custom alerting config should've been valid")
|
t.Fatal("Custom alerting config should've been valid")
|
||||||
}
|
}
|
||||||
if config.Alerting.Custom.GetAlertStatePlaceholderValue(true) != "operational" {
|
cfg, _ := config.Alerting.Custom.GetConfig("", &alert.Alert{})
|
||||||
|
if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, true) != "operational" {
|
||||||
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for RESOLVED should've been 'operational'")
|
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for RESOLVED should've been 'operational'")
|
||||||
}
|
}
|
||||||
if config.Alerting.Custom.GetAlertStatePlaceholderValue(false) != "partial_outage" {
|
if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, false) != "partial_outage" {
|
||||||
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'partial_outage'")
|
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'partial_outage'")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1418,7 +1632,7 @@ endpoints:
|
|||||||
t.Error("expected no error, got", err.Error())
|
t.Error("expected no error, got", err.Error())
|
||||||
}
|
}
|
||||||
if config == nil {
|
if config == nil {
|
||||||
t.Fatal("Config shouldn't have been nil")
|
t.Fatal("DefaultConfig shouldn't have been nil")
|
||||||
}
|
}
|
||||||
if config.Alerting == nil {
|
if config.Alerting == nil {
|
||||||
t.Fatal("config.Alerting shouldn't have been nil")
|
t.Fatal("config.Alerting shouldn't have been nil")
|
||||||
@@ -1426,13 +1640,14 @@ endpoints:
|
|||||||
if config.Alerting.Custom == nil {
|
if config.Alerting.Custom == nil {
|
||||||
t.Fatal("Custom alerting config shouldn't have been nil")
|
t.Fatal("Custom alerting config shouldn't have been nil")
|
||||||
}
|
}
|
||||||
if !config.Alerting.Custom.IsValid() {
|
if err := config.Alerting.Custom.Validate(); err != nil {
|
||||||
t.Fatal("Custom alerting config should've been valid")
|
t.Fatal("Custom alerting config should've been valid")
|
||||||
}
|
}
|
||||||
if config.Alerting.Custom.GetAlertStatePlaceholderValue(true) != "RESOLVED" {
|
cfg, _ := config.Alerting.Custom.GetConfig("", &alert.Alert{})
|
||||||
|
if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, true) != "RESOLVED" {
|
||||||
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for RESOLVED should've been 'RESOLVED'")
|
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for RESOLVED should've been 'RESOLVED'")
|
||||||
}
|
}
|
||||||
if config.Alerting.Custom.GetAlertStatePlaceholderValue(false) != "partial_outage" {
|
if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, false) != "partial_outage" {
|
||||||
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'partial_outage'")
|
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'partial_outage'")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1450,6 +1665,99 @@ endpoints:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseAndValidateConfigBytesWithDuplicateEndpointName(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
shouldError bool
|
||||||
|
config string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "same-name-no-group",
|
||||||
|
shouldError: true,
|
||||||
|
config: `
|
||||||
|
endpoints:
|
||||||
|
- name: ep1
|
||||||
|
url: https://twin.sh/health
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"
|
||||||
|
- name: ep1
|
||||||
|
url: https://twin.sh/health
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "same-name-different-group",
|
||||||
|
shouldError: false,
|
||||||
|
config: `
|
||||||
|
endpoints:
|
||||||
|
- name: ep1
|
||||||
|
url: https://twin.sh/health
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"
|
||||||
|
- name: ep1
|
||||||
|
group: g1
|
||||||
|
url: https://twin.sh/health
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "same-name-same-group",
|
||||||
|
shouldError: true,
|
||||||
|
config: `
|
||||||
|
endpoints:
|
||||||
|
- name: ep1
|
||||||
|
group: g1
|
||||||
|
url: https://twin.sh/health
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"
|
||||||
|
- name: ep1
|
||||||
|
group: g1
|
||||||
|
url: https://twin.sh/health
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "same-name-different-endpoint-type",
|
||||||
|
shouldError: true,
|
||||||
|
config: `
|
||||||
|
external-endpoints:
|
||||||
|
- name: ep1
|
||||||
|
token: "12345678"
|
||||||
|
|
||||||
|
endpoints:
|
||||||
|
- name: ep1
|
||||||
|
url: https://twin.sh/health
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "same-name-different-group-different-endpoint-type",
|
||||||
|
shouldError: false,
|
||||||
|
config: `
|
||||||
|
external-endpoints:
|
||||||
|
- name: ep1
|
||||||
|
group: gr1
|
||||||
|
token: "12345678"
|
||||||
|
|
||||||
|
endpoints:
|
||||||
|
- name: ep1
|
||||||
|
url: https://twin.sh/health
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
|
_, err := parseAndValidateConfigBytes([]byte(scenario.config))
|
||||||
|
if scenario.shouldError && err == nil {
|
||||||
|
t.Error("should've returned an error")
|
||||||
|
} else if !scenario.shouldError && err != nil {
|
||||||
|
t.Error("shouldn't have returned an error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseAndValidateConfigBytesWithInvalidStorageConfig(t *testing.T) {
|
func TestParseAndValidateConfigBytesWithInvalidStorageConfig(t *testing.T) {
|
||||||
_, err := parseAndValidateConfigBytes([]byte(`
|
_, err := parseAndValidateConfigBytes([]byte(`
|
||||||
storage:
|
storage:
|
||||||
@@ -1500,7 +1808,7 @@ endpoints:
|
|||||||
func TestParseAndValidateConfigBytesWithValidSecurityConfig(t *testing.T) {
|
func TestParseAndValidateConfigBytesWithValidSecurityConfig(t *testing.T) {
|
||||||
const expectedUsername = "admin"
|
const expectedUsername = "admin"
|
||||||
const expectedPasswordHash = "JDJhJDEwJHRiMnRFakxWazZLdXBzRERQazB1TE8vckRLY05Yb1hSdnoxWU0yQ1FaYXZRSW1McmladDYu"
|
const expectedPasswordHash = "JDJhJDEwJHRiMnRFakxWazZLdXBzRERQazB1TE8vckRLY05Yb1hSdnoxWU0yQ1FaYXZRSW1McmladDYu"
|
||||||
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`debug: true
|
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`
|
||||||
security:
|
security:
|
||||||
basic:
|
basic:
|
||||||
username: "%s"
|
username: "%s"
|
||||||
@@ -1515,7 +1823,7 @@ endpoints:
|
|||||||
t.Error("expected no error, got", err.Error())
|
t.Error("expected no error, got", err.Error())
|
||||||
}
|
}
|
||||||
if config == nil {
|
if config == nil {
|
||||||
t.Fatal("Config shouldn't have been nil")
|
t.Fatal("DefaultConfig shouldn't have been nil")
|
||||||
}
|
}
|
||||||
if config.Security == nil {
|
if config.Security == nil {
|
||||||
t.Fatal("config.Security shouldn't have been nil")
|
t.Fatal("config.Security shouldn't have been nil")
|
||||||
@@ -1548,7 +1856,7 @@ endpoints:
|
|||||||
t.Error("expected no error, got", err.Error())
|
t.Error("expected no error, got", err.Error())
|
||||||
}
|
}
|
||||||
if config == nil {
|
if config == nil {
|
||||||
t.Fatal("Config shouldn't have been nil")
|
t.Fatal("DefaultConfig shouldn't have been nil")
|
||||||
}
|
}
|
||||||
if config.Endpoints[0].URL != "https://twin.sh/health" {
|
if config.Endpoints[0].URL != "https://twin.sh/health" {
|
||||||
t.Errorf("URL should have been %s", "https://twin.sh/health")
|
t.Errorf("URL should have been %s", "https://twin.sh/health")
|
||||||
@@ -1563,39 +1871,51 @@ endpoints:
|
|||||||
|
|
||||||
func TestParseAndValidateConfigBytesWithNoEndpoints(t *testing.T) {
|
func TestParseAndValidateConfigBytesWithNoEndpoints(t *testing.T) {
|
||||||
_, err := parseAndValidateConfigBytes([]byte(``))
|
_, err := parseAndValidateConfigBytes([]byte(``))
|
||||||
if err != ErrNoEndpointInConfig {
|
if !errors.Is(err, ErrNoEndpointInConfig) {
|
||||||
t.Error("The error returned should have been of type ErrNoEndpointInConfig")
|
t.Error("The error returned should have been of type ErrNoEndpointInConfig")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetAlertingProviderByAlertType(t *testing.T) {
|
func TestGetAlertingProviderByAlertType(t *testing.T) {
|
||||||
alertingConfig := &alerting.Config{
|
alertingConfig := &alerting.Config{
|
||||||
Custom: &custom.AlertProvider{},
|
AWSSimpleEmailService: &awsses.AlertProvider{},
|
||||||
Discord: &discord.AlertProvider{},
|
Custom: &custom.AlertProvider{},
|
||||||
Email: &email.AlertProvider{},
|
Discord: &discord.AlertProvider{},
|
||||||
GitHub: &github.AlertProvider{},
|
Email: &email.AlertProvider{},
|
||||||
GoogleChat: &googlechat.AlertProvider{},
|
Gitea: &gitea.AlertProvider{},
|
||||||
Matrix: &matrix.AlertProvider{},
|
GitHub: &github.AlertProvider{},
|
||||||
Mattermost: &mattermost.AlertProvider{},
|
GitLab: &gitlab.AlertProvider{},
|
||||||
Messagebird: &messagebird.AlertProvider{},
|
GoogleChat: &googlechat.AlertProvider{},
|
||||||
Ntfy: &ntfy.AlertProvider{},
|
Gotify: &gotify.AlertProvider{},
|
||||||
Opsgenie: &opsgenie.AlertProvider{},
|
JetBrainsSpace: &jetbrainsspace.AlertProvider{},
|
||||||
PagerDuty: &pagerduty.AlertProvider{},
|
Matrix: &matrix.AlertProvider{},
|
||||||
Pushover: &pushover.AlertProvider{},
|
Mattermost: &mattermost.AlertProvider{},
|
||||||
Slack: &slack.AlertProvider{},
|
Messagebird: &messagebird.AlertProvider{},
|
||||||
Telegram: &telegram.AlertProvider{},
|
Ntfy: &ntfy.AlertProvider{},
|
||||||
Twilio: &twilio.AlertProvider{},
|
Opsgenie: &opsgenie.AlertProvider{},
|
||||||
Teams: &teams.AlertProvider{},
|
PagerDuty: &pagerduty.AlertProvider{},
|
||||||
|
Pushover: &pushover.AlertProvider{},
|
||||||
|
Slack: &slack.AlertProvider{},
|
||||||
|
Telegram: &telegram.AlertProvider{},
|
||||||
|
Teams: &teams.AlertProvider{},
|
||||||
|
TeamsWorkflows: &teamsworkflows.AlertProvider{},
|
||||||
|
Twilio: &twilio.AlertProvider{},
|
||||||
|
Zulip: &zulip.AlertProvider{},
|
||||||
}
|
}
|
||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
alertType alert.Type
|
alertType alert.Type
|
||||||
expected provider.AlertProvider
|
expected provider.AlertProvider
|
||||||
}{
|
}{
|
||||||
|
{alertType: alert.TypeAWSSES, expected: alertingConfig.AWSSimpleEmailService},
|
||||||
{alertType: alert.TypeCustom, expected: alertingConfig.Custom},
|
{alertType: alert.TypeCustom, expected: alertingConfig.Custom},
|
||||||
{alertType: alert.TypeDiscord, expected: alertingConfig.Discord},
|
{alertType: alert.TypeDiscord, expected: alertingConfig.Discord},
|
||||||
{alertType: alert.TypeEmail, expected: alertingConfig.Email},
|
{alertType: alert.TypeEmail, expected: alertingConfig.Email},
|
||||||
|
{alertType: alert.TypeGitea, expected: alertingConfig.Gitea},
|
||||||
{alertType: alert.TypeGitHub, expected: alertingConfig.GitHub},
|
{alertType: alert.TypeGitHub, expected: alertingConfig.GitHub},
|
||||||
|
{alertType: alert.TypeGitLab, expected: alertingConfig.GitLab},
|
||||||
{alertType: alert.TypeGoogleChat, expected: alertingConfig.GoogleChat},
|
{alertType: alert.TypeGoogleChat, expected: alertingConfig.GoogleChat},
|
||||||
|
{alertType: alert.TypeGotify, expected: alertingConfig.Gotify},
|
||||||
|
{alertType: alert.TypeJetBrainsSpace, expected: alertingConfig.JetBrainsSpace},
|
||||||
{alertType: alert.TypeMatrix, expected: alertingConfig.Matrix},
|
{alertType: alert.TypeMatrix, expected: alertingConfig.Matrix},
|
||||||
{alertType: alert.TypeMattermost, expected: alertingConfig.Mattermost},
|
{alertType: alert.TypeMattermost, expected: alertingConfig.Mattermost},
|
||||||
{alertType: alert.TypeMessagebird, expected: alertingConfig.Messagebird},
|
{alertType: alert.TypeMessagebird, expected: alertingConfig.Messagebird},
|
||||||
@@ -1605,8 +1925,10 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
|
|||||||
{alertType: alert.TypePushover, expected: alertingConfig.Pushover},
|
{alertType: alert.TypePushover, expected: alertingConfig.Pushover},
|
||||||
{alertType: alert.TypeSlack, expected: alertingConfig.Slack},
|
{alertType: alert.TypeSlack, expected: alertingConfig.Slack},
|
||||||
{alertType: alert.TypeTelegram, expected: alertingConfig.Telegram},
|
{alertType: alert.TypeTelegram, expected: alertingConfig.Telegram},
|
||||||
{alertType: alert.TypeTwilio, expected: alertingConfig.Twilio},
|
|
||||||
{alertType: alert.TypeTeams, expected: alertingConfig.Teams},
|
{alertType: alert.TypeTeams, expected: alertingConfig.Teams},
|
||||||
|
{alertType: alert.TypeTeamsWorkflows, expected: alertingConfig.TeamsWorkflows},
|
||||||
|
{alertType: alert.TypeTwilio, expected: alertingConfig.Twilio},
|
||||||
|
{alertType: alert.TypeZulip, expected: alertingConfig.Zulip},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(string(scenario.alertType), func(t *testing.T) {
|
t.Run(string(scenario.alertType), func(t *testing.T) {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ type Checker struct {
|
|||||||
lastCheck time.Time
|
lastCheck time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c Checker) Check() bool {
|
func (c *Checker) Check() bool {
|
||||||
return client.CanCreateTCPConnection(c.Target, &client.Config{Timeout: 5 * time.Second})
|
return client.CanCreateTCPConnection(c.Target, &client.Config{Timeout: 5 * time.Second})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
32
config/endpoint/common.go
Normal file
32
config/endpoint/common.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package endpoint
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrEndpointWithNoName is the error with which Gatus will panic if an endpoint is configured with no name
|
||||||
|
ErrEndpointWithNoName = errors.New("you must specify a name for each endpoint")
|
||||||
|
|
||||||
|
// ErrEndpointWithInvalidNameOrGroup is the error with which Gatus will panic if an endpoint has an invalid character where it shouldn't
|
||||||
|
ErrEndpointWithInvalidNameOrGroup = errors.New("endpoint name and group must not have \" or \\")
|
||||||
|
)
|
||||||
|
|
||||||
|
// validateEndpointNameGroupAndAlerts validates the name, group and alerts of an endpoint
|
||||||
|
func validateEndpointNameGroupAndAlerts(name, group string, alerts []*alert.Alert) error {
|
||||||
|
if len(name) == 0 {
|
||||||
|
return ErrEndpointWithNoName
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(name, "\"\\") || strings.ContainsAny(group, "\"\\") {
|
||||||
|
return ErrEndpointWithInvalidNameOrGroup
|
||||||
|
}
|
||||||
|
for _, endpointAlert := range alerts {
|
||||||
|
if err := endpointAlert.ValidateAndSetDefaults(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user