Compare commits
1400 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 005985387e | |||
| 55d7bcdf2e | |||
| b79fb09fe5 | |||
| 46499e13a0 | |||
| 43b0772e7d | |||
| 6d807e322e | |||
| efa25c3498 | |||
| ee96ba8721 | |||
| e0bdda5225 | |||
| fc07f15b67 | |||
| 168dafe5bb | |||
| ca2c6899ed | |||
| da3607106c | |||
| e3e951aec8 | |||
| a4cb2acd24 | |||
| 467e811fd5 | |||
| 11292b5f1e | |||
| 4cd35ef51d | |||
| bb62a400f1 | |||
| b750fcd209 | |||
| 62af9e842f | |||
| 2049601be5 | |||
| abf5fbee9e | |||
| 8ad8a05535 | |||
| 3f94e16950 | |||
| 46b24b849f | |||
| a1f7bd7b73 | |||
| 7e122a9fd9 | |||
| 52c142ec81 | |||
| 18e90d0e63 | |||
| 64b4c53b4e | |||
| 9fd134ca9c | |||
| d9e0ee04f9 | |||
| 5227da3407 | |||
| 541a70584d | |||
| ea2b7c4bdf | |||
| 9d8928dee0 | |||
| 41085757f9 | |||
| 5b3e0c8074 | |||
| 975ac3592e | |||
| dd839be918 | |||
| 1c99386807 | |||
| fa3e5dcc6e | |||
| 0bba77ab2b | |||
| 31073365ed | |||
| 69dbe4fa23 | |||
| 9157b5bf67 | |||
| 1ddaf5f3e5 | |||
| e1675cc747 | |||
| 47246ddcf7 | |||
| c259364edf | |||
| b650518ccc | |||
| d69844dc13 | |||
| 8aae277113 | |||
| c44d998fb3 | |||
| e88f47f0f4 | |||
| 19713c2d79 | |||
| 6eca0201ce | |||
| f2a82e0681 | |||
| 9a12adbaf5 | |||
| efbb739a44 | |||
| 78c9a1bd41 | |||
| 0440deb6b5 | |||
| 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 | |||
| 2f6b8f23f7 | |||
| 0a37a61619 | |||
| 70f9f8738c | |||
| a725e7e770 | |||
| fc29c45e5f | |||
| 6a8c308af7 | |||
| 0e33556775 | |||
| 6bb65f4eec | |||
| 9142ff837c | |||
| 11eb7fb02e | |||
| 271b836160 | |||
| c42f3ef787 | |||
| c7a774b213 | |||
| 36207490b2 | |||
| 5eebe6d9cc | |||
| ecda4a9987 | |||
| 162dd9bc7c | |||
| 60b0f3fa29 | |||
| 0cf7621162 | |||
| 8d27864aaa | |||
| 3bf880a199 | |||
| 719f684982 | |||
| a3df822df3 | |||
| d62a6c5054 | |||
| fcf8b5d86f | |||
| 5bbd240dd9 | |||
| 7e163c3fcf | |||
| 1dd0ecf10b | |||
| 3350e81443 | |||
| c3e1835dd6 | |||
| 2a45a151da | |||
| 74cde8ae8d | |||
| 669877baf4 | |||
| 447e140479 | |||
| 6908199716 | |||
| b12f652553 | |||
| 83edca6e80 | |||
| 636688b43e | |||
| 4fdb55d632 | |||
| a05daeda2e | |||
| 0bd0c1fd15 | |||
| eb3ca71c72 | |||
| 37325cd78a | |||
| f6e7e346b6 | |||
| b5e742acde | |||
| 685351a025 | |||
| ee8e0c4b40 | |||
| fb94eea914 | |||
| a69ccfdb08 | |||
| 018f723e78 | |||
| 038c8c8d8e | |||
| f8f61deb2c | |||
| 32a15decfd | |||
| 0dba6e8674 | |||
| 0c92534432 | |||
| 6ab8899dc6 | |||
| 819abf4263 | |||
| 6950a080df | |||
| 7d6923730e | |||
| 542da61215 | |||
| 45fe7beb6d | |||
| 26611b7793 | |||
| a29cf158dd | |||
| 9d14e3011b | |||
| d13998d13d | |||
| f6a39f6df0 | |||
| 9e2006910d | |||
| 6e4b88dc6e | |||
| 277e805dbb | |||
| 941c10ca45 | |||
| 21f62f362f | |||
| d75180c341 | |||
| a82b883276 | |||
| 24e207c0c6 | |||
| 90bb8f7b5f | |||
| 0db92f46da | |||
| 0ffa03f42d | |||
| e61a42220c | |||
| 78dccc90e1 | |||
| 6bdd3c94fe | |||
| 4225d22369 | |||
| 3059e3e028 | |||
| 87740e74a6 | |||
| 8e14302765 | |||
| 844f417ea1 | |||
| 2f7f782f11 | |||
| 37bea336ca | |||
| 616a654b27 | |||
| a1c8422c2f | |||
| 947173bf71 | |||
| a81a83e2d4 | |||
| 4599fe4da7 | |||
| 19e90cdf31 | |||
| ecc0636a59 | |||
| 27502acd10 | |||
| 51255e33ea | |||
| be0962112e | |||
| dfcea93080 | |||
| a5f135c675 | |||
| 9acace7d37 | |||
| 184c7f23ad | |||
| 5ce890bbff | |||
| b0bec5ff94 | |||
| e503dd3861 | |||
| f2d51f3e50 | |||
| a1a2fba326 | |||
| fdd51869a3 | |||
| f6a621da28 | |||
| 2346a6ee4f | |||
| 741109f25d | |||
| d058d7a54b | |||
| 7dccf5f08c | |||
| 9e46e3972d | |||
| 9fc8374a4d | |||
| 1aeb045703 | |||
| cdec353744 | |||
| 080563bd4f | |||
| bcb565ba37 | |||
| 2327854641 | |||
| 79eacc5e50 | |||
| 048a1d4a88 | |||
| c09ee0b82f | |||
| 7908eea2df | |||
| f8140e0d96 | |||
| 4f569b7a0e | |||
| e9f46c58f8 | |||
| 502e159dca | |||
| cdbf5902c7 | |||
| c7f80f1301 | |||
| eb4e22e76b | |||
| f37a0ef2d7 | |||
| 114b78c75c | |||
| d24ff5bd07 | |||
| c172e733be | |||
| f1ce83c211 | |||
| 64f4dac705 | |||
| 861c443842 | |||
| b801cc5801 | |||
| f1711b5c0b | |||
| 0ebd6c7a67 | |||
| 967124eb43 | |||
| fa47a199e5 | |||
| 1f84f2afa0 | |||
| ed3683cb32 | |||
| 6e92c0eb40 | |||
| cd927f630b | |||
| c6c9bc8fa5 | |||
| a3facc3887 | |||
| 991d7e876d | |||
| 3b7fb083ca | |||
| ebdf5bde49 | |||
| d4983733f5 | |||
| fed021826a | |||
| 8f9eca51c0 | |||
| e13730f119 | |||
| 22d74a5ea8 | |||
| fe4d9821f3 | |||
| d01a5d418b | |||
| 34f8cd1eca | |||
| d101c17136 | |||
| ade3d05983 | |||
| fbab0ef7ca | |||
| 9121ec1cc8 | |||
| 6ddf1258e5 | |||
| 490610ccfd | |||
| 0eb6958085 | |||
| d20a41c7a7 | |||
| 4c18e0d602 | |||
| da24b7e8ac | |||
| c619066e25 | |||
| 3688dd6e6f | |||
| fc778300be | |||
| df560ad872 | |||
| de9c366777 | |||
| 6a5fec2c55 | |||
| 01d2ed3f02 | |||
| 92b85ee1ab | |||
| a789deb8c2 | |||
| e5a94979dd | |||
| 3c0ea72a5c | |||
| d17e893131 | |||
| 7ea34ec8a8 | |||
| f6b99f34db | |||
| 37495ac3f3 | |||
| 557f696f88 | |||
| c86492dbfd | |||
| 8a4db600c9 | |||
| 02879e2645 | |||
| 00b56ecefd | |||
| 47dd18a0b5 | |||
| 1a708ebca2 | |||
| 5f8e62dad0 | |||
| b74f7758dc | |||
| 899c19b2d7 | |||
| 35038a63c4 | |||
| 7b2af3c514 | |||
| 4ab7428599 | |||
| be88af5d48 | |||
| 5bb3f6d0a9 | |||
| 17c14a7243 | |||
| f44d4055e6 | |||
| 38054f57e5 | |||
| 33ce0e99b5 | |||
| b5e6466c1d | |||
| f89ecd5c64 | |||
| e434178a5c | |||
| 7a3ee1b557 | |||
| e51abaf5bd | |||
| 46d6d6c733 | |||
| d9f86f1155 | |||
| 01484832fc | |||
| 4857b43771 | |||
| 52d7cb6f04 | |||
| 5c6bf84106 | |||
| c84ae1cd55 | |||
| daf8e3a16f | |||
| df719958cf | |||
| 2be81b8e1a | |||
| 4bed86dec9 | |||
| 072cf20cc6 | |||
| cca421e283 | |||
| a044f1d274 | |||
| 9de6334f21 | |||
| f01b66f083 | |||
| 262d436533 | |||
| b8ab17eee1 | |||
| 7bbd7bcee3 | |||
| 4865d12147 | |||
| 0713ca1c1a | |||
| dce202d0be | |||
| 4673d147db | |||
| 0943c45ae6 | |||
| 798c4248ff | |||
| 1bce4e727e | |||
| 1aa94a3365 | |||
| 319f460553 | |||
| 7daf2b5cac | |||
| f0fc275f67 | |||
| 04a682eddc | |||
| 2fb807632c | |||
| 4b339bca37 | |||
| 09c3a6c72b | |||
| 755c8bb43a | |||
| 9d4a639f31 | |||
| 60e6b2b039 | |||
| 37f3f964ea | |||
| 4a1a8ff380 | |||
| 6787fed062 | |||
| ab2bee9c4b | |||
| d1ced94030 | |||
| a3e35c862c | |||
| 0193a200b8 | |||
| 7224464202 | |||
| c457aadcab | |||
| f38b12d55b | |||
| e4c9ad8796 | |||
| 5be1465b13 | |||
| 7215aa4bd6 | |||
| 829a9c2679 | |||
| dfcdc57a18 | |||
| 43e8c57701 | |||
| 076f5c45e8 | |||
| 6d3c3d0892 | |||
| e620fd1214 | |||
| 5807d76c2f | |||
| 017847240d | |||
| c873b0ba0c | |||
| 6f3150d936 | |||
| 0792f5490b | |||
| 326ea1c3d1 | |||
| fea95b8479 | |||
| 6d64c3c250 | |||
| 2b9d3e99d3 | |||
| 9a5f245440 | |||
| 793172c783 | |||
| 9f343bacf7 | |||
| c31cb7540d | |||
| f9efa28223 | |||
| 2cbb35fe3b | |||
| f23fcbedb8 | |||
| ad10f975b4 | |||
| 1c03524ca8 | |||
| 4af135d1fb | |||
| 93b5a867bb | |||
| f899f41d16 | |||
| ab52676f23 | |||
| 27fc784411 | |||
| d929c09c56 | |||
| cff06e38cb | |||
| 5b1aeaeb0c | |||
| 90e9b55109 | |||
| cf9c00a2ad | |||
| fbdb5a3f0f | |||
| dde930bed7 | |||
| a9fc876173 | |||
| 08b31ba263 | |||
| 9ede992e4e | |||
| dcb997f501 | |||
| c8efdac23a | |||
| e307d1ab35 | |||
| e6c6b4e06f | |||
| 5843c58a36 | |||
| 5281f8068d | |||
| 86d5dabf90 | |||
| a81c81e42c | |||
| bec2820969 | |||
| 0bf2271a73 | |||
| bd4b91bbbd | |||
| fdec317df0 | |||
| 8970ad5ad5 | |||
| c4255e65bc | |||
| fcf046cbe8 | |||
| 6932edc6d0 | |||
| 3f961a7408 | |||
| 4d0f3b6997 | |||
| 5a06599d96 | |||
| d2a73a3590 | |||
| 932ecc436a | |||
| 1613274cb0 | |||
| 0b4720d94b | |||
| 16df341581 | |||
| a848776a34 | |||
| 681b1c63f1 | |||
| 51a4b63fb5 | |||
| 3a7977d086 | |||
| c682520dd9 | |||
| 24b7258338 | |||
| 89e6e4abd8 | |||
| 4700f54798 | |||
| 9ca4442e6a | |||
| ce6f58f403 | |||
| c466542990 | |||
| 9cb8c37298 | |||
| f6f7e15735 | |||
| c712133df0 | |||
| fc016bd682 | |||
| 0e586e4152 | |||
| ea425773e0 | |||
| 10949b11f4 | |||
| 0e022d04b1 | |||
| 3319e158b5 | |||
| f467a77ae2 | |||
| 56048725e4 | |||
| 425c1d3674 | |||
| 8838f6f2ad | |||
| 139a78b2f6 | |||
| dd5e3ee7ee | |||
| 9f8f7bb45e | |||
| 27e246859e | |||
| f1688ac87a | |||
| 54779e1db8 | |||
| be9087bee3 | |||
| 4ab5724fc1 | |||
| 45a47940ad | |||
| 1777d69495 | |||
| 8676b83fe3 | |||
| b67701ff6d | |||
| eb9acef9b5 | |||
| 00aec70fb8 | |||
| 18d28fc362 | |||
| eb3545e994 | |||
| ad71c8db34 | |||
| 6da281bf4e | |||
| 3dd8ba1a99 | |||
| 2503d21522 | |||
| 36a3419aec | |||
| 7353fad809 | |||
| b5a26caa08 | |||
| d7206546af | |||
| 4fa86a2c46 | |||
| a6ed23b169 | |||
| d9201c5084 | |||
| d0ba8261e3 | |||
| f89447badc | |||
| 14c42f6e6d | |||
| 7a05bdcb82 | |||
| 5eb7763052 | |||
| 8c73ae6035 | |||
| 6954e9dde7 | |||
| f6336eac4e | |||
| 0331c18401 | |||
| 1f8fd29dad | |||
| 5877c4b2be | |||
| c6d0809ecc | |||
| cf8a601104 | |||
| 96a0eebc0c | |||
| 2af3425b9e | |||
| 31bf2aeb80 | |||
| 787f6f0d74 | |||
| 17a431321c | |||
| 05e9add16d | |||
| c4ef56511d | |||
| cfa2c8ef6f | |||
| f36b6863ce | |||
| 24482cf7a0 | |||
| d661a0ea6d | |||
| a0ec6941ab | |||
| 5e711fb3b9 | |||
| ab66e7ec8a | |||
| 08aba6cd51 | |||
| d3805cd77a | |||
| dd70136e6c | |||
| a94c480c22 | |||
| 10fd4ecd6b | |||
| 9287e2f9e2 | |||
| 257f859825 | |||
| 3a4ab62ddd | |||
| a4e9d8e9b0 | |||
| 3be6d04d29 | |||
| b59ff6f89e | |||
| 813fea93ee | |||
| 8f50e44b45 | |||
| fb2448c15a | |||
| db575aad13 | |||
| 6ed93d4b82 | |||
| 634123d723 | |||
| 75c25ac053 | |||
| 8088736d6e | |||
| 6c45f5b99c | |||
| 422eaa6d37 | |||
| c423afb0bf | |||
| 835f768337 | |||
| b3d0e54af2 | |||
| 1451cdfa64 | |||
| 53cc9d88e5 | |||
| a6bc0039e9 | |||
| adbc2c5ad7 | |||
| 154bc7dbc6 | |||
| 2d3fe9795f | |||
| d19f564e4e | |||
| babe7b0be9 | |||
| dee04945d0 | |||
| bf455fb7cc | |||
| dfd2f7943f | |||
| fece11540b | |||
| ac43ef4ab7 | |||
| bc25fea1c0 | |||
| 30cb7b6ec8 | |||
| 289d834587 | |||
| 428e415616 | |||
| 0d284c2494 | |||
| 4a46a5ae9e | |||
| df3a2016ff | |||
| dda83761b5 | |||
| 882444e0d5 | |||
| fa4736c672 | |||
| dc173b29bc | |||
| c3a4ce1eb4 | |||
| 044f0454f8 | |||
| 9bd5c38a96 | |||
| d6b4c2394a | |||
| 9fe4678193 | |||
| f41560cd3e | |||
| d7de795a9f | |||
| f79e87844b | |||
| c57a930bf3 | |||
| d86afb2381 | |||
| d69df41ef0 | |||
| cbfdc359d3 | |||
| f3822a949d | |||
| db5fc8bc11 | |||
| 7a68920889 | |||
| effad21c64 | |||
| dafd547656 | |||
| 20487790ca | |||
| b58094e10b | |||
| bacf7d841b | |||
| 06ef7f9efe | |||
| bfbe928173 | |||
| 7887ca66bc | |||
| a917b31591 | |||
| 556f559221 | |||
| 670e35949e | |||
| 67642b130c | |||
| 7c9e2742c1 | |||
| 66e312b72f | |||
| 6e38114e27 | |||
| 9c99cc522d | |||
| becc17202b | |||
| c61b406483 | |||
| 44c36a8a5e | |||
| cfa7b0ed51 | |||
| ce433b57e0 | |||
| d67c2ec251 | |||
| 74e7bdae8c | |||
| 8b0f432ffb | |||
| 2577b196be | |||
| 30b17f7bca | |||
| a626b00b59 | |||
| 0e7f1d19f4 | |||
| 82d697b032 | |||
| 470e3a3ebc | |||
| bab69478dd | |||
| f28d1b61f0 | |||
| 75d8b40327 | |||
| e8adc75afe | |||
| 6942f0f8e0 | |||
| 733760dc06 | |||
| 1a8452f375 | |||
| 1cbee5b732 | |||
| d65cebb1fb | |||
| 0b6fc6b520 | |||
| 968b960283 | |||
| 77ba2169cf | |||
| f6c32a90ac | |||
| 932a67d9e7 | |||
| ee414df03f | |||
| 718f8260bb | |||
| 3cbe068fc1 | |||
| 4ada6ee7c9 | |||
| 1e28905c8d | |||
| 4dbde07b85 | |||
| 8f35679299 | |||
| 897e1590ac | |||
| 48ef7c7313 | |||
| 941cc03f19 | |||
| a4c429a0e0 | |||
| 2074697efa | |||
| 2ce02b0d7f | |||
| 8edca65041 | |||
| cdbc075439 | |||
| 949fd65cb7 | |||
| 54d06b8688 | |||
| 07b1a2eafb | |||
| d3e0ef6519 | |||
| 9cd6355056 | |||
| 7416384efe | |||
| 23fb69fca9 | |||
| be4e9aba1e | |||
| ac0d00fdb5 | |||
| 3293222cd6 | |||
| 892f3ada6f | |||
| f22a79eb7d | |||
| 911deb91d1 | |||
| bcd4105af3 | |||
| 423ada68b3 | |||
| 70fa17349f | |||
| e640ede709 | |||
| fb3447eaf3 | |||
| 46cf616a57 | |||
| cf48072167 | |||
| 97dd868ae8 | |||
| c18b2728c9 | |||
| b3fd290e4d | |||
| 89e23a986c | |||
| c454c868f6 | |||
| 6d82a54518 | |||
| bd3c01a4f4 | |||
| 43150ae484 | |||
| acb6757dc8 | |||
| 2037d9aca6 | |||
| c700154f5e | |||
| aac72e3741 | |||
| 1a597f92ba | |||
| 56fedcedd1 | |||
| 6bdce4fe29 | |||
| 381488a1b2 | |||
| 42a909c1ad | |||
| 5a4fa6f2b0 | |||
| bbbfe7f466 | |||
| 7cf1750f86 | |||
| b88ae5fcf6 | |||
| 8516c41b43 | |||
| b90a64e2a6 | |||
| 627173e64f | |||
| 8b5e5f54cc | |||
| 2c95cce7b3 | |||
| 2ef9329fa6 | |||
| 9384373f43 | |||
| d3a81a2d57 | |||
| fed32d3909 | |||
| c1d9006aaf | |||
| 7126d36d85 | |||
| 677c7faffe | |||
| 8dedcf7c74 | |||
| a4c69d6fc3 | |||
| 943d0a19d1 | |||
| fd08c8b1e5 | |||
| 393147c300 | |||
| f73e8a56ef | |||
| 4203355edc | |||
| 5cc1c11b1a | |||
| 796228466d | |||
| 23ba9795a6 | |||
| 1291e86a6f | |||
| 14316cfd31 | |||
| 670272f411 | |||
| ffc3e644c5 | |||
| bc42d15625 | |||
| 20594b902c | |||
| 0a3267e499 | |||
| 9c8bf2b69e | |||
| bd1eb7c61b | |||
| e6335da94f | |||
| 1498b6d8a2 | |||
| 7aed826d65 | |||
| 9b68582622 | |||
| a1afeea56b | |||
| 38de0ec9cd | |||
| 9d8a3f1574 | |||
| b904afb8b5 | |||
| 5bf560221f | |||
| 574dd50b98 | |||
| 35c33620a5 | |||
| fc0c3499f4 | |||
| d03271d128 | |||
| 0560b98de4 | |||
| ca87547430 | |||
| e214d56af1 | |||
| 8997eeef05 | |||
| 5e00752c5a | |||
| f9d132c369 | |||
| ca977fefa8 | |||
| d07d3434a6 | |||
| 2131fa4412 | |||
| 81aeb7a48e | |||
| eaf395738d | |||
| f6f1ecf623 | |||
| 177081cf54 | |||
| 651bfcba22 | |||
| 3cd1953c6c | |||
| 9dd4e7047d | |||
| 067ab78666 | |||
| 28acaeb067 | |||
| 749aeb9e42 | |||
| 8e02572880 | |||
| 1f6f0ce426 | |||
| 7bc381b356 | |||
| 18420c2d60 | |||
| 0b4dc34c57 | |||
| 030212c156 | |||
| 63b0ac8b35 | |||
| 263b2f0f94 | |||
| db23bd9073 | |||
| 40dc1cc270 | |||
| 67c3bf6e5e | |||
| 57ef931d38 | |||
| e3038f0e80 | |||
| 8106832d69 | |||
| 758428b312 | |||
| 77de4c4742 | |||
| a85c5d5486 | |||
| c7d554efa5 | |||
| f3afdf2977 | |||
| 19a0ba7271 | |||
| 4a4c88ae17 | |||
| 253e6f8338 | |||
| 1d412678ff | |||
| 48c7514fa5 | |||
| d7b437595c | |||
| 50f530a05c | |||
| 2a632e8f87 | |||
| 857ad584e7 | |||
| 8b3b2f70bf | |||
| 4308f2c1ef | |||
| 425c93ed8f | |||
| 752e82d80b | |||
| e91462ce41 | |||
| 347297a8ea | |||
| 56dbe2fea0 | |||
| ebcca4317d | |||
| e6355dfee8 | |||
| 7309888db5 | |||
| f60eee86ee | |||
| e46acb885c | |||
| 24da853820 | |||
| 4e5a86031f | |||
| 7f6f127f4f | |||
| 12c352254f | |||
| 2b9d986932 | |||
| cdbf5f6c6f | |||
| 33562e97f4 | |||
| c9acc83141 | |||
| 8c4c360472 | |||
| 2c8714f1fa | |||
| 8ec256edbf | |||
| a48ec41bca | |||
| 541e0264ab | |||
| f945e4b8a2 | |||
| 076b92a2b4 | |||
| 02e9f74a04 | |||
| b37dd5e819 | |||
| 1775f80ffe | |||
| 3187db1e9a | |||
| 932eab00a0 | |||
| c842ac2343 | |||
| 6320237326 | |||
| 8fe9d013b5 | |||
| c094c06e56 | |||
| f961bf961e | |||
| 404a3cea64 | |||
| d000460a99 | |||
| 455fae05c1 | |||
| 85dff34350 | |||
| 99fa632021 | |||
| cafcc9d45b | |||
| dc929dac70 | |||
| 42825b62fb | |||
| a89bb392ed | |||
| e7c4d03c22 | |||
| 4e6bf91651 | |||
| de31a7a62e | |||
| 7f0543ebd2 | |||
| 9b893aa4e0 | |||
| 50435f4030 | |||
| 9649d80388 | |||
| 8c3ab1eac2 | |||
| cdb5ba080a | |||
| 0a145da912 | |||
| b603cdb0ea | |||
| 11d1f24ceb | |||
| c74472d332 | |||
| 78a1262e7c | |||
| 7ff8907eda | |||
| 1d21f5889d | |||
| d7d904ae5f | |||
| 7390895514 | |||
| 2873d96b9f | |||
| ea9623f695 | |||
| 9cdef02bdc | |||
| 16229592a2 | |||
| 52ad4ee9e5 | |||
| b47c6dc408 | |||
| 8e2a2c4dbc | |||
| 8698736e7d | |||
| cd1430f043 | |||
| 79bef8d391 | |||
| 9196f57487 | |||
| 1e0d9e184c | |||
| bc42497cb7 | |||
| b0b0ab574d | |||
| e369484e5f | |||
| d18618449f | |||
| 9186049589 | |||
| 4362831f71 | |||
| 99f558d43e | |||
| 4a3d5944b6 | |||
| 688456d7cf | |||
| 431fb3e9f2 | |||
| a1679ddc5e | |||
| d8d8e8720b | |||
| 4a0d9d058a | |||
| c8ccf9b352 | |||
| 45c966fbca | |||
| d8d4756ef3 | |||
| 1e9c54cc0f | |||
| 80570688e1 | |||
| 43e6e3e8f5 | |||
| 8b4c5c20f3 | |||
| 6f6db36b0f | |||
| 467874de10 | |||
| 8337f41425 | |||
| 601d676e34 | |||
| fbb5d48bf7 | |||
| 119b80edc0 | |||
| 99e8cfb1ce | |||
| dcbbec7931 | |||
| 2ccd656386 | |||
| 5755f3a699 | |||
| 911d809376 | |||
| 752c872d3b | |||
| 67a3e4e330 | |||
| 668ed3b1a2 | |||
| dc6cb8fc1d | |||
| f1aa5191bf | |||
| bc6ca2ebd0 | |||
| 30801938b2 | |||
| ddddd405bb | |||
| 2207dd9c32 | |||
| 3204a79eb6 | |||
| e9ac115a95 | |||
| c90c786f39 | |||
| f10e2ac639 | |||
| c2d899f2a3 | |||
| 7415d8e361 | |||
| 298dcc4790 | |||
| 2f2890c093 | |||
| e463aec5f6 | |||
| 6b3e11a47c | |||
| 0985e3bed8 | |||
| 6d8fd267de | |||
| e89bb932ea | |||
| 77737dbab6 | |||
| 271c3dc91d | |||
| 5860a27ab5 | |||
| 819093cb7e | |||
| 855c106e9b | |||
| 04de262268 | |||
| 26d8870cab | |||
| aec867ae69 | |||
| a515335c15 | |||
| 96dd9809f4 | |||
| 20b4c86023 | |||
| 6f8a728c5f | |||
| 1669f91a2d | |||
| 3d265afa37 | |||
| aa050e9292 | |||
| 91a9fa5274 | |||
| 150e33a1c7 | |||
| da9a6282c7 | |||
| 907b611505 | |||
| eaf205eded | |||
| 329bd86e09 | |||
| 19bb831fbf | |||
| bca38bd372 | |||
| 9095649afb | |||
| 4ff391bcf3 | |||
| c27cb7af08 | |||
| f1c0bbe73c | |||
| 2b4fc7138a | |||
| 7f647305ce | |||
| fb5477f50b | |||
| fc4858b1a8 | |||
| 0af8837710 | |||
| 8ca9fd7db5 | |||
| 029c87df89 | |||
| 4d24a4d647 | |||
| 5eb289c4d3 | |||
| dbd95b1bbd | |||
| 678b78ac01 | |||
| f8e1fc25a4 | |||
| c3bc375ff1 | |||
| 8ecaf4cfd5 | |||
| 143e6cb0da | |||
| be75ef9c58 | |||
| 118b62c979 | |||
| d8cfcd04f1 | |||
| 641c2029f7 | |||
| 1209c477df | |||
| 6ca1e39b90 | |||
| d206d63151 | |||
| bc4380db19 | |||
| 3eb8299316 | |||
| a83f6c7619 | |||
| bbf39e70fd | |||
| ddbf391c39 | |||
| 2eca1ab145 | |||
| f0995f696f | |||
| 746032397e | |||
| cf923af230 | |||
| 416178fb28 | |||
| 10ab9265d9 | |||
| ef7b435f1b | |||
| dea469d6fc | |||
| 83a5813daf | |||
| c86173d46f | |||
| c23ba7b85d | |||
| 0541adec5e | |||
| eee5bc8f9d | |||
| 4d186c6e71 | |||
| 4be2273662 | |||
| ef9ba10e45 | |||
| b35747bf31 | |||
| 8cd82605dd | |||
| 7f0b862822 | |||
| 7f9fe9acdf | |||
| 46d476128d | |||
| e309ffb1b6 | |||
| 48a1e435e4 | |||
| 6c91bbcc4a | |||
| d1fe8d93ee | |||
| afdbb1ea77 | |||
| d58a256628 | |||
| 971967ae78 | |||
| 43504913b4 | |||
| f034bd15de | |||
| 35c232d925 | |||
| 9c8bfcd19f | |||
| 6a08c816e5 | |||
| da4fb10bfc | |||
| 35b9758b23 | |||
| 94eb3868e6 | |||
| 68f32b0fcc | |||
| 66946e20f0 | |||
| 64f1c41e0c | |||
| 199f14667e | |||
| c5e2b0f159 | |||
| a645c14452 | |||
| daf27fd6d0 | |||
| 978acf3cd2 | |||
| a870d3e43f | |||
| 66bf1c23df | |||
| 4f17bc1b14 | |||
| 5850ed82e4 | |||
| 53e1012ca1 | |||
| f36d99ef07 | |||
| 9af8a02f05 | |||
| c827eb7948 | |||
| c791986143 | |||
| 5f10a92c36 | |||
| f9706a98ed | |||
| 76d45d7eb8 | |||
| 1b23b0dd1d | |||
| 315791151e | |||
| 07312794c4 | |||
| 4dd57b45e9 | |||
| 3985c6c483 | |||
| 09ff879b3b | |||
| 10dfdd47a7 | |||
| 3a13dce3f0 | |||
| faff3dc8de | |||
| 7bfb583ac7 | |||
| 4c151bdf8f | |||
| 10310cf380 | |||
| 83eb696fe4 | |||
| 8789a4ad55 | |||
| 1fb2635226 | |||
| 1901dfcd81 | |||
| cef94299e5 | |||
| 7f21fdee68 | |||
| 85c7308448 | |||
| 7e35a6ebbd | |||
| 4ad5c7b99c | |||
| bc914e12b0 | |||
| 9f485b14e0 | |||
| 9039ef79cf | |||
| be72a73082 | |||
| 5433653cbb | |||
| 60dea90921 | |||
| e79c849e6d | |||
| 07ca8be732 | |||
| 4a84368d24 | |||
| 2150942876 | |||
| c23e21cb41 | |||
| 5699a1c236 | |||
| 573b5f89e1 | |||
| 108a88ae57 | |||
| 121369d9c0 | |||
| d1f24dbea4 | |||
| 27c8c552a2 | |||
| e0109e79af | |||
| 7d97e83875 | |||
| d50721c8f0 | |||
| d184786fd1 | |||
| f273ee4a27 | |||
| 37dc52f560 | |||
| b20e2789e1 | |||
| 7b5c40d68c | |||
| 39aaae8b50 | |||
| 79c60d834e | |||
| 3773f952a8 | |||
| 7699552c5b | |||
| aeb0fb35d8 | |||
| 44d7b9b82c | |||
| 5d29f944c4 | |||
| cf968cbf86 | |||
| 0909024e43 | |||
| d4d4ca236a | |||
| dbc893fbc4 | |||
| 3a0a8db898 | |||
| a658100da4 | |||
| f406206eae | |||
| ea6c788fa0 | |||
| 5df5e6755e | |||
| 7e0243ee29 | |||
| 7af67af086 | |||
| fff34fff58 | |||
| 799c3e9187 | |||
| c746579222 | |||
| 9ee6d77179 | |||
| 42659a0878 | |||
| 559d286121 | |||
| 38c76aefc2 | |||
| 81e6e0b188 | |||
| 3df8fc8c3c | |||
| 835704bf8a | |||
| 2066497553 | |||
| babc7f9f07 | |||
| d76caeeb35 | |||
| 8c73d0ce88 | |||
| 72e6d1cda9 | |||
| 4655e74c2a | |||
| 77ad91a297 | |||
| 0b1f20d0de | |||
| 4e3856c638 | |||
| 1bde98868e | |||
| c09cd9df7e | |||
| 147fbe04d7 | |||
| 947a28f45d | |||
| 8c61044e7c | |||
| 89ffc5a788 | |||
| 20515b5dd4 | |||
| 7dc200d887 | |||
| a32d98ab96 | |||
| dca517e077 | |||
| aa671bc72d | |||
| ce6cc9e18d | |||
| 1b995e0d48 | |||
| 030316b8d4 | |||
| 0a7988f2ff | |||
| 816bc95905 | |||
| 58b9b17944 | |||
| a66cfc094a | |||
| 8fd6eddc16 | |||
| 402525d572 | |||
| 68c8076c73 | |||
| 331b089afb | |||
| ea454eb3b2 | |||
| 3e9aba6cbb | |||
| 3fb7d27f3a | |||
| c0b1fefec8 | |||
| 9220a777bb | |||
| 70c9c4b87c | |||
| 148b7bbdfd | |||
| a556ca797b | |||
| 3d7dbf4bba | |||
| 230c8821ec | |||
| ecef3d9538 | |||
| 3ecfe4d322 | |||
| 6a3f65db7f | |||
| ed490669b1 | |||
| 187b38ee22 | |||
| 261fe27b2e | |||
| a29f175cba | |||
| f26dacaf22 | |||
| b4d81a9661 | |||
| 81c59f02d8 | |||
| 8101646ba5 | |||
| 93b7220d5f | |||
| 18d3236586 | |||
| bc16ae1794 | |||
| d019942278 | |||
| 8c5551f93b | |||
| 3b15feaf7d | |||
| 326ad2c80d | |||
| b4b9f5c1be | |||
| ac5ad9d173 | |||
| 7dcd462883 | |||
| 5655661778 | |||
| fac7b8551a | |||
| 59fed008e0 | |||
| 286e8f8590 | |||
| 4daf261d95 | |||
| b94f494b24 | |||
| 4c72746286 | |||
| ae2c4b1ea9 | |||
| abd49bca8e | |||
| 5820a17659 | |||
| a6a8cca427 | |||
| faf478be99 | |||
| f7c2905aa4 | |||
| e283c6eba1 | |||
| 39d3459555 | |||
| f0ceebf55d | |||
| c6f11e63e4 | |||
| fefc728201 | |||
| 75b7a41c9d | |||
| cf6a74f862 | |||
| 9f8a74f5b1 | |||
| 2888254bda | |||
| e31c017a00 | |||
| ed4ed520b7 | |||
| 6e1f888e39 | |||
| 4df1baf432 | |||
| 5a7164b17d | |||
| d4623f5c61 | |||
| 139e186ac2 | |||
| 8a0a2ef51f | |||
| 51ea912cf9 | |||
| db7c516819 | |||
| f893c0ee7f | |||
| 0454854f04 | |||
| 42dd6a1e88 | |||
| 64a160923b | |||
| dad09e780e | |||
| 2cb1600f94 | |||
| 37c4715453 | |||
| 4b57654592 | |||
| af6298de05 | |||
| 22fef4e9aa | |||
| 9a3c9e4d61 | |||
| 62f7bdbd63 | |||
| 04d6c8bb82 | |||
| e1721fa237 | |||
| 6f4cf69c4e | |||
| 6596d253aa | |||
| 857fe5eb8c | |||
| 8abcab6a8f | |||
| 0fd8bf4198 | |||
| 946101e995 | |||
| f930687b4a | |||
| 43aa31be58 | |||
| adfee25a22 | |||
| 1f241ecdb3 | |||
| 7849cc6dd4 | |||
| a62eab58ef | |||
| da92907873 | |||
| 937b136e60 | |||
| 12db0d7c40 | |||
| f50589e3c4 | |||
| 98221626d3 | |||
| 60e30da7e5 | |||
| b05ae1c2d2 | |||
| 3b309500c3 | |||
| bf2fbcb395 | |||
| fb90a0b299 | |||
| 72811311eb | |||
| cc43fec366 | |||
| eacd182302 | |||
| f8edf8862f | |||
| 4d211a7288 | |||
| 18b8290e86 | |||
| 2878ad6a27 | |||
| fe3e60dbd4 | |||
| 88d0d8a724 | |||
| 5803dca3a5 | |||
| f98c60467d | |||
| c51f35bcea | |||
| 02098a9742 | |||
| 3e2b56ba89 | |||
| 15b8f8a293 | |||
| 58327394dd | |||
| 49f5940a43 | |||
| 1f177902e6 | |||
| 6888ced1a7 | |||
| cc159fa8fb | |||
| 92cb9c86d1 | |||
| 1701ced07b | |||
| fe82465c19 | |||
| ab73c4666e | |||
| 16837562ea | |||
| 6b30125a78 | |||
| 32f05057a1 | |||
| 50cdc159db | |||
| c4f7370397 | |||
| b71edc57ca | |||
| 94a742bc23 | |||
| c90ca8fa85 | |||
| 753435dc37 | |||
| c9c076a959 | |||
| 66e48609b4 | |||
| 87d4b14b66 | |||
| 20267e686d | |||
| cc7acc780e | |||
| ab5330c44c | |||
| 0ed525e032 | |||
| 0bd58f8cd7 | |||
| b08e563b2a | |||
| e162740654 | |||
| 3620e5306c | |||
| 9cf118996d | |||
| 5f93894341 | |||
| fc88d4fd3d | |||
| a90b0ae565 | |||
| 8e71338a39 | |||
| df83060c30 | |||
| ffc50af67e | |||
| d1bb0cac16 | |||
| 339c1dfb04 | |||
| f979957166 | |||
| 894122fb29 | |||
| 40eb35aab2 | |||
| c4e02e3a75 | |||
| d612e6c4b9 | |||
| 8953cdfa88 | |||
| 46fbba25df | |||
| a3f2591bbf | |||
| c734e32e21 | |||
| fb93c33966 | |||
| 88c35e30b4 | |||
| f1497bec49 | |||
| 14ac4dfeca | |||
| a1267f4bcc | |||
| ee225c977f | |||
| f2a663c9d3 | |||
| 31b39bfd28 | |||
| 825906145f | |||
| 8a7aa96b97 | |||
| fd818bb7e8 | |||
| d57da0a1dd | |||
| 1c05871975 | |||
| ee479be716 | |||
| eea38c8618 | |||
| 7bb341fe92 | |||
| e93fefea60 | |||
| f4163880bb | |||
| c1c46b6b30 | |||
| b753443516 | |||
| f84f84086d |
8
.dockerignore
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
.examples
|
||||||
|
Dockerfile
|
||||||
|
.github
|
||||||
|
.idea
|
||||||
|
.git
|
||||||
|
web/app
|
||||||
|
*.db
|
||||||
|
testdata
|
||||||
41
.examples/docker-compose-grafana-prometheus/README.md
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
## Usage
|
||||||
|
Gatus exposes Prometheus metrics at `/metrics` if the `metrics` configuration option is set to `true`.
|
||||||
|
|
||||||
|
To run this example, all you need to do is execute the following command:
|
||||||
|
```console
|
||||||
|
docker-compose up
|
||||||
|
```
|
||||||
|
Once you've done the above, you should be able to access the Grafana dashboard at `http://localhost:3000`.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
## Queries
|
||||||
|
By default, this example has a Grafana dashboard with some panels, but for the sake of verbosity, you'll find
|
||||||
|
a list of simple queries below. Those make use of the `key` parameter, which is a concatenation of the endpoint's
|
||||||
|
group and name.
|
||||||
|
|
||||||
|
### Success rate
|
||||||
|
```
|
||||||
|
sum(rate(gatus_results_total{success="true"}[30s])) by (key) / sum(rate(gatus_results_total[30s])) by (key)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response time
|
||||||
|
```
|
||||||
|
gatus_results_duration_seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
### Total results per minute
|
||||||
|
```
|
||||||
|
sum(rate(gatus_results_total[5m])*60) by (key)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Total successful results per minute
|
||||||
|
```
|
||||||
|
sum(rate(gatus_results_total{success="true"}[5m])*60) by (key)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Total unsuccessful results per minute
|
||||||
|
```
|
||||||
|
sum(rate(gatus_results_total{success="false"}[5m])*60) by (key)
|
||||||
|
```
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
metrics: true
|
||||||
|
endpoints:
|
||||||
|
- name: website
|
||||||
|
url: https://twin.sh/health
|
||||||
|
interval: 5m
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"
|
||||||
|
|
||||||
|
- name: example
|
||||||
|
url: https://example.com/
|
||||||
|
interval: 5m
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"
|
||||||
|
|
||||||
|
- name: github
|
||||||
|
url: https://api.github.com/healthz
|
||||||
|
interval: 5m
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
version: "3.9"
|
||||||
|
services:
|
||||||
|
gatus:
|
||||||
|
container_name: gatus
|
||||||
|
image: twinproduction/gatus
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./config:/config
|
||||||
|
networks:
|
||||||
|
- metrics
|
||||||
|
|
||||||
|
prometheus:
|
||||||
|
container_name: prometheus
|
||||||
|
image: prom/prometheus:v2.14.0
|
||||||
|
restart: always
|
||||||
|
command: --config.file=/etc/prometheus/prometheus.yml
|
||||||
|
ports:
|
||||||
|
- "9090:9090"
|
||||||
|
volumes:
|
||||||
|
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
|
||||||
|
networks:
|
||||||
|
- metrics
|
||||||
|
|
||||||
|
grafana:
|
||||||
|
container_name: grafana
|
||||||
|
image: grafana/grafana:6.4.4
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
GF_SECURITY_ADMIN_PASSWORD: secret
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- ./grafana/grafana.ini/:/etc/grafana/grafana.ini:ro
|
||||||
|
- ./grafana/provisioning/:/etc/grafana/provisioning/:ro
|
||||||
|
networks:
|
||||||
|
- metrics
|
||||||
|
|
||||||
|
networks:
|
||||||
|
metrics:
|
||||||
|
driver: bridge
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
[paths]
|
||||||
|
|
||||||
|
[server]
|
||||||
|
|
||||||
|
[database]
|
||||||
|
|
||||||
|
[session]
|
||||||
|
|
||||||
|
[dataproxy]
|
||||||
|
|
||||||
|
[analytics]
|
||||||
|
reporting_enabled = false
|
||||||
|
|
||||||
|
[security]
|
||||||
|
|
||||||
|
[snapshots]
|
||||||
|
|
||||||
|
[dashboards]
|
||||||
|
|
||||||
|
[users]
|
||||||
|
allow_sign_up = false
|
||||||
|
default_theme = light
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
|
||||||
|
[auth.anonymous]
|
||||||
|
enabled = true
|
||||||
|
org_name = Main Org.
|
||||||
|
org_role = Admin
|
||||||
|
|
||||||
|
[auth.github]
|
||||||
|
|
||||||
|
[auth.google]
|
||||||
|
|
||||||
|
[auth.generic_oauth]
|
||||||
|
|
||||||
|
[auth.grafana_com]
|
||||||
|
|
||||||
|
[auth.proxy]
|
||||||
|
|
||||||
|
[auth.basic]
|
||||||
|
|
||||||
|
[auth.ldap]
|
||||||
|
|
||||||
|
[smtp]
|
||||||
|
|
||||||
|
[emails]
|
||||||
|
|
||||||
|
[log]
|
||||||
|
mode = console
|
||||||
|
|
||||||
|
[log.console]
|
||||||
|
|
||||||
|
[log.file]
|
||||||
|
|
||||||
|
[log.syslog]
|
||||||
|
|
||||||
|
[alerting]
|
||||||
|
|
||||||
|
[explore]
|
||||||
|
|
||||||
|
[metrics]
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
[metrics.graphite]
|
||||||
|
|
||||||
|
[tracing.jaeger]
|
||||||
|
|
||||||
|
[grafana_com]
|
||||||
|
|
||||||
|
[external_image_storage]
|
||||||
|
|
||||||
|
[external_image_storage.s3]
|
||||||
|
|
||||||
|
[external_image_storage.webdav]
|
||||||
|
|
||||||
|
[external_image_storage.gcs]
|
||||||
|
|
||||||
|
[external_image_storage.azure_blob]
|
||||||
|
|
||||||
|
[external_image_storage.local]
|
||||||
|
|
||||||
|
[rendering]
|
||||||
|
|
||||||
|
[enterprise]
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
providers:
|
||||||
|
- name: 'Prometheus'
|
||||||
|
orgId: 1
|
||||||
|
folder: ''
|
||||||
|
type: file
|
||||||
|
disableDeletion: false
|
||||||
|
editable: true
|
||||||
|
options:
|
||||||
|
path: /etc/grafana/provisioning/dashboards
|
||||||
@ -0,0 +1,582 @@
|
|||||||
|
{
|
||||||
|
"annotations": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"builtIn": 1,
|
||||||
|
"datasource": "-- Grafana --",
|
||||||
|
"enable": true,
|
||||||
|
"hide": true,
|
||||||
|
"iconColor": "rgba(0, 211, 255, 1)",
|
||||||
|
"name": "Annotations & Alerts",
|
||||||
|
"type": "dashboard"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"editable": true,
|
||||||
|
"gnetId": null,
|
||||||
|
"graphTooltip": 0,
|
||||||
|
"id": 3,
|
||||||
|
"links": [],
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"cacheTimeout": null,
|
||||||
|
"datasource": null,
|
||||||
|
"description": "Number of successful results compared to the total number of results during the current interval",
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 0,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"id": 9,
|
||||||
|
"links": [],
|
||||||
|
"options": {
|
||||||
|
"fieldOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"mean"
|
||||||
|
],
|
||||||
|
"defaults": {
|
||||||
|
"mappings": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"op": "=",
|
||||||
|
"text": "N/A",
|
||||||
|
"type": 1,
|
||||||
|
"value": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"max": 1,
|
||||||
|
"min": 0,
|
||||||
|
"nullValueMode": "connected",
|
||||||
|
"thresholds": [
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "semi-dark-orange",
|
||||||
|
"value": 0.6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "yellow",
|
||||||
|
"value": 0.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "dark-green",
|
||||||
|
"value": 0.95
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"unit": "percentunit"
|
||||||
|
},
|
||||||
|
"override": {},
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"orientation": "horizontal",
|
||||||
|
"showThresholdLabels": false,
|
||||||
|
"showThresholdMarkers": false
|
||||||
|
},
|
||||||
|
"pluginVersion": "6.4.4",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum(rate(gatus_results_total{success=\"true\"}[30s])) by (key) / sum(rate(gatus_results_total[30s])) by (key)",
|
||||||
|
"hide": false,
|
||||||
|
"legendFormat": "{{key}}",
|
||||||
|
"refId": "B"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"timeFrom": null,
|
||||||
|
"timeShift": null,
|
||||||
|
"title": "Success rate",
|
||||||
|
"type": "gauge"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"aliasColors": {},
|
||||||
|
"bars": false,
|
||||||
|
"cacheTimeout": null,
|
||||||
|
"dashLength": 10,
|
||||||
|
"dashes": false,
|
||||||
|
"datasource": null,
|
||||||
|
"fill": 1,
|
||||||
|
"fillGradient": 0,
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 12,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"id": 11,
|
||||||
|
"legend": {
|
||||||
|
"avg": false,
|
||||||
|
"current": false,
|
||||||
|
"max": false,
|
||||||
|
"min": false,
|
||||||
|
"show": true,
|
||||||
|
"total": false,
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"lines": true,
|
||||||
|
"linewidth": 1,
|
||||||
|
"links": [],
|
||||||
|
"nullPointMode": "null as zero",
|
||||||
|
"options": {
|
||||||
|
"dataLinks": []
|
||||||
|
},
|
||||||
|
"percentage": false,
|
||||||
|
"pluginVersion": "6.4.4",
|
||||||
|
"pointradius": 2,
|
||||||
|
"points": false,
|
||||||
|
"renderer": "flot",
|
||||||
|
"seriesOverrides": [],
|
||||||
|
"spaceLength": 10,
|
||||||
|
"stack": false,
|
||||||
|
"steppedLine": false,
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "gatus_results_duration_seconds",
|
||||||
|
"format": "time_series",
|
||||||
|
"instant": false,
|
||||||
|
"interval": "",
|
||||||
|
"intervalFactor": 1,
|
||||||
|
"legendFormat": "{{key}}",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"thresholds": [],
|
||||||
|
"timeFrom": null,
|
||||||
|
"timeRegions": [],
|
||||||
|
"timeShift": null,
|
||||||
|
"title": "Response time",
|
||||||
|
"tooltip": {
|
||||||
|
"shared": true,
|
||||||
|
"sort": 0,
|
||||||
|
"value_type": "individual"
|
||||||
|
},
|
||||||
|
"type": "graph",
|
||||||
|
"xaxis": {
|
||||||
|
"buckets": null,
|
||||||
|
"mode": "time",
|
||||||
|
"name": null,
|
||||||
|
"show": true,
|
||||||
|
"values": []
|
||||||
|
},
|
||||||
|
"yaxes": [
|
||||||
|
{
|
||||||
|
"format": "short",
|
||||||
|
"label": null,
|
||||||
|
"logBase": 1,
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"show": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"format": "short",
|
||||||
|
"label": null,
|
||||||
|
"logBase": 1,
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"show": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"yaxis": {
|
||||||
|
"align": false,
|
||||||
|
"alignLevel": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"aliasColors": {},
|
||||||
|
"bars": false,
|
||||||
|
"cacheTimeout": null,
|
||||||
|
"dashLength": 10,
|
||||||
|
"dashes": false,
|
||||||
|
"datasource": null,
|
||||||
|
"fill": 1,
|
||||||
|
"fillGradient": 0,
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 0,
|
||||||
|
"y": 8
|
||||||
|
},
|
||||||
|
"id": 10,
|
||||||
|
"legend": {
|
||||||
|
"avg": false,
|
||||||
|
"current": false,
|
||||||
|
"max": false,
|
||||||
|
"min": false,
|
||||||
|
"show": true,
|
||||||
|
"total": false,
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"lines": true,
|
||||||
|
"linewidth": 1,
|
||||||
|
"links": [],
|
||||||
|
"nullPointMode": "connected",
|
||||||
|
"options": {
|
||||||
|
"dataLinks": []
|
||||||
|
},
|
||||||
|
"percentage": false,
|
||||||
|
"pluginVersion": "6.4.4",
|
||||||
|
"pointradius": 2,
|
||||||
|
"points": true,
|
||||||
|
"renderer": "flot",
|
||||||
|
"seriesOverrides": [],
|
||||||
|
"spaceLength": 10,
|
||||||
|
"stack": false,
|
||||||
|
"steppedLine": false,
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum(rate(gatus_results_total{success=\"true\"}[30s])) by (key) / sum(rate(gatus_results_total[30s])) by (key)",
|
||||||
|
"format": "time_series",
|
||||||
|
"instant": false,
|
||||||
|
"interval": "",
|
||||||
|
"intervalFactor": 1,
|
||||||
|
"legendFormat": "{{key}}",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"thresholds": [],
|
||||||
|
"timeFrom": null,
|
||||||
|
"timeRegions": [],
|
||||||
|
"timeShift": null,
|
||||||
|
"title": "Success rate",
|
||||||
|
"tooltip": {
|
||||||
|
"shared": true,
|
||||||
|
"sort": 0,
|
||||||
|
"value_type": "individual"
|
||||||
|
},
|
||||||
|
"type": "graph",
|
||||||
|
"xaxis": {
|
||||||
|
"buckets": null,
|
||||||
|
"mode": "time",
|
||||||
|
"name": null,
|
||||||
|
"show": true,
|
||||||
|
"values": []
|
||||||
|
},
|
||||||
|
"yaxes": [
|
||||||
|
{
|
||||||
|
"format": "short",
|
||||||
|
"label": null,
|
||||||
|
"logBase": 1,
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"show": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"format": "short",
|
||||||
|
"label": null,
|
||||||
|
"logBase": 1,
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"show": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"yaxis": {
|
||||||
|
"align": false,
|
||||||
|
"alignLevel": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"aliasColors": {},
|
||||||
|
"bars": false,
|
||||||
|
"dashLength": 10,
|
||||||
|
"dashes": false,
|
||||||
|
"datasource": null,
|
||||||
|
"description": "Number of results per minute",
|
||||||
|
"fill": 1,
|
||||||
|
"fillGradient": 0,
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 12,
|
||||||
|
"y": 8
|
||||||
|
},
|
||||||
|
"id": 2,
|
||||||
|
"interval": "",
|
||||||
|
"legend": {
|
||||||
|
"alignAsTable": false,
|
||||||
|
"avg": false,
|
||||||
|
"current": false,
|
||||||
|
"hideEmpty": false,
|
||||||
|
"hideZero": false,
|
||||||
|
"max": false,
|
||||||
|
"min": false,
|
||||||
|
"rightSide": false,
|
||||||
|
"show": true,
|
||||||
|
"total": false,
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"lines": true,
|
||||||
|
"linewidth": 1,
|
||||||
|
"nullPointMode": "null",
|
||||||
|
"options": {
|
||||||
|
"dataLinks": []
|
||||||
|
},
|
||||||
|
"percentage": false,
|
||||||
|
"pointradius": 2,
|
||||||
|
"points": false,
|
||||||
|
"renderer": "flot",
|
||||||
|
"seriesOverrides": [],
|
||||||
|
"spaceLength": 10,
|
||||||
|
"stack": false,
|
||||||
|
"steppedLine": false,
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum(rate(gatus_results_total[5m])*60) by (key)",
|
||||||
|
"format": "time_series",
|
||||||
|
"hide": false,
|
||||||
|
"instant": false,
|
||||||
|
"interval": "30s",
|
||||||
|
"intervalFactor": 1,
|
||||||
|
"legendFormat": "{{key}}",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"thresholds": [],
|
||||||
|
"timeFrom": null,
|
||||||
|
"timeRegions": [],
|
||||||
|
"timeShift": null,
|
||||||
|
"title": "Total results per minute",
|
||||||
|
"tooltip": {
|
||||||
|
"shared": true,
|
||||||
|
"sort": 0,
|
||||||
|
"value_type": "individual"
|
||||||
|
},
|
||||||
|
"type": "graph",
|
||||||
|
"xaxis": {
|
||||||
|
"buckets": null,
|
||||||
|
"mode": "time",
|
||||||
|
"name": null,
|
||||||
|
"show": true,
|
||||||
|
"values": []
|
||||||
|
},
|
||||||
|
"yaxes": [
|
||||||
|
{
|
||||||
|
"decimals": null,
|
||||||
|
"format": "short",
|
||||||
|
"label": null,
|
||||||
|
"logBase": 1,
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"show": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"format": "short",
|
||||||
|
"label": null,
|
||||||
|
"logBase": 1,
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"show": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"yaxis": {
|
||||||
|
"align": false,
|
||||||
|
"alignLevel": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"aliasColors": {},
|
||||||
|
"bars": false,
|
||||||
|
"dashLength": 10,
|
||||||
|
"dashes": false,
|
||||||
|
"datasource": null,
|
||||||
|
"fill": 1,
|
||||||
|
"fillGradient": 0,
|
||||||
|
"gridPos": {
|
||||||
|
"h": 7,
|
||||||
|
"w": 12,
|
||||||
|
"x": 0,
|
||||||
|
"y": 16
|
||||||
|
},
|
||||||
|
"id": 5,
|
||||||
|
"legend": {
|
||||||
|
"avg": false,
|
||||||
|
"current": false,
|
||||||
|
"max": false,
|
||||||
|
"min": false,
|
||||||
|
"show": true,
|
||||||
|
"total": false,
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"lines": true,
|
||||||
|
"linewidth": 1,
|
||||||
|
"nullPointMode": "null",
|
||||||
|
"options": {
|
||||||
|
"dataLinks": []
|
||||||
|
},
|
||||||
|
"percentage": false,
|
||||||
|
"pointradius": 2,
|
||||||
|
"points": false,
|
||||||
|
"renderer": "flot",
|
||||||
|
"seriesOverrides": [],
|
||||||
|
"spaceLength": 10,
|
||||||
|
"stack": false,
|
||||||
|
"steppedLine": false,
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum(rate(gatus_results_total{success=\"true\"}[5m])*60) by (key)",
|
||||||
|
"instant": false,
|
||||||
|
"interval": "30s",
|
||||||
|
"legendFormat": "{{key}}",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"thresholds": [],
|
||||||
|
"timeFrom": null,
|
||||||
|
"timeRegions": [],
|
||||||
|
"timeShift": null,
|
||||||
|
"title": "Successful results per minute",
|
||||||
|
"tooltip": {
|
||||||
|
"shared": true,
|
||||||
|
"sort": 0,
|
||||||
|
"value_type": "individual"
|
||||||
|
},
|
||||||
|
"type": "graph",
|
||||||
|
"xaxis": {
|
||||||
|
"buckets": null,
|
||||||
|
"mode": "time",
|
||||||
|
"name": null,
|
||||||
|
"show": true,
|
||||||
|
"values": []
|
||||||
|
},
|
||||||
|
"yaxes": [
|
||||||
|
{
|
||||||
|
"decimals": null,
|
||||||
|
"format": "short",
|
||||||
|
"label": null,
|
||||||
|
"logBase": 1,
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"show": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"format": "short",
|
||||||
|
"label": null,
|
||||||
|
"logBase": 1,
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"show": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"yaxis": {
|
||||||
|
"align": false,
|
||||||
|
"alignLevel": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"aliasColors": {},
|
||||||
|
"bars": false,
|
||||||
|
"dashLength": 10,
|
||||||
|
"dashes": false,
|
||||||
|
"datasource": null,
|
||||||
|
"fill": 1,
|
||||||
|
"fillGradient": 0,
|
||||||
|
"gridPos": {
|
||||||
|
"h": 7,
|
||||||
|
"w": 12,
|
||||||
|
"x": 12,
|
||||||
|
"y": 16
|
||||||
|
},
|
||||||
|
"id": 3,
|
||||||
|
"legend": {
|
||||||
|
"avg": false,
|
||||||
|
"current": false,
|
||||||
|
"max": false,
|
||||||
|
"min": false,
|
||||||
|
"show": true,
|
||||||
|
"total": false,
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"lines": true,
|
||||||
|
"linewidth": 1,
|
||||||
|
"nullPointMode": "null",
|
||||||
|
"options": {
|
||||||
|
"dataLinks": []
|
||||||
|
},
|
||||||
|
"percentage": false,
|
||||||
|
"pointradius": 2,
|
||||||
|
"points": false,
|
||||||
|
"renderer": "flot",
|
||||||
|
"seriesOverrides": [],
|
||||||
|
"spaceLength": 10,
|
||||||
|
"stack": false,
|
||||||
|
"steppedLine": false,
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum(rate(gatus_results_total{success=\"false\"}[5m])*60) by (key)",
|
||||||
|
"interval": "30s",
|
||||||
|
"legendFormat": "{{key}} ",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"thresholds": [],
|
||||||
|
"timeFrom": null,
|
||||||
|
"timeRegions": [],
|
||||||
|
"timeShift": null,
|
||||||
|
"title": "Unsuccessful results per minute",
|
||||||
|
"tooltip": {
|
||||||
|
"shared": true,
|
||||||
|
"sort": 0,
|
||||||
|
"value_type": "individual"
|
||||||
|
},
|
||||||
|
"type": "graph",
|
||||||
|
"xaxis": {
|
||||||
|
"buckets": null,
|
||||||
|
"mode": "time",
|
||||||
|
"name": null,
|
||||||
|
"show": true,
|
||||||
|
"values": []
|
||||||
|
},
|
||||||
|
"yaxes": [
|
||||||
|
{
|
||||||
|
"format": "short",
|
||||||
|
"label": null,
|
||||||
|
"logBase": 1,
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"show": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"format": "short",
|
||||||
|
"label": null,
|
||||||
|
"logBase": 1,
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"show": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"yaxis": {
|
||||||
|
"align": false,
|
||||||
|
"alignLevel": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"refresh": "1m",
|
||||||
|
"schemaVersion": 20,
|
||||||
|
"style": "dark",
|
||||||
|
"tags": [],
|
||||||
|
"templating": {
|
||||||
|
"list": []
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"from": "now-1h",
|
||||||
|
"to": "now"
|
||||||
|
},
|
||||||
|
"timepicker": {
|
||||||
|
"refresh_intervals": [
|
||||||
|
"5s",
|
||||||
|
"10s",
|
||||||
|
"30s",
|
||||||
|
"1m",
|
||||||
|
"5m",
|
||||||
|
"15m",
|
||||||
|
"30m",
|
||||||
|
"1h",
|
||||||
|
"2h",
|
||||||
|
"1d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"timezone": "",
|
||||||
|
"title": "Gatus",
|
||||||
|
"uid": "KPI7Qj1Wk",
|
||||||
|
"version": 2
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
datasources:
|
||||||
|
- name: Prometheus
|
||||||
|
type: prometheus
|
||||||
|
access: proxy
|
||||||
|
url: http://prometheus:9090
|
||||||
|
isDefault: true
|
||||||
|
version: 1
|
||||||
|
editable: false
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
scrape_configs:
|
||||||
|
- job_name: gatus
|
||||||
|
scrape_interval: 10s
|
||||||
|
static_configs:
|
||||||
|
- targets:
|
||||||
|
- gatus:8080
|
||||||
15
.examples/docker-compose-mattermost/config/config.yaml
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
alerting:
|
||||||
|
mattermost:
|
||||||
|
webhook-url: "http://mattermost:8065/hooks/tokengoeshere"
|
||||||
|
insecure: true
|
||||||
|
|
||||||
|
endpoints:
|
||||||
|
- name: example
|
||||||
|
url: https://example.org
|
||||||
|
interval: 1m
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"
|
||||||
|
alerts:
|
||||||
|
- type: mattermost
|
||||||
|
description: "health check failed 3 times in a row"
|
||||||
|
send-on-resolved: true
|
||||||
23
.examples/docker-compose-mattermost/docker-compose.yml
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
version: "3.9"
|
||||||
|
services:
|
||||||
|
gatus:
|
||||||
|
container_name: gatus
|
||||||
|
image: twinproduction/gatus:latest
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./config:/config
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
|
||||||
|
mattermost:
|
||||||
|
container_name: mattermost
|
||||||
|
image: mattermost/mattermost-preview:5.26.0
|
||||||
|
ports:
|
||||||
|
- "8065:8065"
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
driver: bridge
|
||||||
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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
endpoints:
|
||||||
|
- name: check-if-api-is-healthy
|
||||||
|
group: backend
|
||||||
|
url: "https://twin.sh/health"
|
||||||
|
interval: 5m
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"
|
||||||
|
- "[BODY].status == UP"
|
||||||
|
- "[RESPONSE_TIME] < 1000"
|
||||||
|
|
||||||
|
- name: check-if-website-is-pingable
|
||||||
|
url: "icmp://example.org"
|
||||||
|
interval: 1m
|
||||||
|
conditions:
|
||||||
|
- "[CONNECTED] == true"
|
||||||
|
|
||||||
|
- name: check-domain-expiration
|
||||||
|
url: "https://example.org"
|
||||||
|
interval: 6h
|
||||||
|
conditions:
|
||||||
|
- "[DOMAIN_EXPIRATION] > 720h"
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
endpoints:
|
||||||
|
- name: make-sure-html-rendering-works
|
||||||
|
group: frontend
|
||||||
|
url: "https://example.org"
|
||||||
|
interval: 5m
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"
|
||||||
|
- "[BODY] == pat(*<h1>Example Domain</h1>*)" # Check for header in HTML page
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
metrics: true
|
||||||
|
ui:
|
||||||
|
header: Example Company
|
||||||
|
link: https://example.org
|
||||||
|
buttons:
|
||||||
|
- name: "Home"
|
||||||
|
link: "https://example.org"
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
version: "3.8"
|
||||||
|
services:
|
||||||
|
gatus:
|
||||||
|
image: twinproduction/gatus:latest
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
- GATUS_CONFIG_PATH=/config
|
||||||
|
volumes:
|
||||||
|
- ./config:/config
|
||||||
42
.examples/docker-compose-postgres-storage/config/config.yaml
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
storage:
|
||||||
|
type: postgres
|
||||||
|
path: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable"
|
||||||
|
|
||||||
|
endpoints:
|
||||||
|
- name: back-end
|
||||||
|
group: core
|
||||||
|
url: "https://example.org/"
|
||||||
|
interval: 5m
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"
|
||||||
|
- "[CERTIFICATE_EXPIRATION] > 48h"
|
||||||
|
|
||||||
|
- name: monitoring
|
||||||
|
group: internal
|
||||||
|
url: "https://example.org/"
|
||||||
|
interval: 5m
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"
|
||||||
|
|
||||||
|
- name: nas
|
||||||
|
group: internal
|
||||||
|
url: "https://example.org/"
|
||||||
|
interval: 5m
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"
|
||||||
|
|
||||||
|
- name: example-dns-query
|
||||||
|
url: "8.8.8.8" # Address of the DNS server to use
|
||||||
|
interval: 5m
|
||||||
|
dns:
|
||||||
|
query-name: "example.com"
|
||||||
|
query-type: "A"
|
||||||
|
conditions:
|
||||||
|
- "[BODY] == 93.184.215.14"
|
||||||
|
- "[DNS_RCODE] == NOERROR"
|
||||||
|
|
||||||
|
- name: icmp-ping
|
||||||
|
url: "icmp://example.org"
|
||||||
|
interval: 1m
|
||||||
|
conditions:
|
||||||
|
- "[CONNECTED] == true"
|
||||||
33
.examples/docker-compose-postgres-storage/docker-compose.yml
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
version: "3.9"
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres
|
||||||
|
volumes:
|
||||||
|
- ./data/db:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=gatus
|
||||||
|
- POSTGRES_USER=username
|
||||||
|
- POSTGRES_PASSWORD=password
|
||||||
|
networks:
|
||||||
|
- web
|
||||||
|
|
||||||
|
gatus:
|
||||||
|
image: twinproduction/gatus:latest
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=username
|
||||||
|
- POSTGRES_PASSWORD=password
|
||||||
|
- POSTGRES_DB=gatus
|
||||||
|
volumes:
|
||||||
|
- ./config:/config
|
||||||
|
networks:
|
||||||
|
- web
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
|
||||||
|
networks:
|
||||||
|
web:
|
||||||
42
.examples/docker-compose-sqlite-storage/config/config.yaml
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
storage:
|
||||||
|
type: sqlite
|
||||||
|
path: /data/data.db
|
||||||
|
|
||||||
|
endpoints:
|
||||||
|
- name: back-end
|
||||||
|
group: core
|
||||||
|
url: "https://example.org/"
|
||||||
|
interval: 5m
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"
|
||||||
|
- "[CERTIFICATE_EXPIRATION] > 48h"
|
||||||
|
|
||||||
|
- name: monitoring
|
||||||
|
group: internal
|
||||||
|
url: "https://example.org/"
|
||||||
|
interval: 5m
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"
|
||||||
|
|
||||||
|
- name: nas
|
||||||
|
group: internal
|
||||||
|
url: "https://example.org/"
|
||||||
|
interval: 5m
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"
|
||||||
|
|
||||||
|
- name: example-dns-query
|
||||||
|
url: "8.8.8.8" # Address of the DNS server to use
|
||||||
|
interval: 5m
|
||||||
|
dns:
|
||||||
|
query-name: "example.com"
|
||||||
|
query-type: "A"
|
||||||
|
conditions:
|
||||||
|
- "[BODY] == 93.184.215.14"
|
||||||
|
- "[DNS_RCODE] == NOERROR"
|
||||||
|
|
||||||
|
- name: icmp-ping
|
||||||
|
url: "icmp://example.org"
|
||||||
|
interval: 1m
|
||||||
|
conditions:
|
||||||
|
- "[CONNECTED] == true"
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
version: "3.9"
|
||||||
|
services:
|
||||||
|
gatus:
|
||||||
|
image: twinproduction/gatus:latest
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./config:/config
|
||||||
|
- ./data:/data/
|
||||||
6
.examples/docker-compose/config/config.yaml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
endpoints:
|
||||||
|
- name: example
|
||||||
|
url: https://example.org
|
||||||
|
interval: 30s
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"
|
||||||
8
.examples/docker-compose/docker-compose.yml
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
version: "3.8"
|
||||||
|
services:
|
||||||
|
gatus:
|
||||||
|
image: twinproduction/gatus:latest
|
||||||
|
ports:
|
||||||
|
- 8080:8080
|
||||||
|
volumes:
|
||||||
|
- ./config:/config
|
||||||
2
.examples/docker-minimal/Dockerfile
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
FROM twinproduction/gatus
|
||||||
|
ADD config.yaml ./config/config.yaml
|
||||||
6
.examples/docker-minimal/config.yaml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
endpoints:
|
||||||
|
- name: example
|
||||||
|
url: https://example.org
|
||||||
|
interval: 30s
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"
|
||||||
115
.examples/kubernetes/gatus.yaml
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: gatus
|
||||||
|
namespace: kube-system
|
||||||
|
data:
|
||||||
|
config.yaml: |
|
||||||
|
metrics: true
|
||||||
|
endpoints:
|
||||||
|
- name: website
|
||||||
|
url: https://twin.sh/health
|
||||||
|
interval: 5m
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"
|
||||||
|
- "[BODY].status == UP"
|
||||||
|
|
||||||
|
- name: github
|
||||||
|
url: https://api.github.com/healthz
|
||||||
|
interval: 5m
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"
|
||||||
|
|
||||||
|
- name: cat-fact
|
||||||
|
url: "https://cat-fact.herokuapp.com/facts/random"
|
||||||
|
interval: 5m
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"
|
||||||
|
- "[BODY].deleted == false"
|
||||||
|
- "len([BODY].text) > 0"
|
||||||
|
- "[BODY].text == pat(*cat*)"
|
||||||
|
- "[STATUS] == pat(2*)"
|
||||||
|
- "[CONNECTED] == true"
|
||||||
|
|
||||||
|
- name: example
|
||||||
|
url: https://example.com/
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: gatus
|
||||||
|
namespace: kube-system
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: gatus
|
||||||
|
namespace: kube-system
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: gatus
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
name: gatus
|
||||||
|
namespace: kube-system
|
||||||
|
labels:
|
||||||
|
app: gatus
|
||||||
|
spec:
|
||||||
|
serviceAccountName: gatus
|
||||||
|
terminationGracePeriodSeconds: 5
|
||||||
|
containers:
|
||||||
|
- image: twinproduction/gatus
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
name: gatus
|
||||||
|
ports:
|
||||||
|
- containerPort: 8080
|
||||||
|
name: http
|
||||||
|
protocol: TCP
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpu: 250m
|
||||||
|
memory: 100M
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 30M
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 8080
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
successThreshold: 1
|
||||||
|
failureThreshold: 3
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 8080
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 10
|
||||||
|
successThreshold: 1
|
||||||
|
failureThreshold: 5
|
||||||
|
volumeMounts:
|
||||||
|
- mountPath: /config
|
||||||
|
name: gatus-config
|
||||||
|
volumes:
|
||||||
|
- configMap:
|
||||||
|
name: gatus
|
||||||
|
name: gatus-config
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: gatus
|
||||||
|
namespace: kube-system
|
||||||
|
spec:
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 8080
|
||||||
|
protocol: TCP
|
||||||
|
targetPort: 8080
|
||||||
|
selector:
|
||||||
|
app: gatus
|
||||||
1
.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
1
.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
github: [TwiN]
|
||||||
BIN
.github/assets/dashboard-conditions.png
vendored
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
.github/assets/dashboard-dark.png
vendored
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
.github/assets/endpoint-groups.png
vendored
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
.github/assets/example.png
vendored
Normal file
|
After Width: | Height: | Size: 43 KiB |
1
.github/assets/gatus-diagram.drawio
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
<mxfile host="app.diagrams.net" modified="2022-12-07T04:00:31.242Z" agent="5.0 (Windows)" etag="4-CttOJPoGYGt_6RMEMf" version="20.5.3" type="device"><diagram id="oCf8YAkR0GE5Fy88uv5t" name="Page-1">7Vxbc6M2FP41frQHxDWPuW13Z9JpZtKddPdNAQWrBcQKObH76ytsyVxkHOxgC6bOS+BICPl856ZzJCbWbbL8jcJs/jsJUTwBRricWHcTAEzDBfxfQVkJiu9ZG0pEcShoJeEJ/4vko4K6wCHKax0ZITHDWZ0YkDRFAavRIKXkvd7tlcT1t2YwQgrhKYCxSn3GIZsLKjCMsuErwtFcvvoKiJYEyt6CkM9hSN4rJOt+Yt1SQtjmKlneorhgn2TM5rkvLa3bmVGUsi4PfMHmwwP9av/5bDqPP9Obbw/4j6kn5sZW8hejkDNA3BLK5iQiKYzvS+oNJYs0RMWoBr8r+zwQknGiyYl/I8ZWAk24YIST5iyJRSufMF39JZ5f3/wobmaOvL1bVhvvVuLulaRMDGr6/H4z92LCrSwRpJwsaID28EHKFqQRYnv6uVvguMwjkiA+P/4cRTFk+K0+DyhkL9r2K9HhFwKgA8AS477BeCHe9D1HVEGQC1pWXC6S+DpghHJOvSHKMJfqB/iC4keSY4ZJyru8EMZIUulwHeOoaGAFlFXMyILFOEW3Wz0ztgAUz6LlfghUlokHHKEdwkA4UvHfS22zBGle0TPXOBGPry4KUQp6B4XwdSoEsBSNmAA3ZoI1NRzdXwsiG6b5mmnXvINpZ8s152Q7v4qK/zgvWLnMSM6RBcbLSo7MZ7oZfNNvp7ys9ayOMRSaFXAoEN2hcgkOw404IT49+LIer0A5Izhla9Y5NxPnrhX3HXoo3KIYrPRFVYloV4NWpZ0aM9Mz7c1YnaEWwz0WP6ccywQ1AzCV93IE8vqacwlsisp2UsdLj/t54fFbhEcOlGcwlbTv3yoSVG3oKlh1M/M+xww9ZXCtxe88/KqL21pExLNmq3x0t9Om/bGhNsE5LbWpukPKB8boDRWqG0IGi4iPcv+m1aSXVvxHte38Jt3vaNJNY7ckHKbo15TCVaWDsGKtdsB26wJmXzXC2cP684vNDHo1Gb4icjwgYpTEMaJ5DwpbAb5vBbaNoSkwcMcaa9k9Lz6MjpoJHK3Rlvd5hwn2RVs4yWKUFPHRoCOuEv0+Iy7QsrQsQy4LeE4jVuonAvMbozZGOF0EJgW/IlFPfMFa5GRGZUstX7ctNR2Fk6i4hgypfun/Gf7IhcXH8Y+t08pKbRyfV9SGl6UVr0vG6FC8Pru++FwUo/qcjAfvOGfrECTVHV34a/FgUGRo96ZZjw42zI/zO7ZfDzZAL7HGtJ7tOV+yR76pAvszZME8JNGwY41m4mWbMdcWa1haLN4pLZfV0XJZWrPdpprtvo4Lo5MOXISb4fJWXPWFy7bCyvs0LFNUw+Vl0xzo56X8DRVeMoqjaFdGbOS+dD80ltWw1KYxMwzbND3X9gzHd+zDvSUfoPZn197gnst7WpYOkz/AIBc4HV0FaImwzuMqLPuC12F4WS26fSa8nAteB+KldxGpptt6qx0XGVB0eE3YPCw22ZvE7u7zXHtomVBwdTpoEpQQ2rkscXTcCPrBxm9i43XEBpxs5ajmXnrDJiM5i2iR7R4nOralHR01kO8NnfxXzBk8WmwM3dg4ow3oSkh6CRCsrrtYbK1VHGe0AZ02vLTubXBGuxdFG15at8I7oz24oA0vTyteo61qa8NLa63BGW1VWxteWhN+cjl9wasrXo7WBJJrXvA6EC+tCVp5kPWCV2e8gFa8Rluw0oaX1l10lnqy4pGSNxzuON96dM5771G543Pe+kvwdg/nIC878z8E3vHcOvD2yQBV96cEi5ztOMo2tFRpV104WabUVktzIc4DQsPB867rzr7T8U49EosSiOPBc65r1fF0nFPPRkWERDEK5pANnn1m17OAp+Of6v8TyCheDp93tnbeqRVvzjvuWBOSj0D2tFelpdmt8C9lr6vBcw7or0qqFePig0g0XLARsE97wd0BCvvyGAb/DJ91rnbWqSsexILZbDZ83l3p5p2ruRZz/InQnnMjrlhlDfurX57qnpqLe/nZPLl2t3Zsj6KE8dVv5+1RZ16571W2o4+5uS01NDF8ccrtCtS0c9rPR42m9X36Z/yokae5sDCY496dlVtrndVT/VipqXpV8jQfFvP2e0tjBky/oTzW51RSDnO2wzKumrx7QsGC4j6D4ubZMqMVqu6hCvDqoYrbdWl7RGKb35afPt1wvvyErHX/Hw==</diagram></mxfile>
|
||||||
BIN
.github/assets/gatus-diagram.jpg
vendored
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
.github/assets/gitea-alerts.png
vendored
Normal file
|
After Width: | Height: | Size: 638 KiB |
BIN
.github/assets/github-alerts.png
vendored
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
.github/assets/gitlab-alerts.png
vendored
Normal file
|
After Width: | Height: | Size: 252 KiB |
BIN
.github/assets/gotify-alerts.png
vendored
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
.github/assets/grafana-dashboard.png
vendored
Normal file
|
After Width: | Height: | Size: 121 KiB |
BIN
.github/assets/jetbrains-space-alerts.png
vendored
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
.github/assets/logo-with-dark-text.png
vendored
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
.github/assets/logo.png
vendored
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
.github/assets/mattermost-alerts.png
vendored
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
.github/assets/pagerduty-integration-key.png
vendored
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
.github/assets/slack-alerts.png
vendored
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
.github/assets/teams-alerts.png
vendored
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
.github/assets/teams-workflows-alerts.png
vendored
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
.github/assets/telegram-alerts.png
vendored
Normal file
|
After Width: | Height: | Size: 36 KiB |
12
.github/codecov.yml
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
ignore:
|
||||||
|
- "watchdog/watchdog.go"
|
||||||
|
- "storage/store/sql/specific_postgres.go" # Can't test for postgres
|
||||||
|
comment: false
|
||||||
|
coverage:
|
||||||
|
status:
|
||||||
|
patch: off
|
||||||
|
project:
|
||||||
|
default:
|
||||||
|
target: 75%
|
||||||
|
threshold: null
|
||||||
|
|
||||||
13
.github/dependabot.yml
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
labels: ["dependencies"]
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
|
- package-ecosystem: "gomod"
|
||||||
|
directory: "/"
|
||||||
|
open-pull-requests-limit: 3
|
||||||
|
labels: ["dependencies"]
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
30
.github/workflows/benchmark.yml
vendored
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
name: benchmark
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: [publish-latest]
|
||||||
|
branches: [master]
|
||||||
|
types: [completed]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
repository:
|
||||||
|
description: "Repository to checkout. Useful for benchmarking a fork. Format should be <owner>/<repository>."
|
||||||
|
required: true
|
||||||
|
default: "TwiN/gatus"
|
||||||
|
ref:
|
||||||
|
description: "Branch, tag or SHA to checkout"
|
||||||
|
required: true
|
||||||
|
default: "master"
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: benchmark
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: 1.23.3
|
||||||
|
repository: "${{ github.event.inputs.repository || 'TwiN/gatus' }}"
|
||||||
|
ref: "${{ github.event.inputs.ref || 'master' }}"
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Benchmark
|
||||||
|
run: go test -bench=. ./storage/store
|
||||||
42
.github/workflows/labeler.yml
vendored
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
name: labeler
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
issues:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
jobs:
|
||||||
|
labeler:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- name: Label
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
TITLE: ${{ github.event.issue.title }}${{ github.event.pull_request.title }}
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
GH_REPO: ${{ github.repository }}
|
||||||
|
NUMBER: ${{ github.event.issue.number }}${{ github.event.pull_request.number }}
|
||||||
|
run: |
|
||||||
|
if [[ $TITLE == *"feat:"* || $TITLE == *"feat("* ]]; then
|
||||||
|
gh issue edit "$NUMBER" --add-label "feature"
|
||||||
|
elif [[ $TITLE == *"fix:"* || $TITLE == *"fix("* ]]; then
|
||||||
|
gh issue edit "$NUMBER" --add-label "bug"
|
||||||
|
fi
|
||||||
|
if [[ $TITLE == *"alerting"* || $TITLE == *"provider"* || $TITLE == *"alert"* ]]; then
|
||||||
|
gh issue edit "$NUMBER" --add-label "area/alerting"
|
||||||
|
fi
|
||||||
|
if [[ $TITLE == *"storage"* || $TITLE == *"postgres"* || $TITLE == *"sqlite"* ]]; then
|
||||||
|
gh issue edit "$NUMBER" --add-label "area/storage"
|
||||||
|
fi
|
||||||
|
if [[ $TITLE == *"security"* || $TITLE == *"oidc"* || $TITLE == *"oauth2"* ]]; then
|
||||||
|
gh issue edit "$NUMBER" --add-label "area/security"
|
||||||
|
fi
|
||||||
|
if [[ $TITLE == *"metric"* || $TITLE == *"prometheus"* ]]; then
|
||||||
|
gh issue edit "$NUMBER" --add-label "area/metrics"
|
||||||
|
fi
|
||||||
|
|
||||||
39
.github/workflows/publish-custom.yml
vendored
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
name: publish-custom
|
||||||
|
run-name: "${{ inputs.tag }}"
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: Custom tag to publish
|
||||||
|
jobs:
|
||||||
|
publish-custom:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Get image repository
|
||||||
|
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||||
|
- name: Login to Docker Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
- name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.IMAGE_REPOSITORY }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=${{ inputs.tag }}
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
platforms: linux/amd64
|
||||||
|
pull: true
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
34
.github/workflows/publish-experimental.yml
vendored
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
name: publish-experimental
|
||||||
|
on: [workflow_dispatch]
|
||||||
|
jobs:
|
||||||
|
publish-experimental:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Get image repository
|
||||||
|
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||||
|
- name: Login to Docker Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
- name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.IMAGE_REPOSITORY }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=experimental
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
platforms: linux/amd64
|
||||||
|
pull: true
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
52
.github/workflows/publish-latest.yml
vendored
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
name: publish-latest
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: [test]
|
||||||
|
branches: [master]
|
||||||
|
types: [completed]
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.event.workflow_run.head_repository.full_name }}::${{ github.event.workflow_run.head_branch }}::${{ github.workflow }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
jobs:
|
||||||
|
publish-latest:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: ${{ (github.event.workflow_run.conclusion == 'success') && (github.event.workflow_run.head_repository.full_name == github.repository) }}
|
||||||
|
timeout-minutes: 90
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Get image repository
|
||||||
|
run: |
|
||||||
|
echo DOCKER_IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||||
|
echo GHCR_IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||||
|
- name: Login to Docker Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
${{ env.DOCKER_IMAGE_REPOSITORY }}
|
||||||
|
${{ env.GHCR_IMAGE_REPOSITORY }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||||
|
pull: true
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
51
.github/workflows/publish-release.yml
vendored
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
name: publish-release
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
jobs:
|
||||||
|
publish-release:
|
||||||
|
name: publish-release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Get image repository
|
||||||
|
run: |
|
||||||
|
echo DOCKER_IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||||
|
echo GHCR_IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||||
|
- name: Get the release
|
||||||
|
run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV
|
||||||
|
- name: Login to Docker Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
${{ env.DOCKER_IMAGE_REPOSITORY }}
|
||||||
|
${{ env.GHCR_IMAGE_REPOSITORY }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=${{ env.RELEASE }}
|
||||||
|
type=raw,value=stable
|
||||||
|
type=raw,value=latest
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||||
|
pull: true
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
18
.github/workflows/test-ui.yml
vendored
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
name: test-ui
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'web/**'
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
paths:
|
||||||
|
- 'web/**'
|
||||||
|
jobs:
|
||||||
|
test-ui:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: make frontend-install-dependencies
|
||||||
|
- run: make frontend-build
|
||||||
34
.github/workflows/test.yml
vendored
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
name: test
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths-ignore:
|
||||||
|
- '*.md'
|
||||||
|
- '.examples/**'
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
paths-ignore:
|
||||||
|
- '*.md'
|
||||||
|
- '.github/**'
|
||||||
|
- '.examples/**'
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: 1.23.3
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Build binary to make sure it works
|
||||||
|
run: go build
|
||||||
|
- name: Test
|
||||||
|
# We're using "sudo" because one of the tests leverages ping, which requires super-user privileges.
|
||||||
|
# As for the 'env "PATH=$PATH" "GOROOT=$GOROOT"', we need it to use the same "go" executable that
|
||||||
|
# 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
|
||||||
|
- name: Codecov
|
||||||
|
uses: codecov/codecov-action@v5.4.0
|
||||||
|
with:
|
||||||
|
files: ./coverage.txt
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
23
.gitignore
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# IDE
|
||||||
|
*.iml
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# JS
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Go
|
||||||
|
/vendor
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
gatus
|
||||||
|
config/config.yml
|
||||||
|
config.yaml
|
||||||
|
gatus.exe
|
||||||
|
.IFXApps
|
||||||
22
Dockerfile
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# Build the go application into a binary
|
||||||
|
FROM golang:alpine AS builder
|
||||||
|
RUN apk --update add ca-certificates
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . ./
|
||||||
|
RUN go mod tidy
|
||||||
|
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 apk update && apk add --virtual build-dependencies build-base gcc
|
||||||
|
#RUN go test ./... -mod vendor
|
||||||
|
|
||||||
|
# Run the binary on an empty container
|
||||||
|
FROM scratch
|
||||||
|
COPY --from=builder /app/gatus .
|
||||||
|
COPY --from=builder /app/config.yaml ./config/config.yaml
|
||||||
|
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||||
|
ENV GATUS_CONFIG_PATH=""
|
||||||
|
ENV GATUS_LOG_LEVEL="INFO"
|
||||||
|
ENV PORT="8080"
|
||||||
|
EXPOSE ${PORT}
|
||||||
|
ENTRYPOINT ["/gatus"]
|
||||||
1
LICENSE
@ -199,3 +199,4 @@
|
|||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
See the License for the specific language governing permissions and
|
See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
|
|
||||||
|
|||||||
48
Makefile
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
BINARY=gatus
|
||||||
|
|
||||||
|
.PHONY: install
|
||||||
|
install:
|
||||||
|
go build -v -o $(BINARY) .
|
||||||
|
|
||||||
|
.PHONY: run
|
||||||
|
run:
|
||||||
|
ENVIRONMENT=dev GATUS_CONFIG_PATH=./config.yaml go run main.go
|
||||||
|
|
||||||
|
.PHONY: run-binary
|
||||||
|
run-binary:
|
||||||
|
ENVIRONMENT=dev GATUS_CONFIG_PATH=./config.yaml ./$(BINARY)
|
||||||
|
|
||||||
|
.PHONY: clean
|
||||||
|
clean:
|
||||||
|
rm $(BINARY)
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
|
test:
|
||||||
|
go test ./... -cover
|
||||||
|
|
||||||
|
|
||||||
|
##########
|
||||||
|
# Docker #
|
||||||
|
##########
|
||||||
|
|
||||||
|
docker-build:
|
||||||
|
docker build -t twinproduction/gatus:latest .
|
||||||
|
|
||||||
|
docker-run:
|
||||||
|
docker run -p 8080:8080 --name gatus twinproduction/gatus:latest
|
||||||
|
|
||||||
|
docker-build-and-run: docker-build docker-run
|
||||||
|
|
||||||
|
|
||||||
|
#############
|
||||||
|
# Front end #
|
||||||
|
#############
|
||||||
|
|
||||||
|
frontend-install-dependencies:
|
||||||
|
npm --prefix web/app install
|
||||||
|
|
||||||
|
frontend-build:
|
||||||
|
npm --prefix web/app run build
|
||||||
|
|
||||||
|
frontend-run:
|
||||||
|
npm --prefix web/app run serve
|
||||||
128
alerting/alert/alert.go
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
package alert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/TwiN/logr"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrAlertWithInvalidDescription is the error with which Gatus will panic if an alert has an invalid character
|
||||||
|
ErrAlertWithInvalidDescription = errors.New("alert description must not have \" or \\")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Alert is endpoint.Endpoint's alert configuration
|
||||||
|
type Alert struct {
|
||||||
|
// Type of alert (required)
|
||||||
|
Type Type `yaml:"type"`
|
||||||
|
|
||||||
|
// Enabled defines whether the alert is enabled
|
||||||
|
//
|
||||||
|
// Use Alert.IsEnabled() to retrieve the value of this field.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
Enabled *bool `yaml:"enabled,omitempty"`
|
||||||
|
|
||||||
|
// FailureThreshold is the number of failures in a row needed before triggering the alert
|
||||||
|
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.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
Description *string `yaml:"description,omitempty"`
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// or not for provider.ParseWithDefaultAlert to work. Use Alert.IsSendingOnResolved() for a non-pointer
|
||||||
|
SendOnResolved *bool `yaml:"send-on-resolved,omitempty"`
|
||||||
|
|
||||||
|
// ProviderOverride is an optional field that can be used to override the provider's configuration
|
||||||
|
// 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
|
||||||
|
// ongoing/triggered incidents
|
||||||
|
ResolveKey string `yaml:"-"`
|
||||||
|
|
||||||
|
// Triggered is used to determine whether an alert has been triggered. When an alert is resolved, this value
|
||||||
|
// should be set back to false. It is used to prevent the same alert from going out twice.
|
||||||
|
//
|
||||||
|
// This value should only be modified if the provider.AlertProvider's Send function does not return an error for an
|
||||||
|
// alert that hasn't been triggered yet. This doubles as a lazy retry. The reason why this behavior isn't also
|
||||||
|
// applied for alerts that are already triggered and has become "healthy" again is to prevent a case where, for
|
||||||
|
// some reason, the alert provider always returns errors when trying to send the resolved notification
|
||||||
|
// (SendOnResolved).
|
||||||
|
Triggered bool `yaml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateAndSetDefaults validates the alert's configuration and sets the default value of fields that have one
|
||||||
|
func (alert *Alert) ValidateAndSetDefaults() error {
|
||||||
|
if alert.FailureThreshold <= 0 {
|
||||||
|
alert.FailureThreshold = 3
|
||||||
|
}
|
||||||
|
if alert.SuccessThreshold <= 0 {
|
||||||
|
alert.SuccessThreshold = 2
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(alert.GetDescription(), "\"\\") {
|
||||||
|
return ErrAlertWithInvalidDescription
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDescription retrieves the description of the alert
|
||||||
|
func (alert *Alert) GetDescription() string {
|
||||||
|
if alert.Description == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *alert.Description
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEnabled returns whether an alert is enabled or not
|
||||||
|
// Returns true if not set
|
||||||
|
func (alert *Alert) IsEnabled() bool {
|
||||||
|
if alert.Enabled == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return *alert.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSendingOnResolved returns whether an alert is sending on resolve or not
|
||||||
|
func (alert *Alert) IsSendingOnResolved() bool {
|
||||||
|
if alert.SendOnResolved == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
192
alerting/alert/alert_test.go
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
package alert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlert_ValidateAndSetDefaults(t *testing.T) {
|
||||||
|
invalidDescription := "\""
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
alert Alert
|
||||||
|
expectedError error
|
||||||
|
expectedSuccessThreshold int
|
||||||
|
expectedFailureThreshold int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid-empty",
|
||||||
|
alert: Alert{
|
||||||
|
Description: nil,
|
||||||
|
FailureThreshold: 0,
|
||||||
|
SuccessThreshold: 0,
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
expectedFailureThreshold: 3,
|
||||||
|
expectedSuccessThreshold: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-description",
|
||||||
|
alert: Alert{
|
||||||
|
Description: &invalidDescription,
|
||||||
|
FailureThreshold: 10,
|
||||||
|
SuccessThreshold: 5,
|
||||||
|
},
|
||||||
|
expectedError: ErrAlertWithInvalidDescription,
|
||||||
|
expectedFailureThreshold: 10,
|
||||||
|
expectedSuccessThreshold: 5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
|
if err := scenario.alert.ValidateAndSetDefaults(); !errors.Is(err, scenario.expectedError) {
|
||||||
|
t.Errorf("expected error %v, got %v", scenario.expectedError, err)
|
||||||
|
}
|
||||||
|
if scenario.alert.SuccessThreshold != scenario.expectedSuccessThreshold {
|
||||||
|
t.Errorf("expected success threshold %v, got %v", scenario.expectedSuccessThreshold, scenario.alert.SuccessThreshold)
|
||||||
|
}
|
||||||
|
if scenario.alert.FailureThreshold != scenario.expectedFailureThreshold {
|
||||||
|
t.Errorf("expected failure threshold %v, got %v", scenario.expectedFailureThreshold, scenario.alert.FailureThreshold)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlert_IsEnabled(t *testing.T) {
|
||||||
|
if !(&Alert{Enabled: nil}).IsEnabled() {
|
||||||
|
t.Error("alert.IsEnabled() should've returned true, because Enabled was set to nil")
|
||||||
|
}
|
||||||
|
if value := false; (&Alert{Enabled: &value}).IsEnabled() {
|
||||||
|
t.Error("alert.IsEnabled() should've returned false, because Enabled was set to false")
|
||||||
|
}
|
||||||
|
if value := true; !(&Alert{Enabled: &value}).IsEnabled() {
|
||||||
|
t.Error("alert.IsEnabled() should've returned true, because Enabled was set to true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlert_GetDescription(t *testing.T) {
|
||||||
|
if (&Alert{Description: nil}).GetDescription() != "" {
|
||||||
|
t.Error("alert.GetDescription() should've returned an empty string, because Description was set to nil")
|
||||||
|
}
|
||||||
|
if value := "description"; (&Alert{Description: &value}).GetDescription() != value {
|
||||||
|
t.Error("alert.GetDescription() should've returned false, because Description was set to 'description'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlert_IsSendingOnResolved(t *testing.T) {
|
||||||
|
if (&Alert{SendOnResolved: nil}).IsSendingOnResolved() {
|
||||||
|
t.Error("alert.IsSendingOnResolved() should've returned false, because SendOnResolved was set to nil")
|
||||||
|
}
|
||||||
|
if value := false; (&Alert{SendOnResolved: &value}).IsSendingOnResolved() {
|
||||||
|
t.Error("alert.IsSendingOnResolved() should've returned false, because SendOnResolved was set to false")
|
||||||
|
}
|
||||||
|
if value := true; !(&Alert{SendOnResolved: &value}).IsSendingOnResolved() {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
79
alerting/alert/type.go
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
package alert
|
||||||
|
|
||||||
|
// Type is the type of the alert.
|
||||||
|
// The value will generally be the name of the alert provider
|
||||||
|
type Type string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// TypeAWSSES is the Type for the awsses alerting provider
|
||||||
|
TypeAWSSES Type = "aws-ses"
|
||||||
|
|
||||||
|
// TypeCustom is the Type for the custom alerting provider
|
||||||
|
TypeCustom Type = "custom"
|
||||||
|
|
||||||
|
// TypeDiscord is the Type for the discord alerting provider
|
||||||
|
TypeDiscord Type = "discord"
|
||||||
|
|
||||||
|
// TypeEmail is the Type for the email alerting provider
|
||||||
|
TypeEmail Type = "email"
|
||||||
|
|
||||||
|
// TypeGitHub is the Type for the github alerting provider
|
||||||
|
TypeGitHub Type = "github"
|
||||||
|
|
||||||
|
// TypeGitLab is the Type for the gitlab alerting provider
|
||||||
|
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 Type = "googlechat"
|
||||||
|
|
||||||
|
// TypeGotify is the Type for the gotify alerting provider
|
||||||
|
TypeGotify Type = "gotify"
|
||||||
|
|
||||||
|
// TypeIncidentIO is the Type for the incident-io alerting provider
|
||||||
|
TypeIncidentIO Type = "incident-io"
|
||||||
|
|
||||||
|
// TypeJetBrainsSpace is the Type for the jetbrains alerting provider
|
||||||
|
TypeJetBrainsSpace Type = "jetbrainsspace"
|
||||||
|
|
||||||
|
// TypeMatrix is the Type for the matrix alerting provider
|
||||||
|
TypeMatrix Type = "matrix"
|
||||||
|
|
||||||
|
// TypeMattermost is the Type for the mattermost alerting provider
|
||||||
|
TypeMattermost Type = "mattermost"
|
||||||
|
|
||||||
|
// TypeMessagebird is the Type for the messagebird alerting provider
|
||||||
|
TypeMessagebird Type = "messagebird"
|
||||||
|
|
||||||
|
// TypeNtfy is the Type for the ntfy alerting provider
|
||||||
|
TypeNtfy Type = "ntfy"
|
||||||
|
|
||||||
|
// TypeOpsgenie is the Type for the opsgenie alerting provider
|
||||||
|
TypeOpsgenie Type = "opsgenie"
|
||||||
|
|
||||||
|
// TypePagerDuty is the Type for the pagerduty alerting provider
|
||||||
|
TypePagerDuty Type = "pagerduty"
|
||||||
|
|
||||||
|
// TypePushover is the Type for the pushover alerting provider
|
||||||
|
TypePushover Type = "pushover"
|
||||||
|
|
||||||
|
// TypeSlack is the Type for the slack alerting provider
|
||||||
|
TypeSlack Type = "slack"
|
||||||
|
|
||||||
|
// TypeTeams is the Type for the teams alerting provider
|
||||||
|
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 Type = "telegram"
|
||||||
|
|
||||||
|
// TypeTwilio is the Type for the twilio alerting provider
|
||||||
|
TypeTwilio Type = "twilio"
|
||||||
|
|
||||||
|
// TypeZulip is the Type for the Zulip alerting provider
|
||||||
|
TypeZulip Type = "zulip"
|
||||||
|
)
|
||||||
139
alerting/config.go
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
package alerting
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"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/discord"
|
||||||
|
"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/gitlab"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/gotify"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/ntfy"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/pagerduty"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/slack"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/teams"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/zulip"
|
||||||
|
"github.com/TwiN/logr"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config is the configuration for alerting providers
|
||||||
|
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 *custom.AlertProvider `yaml:"custom,omitempty"`
|
||||||
|
|
||||||
|
// Discord is the configuration for the discord alerting provider
|
||||||
|
Discord *discord.AlertProvider `yaml:"discord,omitempty"`
|
||||||
|
|
||||||
|
// Email is the configuration for the email alerting provider
|
||||||
|
Email *email.AlertProvider `yaml:"email,omitempty"`
|
||||||
|
|
||||||
|
// GitHub is the configuration for the github alerting provider
|
||||||
|
GitHub *github.AlertProvider `yaml:"github,omitempty"`
|
||||||
|
|
||||||
|
// GitLab is the configuration for the gitlab alerting provider
|
||||||
|
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 *googlechat.AlertProvider `yaml:"googlechat,omitempty"`
|
||||||
|
|
||||||
|
// Gotify is the configuration for the gotify alerting provider
|
||||||
|
Gotify *gotify.AlertProvider `yaml:"gotify,omitempty"`
|
||||||
|
|
||||||
|
// IncidentIO is the configuration for the incident-io alerting provider
|
||||||
|
IncidentIO *incidentio.AlertProvider `yaml:"incident-io,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 *matrix.AlertProvider `yaml:"matrix,omitempty"`
|
||||||
|
|
||||||
|
// Mattermost is the configuration for the mattermost alerting provider
|
||||||
|
Mattermost *mattermost.AlertProvider `yaml:"mattermost,omitempty"`
|
||||||
|
|
||||||
|
// Messagebird is the configuration for the messagebird alerting provider
|
||||||
|
Messagebird *messagebird.AlertProvider `yaml:"messagebird,omitempty"`
|
||||||
|
|
||||||
|
// Ntfy is the configuration for the ntfy alerting provider
|
||||||
|
Ntfy *ntfy.AlertProvider `yaml:"ntfy,omitempty"`
|
||||||
|
|
||||||
|
// Opsgenie is the configuration for the opsgenie alerting provider
|
||||||
|
Opsgenie *opsgenie.AlertProvider `yaml:"opsgenie,omitempty"`
|
||||||
|
|
||||||
|
// PagerDuty is the configuration for the pagerduty alerting provider
|
||||||
|
PagerDuty *pagerduty.AlertProvider `yaml:"pagerduty,omitempty"`
|
||||||
|
|
||||||
|
// Pushover is the configuration for the pushover alerting provider
|
||||||
|
Pushover *pushover.AlertProvider `yaml:"pushover,omitempty"`
|
||||||
|
|
||||||
|
// Slack is the configuration for the slack alerting provider
|
||||||
|
Slack *slack.AlertProvider `yaml:"slack,omitempty"`
|
||||||
|
|
||||||
|
// Teams is the configuration for the teams alerting provider
|
||||||
|
Teams *teams.AlertProvider `yaml:"teams,omitempty"`
|
||||||
|
|
||||||
|
// TeamsWorkflows is the configuration for the teams alerting provider using the new Workflow App Webhook Connector
|
||||||
|
TeamsWorkflows *teamsworkflows.AlertProvider `yaml:"teams-workflows,omitempty"`
|
||||||
|
|
||||||
|
// Telegram is the configuration for the telegram alerting provider
|
||||||
|
Telegram *telegram.AlertProvider `yaml:"telegram,omitempty"`
|
||||||
|
|
||||||
|
// Twilio is the configuration for the twilio alerting provider
|
||||||
|
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
|
||||||
|
func (config *Config) GetAlertingProviderByAlertType(alertType alert.Type) provider.AlertProvider {
|
||||||
|
entityType := reflect.TypeOf(config).Elem()
|
||||||
|
for i := 0; i < entityType.NumField(); i++ {
|
||||||
|
field := entityType.Field(i)
|
||||||
|
tag := strings.Split(field.Tag.Get("yaml"), ",")[0]
|
||||||
|
if tag == string(alertType) {
|
||||||
|
fieldValue := reflect.ValueOf(config).Elem().Field(i)
|
||||||
|
if fieldValue.IsNil() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fieldValue.Interface().(provider.AlertProvider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logr.Infof("[alerting.GetAlertingProviderByAlertType] No alerting provider found for alert type %s", alertType)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAlertingProviderToNil Sets an alerting provider to nil to avoid having to revalidate it every time an
|
||||||
|
// alert of its corresponding type is sent.
|
||||||
|
func (config *Config) SetAlertingProviderToNil(p provider.AlertProvider) {
|
||||||
|
entityType := reflect.TypeOf(config).Elem()
|
||||||
|
for i := 0; i < entityType.NumField(); i++ {
|
||||||
|
field := entityType.Field(i)
|
||||||
|
if field.Type == reflect.TypeOf(p) {
|
||||||
|
reflect.ValueOf(config).Elem().Field(i).Set(reflect.Zero(field.Type))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
179
alerting/provider/custom/custom.go
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
package custom
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"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 (
|
||||||
|
ErrURLNotSet = errors.New("url not set")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
URL string `yaml:"url"`
|
||||||
|
Method string `yaml:"method,omitempty"`
|
||||||
|
Body string `yaml:"body,omitempty"`
|
||||||
|
Headers map[string]string `yaml:"headers,omitempty"`
|
||||||
|
Placeholders map[string]map[string]string `yaml:"placeholders,omitempty"`
|
||||||
|
|
||||||
|
// 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.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 *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 {
|
||||||
|
return provider.DefaultConfig.Validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
request := provider.buildHTTPRequest(cfg, ep, alert, result, resolved)
|
||||||
|
response, err := client.GetHTTPClient(cfg.ClientConfig).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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
}
|
||||||
395
alerting/provider/custom/custom_test.go
Normal file
@ -0,0 +1,395 @@
|
|||||||
|
package custom
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"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) {
|
||||||
|
t.Run("invalid-provider", func(t *testing.T) {
|
||||||
|
invalidProvider := AlertProvider{DefaultConfig: Config{URL: ""}}
|
||||||
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
|
t.Error("provider shouldn't have been valid")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("valid-provider", func(t *testing.T) {
|
||||||
|
validProvider := AlertProvider{DefaultConfig: Config{URL: "https://example.com"}}
|
||||||
|
if err := validProvider.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{URL: "https://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{URL: "https://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{URL: "https://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{URL: "https://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_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]",
|
||||||
|
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ENDPOINT_URL],[ALERT_TRIGGERED_OR_RESOLVED]",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
alertDescription := "alert-description"
|
||||||
|
scenarios := []struct {
|
||||||
|
AlertProvider *AlertProvider
|
||||||
|
Resolved bool
|
||||||
|
ExpectedURL string
|
||||||
|
ExpectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
AlertProvider: alertProvider,
|
||||||
|
Resolved: true,
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
AlertProvider: alertProvider,
|
||||||
|
Resolved: false,
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(fmt.Sprintf("resolved-%v-with-default-placeholders", 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: []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,
|
||||||
|
)
|
||||||
|
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 TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
|
||||||
|
alertProvider := &AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
||||||
|
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
||||||
|
Headers: nil,
|
||||||
|
Placeholders: map[string]map[string]string{
|
||||||
|
"ALERT_TRIGGERED_OR_RESOLVED": {
|
||||||
|
"RESOLVED": "fixed",
|
||||||
|
"TRIGGERED": "boom",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
alertDescription := "alert-description"
|
||||||
|
scenarios := []struct {
|
||||||
|
AlertProvider *AlertProvider
|
||||||
|
Resolved bool
|
||||||
|
ExpectedURL string
|
||||||
|
ExpectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
AlertProvider: alertProvider,
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=fixed&description=alert-description",
|
||||||
|
ExpectedBody: "endpoint-name,endpoint-group,alert-description,fixed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
AlertProvider: alertProvider,
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=boom&description=alert-description",
|
||||||
|
ExpectedBody: "endpoint-name,endpoint-group,alert-description,boom",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(fmt.Sprintf("resolved-%v-with-custom-placeholders", scenario.Resolved), func(t *testing.T) {
|
||||||
|
request := alertProvider.buildHTTPRequest(
|
||||||
|
&alertProvider.DefaultConfig,
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||||
|
&alert.Alert{Description: &alertDescription},
|
||||||
|
&endpoint.Result{},
|
||||||
|
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 TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
|
||||||
|
alertProvider := &AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
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 alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, true) != "RESOLVED" {
|
||||||
|
t.Error("expected RESOLVED, got", alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, true))
|
||||||
|
}
|
||||||
|
if alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, false) != "TRIGGERED" {
|
||||||
|
t.Error("expected TRIGGERED, got", alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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{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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
199
alerting/provider/discord/discord.go
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
package discord
|
||||||
|
|
||||||
|
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 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"`
|
||||||
|
|
||||||
|
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||||
|
Overrides []Override `yaml:"overrides,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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.WebhookURL) == 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
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
type Body struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
Embeds []Embed `json:"embeds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Embed struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Color int `json:"color"`
|
||||||
|
Fields []Field `json:"fields,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Field struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Inline bool `json:"inline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 colorCode int
|
||||||
|
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)
|
||||||
|
colorCode = 3066993
|
||||||
|
} 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)
|
||||||
|
colorCode = 15158332
|
||||||
|
}
|
||||||
|
var formattedConditionResults string
|
||||||
|
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
|
||||||
|
}
|
||||||
|
title := ":helmet_with_white_cross: Gatus"
|
||||||
|
if cfg.Title != "" {
|
||||||
|
title = cfg.Title
|
||||||
|
}
|
||||||
|
body := Body{
|
||||||
|
Content: "",
|
||||||
|
Embeds: []Embed{
|
||||||
|
{
|
||||||
|
Title: title,
|
||||||
|
Description: message + description,
|
||||||
|
Color: colorCode,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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
|
||||||
|
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
|
||||||
|
}
|
||||||
332
alerting/provider/discord/discord_test.go
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
package discord
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
title := "provider-title"
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 {
|
||||||
|
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"
|
||||||
|
title := "provider-title"
|
||||||
|
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: "{\"content\":\"\",\"embeds\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"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: "resolved",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
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}]}]}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 {
|
||||||
|
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(
|
||||||
|
&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://group-example.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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
206
alerting/provider/email/email.go
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
gomail "gopkg.in/mail.v2"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
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"`
|
||||||
|
Username string `yaml:"username"`
|
||||||
|
Password string `yaml:"password"`
|
||||||
|
Host string `yaml:"host"`
|
||||||
|
Port int `yaml:"port"`
|
||||||
|
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 *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
|
||||||
|
}
|
||||||
|
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.SetHeader("From", cfg.From)
|
||||||
|
m.SetHeader("To", strings.Split(cfg.To, ",")...)
|
||||||
|
m.SetHeader("Subject", subject)
|
||||||
|
m.SetBody("text/plain", body)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
232
alerting/provider/email/email_test.go
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
validProvider := AlertProvider{DefaultConfig: Config{From: "from@example.com", Password: "password", Host: "smtp.gmail.com", Port: 587, To: "to@example.com"}}
|
||||||
|
if err := validProvider.Validate(); err != nil {
|
||||||
|
t.Error("provider should've been valid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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{
|
||||||
|
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",
|
||||||
|
Password: "password",
|
||||||
|
Host: "smtp.gmail.com",
|
||||||
|
Port: 587,
|
||||||
|
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_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{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
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",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
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",
|
||||||
|
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: "to01@example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
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",
|
||||||
|
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{},
|
||||||
|
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 _, 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 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
@ -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
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
190
alerting/provider/github/github.go
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
package github
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/google/go-github/v48/github"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
"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 GitHub repository to create issues in
|
||||||
|
Token string `yaml:"token"` // Token requires at least RW on issues and RO on metadata
|
||||||
|
|
||||||
|
username string
|
||||||
|
repositoryOwner string
|
||||||
|
repositoryName string
|
||||||
|
githubClient *github.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
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.githubClient != 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]
|
||||||
|
// Create oauth2 HTTP client with GitHub token
|
||||||
|
httpClientWithStaticTokenSource := oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(&oauth2.Token{
|
||||||
|
AccessToken: cfg.Token,
|
||||||
|
}))
|
||||||
|
// Create GitHub client
|
||||||
|
if baseURL == "https://github.com" {
|
||||||
|
cfg.githubClient = github.NewClient(httpClientWithStaticTokenSource)
|
||||||
|
} else {
|
||||||
|
cfg.githubClient, err = github.NewEnterpriseClient(baseURL, baseURL, httpClientWithStaticTokenSource)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create enterprise GitHub client: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Retrieve the username once to validate that the token is valid
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
user, _, err := cfg.githubClient.Users.Get(ctx, "")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to retrieve GitHub user: %w", err)
|
||||||
|
}
|
||||||
|
cfg.username = *user.Login
|
||||||
|
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,
|
||||||
|
// 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.githubClient.Issues.Create(context.Background(), cfg.repositoryOwner, cfg.repositoryName, &github.IssueRequest{
|
||||||
|
Title: github.String(title),
|
||||||
|
Body: github.String(provider.buildIssueBody(ep, alert, result)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create issue: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
issues, _, err := cfg.githubClient.Issues.ListByRepo(context.Background(), cfg.repositoryOwner, cfg.repositoryName, &github.IssueListByRepoOptions{
|
||||||
|
State: "open",
|
||||||
|
Creator: cfg.username,
|
||||||
|
ListOptions: github.ListOptions{PerPage: 100},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list issues: %w", err)
|
||||||
|
}
|
||||||
|
for _, issue := range issues {
|
||||||
|
if *issue.Title == title {
|
||||||
|
_, _, err = cfg.githubClient.Issues.Edit(context.Background(), cfg.repositoryOwner, cfg.repositoryName, *issue.Number, &github.IssueRequest{
|
||||||
|
State: github.String("closed"),
|
||||||
|
})
|
||||||
|
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
|
||||||
|
}
|
||||||
221
alerting/provider/github/github_test.go
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
package github
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"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"
|
||||||
|
"github.com/google/go-github/v48/github"
|
||||||
|
)
|
||||||
|
|
||||||
|
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://github.com/TwiN/test", Token: "12345"}},
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "missing-repository-name",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN", Token: "12345"}},
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "enterprise-client",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.example.com/TwiN/test", Token: "12345"}},
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "invalid-url",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "github.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://github.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://github.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(), "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})
|
||||||
|
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://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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
204
alerting/provider/gitlab/gitlab.go
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
package gitlab
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"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
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if len(alert.ResolveKey) == 0 {
|
||||||
|
alert.ResolveKey = uuid.NewString()
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(provider.buildAlertBody(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")
|
||||||
|
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.AuthorizationKey))
|
||||||
|
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 AlertBody struct {
|
||||||
|
Title string `json:"title,omitempty"` // The title of the alert.
|
||||||
|
Description string `json:"description,omitempty"` // A high-level summary of the problem.
|
||||||
|
StartTime string `json:"start_time,omitempty"` // The time of the alert. If none is provided, a current time is used.
|
||||||
|
EndTime string `json:"end_time,omitempty"` // The resolution time of the alert. If provided, the alert is resolved.
|
||||||
|
Service string `json:"service,omitempty"` // The affected service.
|
||||||
|
MonitoringTool string `json:"monitoring_tool,omitempty"` // The name of the associated monitoring tool.
|
||||||
|
Hosts string `json:"hosts,omitempty"` // One or more hosts, as to where this incident occurred.
|
||||||
|
Severity string `json:"severity,omitempty"` // The severity of the alert. Case-insensitive. Can be one of: critical, high, medium, low, info, unknown. Defaults to critical if missing or value is not in this list.
|
||||||
|
Fingerprint string `json:"fingerprint,omitempty"` // The unique identifier of the alert. This can be used to group occurrences of the same alert.
|
||||||
|
GitlabEnvironmentName string `json:"gitlab_environment_name,omitempty"` // The name of the associated GitLab environment. Required to display alerts on a dashboard.
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildAlertBody builds the body of the alert
|
||||||
|
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{
|
||||||
|
Title: fmt.Sprintf("alert(%s): %s", cfg.MonitoringTool, service),
|
||||||
|
StartTime: result.Timestamp.Format(time.RFC3339),
|
||||||
|
Service: service,
|
||||||
|
MonitoringTool: cfg.MonitoringTool,
|
||||||
|
Hosts: ep.URL,
|
||||||
|
GitlabEnvironmentName: cfg.EnvironmentName,
|
||||||
|
Severity: cfg.Severity,
|
||||||
|
Fingerprint: alert.ResolveKey,
|
||||||
|
}
|
||||||
|
if resolved {
|
||||||
|
body.EndTime = result.Timestamp.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
body.Description = message + description + formattedConditionResults
|
||||||
|
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 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
|
||||||
|
}
|
||||||
223
alerting/provider/gitlab/gitlab_test.go
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
package gitlab
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"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
|
||||||
|
Provider AlertProvider
|
||||||
|
ExpectedError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "invalid",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "", AuthorizationKey: ""}},
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "missing-webhook-url",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "", AuthorizationKey: "12345"}},
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "missing-authorization-key",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/whatever/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: ""}},
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "invalid-url",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: " http://foo.com", AuthorizationKey: "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{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedError: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-error",
|
||||||
|
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},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedError: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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", 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_buildAlertBody(t *testing.T) {
|
||||||
|
firstDescription := "description-1"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Endpoint endpoint.Endpoint
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
ExpectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"}},
|
||||||
|
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\",\"severity\":\"critical\"}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "no-description",
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"}},
|
||||||
|
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\",\"severity\":\"critical\"}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
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(
|
||||||
|
cfg,
|
||||||
|
&scenario.Endpoint,
|
||||||
|
&scenario.Alert,
|
||||||
|
&endpoint.Result{
|
||||||
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: true},
|
||||||
|
{Condition: "[STATUS] == 200", Success: false},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
if strings.TrimSpace(string(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{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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
248
alerting/provider/googlechat/googlechat.go
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
package googlechat
|
||||||
|
|
||||||
|
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"`
|
||||||
|
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
|
||||||
|
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.WebhookURL) == 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
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(provider.buildRequestBody(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(cfg.ClientConfig).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 {
|
||||||
|
Cards []Cards `json:"cards"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Cards struct {
|
||||||
|
Sections []Sections `json:"sections"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Sections struct {
|
||||||
|
Widgets []Widgets `json:"widgets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Widgets struct {
|
||||||
|
KeyValue *KeyValue `json:"keyValue,omitempty"`
|
||||||
|
Buttons []Buttons `json:"buttons,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type KeyValue struct {
|
||||||
|
TopLabel string `json:"topLabel,omitempty"`
|
||||||
|
Content string `json:"content,omitempty"`
|
||||||
|
ContentMultiline string `json:"contentMultiline,omitempty"`
|
||||||
|
BottomLabel string `json:"bottomLabel,omitempty"`
|
||||||
|
Icon string `json:"icon,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Buttons struct {
|
||||||
|
TextButton TextButton `json:"textButton"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TextButton struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
OnClick OnClick `json:"onClick"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OnClick struct {
|
||||||
|
OpenLink OpenLink `json:"openLink"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenLink struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestBody builds the request body for the provider
|
||||||
|
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
|
var message, color string
|
||||||
|
if resolved {
|
||||||
|
color = "#36A64F"
|
||||||
|
message = fmt.Sprintf("<font color='%s'>An alert has been resolved after passing successfully %d time(s) in a row</font>", color, alert.SuccessThreshold)
|
||||||
|
} else {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
var formattedConditionResults string
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
var prefix string
|
||||||
|
if conditionResult.Success {
|
||||||
|
prefix = "✅"
|
||||||
|
} else {
|
||||||
|
prefix = "❌"
|
||||||
|
}
|
||||||
|
formattedConditionResults += fmt.Sprintf("%s %s<br>", prefix, conditionResult.Condition)
|
||||||
|
}
|
||||||
|
var description string
|
||||||
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
|
description = ":: " + alertDescription
|
||||||
|
}
|
||||||
|
payload := Body{
|
||||||
|
Cards: []Cards{
|
||||||
|
{
|
||||||
|
Sections: []Sections{
|
||||||
|
{
|
||||||
|
Widgets: []Widgets{
|
||||||
|
{
|
||||||
|
KeyValue: &KeyValue{
|
||||||
|
TopLabel: ep.DisplayName(),
|
||||||
|
Content: message,
|
||||||
|
ContentMultiline: "true",
|
||||||
|
BottomLabel: description,
|
||||||
|
Icon: "BOOKMARK",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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
|
||||||
|
// 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
|
||||||
|
payload.Cards[0].Sections[0].Widgets = append(payload.Cards[0].Sections[0].Widgets, Widgets{
|
||||||
|
Buttons: []Buttons{
|
||||||
|
{
|
||||||
|
TextButton: TextButton{
|
||||||
|
Text: "URL",
|
||||||
|
OnClick: OnClick{OpenLink: OpenLink{URL: ep.URL}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
305
alerting/provider/googlechat/googlechat_test.go
Normal file
@ -0,0 +1,305 @@
|
|||||||
|
package googlechat
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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", 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"
|
||||||
|
secondDescription := "description-2"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Endpoint endpoint.Endpoint
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
ExpectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: `{"cards":[{"sections":[{"widgets":[{"keyValue":{"topLabel":"endpoint-name","content":"\u003cfont color='#DD0000'\u003eAn alert has been triggered due to having failed 3 time(s) in a row\u003c/font\u003e","contentMultiline":"true","bottomLabel":":: description-1","icon":"BOOKMARK"}},{"keyValue":{"topLabel":"Condition results","content":"❌ [CONNECTED] == true\u003cbr\u003e❌ [STATUS] == 200\u003cbr\u003e","contentMultiline":"true","icon":"DESCRIPTION"}},{"buttons":[{"textButton":{"text":"URL","onClick":{"openLink":{"url":"https://example.org"}}}}]}]}]}]}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: `{"cards":[{"sections":[{"widgets":[{"keyValue":{"topLabel":"endpoint-name","content":"\u003cfont color='#36A64F'\u003eAn alert has been resolved after passing successfully 5 time(s) in a row\u003c/font\u003e","contentMultiline":"true","bottomLabel":":: description-2","icon":"BOOKMARK"}},{"keyValue":{"topLabel":"Condition results","content":"✅ [CONNECTED] == true\u003cbr\u003e✅ [STATUS] == 200\u003cbr\u003e","contentMultiline":"true","icon":"DESCRIPTION"}},{"buttons":[{"textButton":{"text":"URL","onClick":{"openLink":{"url":"https://example.org"}}}}]}]}]}]}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "icmp-should-not-include-url", // See https://github.com/TwiN/gatus/issues/362
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "icmp://example.org"},
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: `{"cards":[{"sections":[{"widgets":[{"keyValue":{"topLabel":"endpoint-name","content":"\u003cfont color='#DD0000'\u003eAn alert has been triggered due to having failed 3 time(s) in a row\u003c/font\u003e","contentMultiline":"true","bottomLabel":":: description-1","icon":"BOOKMARK"}},{"keyValue":{"topLabel":"Condition results","content":"❌ [CONNECTED] == true\u003cbr\u003e❌ [STATUS] == 200\u003cbr\u003e","contentMultiline":"true","icon":"DESCRIPTION"}}]}]}]}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "tcp-should-not-include-url", // See https://github.com/TwiN/gatus/issues/362
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "tcp://example.org"},
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: `{"cards":[{"sections":[{"widgets":[{"keyValue":{"topLabel":"endpoint-name","content":"\u003cfont color='#DD0000'\u003eAn alert has been triggered due to having failed 3 time(s) in a row\u003c/font\u003e","contentMultiline":"true","bottomLabel":":: description-1","icon":"BOOKMARK"}},{"keyValue":{"topLabel":"Condition results","content":"❌ [CONNECTED] == true\u003cbr\u003e❌ [STATUS] == 200\u003cbr\u003e","contentMultiline":"true","icon":"DESCRIPTION"}}]}]}]}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
body := scenario.Provider.buildRequestBody(
|
||||||
|
&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{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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
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
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
210
alerting/provider/incidentio/incidentio.go
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
package incidentio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/TwiN/logr"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
restAPIUrl = "https://api.incident.io/v2/alert_events/http/"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrURLNotSet = errors.New("url not set")
|
||||||
|
ErrURLNotPrefixedWithRestAPIURL = fmt.Errorf("url must be prefixed with %s", restAPIUrl)
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
ErrAuthTokenNotSet = errors.New("auth-token not set")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
URL string `yaml:"url,omitempty"`
|
||||||
|
AuthToken string `yaml:"auth-token,omitempty"`
|
||||||
|
SourceURL string `yaml:"source-url,omitempty"`
|
||||||
|
Metadata map[string]interface{} `yaml:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.URL) == 0 {
|
||||||
|
return ErrURLNotSet
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(cfg.URL, restAPIUrl) {
|
||||||
|
return ErrURLNotPrefixedWithRestAPIURL
|
||||||
|
}
|
||||||
|
if len(cfg.AuthToken) == 0 {
|
||||||
|
return ErrAuthTokenNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.URL) > 0 {
|
||||||
|
cfg.URL = override.URL
|
||||||
|
}
|
||||||
|
if len(override.AuthToken) > 0 {
|
||||||
|
cfg.AuthToken = override.AuthToken
|
||||||
|
}
|
||||||
|
if len(override.SourceURL) > 0 {
|
||||||
|
cfg.SourceURL = override.SourceURL
|
||||||
|
}
|
||||||
|
if len(override.Metadata) > 0 {
|
||||||
|
cfg.Metadata = override.Metadata
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using incident.io
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Override struct {
|
||||||
|
Group string `yaml:"group"`
|
||||||
|
Config `yaml:",inline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
req, err := http.NewRequest(http.MethodPost, cfg.URL, buffer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+cfg.AuthToken)
|
||||||
|
response, err := client.GetHTTPClient(nil).Do(req)
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
incidentioResponse := Response{}
|
||||||
|
err = json.NewDecoder(response.Body).Decode(&incidentioResponse)
|
||||||
|
if err != nil {
|
||||||
|
// Silently fail. We don't want to create tons of alerts just because we failed to parse the body.
|
||||||
|
logr.Errorf("[incidentio.Send] Ran into error decoding pagerduty response: %s", err.Error())
|
||||||
|
}
|
||||||
|
alert.ResolveKey = incidentioResponse.DeduplicationKey
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type Body struct {
|
||||||
|
AlertSourceConfigID string `json:"alert_source_config_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
DeduplicationKey string `json:"deduplication_key,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
SourceURL string `json:"source_url,omitempty"`
|
||||||
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Response struct {
|
||||||
|
DeduplicationKey string `json:"deduplication_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
|
var message, formattedConditionResults, status string
|
||||||
|
if resolved {
|
||||||
|
message = "An alert has been resolved after passing successfully " + strconv.Itoa(alert.SuccessThreshold) + " time(s) in a row"
|
||||||
|
status = "resolved"
|
||||||
|
} else {
|
||||||
|
message = "An alert has been triggered due to having failed " + strconv.Itoa(alert.FailureThreshold) + " time(s) in a row"
|
||||||
|
status = "firing"
|
||||||
|
}
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
var prefix string
|
||||||
|
if conditionResult.Success {
|
||||||
|
prefix = "🟢"
|
||||||
|
} else {
|
||||||
|
prefix = "🔴"
|
||||||
|
}
|
||||||
|
// No need for \n since incident.io trims it anyways.
|
||||||
|
formattedConditionResults += fmt.Sprintf(" %s %s ", prefix, conditionResult.Condition)
|
||||||
|
}
|
||||||
|
if len(alert.GetDescription()) > 0 {
|
||||||
|
message += " with the following description: " + alert.GetDescription()
|
||||||
|
}
|
||||||
|
message += fmt.Sprintf(" and the following conditions: %s ", formattedConditionResults)
|
||||||
|
var body []byte
|
||||||
|
alertSourceID := strings.TrimPrefix(cfg.URL, restAPIUrl)
|
||||||
|
body, _ = json.Marshal(Body{
|
||||||
|
AlertSourceConfigID: alertSourceID,
|
||||||
|
Title: "Gatus: " + ep.DisplayName(),
|
||||||
|
Status: status,
|
||||||
|
DeduplicationKey: alert.ResolveKey,
|
||||||
|
Description: message,
|
||||||
|
SourceURL: cfg.SourceURL,
|
||||||
|
Metadata: cfg.Metadata,
|
||||||
|
})
|
||||||
|
fmt.Printf("%v", string(body))
|
||||||
|
return body
|
||||||
|
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
|
return provider.DefaultAlert
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
390
alerting/provider/incidentio/incidentio_test.go
Normal file
@ -0,0 +1,390 @@
|
|||||||
|
package incidentio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"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) {
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
provider AlertProvider
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid",
|
||||||
|
provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
URL: "https://api.incident.io/v2/alert_events/http/some-id",
|
||||||
|
AuthToken: "some-token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-url",
|
||||||
|
provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
URL: "id-without-rest-api-url-as-prefix",
|
||||||
|
AuthToken: "some-token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-missing-auth-token",
|
||||||
|
provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
URL: "some-id",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-missing-alert-source-config-id",
|
||||||
|
provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
AuthToken: "some-token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid-override",
|
||||||
|
provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
AuthToken: "some-token",
|
||||||
|
URL: "https://api.incident.io/v2/alert_events/http/some-id",
|
||||||
|
},
|
||||||
|
Overrides: []Override{{Group: "core", Config: Config{URL: "https://api.incident.io/v2/alert_events/http/another-id"}}},
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
|
err := scenario.provider.Validate()
|
||||||
|
if scenario.expected && err != nil {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
|
if !scenario.expected && err == nil {
|
||||||
|
t.Error("expected error, got none")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
|
defer client.InjectHTTPClient(nil)
|
||||||
|
firstDescription := "description-1"
|
||||||
|
secondDescription := "description-2"
|
||||||
|
restAPIUrl := "https://api.incident.io/v2/alert_events/http/"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
MockRoundTripper test.MockRoundTripper
|
||||||
|
ExpectedError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{
|
||||||
|
URL: restAPIUrl + "some-id",
|
||||||
|
AuthToken: "some-token",
|
||||||
|
}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
var b bytes.Buffer
|
||||||
|
|
||||||
|
response := Response{DeduplicationKey: "some-key"}
|
||||||
|
json.NewEncoder(&b).Encode(response)
|
||||||
|
reader := io.NopCloser(&b)
|
||||||
|
return &http.Response{StatusCode: http.StatusAccepted, Body: reader}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-error",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{
|
||||||
|
URL: restAPIUrl + "some-id",
|
||||||
|
AuthToken: "some-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{
|
||||||
|
URL: restAPIUrl + "some-id",
|
||||||
|
AuthToken: "some-token",
|
||||||
|
}},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
var b bytes.Buffer
|
||||||
|
response := Response{DeduplicationKey: "some-key"}
|
||||||
|
json.NewEncoder(&b).Encode(response)
|
||||||
|
reader := io.NopCloser(&b)
|
||||||
|
return &http.Response{StatusCode: http.StatusAccepted, Body: reader}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-error",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{}},
|
||||||
|
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"
|
||||||
|
restAPIUrl := "https://api.incident.io/v2/alert_events/http/"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
ExpectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: `{"alert_source_config_id":"some-id","status":"firing","title":"Gatus: endpoint-name","description":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1 and the following conditions: 🔴 [CONNECTED] == true 🔴 [STATUS] == 200 "}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: `{"alert_source_config_id":"some-id","status":"resolved","title":"Gatus: endpoint-name","description":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2 and the following conditions: 🟢 [CONNECTED] == true 🟢 [STATUS] == 200 "}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-with-metadata-source-url",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token", Metadata: map[string]interface{}{"service": "some-service", "team": "very-core"}, SourceURL: "some-source-url"}},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: `{"alert_source_config_id":"some-id","status":"resolved","title":"Gatus: endpoint-name","description":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2 and the following conditions: 🟢 [CONNECTED] == true 🟢 [STATUS] == 200 ","source_url":"some-source-url","metadata":{"service":"some-service","team":"very-core"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "group-override",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}, Overrides: []Override{{Group: "g", Config: Config{URL: restAPIUrl + "different-id", AuthToken: "some-token"}}}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: `{"alert_source_config_id":"different-id","status":"firing","title":"Gatus: endpoint-name","description":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1 and the following conditions: 🔴 [CONNECTED] == true 🔴 [STATUS] == 200 "}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
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(
|
||||||
|
cfg,
|
||||||
|
&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 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{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{URL: "https://api.incident.io/v2/alert_events/http/diff-id"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{URL: "https://api.incident.io/v2/alert_events/http/diff-id", AuthToken: "some-token"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{URL: "https://api.incident.io/v2/alert_events/http/diff-id", AuthToken: "some-token"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{URL: "https://api.incident.io/v2/alert_events/http/diff-id", AuthToken: "some-token"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"url": "https://api.incident.io/v2/alert_events/http/another-id"}},
|
||||||
|
ExpectedOutput: Config{URL: "https://api.incident.io/v2/alert_events/http/another-id", AuthToken: "some-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.URL != scenario.ExpectedOutput.URL {
|
||||||
|
t.Errorf("expected alert source config to be %s, got %s", scenario.ExpectedOutput.URL, got.URL)
|
||||||
|
}
|
||||||
|
if got.AuthToken != scenario.ExpectedOutput.AuthToken {
|
||||||
|
t.Errorf("expected alert auth token to be %s, got %s", scenario.ExpectedOutput.AuthToken, got.AuthToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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_ValidateWithOverride(t *testing.T) {
|
||||||
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Config: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
|
||||||
|
Group: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||||
|
t.Error("provider Group shouldn't have been valid")
|
||||||
|
}
|
||||||
|
providerWithInvalidOverrideTo := AlertProvider{
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Config: Config{URL: "", AuthToken: "some-token"},
|
||||||
|
Group: "group",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
|
||||||
|
t.Error("provider integration key shouldn't have been valid")
|
||||||
|
}
|
||||||
|
providerWithValidOverride := AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "https://api.incident.io/v2/alert_events/http/nice-id", AuthToken: "some-token"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Config: Config{URL: "https://api.incident.io/v2/alert_events/http/very-good-id", AuthToken: "some-token"},
|
||||||
|
Group: "group",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := providerWithValidOverride.Validate(); err != nil {
|
||||||
|
t.Error("provider should've been valid")
|
||||||
|
}
|
||||||
|
}
|
||||||
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
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
247
alerting/provider/matrix/matrix.go
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
package matrix
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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 defaultServerURL = "https://matrix-client.matrix.org"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrAccessTokenNotSet = errors.New("access-token not set")
|
||||||
|
ErrInternalRoomID = errors.New("internal-room-id not set")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
// ServerURL is the custom homeserver to use (optional)
|
||||||
|
ServerURL string `yaml:"server-url"`
|
||||||
|
|
||||||
|
// AccessToken is the bot user's access token to send messages
|
||||||
|
AccessToken string `yaml:"access-token"`
|
||||||
|
|
||||||
|
// InternalRoomID is the room that the bot user has permissions to send messages to
|
||||||
|
InternalRoomID string `yaml:"internal-room-id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
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)
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.AccessToken) == 0 || len(override.InternalRoomID) == 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
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
||||||
|
// The Matrix endpoint requires a unique transaction ID for each event sent
|
||||||
|
txnId := randStringBytes(24)
|
||||||
|
request, err := http.NewRequest(
|
||||||
|
http.MethodPut,
|
||||||
|
fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/send/m.room.message/%s?access_token=%s",
|
||||||
|
cfg.ServerURL,
|
||||||
|
url.PathEscape(cfg.InternalRoomID),
|
||||||
|
txnId,
|
||||||
|
url.QueryEscape(cfg.AccessToken),
|
||||||
|
),
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
type Body struct {
|
||||||
|
MsgType string `json:"msgtype"`
|
||||||
|
Format string `json:"format"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
FormattedBody string `json:"formatted_body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestBody builds the request body for the provider
|
||||||
|
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
|
body, _ := json.Marshal(Body{
|
||||||
|
MsgType: "m.text",
|
||||||
|
Format: "org.matrix.custom.html",
|
||||||
|
Body: buildPlaintextMessageBody(ep, alert, result, resolved),
|
||||||
|
FormattedBody: buildHTMLMessageBody(ep, alert, result, resolved),
|
||||||
|
})
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildPlaintextMessageBody builds the message body in plaintext to include in request
|
||||||
|
func buildPlaintextMessageBody(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)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
var description string
|
||||||
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
|
description = "\n" + alertDescription
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s%s\n%s", message, description, formattedConditionResults)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildHTMLMessageBody builds the message body in HTML to include in request
|
||||||
|
func buildHTMLMessageBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
|
||||||
|
var message string
|
||||||
|
if resolved {
|
||||||
|
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 {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
var formattedConditionResults string
|
||||||
|
if len(result.ConditionResults) > 0 {
|
||||||
|
formattedConditionResults = "\n<h5>Condition results</h5><ul>"
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
var prefix string
|
||||||
|
if conditionResult.Success {
|
||||||
|
prefix = "✅"
|
||||||
|
} else {
|
||||||
|
prefix = "❌"
|
||||||
|
}
|
||||||
|
formattedConditionResults += fmt.Sprintf("<li>%s - <code>%s</code></li>", prefix, conditionResult.Condition)
|
||||||
|
}
|
||||||
|
formattedConditionResults += "</ul>"
|
||||||
|
}
|
||||||
|
var description string
|
||||||
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
|
description = fmt.Sprintf("\n<blockquote>%s</blockquote>", alertDescription)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("<h3>%s</h3>%s%s", message, description, formattedConditionResults)
|
||||||
|
}
|
||||||
|
|
||||||
|
func randStringBytes(n int) string {
|
||||||
|
// All the compatible characters to use in a transaction ID
|
||||||
|
const availableCharacterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
|
b := make([]byte, n)
|
||||||
|
rand.Seed(time.Now().UnixNano())
|
||||||
|
for i := range b {
|
||||||
|
b[i] = availableCharacterBytes[rand.Intn(len(availableCharacterBytes))]
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
387
alerting/provider/matrix/matrix_test.go
Normal file
@ -0,0 +1,387 @@
|
|||||||
|
package matrix
|
||||||
|
|
||||||
|
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{
|
||||||
|
AccessToken: "",
|
||||||
|
InternalRoomID: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
|
t.Error("provider shouldn't have been valid")
|
||||||
|
}
|
||||||
|
validProvider := AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
AccessToken: "1",
|
||||||
|
InternalRoomID: "!a:example.com",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := validProvider.Validate(); err != nil {
|
||||||
|
t.Error("provider should've been valid")
|
||||||
|
}
|
||||||
|
validProviderWithHomeserver := AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
ServerURL: "https://example.com",
|
||||||
|
AccessToken: "1",
|
||||||
|
InternalRoomID: "!a:example.com",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := validProviderWithHomeserver.Validate(); err != nil {
|
||||||
|
t.Error("provider with homeserver should've been valid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||||
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "",
|
||||||
|
Config: Config{
|
||||||
|
AccessToken: "",
|
||||||
|
InternalRoomID: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||||
|
t.Error("provider Group shouldn't have been valid")
|
||||||
|
}
|
||||||
|
providerWithInvalidOverrideTo := AlertProvider{
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{
|
||||||
|
AccessToken: "",
|
||||||
|
InternalRoomID: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
|
||||||
|
t.Error("provider integration key shouldn't have been valid")
|
||||||
|
}
|
||||||
|
providerWithValidOverride := AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
AccessToken: "1",
|
||||||
|
InternalRoomID: "!a:example.com",
|
||||||
|
},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{
|
||||||
|
ServerURL: "https://example.com",
|
||||||
|
AccessToken: "1",
|
||||||
|
InternalRoomID: "!a:example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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-with-bad-config",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: 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,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-error",
|
||||||
|
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.StatusInternalServerError, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{AccessToken: "1", InternalRoomID: "!a: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{AccessToken: "1", InternalRoomID: "!a: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
|
||||||
|
Resolved bool
|
||||||
|
ExpectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: "{\"msgtype\":\"m.text\",\"format\":\"org.matrix.custom.html\",\"body\":\"An alert for `endpoint-name` has been triggered due to having failed 3 time(s) in a row\\ndescription-1\\n\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"formatted_body\":\"\\u003ch3\\u003eAn alert for \\u003ccode\\u003eendpoint-name\\u003c/code\\u003e has been triggered due to having failed 3 time(s) in a row\\u003c/h3\\u003e\\n\\u003cblockquote\\u003edescription-1\\u003c/blockquote\\u003e\\n\\u003ch5\\u003eCondition results\\u003c/h5\\u003e\\u003cul\\u003e\\u003cli\\u003e❌ - \\u003ccode\\u003e[CONNECTED] == true\\u003c/code\\u003e\\u003c/li\\u003e\\u003cli\\u003e❌ - \\u003ccode\\u003e[STATUS] == 200\\u003c/code\\u003e\\u003c/li\\u003e\\u003c/ul\\u003e\"}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: "{\"msgtype\":\"m.text\",\"format\":\"org.matrix.custom.html\",\"body\":\"An alert for `endpoint-name` has been resolved after passing successfully 5 time(s) in a row\\ndescription-2\\n\\n✓ - [CONNECTED] == true\\n✓ - [STATUS] == 200\",\"formatted_body\":\"\\u003ch3\\u003eAn alert for \\u003ccode\\u003eendpoint-name\\u003c/code\\u003e has been resolved after passing successfully 5 time(s) in a row\\u003c/h3\\u003e\\n\\u003cblockquote\\u003edescription-2\\u003c/blockquote\\u003e\\n\\u003ch5\\u003eCondition results\\u003c/h5\\u003e\\u003cul\\u003e\\u003cli\\u003e✅ - \\u003ccode\\u003e[CONNECTED] == true\\u003c/code\\u003e\\u003c/li\\u003e\\u003cli\\u003e✅ - \\u003ccode\\u003e[STATUS] == 200\\u003c/code\\u003e\\u003c/li\\u003e\\u003c/ul\\u003e\"}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
body := scenario.Provider.buildRequestBody(
|
||||||
|
&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 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{
|
||||||
|
ServerURL: "https://example.com",
|
||||||
|
AccessToken: "1",
|
||||||
|
InternalRoomID: "!a:example.com",
|
||||||
|
},
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{
|
||||||
|
ServerURL: "https://example.com",
|
||||||
|
AccessToken: "1",
|
||||||
|
InternalRoomID: "!a:example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
ServerURL: "https://example.com",
|
||||||
|
AccessToken: "1",
|
||||||
|
InternalRoomID: "!a:example.com",
|
||||||
|
},
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{
|
||||||
|
ServerURL: "https://example.com",
|
||||||
|
AccessToken: "1",
|
||||||
|
InternalRoomID: "!a:example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
|
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",
|
||||||
|
InternalRoomID: "!a:group-example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{
|
||||||
|
ServerURL: "https://example.com",
|
||||||
|
AccessToken: "1",
|
||||||
|
InternalRoomID: "!a:example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
|
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",
|
||||||
|
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",
|
||||||
|
InternalRoomID: "!a:example01.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"server-url": "https://alert-example.com", "access-token": "123", "internal-room-id": "!a:alert-example.com"}},
|
||||||
|
ExpectedOutput: Config{
|
||||||
|
ServerURL: "https://alert-example.com",
|
||||||
|
AccessToken: "123",
|
||||||
|
InternalRoomID: "!a:alert-example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
outputConfig, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
211
alerting/provider/mattermost/mattermost.go
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
package mattermost
|
||||||
|
|
||||||
|
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"`
|
||||||
|
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
|
||||||
|
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 {
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
registeredGroups := make(map[string]bool)
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 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
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer([]byte(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(cfg.ClientConfig).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,omitempty"` // Optional channel override
|
||||||
|
Text string `json:"text"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
IconURL string `json:"icon_url"`
|
||||||
|
Attachments []Attachment `json:"attachments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Attachment struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Fallback string `json:"fallback"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Short bool `json:"short"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
Fields []Field `json:"fields"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Field struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Short bool `json:"short"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, color 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)
|
||||||
|
color = "#36A64F"
|
||||||
|
} 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)
|
||||||
|
color = "#DD0000"
|
||||||
|
}
|
||||||
|
var formattedConditionResults string
|
||||||
|
if len(result.ConditionResults) > 0 {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
body := Body{
|
||||||
|
Channel: cfg.Channel,
|
||||||
|
Text: "",
|
||||||
|
Username: "gatus",
|
||||||
|
IconURL: "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png",
|
||||||
|
Attachments: []Attachment{
|
||||||
|
{
|
||||||
|
Title: ":helmet_with_white_cross: Gatus",
|
||||||
|
Fallback: "Gatus - " + message,
|
||||||
|
Text: message + description,
|
||||||
|
Short: false,
|
||||||
|
Color: color,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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
|
||||||
|
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
|
||||||
|
}
|
||||||
287
alerting/provider/mattermost/mattermost_test.go
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
package mattermost
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
providerWithInvalidOverrideWebHookUrl := AlertProvider{
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Config: Config{WebhookURL: ""},
|
||||||
|
Group: "group",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := providerWithInvalidOverrideWebHookUrl.Validate(); err == nil {
|
||||||
|
t.Error("provider WebHookURL 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
|
||||||
|
Resolved bool
|
||||||
|
ExpectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: "{\"text\":\"\",\"username\":\"gatus\",\"icon_url\":\"https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"fallback\":\"Gatus - An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row\",\"text\":\"An alert for *endpoint-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: "resolved",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: "{\"text\":\"\",\"username\":\"gatus\",\"icon_url\":\"https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"fallback\":\"Gatus - An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row\",\"text\":\"An alert for *endpoint-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}]}]}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
body := scenario.Provider.buildRequestBody(
|
||||||
|
&scenario.Provider.DefaultConfig,
|
||||||
|
&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 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||