diff --git a/.examples/docker-compose-mtls/certs/client/client.crt b/.examples/docker-compose-mtls/certs/client/client.crt new file mode 100644 index 00000000..65b70513 --- /dev/null +++ b/.examples/docker-compose-mtls/certs/client/client.crt @@ -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----- diff --git a/.examples/docker-compose-mtls/certs/client/client.key b/.examples/docker-compose-mtls/certs/client/client.key new file mode 100644 index 00000000..c0c38d8b --- /dev/null +++ b/.examples/docker-compose-mtls/certs/client/client.key @@ -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----- diff --git a/.examples/docker-compose-mtls/certs/server/ca.crt b/.examples/docker-compose-mtls/certs/server/ca.crt new file mode 100644 index 00000000..17338ee8 --- /dev/null +++ b/.examples/docker-compose-mtls/certs/server/ca.crt @@ -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----- diff --git a/.examples/docker-compose-mtls/certs/server/server.crt b/.examples/docker-compose-mtls/certs/server/server.crt new file mode 100644 index 00000000..f936274e --- /dev/null +++ b/.examples/docker-compose-mtls/certs/server/server.crt @@ -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----- diff --git a/.examples/docker-compose-mtls/certs/server/server.key b/.examples/docker-compose-mtls/certs/server/server.key new file mode 100644 index 00000000..79bd58c2 --- /dev/null +++ b/.examples/docker-compose-mtls/certs/server/server.key @@ -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----- diff --git a/.examples/docker-compose-mtls/config/config.yaml b/.examples/docker-compose-mtls/config/config.yaml new file mode 100644 index 00000000..3fb44a9e --- /dev/null +++ b/.examples/docker-compose-mtls/config/config.yaml @@ -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 \ No newline at end of file diff --git a/.examples/docker-compose-mtls/docker-compose.yml b/.examples/docker-compose-mtls/docker-compose.yml new file mode 100644 index 00000000..9ea21f50 --- /dev/null +++ b/.examples/docker-compose-mtls/docker-compose.yml @@ -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: diff --git a/.examples/docker-compose-mtls/nginx/default.conf b/.examples/docker-compose-mtls/nginx/default.conf new file mode 100644 index 00000000..67bcbac7 --- /dev/null +++ b/.examples/docker-compose-mtls/nginx/default.conf @@ -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; + } +} \ No newline at end of file diff --git a/.github/assets/gitea-alerts.png b/.github/assets/gitea-alerts.png new file mode 100644 index 00000000..67491f0e Binary files /dev/null and b/.github/assets/gitea-alerts.png differ diff --git a/.github/assets/teams-workflows-alerts.png b/.github/assets/teams-workflows-alerts.png new file mode 100644 index 00000000..45cc040e Binary files /dev/null and b/.github/assets/teams-workflows-alerts.png differ diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index c16be451..d1a30256 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -22,7 +22,7 @@ jobs: steps: - uses: actions/setup-go@v5 with: - go-version: 1.19 + go-version: 1.22.2 repository: "${{ github.event.inputs.repository || 'TwiN/gatus' }}" ref: "${{ github.event.inputs.ref || 'master' }}" - uses: actions/checkout@v4 diff --git a/.github/workflows/publish-experimental.yml b/.github/workflows/publish-experimental.yml index 4c8bc568..5a28a5d4 100644 --- a/.github/workflows/publish-experimental.yml +++ b/.github/workflows/publish-experimental.yml @@ -18,7 +18,7 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Build and push Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: platforms: linux/amd64 pull: true diff --git a/.github/workflows/publish-latest-to-ghcr.yml b/.github/workflows/publish-latest-to-ghcr.yml index 1693884b..6760074e 100644 --- a/.github/workflows/publish-latest-to-ghcr.yml +++ b/.github/workflows/publish-latest-to-ghcr.yml @@ -30,9 +30,9 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: - platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64 + platforms: linux/amd64,linux/arm/v7,linux/arm64 pull: true push: true tags: ${{ env.IMAGE_REPOSITORY }}:latest diff --git a/.github/workflows/publish-latest.yml b/.github/workflows/publish-latest.yml index c3d0b442..22c86cc3 100644 --- a/.github/workflows/publish-latest.yml +++ b/.github/workflows/publish-latest.yml @@ -26,9 +26,9 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Build and push Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: - platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64 + platforms: linux/amd64,linux/arm/v7,linux/arm64 pull: true push: true tags: ${{ env.IMAGE_REPOSITORY }}:latest diff --git a/.github/workflows/publish-release-to-ghcr.yml b/.github/workflows/publish-release-to-ghcr.yml index affdda6f..da5873c8 100644 --- a/.github/workflows/publish-release-to-ghcr.yml +++ b/.github/workflows/publish-release-to-ghcr.yml @@ -26,9 +26,9 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: - platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64 + platforms: linux/amd64,linux/arm/v7,linux/arm64 pull: true push: true tags: | diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index b78de5fc..d12b9065 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -23,9 +23,9 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Build and push Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: - platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64 + platforms: linux/amd64,linux/arm/v7,linux/arm64 pull: true push: true tags: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4f3575fa..8f2f1dd2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,7 +18,7 @@ jobs: steps: - uses: actions/setup-go@v5 with: - go-version: 1.21 + go-version: 1.22.2 - uses: actions/checkout@v4 - name: Build binary to make sure it works run: go build @@ -28,7 +28,7 @@ jobs: # 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@v4.3.0 + uses: codecov/codecov-action@v4.5.0 with: files: ./coverage.txt token: ${{ secrets.CODECOV_TOKEN }} diff --git a/README.md b/README.md index 8c4d0419..de57009f 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,12 @@ [![Gatus](.github/assets/logo-with-dark-text.png)](https://gatus.io) ![test](https://github.com/TwiN/gatus/workflows/test/badge.svg?branch=master) -[![Join Discord server](https://img.shields.io/discord/442432928614449155.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/ka9RySaQ9K) [![Go Report Card](https://goreportcard.com/badge/github.com/TwiN/gatus?)](https://goreportcard.com/report/github.com/TwiN/gatus) [![codecov](https://codecov.io/gh/TwiN/gatus/branch/master/graph/badge.svg)](https://codecov.io/gh/TwiN/gatus) [![Go version](https://img.shields.io/github/go-mod/go-version/TwiN/gatus.svg)](https://github.com/TwiN/gatus) [![Docker pulls](https://img.shields.io/docker/pulls/twinproduction/gatus.svg)](https://cloud.docker.com/repository/docker/twinproduction/gatus) [![Follow TwiN](https://img.shields.io/github/followers/TwiN?label=Follow&style=social)](https://github.com/TwiN) - Gatus is a developer-oriented health dashboard that gives you the ability to monitor your services using HTTP, ICMP, TCP, and even DNS queries as well as evaluate the result of said queries by using a list of conditions on values like the status code, the response time, the certificate expiration, the body and many others. The icing on top is that each of these health @@ -55,6 +53,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga - [Alerting](#alerting) - [Configuring Discord alerts](#configuring-discord-alerts) - [Configuring Email alerts](#configuring-email-alerts) + - [Configuring Gitea alerts](#configuring-gitea-alerts) - [Configuring GitHub alerts](#configuring-github-alerts) - [Configuring GitLab alerts](#configuring-gitlab-alerts) - [Configuring Google Chat alerts](#configuring-google-chat-alerts) @@ -68,11 +67,13 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga - [Configuring PagerDuty alerts](#configuring-pagerduty-alerts) - [Configuring Pushover alerts](#configuring-pushover-alerts) - [Configuring Slack alerts](#configuring-slack-alerts) - - [Configuring Teams alerts](#configuring-teams-alerts) + - [Configuring Teams alerts *(Deprecated)*](#configuring-teams-alerts-deprecated) + - [Configuring Teams Workflow alerts](#configuring-teams-workflow-alerts) - [Configuring Telegram alerts](#configuring-telegram-alerts) - [Configuring Twilio alerts](#configuring-twilio-alerts) - [Configuring AWS SES alerts](#configuring-aws-ses-alerts) - [Configuring custom alerts](#configuring-custom-alerts) + - [Configuring Zulip alerts](#configuring-zulip-alerts) - [Setting a default alert](#setting-a-default-alert) - [Maintenance](#maintenance) - [Security](#security) @@ -110,6 +111,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga - [Configuring a startup delay](#configuring-a-startup-delay) - [Keeping your configuration small](#keeping-your-configuration-small) - [Proxy client configuration](#proxy-client-configuration) + - [How to fix 431 Request Header Fields Too Large error](#how-to-fix-431-request-header-fields-too-large-error) - [Badges](#badges) - [Uptime](#uptime) - [Health](#health) @@ -281,7 +283,7 @@ This allows you to monitor anything you want, even when what you want to check l For instance: - You can create your own agent that lives in a private network and pushes the status of your services to a publicly-exposed Gatus instance -- You can monitor services that are not supported by Gatus +- You can monitor services that are not supported by Gatus - You can implement your own monitoring system while using Gatus as the dashboard | Parameter | Description | Default | @@ -307,12 +309,13 @@ external-endpoints: To push the status of an external endpoint, the request would have to look like this: ``` -POST /api/v1/endpoints/{key}/external?success={success} +POST /api/v1/endpoints/{key}/external?success={success}&error={error} ``` Where: - `{key}` has the pattern `_` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`. - Using the example configuration above, the key would be `core_ext-ep-test`. - `{success}` is a boolean (`true` or `false`) value indicating whether the health check was successful or not. +- `{error}`: a string describing the reason for a failed health check. If {success} is false, this should contain the error message; if the check is successful, it can be omitted or left empty. You must also pass the token as a `Bearer` token in the `Authorization` header. @@ -565,7 +568,8 @@ endpoints: | `alerting.pagerduty` | Configuration for alerts of type `pagerduty`.
See [Configuring PagerDuty alerts](#configuring-pagerduty-alerts). | `{}` | | `alerting.pushover` | Configuration for alerts of type `pushover`.
See [Configuring Pushover alerts](#configuring-pushover-alerts). | `{}` | | `alerting.slack` | Configuration for alerts of type `slack`.
See [Configuring Slack alerts](#configuring-slack-alerts). | `{}` | -| `alerting.teams` | Configuration for alerts of type `teams`.
See [Configuring Teams alerts](#configuring-teams-alerts). | `{}` | +| `alerting.teams` | Configuration for alerts of type `teams`. *(Deprecated)*
See [Configuring Teams alerts](#configuring-teams-alerts-deprecated). | `{}` | +| `alerting.teams-workflows` | Configuration for alerts of type `teams-workflows`.
See [Configuring Teams Workflow alerts](#configuring-teams-workflow-alerts). | `{}` | | `alerting.telegram` | Configuration for alerts of type `telegram`.
See [Configuring Telegram alerts](#configuring-telegram-alerts). | `{}` | | `alerting.twilio` | Settings for alerts of type `twilio`.
See [Configuring Twilio alerts](#configuring-twilio-alerts). | `{}` | @@ -662,8 +666,45 @@ endpoints: > ⚠ Some mail servers are painfully slow. +#### Configuring Gitea alerts + +| Parameter | Description | Default | +|:---------------------------------|:-----------------------------------------------------------------------------------------------------------|:--------------| +| `alerting.gitea` | Configuration for alerts of type `gitea` | `{}` | +| `alerting.gitea.repository-url` | Gitea repository URL (e.g. `https://gitea.com/TwiN/example`) | Required `""` | +| `alerting.gitea.token` | Personal access token to use for authentication.
Must have at least RW on issues and RO on metadata. | Required `""` | +| `alerting.github.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert). | N/A | + +The Gitea alerting provider creates an issue prefixed with `alert(gatus):` and suffixed with the endpoint's display +name for each alert. If `send-on-resolved` is set to `true` on the endpoint alert, the issue will be automatically +closed when the alert is resolved. + +```yaml +alerting: + gitea: + repository-url: "https://gitea.com/TwiN/test" + token: "349d63f16......" + +endpoints: + - name: example + url: "https://twin.sh/health" + interval: 5m + conditions: + - "[STATUS] == 200" + - "[BODY].status == UP" + - "[RESPONSE_TIME] < 75" + alerts: + - type: gitea + failure-threshold: 2 + success-threshold: 3 + send-on-resolved: true + description: "Everything's burning AAAAAHHHHHHHHHHHHHHH" +``` + +![Gitea alert](.github/assets/gitea-alerts.png) #### Configuring GitHub alerts + | Parameter | Description | Default | |:---------------------------------|:-----------------------------------------------------------------------------------------------------------|:--------------| | `alerting.github` | Configuration for alerts of type `github` | `{}` | @@ -699,7 +740,6 @@ endpoints: ![GitHub alert](.github/assets/github-alerts.png) - #### Configuring GitLab alerts | Parameter | Description | Default | |:------------------------------------|:--------------------------------------------------------------------------------------------------------------------|:--------------| @@ -879,6 +919,7 @@ endpoints: |:----------------------------------------------|:--------------------------------------------------------------------------------------------|:--------------| | `alerting.mattermost` | Configuration for alerts of type `mattermost` | `{}` | | `alerting.mattermost.webhook-url` | Mattermost Webhook URL | Required `""` | +| `alerting.mattermost.channel` | Mattermost channel name override (optional) | `""` | | `alerting.mattermost.client` | Client configuration.
See [Client configuration](#client-configuration). | `{}` | | `alerting.mattermost.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert). | N/A | | `alerting.mattermost.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | @@ -945,14 +986,18 @@ endpoints: #### Configuring Ntfy alerts -| Parameter | Description | Default | -|:------------------------------|:-------------------------------------------------------------------------------------------|:------------------| -| `alerting.ntfy` | Configuration for alerts of type `ntfy` | `{}` | -| `alerting.ntfy.topic` | Topic at which the alert will be sent | Required `""` | -| `alerting.ntfy.url` | The URL of the target server | `https://ntfy.sh` | -| `alerting.ntfy.token` | [Access token](https://docs.ntfy.sh/publish/#access-tokens) for restricted topics | `""` | -| `alerting.ntfy.priority` | The priority of the alert | `3` | -| `alerting.ntfy.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | +| Parameter | Description | Default | +|:---------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------|:------------------| +| `alerting.ntfy` | Configuration for alerts of type `ntfy` | `{}` | +| `alerting.ntfy.topic` | Topic at which the alert will be sent | Required `""` | +| `alerting.ntfy.url` | The URL of the target server | `https://ntfy.sh` | +| `alerting.ntfy.token` | [Access token](https://docs.ntfy.sh/publish/#access-tokens) for restricted topics | `""` | +| `alerting.ntfy.email` | E-mail address for additional e-mail notifications | `""` | +| `alerting.ntfy.click` | Website opened when notification is clicked | `""` | +| `alerting.ntfy.priority` | The priority of the alert | `3` | +| `alerting.ntfy.disable-firebase` | Whether message push delivery via firebase should be disabled. [ntfy.sh defaults to enabled](https://docs.ntfy.sh/publish/#disable-firebase) | `false` | +| `alerting.ntfy.disable-cache` | Whether server side message caching should be disabled. [ntfy.sh defaults to enabled](https://docs.ntfy.sh/publish/#message-caching) | `false` | +| `alerting.ntfy.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | [ntfy](https://github.com/binwiederhier/ntfy) is an amazing project that allows you to subscribe to desktop and mobile notifications, making it an awesome addition to Gatus. @@ -1134,20 +1179,29 @@ Here's an example of what the notifications look like: ![Slack notifications](.github/assets/slack-alerts.png) -#### Configuring Teams alerts -| Parameter | Description | Default | -|:-----------------------------------------|:-------------------------------------------------------------------------------------------|:--------------| -| `alerting.teams` | Configuration for alerts of type `teams` | `{}` | -| `alerting.teams.webhook-url` | Teams Webhook URL | Required `""` | -| `alerting.teams.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | -| `alerting.teams.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | -| `alerting.teams.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | -| `alerting.teams.overrides[].webhook-url` | Teams Webhook URL | `""` | +#### Configuring Teams alerts *(Deprecated)* + +> [!CAUTION] +> **Deprecated:** Office 365 Connectors within Microsoft Teams are being retired ([Source: Microsoft DevBlog](https://devblogs.microsoft.com/microsoft365dev/retirement-of-office-365-connectors-within-microsoft-teams/)). +> Existing connectors will continue to work until December 2025. The new [Teams Workflow Alerts](#configuring-teams-workflow-alerts) should be used with Microsoft Workflows instead of this legacy configuration. + +| Parameter | Description | Default | +|:-----------------------------------------|:-------------------------------------------------------------------------------------------|:--------------------| +| `alerting.teams` | Configuration for alerts of type `teams` | `{}` | +| `alerting.teams.webhook-url` | Teams Webhook URL | Required `""` | +| `alerting.teams.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | +| `alerting.teams.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | +| `alerting.teams.title` | Title of the notification | `"🚨 Gatus"` | +| `alerting.teams.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | +| `alerting.teams.overrides[].webhook-url` | Teams Webhook URL | `""` | +| `alerting.teams.client.insecure` | Whether to skip TLS verification | `false` | ```yaml alerting: teams: webhook-url: "https://********.webhook.office.com/webhookb2/************" + client: + insecure: false # You can also add group-specific to keys, which will # override the to key above for the specified groups overrides: @@ -1184,16 +1238,75 @@ Here's an example of what the notifications look like: ![Teams notifications](.github/assets/teams-alerts.png) +#### Configuring Teams Workflow alerts + +> [!NOTE] +> This alert is compatible with Workflows for Microsoft Teams. To setup the workflow and get the webhook URL, follow the [Microsoft Documentation](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498). + +| Parameter | Description | Default | +|:---------------------------------------------------|:-------------------------------------------------------------------------------------------|:-------------------| +| `alerting.teams-workflows` | Configuration for alerts of type `teams` | `{}` | +| `alerting.teams-workflows.webhook-url` | Teams Webhook URL | Required `""` | +| `alerting.teams-workflows.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | +| `alerting.teams-workflows.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | +| `alerting.teams-workflows.title` | Title of the notification | `"⛑ Gatus"` | +| `alerting.teams-workflows.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | +| `alerting.teams-workflows.overrides[].webhook-url` | Teams WorkFlow Webhook URL | `""` | + +```yaml +alerting: + teams-workflows: + webhook-url: "https://********.webhook.office.com/webhookb2/************" + # You can also add group-specific to keys, which will + # override the to key above for the specified groups + overrides: + - group: "core" + webhook-url: "https://********.webhook.office.com/webhookb3/************" + +endpoints: + - name: website + url: "https://twin.sh/health" + interval: 30s + conditions: + - "[STATUS] == 200" + - "[BODY].status == UP" + - "[RESPONSE_TIME] < 300" + alerts: + - type: teams-workflows + description: "healthcheck failed" + send-on-resolved: true + + - name: back-end + group: core + url: "https://example.org/" + interval: 5m + conditions: + - "[STATUS] == 200" + - "[CERTIFICATE_EXPIRATION] > 48h" + alerts: + - type: teams-workflows + description: "healthcheck failed" + send-on-resolved: true +``` + +Here's an example of what the notifications look like: + +![Teams Workflow notifications](.github/assets/teams-workflows-alerts.png) + #### Configuring Telegram alerts -| Parameter | Description | Default | -|:----------------------------------|:-------------------------------------------------------------------------------------------|:---------------------------| -| `alerting.telegram` | Configuration for alerts of type `telegram` | `{}` | -| `alerting.telegram.token` | Telegram Bot Token | Required `""` | -| `alerting.telegram.id` | Telegram User ID | Required `""` | -| `alerting.telegram.api-url` | Telegram API URL | `https://api.telegram.org` | -| `alerting.telegram.client` | Client configuration.
See [Client configuration](#client-configuration). | `{}` | -| `alerting.telegram.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | +| Parameter | Description | Default | +|:--------------------------------------|:-------------------------------------------------------------------------------------------|:---------------------------| +| `alerting.telegram` | Configuration for alerts of type `telegram` | `{}` | +| `alerting.telegram.token` | Telegram Bot Token | Required `""` | +| `alerting.telegram.id` | Telegram User ID | Required `""` | +| `alerting.telegram.api-url` | Telegram API URL | `https://api.telegram.org` | +| `alerting.telegram.client` | Client configuration.
See [Client configuration](#client-configuration). | `{}` | +| `alerting.telegram.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | +| `alerting.telegram.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | +| `alerting.telegram.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | +| `alerting.telegram.overrides[].token` | Telegram Bot Token for override default value | `""` | +| `alerting.telegram.overrides[].id` | Telegram User ID for override default value | `""` | ```yaml alerting: @@ -1315,6 +1428,7 @@ Furthermore, you may use the following placeholders in the body (`alerting.custo - `[ENDPOINT_NAME]` (resolved from `endpoints[].name`) - `[ENDPOINT_GROUP]` (resolved from `endpoints[].group`) - `[ENDPOINT_URL]` (resolved from `endpoints[].url`) +- `[RESULT_ERRORS]` (resolved from the health evaluation of a given health check) If you have an alert using the `custom` provider with `send-on-resolved` set to `true`, you can use the `[ALERT_TRIGGERED_OR_RESOLVED]` placeholder to differentiate the notifications. @@ -1329,7 +1443,7 @@ alerting: method: "POST" body: | { - "text": "[ALERT_TRIGGERED_OR_RESOLVED]: [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION]" + "text": "[ALERT_TRIGGERED_OR_RESOLVED]: [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION] - [RESULT_ERRORS]" } endpoints: - name: website @@ -1450,6 +1564,42 @@ endpoints: - type: pagerduty ``` +#### Configuring Zulip alerts +| Parameter | Description | Default | +|:-----------------------------------------|:------------------------------------------------------------------------------------|:------------------------------------| +| `alerting.zulip` | Configuration for alerts of type `discord` | `{}` | +| `alerting.zulip.bot-email` | Bot Email | Required `""` | +| `alerting.zulip.bot-api-key` | Bot API key | Required `""` | +| `alerting.zulip.domain` | Full organization domain (e.g.: yourZulipDomain.zulipchat.com) | Required `""` | +| `alerting.zulip.channel-id` | The channel ID where Gatus will send the alerts | Required `""` | +| `alerting.zulip.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | +| `alerting.zulip.overrides[].bot-email` | . | `""` | +| `alerting.zulip.overrides[].bot-api-key` | . | `""` | +| `alerting.zulip.overrides[].domain` | . | `""` | +| `alerting.zulip.overrides[].channel-id` | . | `""` | + +```yaml +alerting: + zulip: + bot-email: gatus-bot@some.zulip.org + bot-api-key: "********************************" + domain: some.zulip.org + channel-id: 123456 + +endpoints: + - name: website + url: "https://twin.sh/health" + interval: 5m + conditions: + - "[STATUS] == 200" + - "[BODY].status == UP" + - "[RESPONSE_TIME] < 300" + alerts: + - type: zulip + description: "healthcheck failed" + send-on-resolved: true +``` + ### Maintenance If you have maintenance windows, you may not want to be annoyed by alerts. @@ -1460,15 +1610,15 @@ To do that, you'll have to use the maintenance configuration: | `maintenance.enabled` | Whether the maintenance period is enabled | `true` | | `maintenance.start` | Time at which the maintenance window starts in `hh:mm` format (e.g. `23:00`) | Required `""` | | `maintenance.duration` | Duration of the maintenance window (e.g. `1h`, `30m`) | Required `""` | +| `maintenance.timezone` | Timezone of the maintenance window format (e.g. `Europe/Amsterdam`).
See [List of tz database time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for more info | `UTC` | | `maintenance.every` | Days on which the maintenance period applies (e.g. `[Monday, Thursday]`).
If left empty, the maintenance window applies every day | `[]` | -> 📝 The maintenance configuration uses UTC - Here's an example: ```yaml maintenance: start: 23:00 duration: 1h + timezone: "Europe/Amsterdam" every: [Monday, Thursday] ``` Note that you can also specify each day on separate lines: @@ -1476,6 +1626,7 @@ Note that you can also specify each day on separate lines: maintenance: start: 23:00 duration: 1h + timezone: "Europe/Amsterdam" every: - Monday - Thursday @@ -1646,11 +1797,12 @@ Please refer to Helm's [documentation](https://helm.sh/docs/) to get started. Once Helm is set up properly, add the repository as follows: ```console -helm repo add minicloudlabs https://minicloudlabs.github.io/helm-charts +helm repo add twin https://twin.github.io/helm-charts +helm repo update +helm install gatus twin/gatus ``` -To get more details, please check [chart's configuration](https://github.com/minicloudlabs/helm-charts/tree/main/charts/gatus#configuration) -and [helm file example](https://github.com/minicloudlabs/helm-charts/tree/main/charts/gatus#helmfileyaml-example) +To get more details, please check [chart's configuration](https://github.com/TwiN/helm-charts/blob/master/charts/gatus/README.md). ### Terraform @@ -2123,7 +2275,7 @@ The path to generate a badge is the following: /api/v1/endpoints/{key}/uptimes/{duration}/badge.svg ``` Where: -- `{duration}` is `7d`, `24h` or `1h` +- `{duration}` is `30d` (alpha), `7d`, `24h` or `1h` - `{key}` has the pattern `_` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`. For instance, if you want the uptime during the last 24 hours from the endpoint `frontend` in the group `core`, @@ -2188,7 +2340,7 @@ The endpoint to generate a badge is the following: /api/v1/endpoints/{key}/response-times/{duration}/badge.svg ``` Where: -- `{duration}` is `7d`, `24h` or `1h` +- `{duration}` is `30d` (alpha), `7d`, `24h` or `1h` - `{key}` has the pattern `_` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`. diff --git a/alerting/alert/alert.go b/alerting/alert/alert.go index c1fbdcf8..2afcd8b5 100644 --- a/alerting/alert/alert.go +++ b/alerting/alert/alert.go @@ -1,7 +1,10 @@ package alert import ( + "crypto/sha256" + "encoding/hex" "errors" + "strconv" "strings" ) @@ -10,7 +13,7 @@ var ( ErrAlertWithInvalidDescription = errors.New("alert description must not have \" or \\") ) -// Alert is a core.Endpoint's alert configuration +// Alert is a endpoint.Endpoint's alert configuration type Alert struct { // Type of alert (required) Type Type `yaml:"type"` @@ -26,6 +29,9 @@ type Alert struct { // 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 @@ -38,9 +44,6 @@ type Alert struct { // or not for provider.ParseWithDefaultAlert to work. Use Alert.IsSendingOnResolved() for a non-pointer SendOnResolved *bool `yaml:"send-on-resolved"` - // SuccessThreshold defines how many successful executions must happen in a row before an ongoing incident is marked as resolved - SuccessThreshold int `yaml:"success-threshold"` - // 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:"-"` @@ -94,3 +97,17 @@ func (alert *Alert) IsSendingOnResolved() bool { } 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)) +} diff --git a/alerting/alert/alert_test.go b/alerting/alert/alert_test.go index 5f51eab1..ffe03eee 100644 --- a/alerting/alert/alert_test.go +++ b/alerting/alert/alert_test.go @@ -84,3 +84,109 @@ func TestAlert_IsSendingOnResolved(t *testing.T) { 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) + } + }) + } +} diff --git a/alerting/alert/type.go b/alerting/alert/type.go index 51febc00..ed282527 100644 --- a/alerting/alert/type.go +++ b/alerting/alert/type.go @@ -23,6 +23,9 @@ const ( // 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" @@ -64,4 +67,7 @@ const ( // TypeTwilio is the Type for the twilio alerting provider TypeTwilio Type = "twilio" + + // TypeZulip is the Type for the Zulip alerting provider + TypeZulip Type = "zulip" ) diff --git a/alerting/config.go b/alerting/config.go index c17d5649..9148670f 100644 --- a/alerting/config.go +++ b/alerting/config.go @@ -11,6 +11,7 @@ import ( "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" @@ -25,8 +26,10 @@ import ( "github.com/TwiN/gatus/v5/alerting/provider/pushover" "github.com/TwiN/gatus/v5/alerting/provider/slack" "github.com/TwiN/gatus/v5/alerting/provider/teams" + "github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows" "github.com/TwiN/gatus/v5/alerting/provider/telegram" "github.com/TwiN/gatus/v5/alerting/provider/twilio" + "github.com/TwiN/gatus/v5/alerting/provider/zulip" ) // Config is the configuration for alerting providers @@ -49,6 +52,9 @@ type Config struct { // 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"` @@ -85,11 +91,17 @@ type Config struct { // Teams is the configuration for the teams alerting provider Teams *teams.AlertProvider `yaml:"teams,omitempty"` + // TeamsWorkflows is the configuration for the teams alerting provider using the new Workflow App Webhook Connector + TeamsWorkflows *teamsworkflows.AlertProvider `yaml:"teams-workflows,omitempty"` + // Telegram is the configuration for the telegram alerting provider Telegram *telegram.AlertProvider `yaml:"telegram,omitempty"` // 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 diff --git a/alerting/provider/awsses/awsses.go b/alerting/provider/awsses/awsses.go index 17ba05cc..955531a1 100644 --- a/alerting/provider/awsses/awsses.go +++ b/alerting/provider/awsses/awsses.go @@ -5,7 +5,7 @@ import ( "strings" "github.com/TwiN/gatus/v5/alerting/alert" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/aws/credentials" @@ -57,14 +57,14 @@ func (provider *AlertProvider) IsValid() bool { } // Send an alert using the provider -func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { sess, err := provider.createSession() if err != nil { return err } svc := ses.New(sess) - subject, body := provider.buildMessageSubjectAndBody(endpoint, alert, result, resolved) - emails := strings.Split(provider.getToForGroup(endpoint.Group), ",") + subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved) + emails := strings.Split(provider.getToForGroup(ep.Group), ",") input := &ses.SendEmailInput{ Destination: &ses.Destination{ @@ -110,14 +110,14 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, } // buildMessageSubjectAndBody builds the message subject and body -func (provider *AlertProvider) buildMessageSubjectAndBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) (string, string) { +func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) { var subject, message string if resolved { - subject = fmt.Sprintf("[%s] Alert resolved", endpoint.DisplayName()) - message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold) + subject = fmt.Sprintf("[%s] Alert resolved", ep.DisplayName()) + message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold) } else { - subject = fmt.Sprintf("[%s] Alert triggered", endpoint.DisplayName()) - message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) + subject = fmt.Sprintf("[%s] Alert triggered", ep.DisplayName()) + message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold) } var formattedConditionResults string if len(result.ConditionResults) > 0 { diff --git a/alerting/provider/awsses/awsses_test.go b/alerting/provider/awsses/awsses_test.go index d13022b1..35b5eaf3 100644 --- a/alerting/provider/awsses/awsses_test.go +++ b/alerting/provider/awsses/awsses_test.go @@ -4,7 +4,7 @@ import ( "testing" "github.com/TwiN/gatus/v5/alerting/alert" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" ) func TestAlertDefaultProvider_IsValid(t *testing.T) { @@ -95,10 +95,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { for _, scenario := range scenarios { t.Run(scenario.Name, func(t *testing.T) { subject, body := scenario.Provider.buildMessageSubjectAndBody( - &core.Endpoint{Name: "endpoint-name"}, + &endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, - &core.Result{ - ConditionResults: []*core.ConditionResult{ + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved}, }, diff --git a/alerting/provider/custom/custom.go b/alerting/provider/custom/custom.go index 052af1ab..91555547 100644 --- a/alerting/provider/custom/custom.go +++ b/alerting/provider/custom/custom.go @@ -9,7 +9,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" ) // AlertProvider is the configuration necessary for sending an alert using a custom HTTP request @@ -50,16 +50,18 @@ func (provider *AlertProvider) GetAlertStatePlaceholderValue(resolved bool) stri return status } -func (provider *AlertProvider) buildHTTPRequest(endpoint *core.Endpoint, alert *alert.Alert, resolved bool) *http.Request { +func (provider *AlertProvider) buildHTTPRequest(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) *http.Request { body, url, method := provider.Body, provider.URL, provider.Method body = strings.ReplaceAll(body, "[ALERT_DESCRIPTION]", alert.GetDescription()) url = strings.ReplaceAll(url, "[ALERT_DESCRIPTION]", alert.GetDescription()) - body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", endpoint.Name) - url = strings.ReplaceAll(url, "[ENDPOINT_NAME]", endpoint.Name) - body = strings.ReplaceAll(body, "[ENDPOINT_GROUP]", endpoint.Group) - url = strings.ReplaceAll(url, "[ENDPOINT_GROUP]", endpoint.Group) - body = strings.ReplaceAll(body, "[ENDPOINT_URL]", endpoint.URL) - url = strings.ReplaceAll(url, "[ENDPOINT_URL]", endpoint.URL) + 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(true)) url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true)) @@ -78,8 +80,8 @@ func (provider *AlertProvider) buildHTTPRequest(endpoint *core.Endpoint, alert * return request } -func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { - request := provider.buildHTTPRequest(endpoint, alert, resolved) +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + request := provider.buildHTTPRequest(ep, alert, result, resolved) response, err := client.GetHTTPClient(provider.ClientConfig).Do(request) if err != nil { return err diff --git a/alerting/provider/custom/custom_test.go b/alerting/provider/custom/custom_test.go index 3927bb6c..67d94f65 100644 --- a/alerting/provider/custom/custom_test.go +++ b/alerting/provider/custom/custom_test.go @@ -8,7 +8,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/test" ) @@ -90,10 +90,10 @@ func TestAlertProvider_Send(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) { client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) err := scenario.Provider.Send( - &core.Endpoint{Name: "endpoint-name"}, + &endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, - &core.Result{ - ConditionResults: []*core.ConditionResult{ + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved}, }, @@ -138,8 +138,55 @@ func TestAlertProvider_buildHTTPRequest(t *testing.T) { for _, scenario := range scenarios { t.Run(fmt.Sprintf("resolved-%v-with-default-placeholders", scenario.Resolved), func(t *testing.T) { request := customAlertProvider.buildHTTPRequest( - &core.Endpoint{Name: "endpoint-name", Group: "endpoint-group", URL: "https://example.com"}, + &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) { + customAlertWithErrorsProvider := &AlertProvider{ + 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: customAlertWithErrorsProvider, + 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: customAlertWithErrorsProvider, + 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 := customAlertWithErrorsProvider.buildHTTPRequest( + &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 { @@ -188,8 +235,9 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) { for _, scenario := range scenarios { t.Run(fmt.Sprintf("resolved-%v-with-custom-placeholders", scenario.Resolved), func(t *testing.T) { request := customAlertProvider.buildHTTPRequest( - &core.Endpoint{Name: "endpoint-name", Group: "endpoint-group"}, + &endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"}, &alert.Alert{Description: &alertDescription}, + &endpoint.Result{}, scenario.Resolved, ) if request.URL.String() != scenario.ExpectedURL { diff --git a/alerting/provider/discord/discord.go b/alerting/provider/discord/discord.go index d4ac2c96..fe302934 100644 --- a/alerting/provider/discord/discord.go +++ b/alerting/provider/discord/discord.go @@ -9,7 +9,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" ) // AlertProvider is the configuration necessary for sending an alert using Discord @@ -47,9 +47,9 @@ func (provider *AlertProvider) IsValid() bool { } // Send an alert using the provider -func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { - buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) - request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer) +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved)) + request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer) if err != nil { return err } @@ -85,14 +85,14 @@ type Field struct { } // buildRequestBody builds the request body for the provider -func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { +func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { var message 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", endpoint.DisplayName(), alert.SuccessThreshold) + message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold) colorCode = 3066993 } else { - message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) + message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold) colorCode = 15158332 } var formattedConditionResults string diff --git a/alerting/provider/discord/discord_test.go b/alerting/provider/discord/discord_test.go index 2c8f1230..14d2d9bf 100644 --- a/alerting/provider/discord/discord_test.go +++ b/alerting/provider/discord/discord_test.go @@ -7,7 +7,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/test" ) @@ -127,10 +127,10 @@ func TestAlertProvider_Send(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) { client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) err := scenario.Provider.Send( - &core.Endpoint{Name: "endpoint-name"}, + &endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, - &core.Result{ - ConditionResults: []*core.ConditionResult{ + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved}, }, @@ -191,18 +191,18 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { } for _, scenario := range scenarios { t.Run(scenario.Name, func(t *testing.T) { - var conditionResults []*core.ConditionResult + var conditionResults []*endpoint.ConditionResult if !scenario.NoConditions { - conditionResults = []*core.ConditionResult{ + conditionResults = []*endpoint.ConditionResult{ {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved}, {Condition: "[BODY] != \"\"", Success: scenario.Resolved}, } } body := scenario.Provider.buildRequestBody( - &core.Endpoint{Name: "endpoint-name"}, + &endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, - &core.Result{ + &endpoint.Result{ ConditionResults: conditionResults, }, scenario.Resolved, diff --git a/alerting/provider/email/email.go b/alerting/provider/email/email.go index e2fe542b..391d607d 100644 --- a/alerting/provider/email/email.go +++ b/alerting/provider/email/email.go @@ -8,7 +8,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" gomail "gopkg.in/mail.v2" ) @@ -53,17 +53,17 @@ func (provider *AlertProvider) IsValid() bool { } // Send an alert using the provider -func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { var username string if len(provider.Username) > 0 { username = provider.Username } else { username = provider.From } - subject, body := provider.buildMessageSubjectAndBody(endpoint, alert, result, resolved) + subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved) m := gomail.NewMessage() m.SetHeader("From", provider.From) - m.SetHeader("To", strings.Split(provider.getToForGroup(endpoint.Group), ",")...) + m.SetHeader("To", strings.Split(provider.getToForGroup(ep.Group), ",")...) m.SetHeader("Subject", subject) m.SetBody("text/plain", body) var d *gomail.Dialer @@ -87,14 +87,14 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, } // buildMessageSubjectAndBody builds the message subject and body -func (provider *AlertProvider) buildMessageSubjectAndBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) (string, string) { +func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) { var subject, message string if resolved { - subject = fmt.Sprintf("[%s] Alert resolved", endpoint.DisplayName()) - message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold) + subject = fmt.Sprintf("[%s] Alert resolved", ep.DisplayName()) + message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold) } else { - subject = fmt.Sprintf("[%s] Alert triggered", endpoint.DisplayName()) - message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) + subject = fmt.Sprintf("[%s] Alert triggered", ep.DisplayName()) + message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold) } var formattedConditionResults string if len(result.ConditionResults) > 0 { diff --git a/alerting/provider/email/email_test.go b/alerting/provider/email/email_test.go index 4cab7134..a1134f27 100644 --- a/alerting/provider/email/email_test.go +++ b/alerting/provider/email/email_test.go @@ -4,7 +4,7 @@ import ( "testing" "github.com/TwiN/gatus/v5/alerting/alert" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" ) func TestAlertDefaultProvider_IsValid(t *testing.T) { @@ -97,10 +97,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { for _, scenario := range scenarios { t.Run(scenario.Name, func(t *testing.T) { subject, body := scenario.Provider.buildMessageSubjectAndBody( - &core.Endpoint{Name: "endpoint-name"}, + &endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, - &core.Result{ - ConditionResults: []*core.ConditionResult{ + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved}, }, diff --git a/alerting/provider/gitea/gitea.go b/alerting/provider/gitea/gitea.go new file mode 100644 index 00000000..489c0a86 --- /dev/null +++ b/alerting/provider/gitea/gitea.go @@ -0,0 +1,167 @@ +package gitea + +import ( + "crypto/tls" + "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" +) + +// AlertProvider is the configuration necessary for sending an alert using Discord +type AlertProvider 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 + + // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type + DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` + + // ClientConfig is the configuration of the client used to communicate with the provider's target + ClientConfig *client.Config `yaml:"client,omitempty"` + + // Assignees is a list of users to assign the issue to + Assignees []string `yaml:"assignees,omitempty"` + + username string + repositoryOwner string + repositoryName string + giteaClient *gitea.Client +} + +// IsValid returns whether the provider's configuration is valid +func (provider *AlertProvider) IsValid() bool { + if provider.ClientConfig == nil { + provider.ClientConfig = client.GetDefaultConfig() + } + + if len(provider.Token) == 0 || len(provider.RepositoryURL) == 0 { + return false + } + // Validate format of the repository URL + repositoryURL, err := url.Parse(provider.RepositoryURL) + if err != nil { + return false + } + baseURL := repositoryURL.Scheme + "://" + repositoryURL.Host + pathParts := strings.Split(repositoryURL.Path, "/") + if len(pathParts) != 3 { + return false + } + provider.repositoryOwner = pathParts[1] + provider.repositoryName = pathParts[2] + + opts := []gitea.ClientOption{ + gitea.SetToken(provider.Token), + } + + if provider.ClientConfig != nil && provider.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)) + } + + provider.giteaClient, err = gitea.NewClient(baseURL, opts...) + if err != nil { + return false + } + + user, _, err := provider.giteaClient.GetMyUserInfo() + if err != nil { + return false + } + + provider.username = user.UserName + + return true +} + +// 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 { + title := "alert(gatus): " + ep.DisplayName() + if !resolved { + _, _, err := provider.giteaClient.CreateIssue( + provider.repositoryOwner, + provider.repositoryName, + gitea.CreateIssueOption{ + Title: title, + Body: provider.buildIssueBody(ep, alert, result), + Assignees: provider.Assignees, + }, + ) + if err != nil { + return fmt.Errorf("failed to create issue: %w", err) + } + return nil + } + + issues, _, err := provider.giteaClient.ListRepoIssues( + provider.repositoryOwner, + provider.repositoryName, + gitea.ListIssueOption{ + State: gitea.StateOpen, + CreatedBy: provider.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 = provider.giteaClient.EditIssue( + provider.repositoryOwner, + provider.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 +} diff --git a/alerting/provider/gitea/gitea_test.go b/alerting/provider/gitea/gitea_test.go new file mode 100644 index 00000000..bac9dacd --- /dev/null +++ b/alerting/provider/gitea/gitea_test.go @@ -0,0 +1,169 @@ +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 TestAlertDefaultProvider_IsValid(t *testing.T) { + scenarios := []struct { + Name string + Provider AlertProvider + Expected bool + }{ + { + Name: "invalid", + Provider: AlertProvider{RepositoryURL: "", Token: ""}, + Expected: false, + }, + { + Name: "invalid-token", + Provider: AlertProvider{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"}, + Expected: false, + }, + { + Name: "missing-repository-name", + Provider: AlertProvider{RepositoryURL: "https://gitea.com/TwiN", Token: "12345"}, + Expected: false, + }, + { + Name: "enterprise-client", + Provider: AlertProvider{RepositoryURL: "https://gitea.example.com/TwiN/test", Token: "12345"}, + Expected: false, + }, + { + Name: "invalid-url", + Provider: AlertProvider{RepositoryURL: "gitea.com/TwiN/test", Token: "12345"}, + Expected: false, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + if scenario.Provider.IsValid() != scenario.Expected { + t.Errorf("expected %t, got %t", scenario.Expected, scenario.Provider.IsValid()) + } + }) + } +} + +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{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{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) { + scenario.Provider.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") + } +} diff --git a/alerting/provider/github/github.go b/alerting/provider/github/github.go index f86b9053..c425d669 100644 --- a/alerting/provider/github/github.go +++ b/alerting/provider/github/github.go @@ -8,7 +8,7 @@ import ( "time" "github.com/TwiN/gatus/v5/alerting/alert" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/google/go-github/v48/github" "golang.org/x/oauth2" ) @@ -70,12 +70,12 @@ func (provider *AlertProvider) IsValid() bool { // 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(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { - title := "alert(gatus): " + endpoint.DisplayName() +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + title := "alert(gatus): " + ep.DisplayName() if !resolved { _, _, err := provider.githubClient.Issues.Create(context.Background(), provider.repositoryOwner, provider.repositoryName, &github.IssueRequest{ Title: github.String(title), - Body: github.String(provider.buildIssueBody(endpoint, alert, result)), + Body: github.String(provider.buildIssueBody(ep, alert, result)), }) if err != nil { return fmt.Errorf("failed to create issue: %w", err) @@ -104,7 +104,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, } // buildIssueBody builds the body of the issue -func (provider *AlertProvider) buildIssueBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result) string { +func (provider *AlertProvider) buildIssueBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result) string { var formattedConditionResults string if len(result.ConditionResults) > 0 { formattedConditionResults = "\n\n## Condition results\n" @@ -122,7 +122,7 @@ func (provider *AlertProvider) buildIssueBody(endpoint *core.Endpoint, alert *al 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", endpoint.DisplayName(), alert.FailureThreshold) + message := fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold) return message + description + formattedConditionResults } diff --git a/alerting/provider/github/github_test.go b/alerting/provider/github/github_test.go index d1f45e48..e69a3197 100644 --- a/alerting/provider/github/github_test.go +++ b/alerting/provider/github/github_test.go @@ -7,7 +7,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/test" "github.com/google/go-github/v48/github" ) @@ -85,10 +85,10 @@ func TestAlertProvider_Send(t *testing.T) { scenario.Provider.githubClient = github.NewClient(nil) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) err := scenario.Provider.Send( - &core.Endpoint{Name: "endpoint-name", Group: "endpoint-group"}, + &endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"}, &scenario.Alert, - &core.Result{ - ConditionResults: []*core.ConditionResult{ + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved}, }, @@ -109,7 +109,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { firstDescription := "description-1" scenarios := []struct { Name string - Endpoint core.Endpoint + Endpoint endpoint.Endpoint Provider AlertProvider Alert alert.Alert NoConditions bool @@ -117,14 +117,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { }{ { Name: "triggered", - Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"}, + 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: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"}, + 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`", @@ -132,7 +132,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { { Name: "triggered-with-no-conditions", NoConditions: true, - Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"}, + 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", @@ -140,9 +140,9 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { } for _, scenario := range scenarios { t.Run(scenario.Name, func(t *testing.T) { - var conditionResults []*core.ConditionResult + var conditionResults []*endpoint.ConditionResult if !scenario.NoConditions { - conditionResults = []*core.ConditionResult{ + conditionResults = []*endpoint.ConditionResult{ {Condition: "[CONNECTED] == true", Success: true}, {Condition: "[STATUS] == 200", Success: false}, } @@ -150,7 +150,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { body := scenario.Provider.buildIssueBody( &scenario.Endpoint, &scenario.Alert, - &core.Result{ConditionResults: conditionResults}, + &endpoint.Result{ConditionResults: conditionResults}, ) if strings.TrimSpace(body) != strings.TrimSpace(scenario.ExpectedBody) { t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body) diff --git a/alerting/provider/gitlab/gitlab.go b/alerting/provider/gitlab/gitlab.go index f87b4c4e..d9d5676a 100644 --- a/alerting/provider/gitlab/gitlab.go +++ b/alerting/provider/gitlab/gitlab.go @@ -11,7 +11,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/google/uuid" ) @@ -51,11 +51,11 @@ func (provider *AlertProvider) IsValid() bool { // 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(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { if len(alert.ResolveKey) == 0 { alert.ResolveKey = uuid.NewString() } - buffer := bytes.NewBuffer(provider.buildAlertBody(endpoint, alert, result, resolved)) + buffer := bytes.NewBuffer(provider.buildAlertBody(ep, alert, result, resolved)) request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer) if err != nil { return err @@ -94,21 +94,21 @@ func (provider *AlertProvider) monitoringTool() string { return "gatus" } -func (provider *AlertProvider) service(endpoint *core.Endpoint) string { +func (provider *AlertProvider) service(ep *endpoint.Endpoint) string { if len(provider.Service) > 0 { return provider.Service } - return endpoint.DisplayName() + return ep.DisplayName() } // buildAlertBody builds the body of the alert -func (provider *AlertProvider) buildAlertBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { +func (provider *AlertProvider) buildAlertBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { body := AlertBody{ - Title: fmt.Sprintf("alert(%s): %s", provider.monitoringTool(), provider.service(endpoint)), + Title: fmt.Sprintf("alert(%s): %s", provider.monitoringTool(), provider.service(ep)), StartTime: result.Timestamp.Format(time.RFC3339), - Service: provider.service(endpoint), + Service: provider.service(ep), MonitoringTool: provider.monitoringTool(), - Hosts: endpoint.URL, + Hosts: ep.URL, GitlabEnvironmentName: provider.EnvironmentName, Severity: provider.Severity, Fingerprint: alert.ResolveKey, @@ -135,9 +135,9 @@ func (provider *AlertProvider) buildAlertBody(endpoint *core.Endpoint, alert *al } var message string if resolved { - message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold) + message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold) } else { - message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) + message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold) } body.Description = message + description + formattedConditionResults bodyAsJSON, _ := json.Marshal(body) diff --git a/alerting/provider/gitlab/gitlab_test.go b/alerting/provider/gitlab/gitlab_test.go index 8ea389fd..290b2e6e 100644 --- a/alerting/provider/gitlab/gitlab_test.go +++ b/alerting/provider/gitlab/gitlab_test.go @@ -7,7 +7,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/test" ) @@ -84,10 +84,10 @@ func TestAlertProvider_Send(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) { client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) err := scenario.Provider.Send( - &core.Endpoint{Name: "endpoint-name", Group: "endpoint-group"}, + &endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"}, &scenario.Alert, - &core.Result{ - ConditionResults: []*core.ConditionResult{ + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved}, }, @@ -108,21 +108,21 @@ func TestAlertProvider_buildAlertBody(t *testing.T) { firstDescription := "description-1" scenarios := []struct { Name string - Endpoint core.Endpoint + Endpoint endpoint.Endpoint Provider AlertProvider Alert alert.Alert ExpectedBody string }{ { Name: "triggered", - Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"}, + Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"}, Provider: AlertProvider{}, 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\"}", }, { Name: "no-description", - Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"}, + Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"}, Provider: AlertProvider{}, 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\"}", @@ -133,8 +133,8 @@ func TestAlertProvider_buildAlertBody(t *testing.T) { body := scenario.Provider.buildAlertBody( &scenario.Endpoint, &scenario.Alert, - &core.Result{ - ConditionResults: []*core.ConditionResult{ + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ {Condition: "[CONNECTED] == true", Success: true}, {Condition: "[STATUS] == 200", Success: false}, }, diff --git a/alerting/provider/googlechat/googlechat.go b/alerting/provider/googlechat/googlechat.go index 19330c92..fb2f8f97 100644 --- a/alerting/provider/googlechat/googlechat.go +++ b/alerting/provider/googlechat/googlechat.go @@ -9,7 +9,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" ) // AlertProvider is the configuration necessary for sending an alert using Google chat @@ -50,9 +50,9 @@ func (provider *AlertProvider) IsValid() bool { } // Send an alert using the provider -func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { - buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) - request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer) +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved)) + request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer) if err != nil { return err } @@ -112,7 +112,7 @@ type OpenLink struct { } // buildRequestBody builds the request body for the provider -func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { +func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { var message, color string if resolved { color = "#36A64F" @@ -143,7 +143,7 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert * Widgets: []Widgets{ { KeyValue: &KeyValue{ - TopLabel: endpoint.DisplayName(), + TopLabel: ep.DisplayName(), Content: message, ContentMultiline: "true", BottomLabel: description, @@ -166,7 +166,7 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert * }, }) } - if endpoint.Type() == core.EndpointTypeHTTP { + 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 @@ -175,7 +175,7 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert * { TextButton: TextButton{ Text: "URL", - OnClick: OnClick{OpenLink: OpenLink{URL: endpoint.URL}}, + OnClick: OnClick{OpenLink: OpenLink{URL: ep.URL}}, }, }, }, diff --git a/alerting/provider/googlechat/googlechat_test.go b/alerting/provider/googlechat/googlechat_test.go index 7cd2c0a9..78e5e6ea 100644 --- a/alerting/provider/googlechat/googlechat_test.go +++ b/alerting/provider/googlechat/googlechat_test.go @@ -7,7 +7,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/test" ) @@ -116,10 +116,10 @@ func TestAlertProvider_Send(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) { client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) err := scenario.Provider.Send( - &core.Endpoint{Name: "endpoint-name", Group: "endpoint-group"}, + &endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"}, &scenario.Alert, - &core.Result{ - ConditionResults: []*core.ConditionResult{ + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved}, }, @@ -141,7 +141,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { secondDescription := "description-2" scenarios := []struct { Name string - Endpoint core.Endpoint + Endpoint endpoint.Endpoint Provider AlertProvider Alert alert.Alert Resolved bool @@ -149,7 +149,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { }{ { Name: "triggered", - Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"}, + Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"}, Provider: AlertProvider{}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Resolved: false, @@ -157,7 +157,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { }, { Name: "resolved", - Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"}, + Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"}, Provider: AlertProvider{}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Resolved: true, @@ -165,7 +165,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { }, { Name: "icmp-should-not-include-url", // See https://github.com/TwiN/gatus/issues/362 - Endpoint: core.Endpoint{Name: "endpoint-name", URL: "icmp://example.org"}, + Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "icmp://example.org"}, Provider: AlertProvider{}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Resolved: false, @@ -173,7 +173,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { }, { Name: "tcp-should-not-include-url", // See https://github.com/TwiN/gatus/issues/362 - Endpoint: core.Endpoint{Name: "endpoint-name", URL: "tcp://example.org"}, + Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "tcp://example.org"}, Provider: AlertProvider{}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Resolved: false, @@ -185,8 +185,8 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { body := scenario.Provider.buildRequestBody( &scenario.Endpoint, &scenario.Alert, - &core.Result{ - ConditionResults: []*core.ConditionResult{ + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved}, }, diff --git a/alerting/provider/gotify/gotify.go b/alerting/provider/gotify/gotify.go index 7f4bfcf6..7b1f7a61 100644 --- a/alerting/provider/gotify/gotify.go +++ b/alerting/provider/gotify/gotify.go @@ -9,7 +9,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" ) const DefaultPriority = 5 @@ -41,8 +41,8 @@ func (provider *AlertProvider) IsValid() bool { } // Send an alert using the provider -func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { - buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved)) request, err := http.NewRequest(http.MethodPost, provider.ServerURL+"/message?token="+provider.Token, buffer) if err != nil { return err @@ -67,12 +67,12 @@ type Body struct { } // buildRequestBody builds the request body for the provider -func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { +func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { var message string if resolved { - message = fmt.Sprintf("An alert for `%s` has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold) + message = fmt.Sprintf("An alert for `%s` has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold) } else { - message = fmt.Sprintf("An alert for `%s` has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) + message = fmt.Sprintf("An alert for `%s` has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold) } var formattedConditionResults string for _, conditionResult := range result.ConditionResults { @@ -88,7 +88,7 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert * message += " with the following description: " + alert.GetDescription() } message += formattedConditionResults - title := "Gatus: " + endpoint.DisplayName() + title := "Gatus: " + ep.DisplayName() if provider.Title != "" { title = provider.Title } diff --git a/alerting/provider/gotify/gotify_test.go b/alerting/provider/gotify/gotify_test.go index 19a68b97..af644f48 100644 --- a/alerting/provider/gotify/gotify_test.go +++ b/alerting/provider/gotify/gotify_test.go @@ -6,7 +6,7 @@ import ( "testing" "github.com/TwiN/gatus/v5/alerting/alert" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" ) func TestAlertProvider_IsValid(t *testing.T) { @@ -49,7 +49,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { var ( description = "custom-description" //title = "custom-title" - endpoint = "custom-endpoint" + endpointName = "custom-endpoint" ) scenarios := []struct { Name string @@ -63,30 +63,30 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { Provider: AlertProvider{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}", endpoint, description), + 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{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}", endpoint, description), + 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{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}", endpoint, description), + 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( - &core.Endpoint{Name: endpoint}, + &endpoint.Endpoint{Name: endpointName}, &scenario.Alert, - &core.Result{ - ConditionResults: []*core.ConditionResult{ + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved}, }, diff --git a/alerting/provider/jetbrainsspace/jetbrainsspace.go b/alerting/provider/jetbrainsspace/jetbrainsspace.go index bf031663..aa9d928f 100644 --- a/alerting/provider/jetbrainsspace/jetbrainsspace.go +++ b/alerting/provider/jetbrainsspace/jetbrainsspace.go @@ -9,7 +9,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" ) // AlertProvider is the configuration necessary for sending an alert using JetBrains Space @@ -46,8 +46,8 @@ func (provider *AlertProvider) IsValid() bool { } // Send an alert using the provider -func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { - buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved)) url := fmt.Sprintf("https://%s.jetbrains.space/api/http/chats/messages/send-message", provider.Project) request, err := http.NewRequest(http.MethodPost, url, buffer) if err != nil { @@ -103,9 +103,9 @@ type Icon struct { } // buildRequestBody builds the request body for the provider -func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { +func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { body := Body{ - Channel: "id:" + provider.getChannelIDForGroup(endpoint.Group), + Channel: "id:" + provider.getChannelIDForGroup(ep.Group), Content: Content{ ClassName: "ChatMessage.Block", Sections: []Section{{ @@ -116,10 +116,10 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert * } 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", endpoint.DisplayName(), alert.SuccessThreshold) + 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", endpoint.DisplayName(), alert.FailureThreshold) + 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" diff --git a/alerting/provider/jetbrainsspace/jetbrainsspace_test.go b/alerting/provider/jetbrainsspace/jetbrainsspace_test.go index 8eae2590..c9fbcd9c 100644 --- a/alerting/provider/jetbrainsspace/jetbrainsspace_test.go +++ b/alerting/provider/jetbrainsspace/jetbrainsspace_test.go @@ -7,7 +7,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/test" ) @@ -120,10 +120,10 @@ func TestAlertProvider_Send(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) { client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) err := scenario.Provider.Send( - &core.Endpoint{Name: "endpoint-name"}, + &endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, - &core.Result{ - ConditionResults: []*core.ConditionResult{ + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved}, }, @@ -146,7 +146,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { scenarios := []struct { Name string Provider AlertProvider - Endpoint core.Endpoint + Endpoint endpoint.Endpoint Alert alert.Alert Resolved bool ExpectedBody string @@ -154,7 +154,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { { Name: "triggered", Provider: AlertProvider{}, - Endpoint: core.Endpoint{Name: "name"}, + Endpoint: endpoint.Endpoint{Name: "name"}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Resolved: false, ExpectedBody: `{"channel":"id:","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"}]}}`, @@ -162,7 +162,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { { Name: "triggered-with-group", Provider: AlertProvider{}, - Endpoint: core.Endpoint{Name: "name", Group: "group"}, + Endpoint: endpoint.Endpoint{Name: "name", Group: "group"}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Resolved: false, ExpectedBody: `{"channel":"id:","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"}]}}`, @@ -170,7 +170,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { { Name: "resolved", Provider: AlertProvider{}, - Endpoint: core.Endpoint{Name: "name"}, + Endpoint: endpoint.Endpoint{Name: "name"}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Resolved: true, ExpectedBody: `{"channel":"id:","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"}]}}`, @@ -178,7 +178,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { { Name: "resolved-with-group", Provider: AlertProvider{}, - Endpoint: core.Endpoint{Name: "name", Group: "group"}, + Endpoint: endpoint.Endpoint{Name: "name", Group: "group"}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Resolved: true, ExpectedBody: `{"channel":"id:","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"}]}}`, @@ -189,8 +189,8 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { body := scenario.Provider.buildRequestBody( &scenario.Endpoint, &scenario.Alert, - &core.Result{ - ConditionResults: []*core.ConditionResult{ + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved}, }, diff --git a/alerting/provider/matrix/matrix.go b/alerting/provider/matrix/matrix.go index 92907889..4bc8754d 100644 --- a/alerting/provider/matrix/matrix.go +++ b/alerting/provider/matrix/matrix.go @@ -12,7 +12,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" ) // AlertProvider is the configuration necessary for sending an alert using Matrix @@ -61,9 +61,9 @@ func (provider *AlertProvider) IsValid() bool { } // Send an alert using the provider -func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { - buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) - config := provider.getConfigForGroup(endpoint.Group) +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved)) + config := provider.getConfigForGroup(ep.Group) if config.ServerURL == "" { config.ServerURL = defaultServerURL } @@ -103,23 +103,23 @@ type Body struct { } // buildRequestBody builds the request body for the provider -func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { +func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { body, _ := json.Marshal(Body{ MsgType: "m.text", Format: "org.matrix.custom.html", - Body: buildPlaintextMessageBody(endpoint, alert, result, resolved), - FormattedBody: buildHTMLMessageBody(endpoint, alert, result, resolved), + 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(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string { +func buildPlaintextMessageBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string { var message string if resolved { - message = fmt.Sprintf("An alert for `%s` has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold) + message = fmt.Sprintf("An alert for `%s` has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold) } else { - message = fmt.Sprintf("An alert for `%s` has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) + message = fmt.Sprintf("An alert for `%s` has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold) } var formattedConditionResults string for _, conditionResult := range result.ConditionResults { @@ -139,12 +139,12 @@ func buildPlaintextMessageBody(endpoint *core.Endpoint, alert *alert.Alert, resu } // buildHTMLMessageBody builds the message body in HTML to include in request -func buildHTMLMessageBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string { +func buildHTMLMessageBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string { var message string if resolved { - message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold) + message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold) } else { - message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) + message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold) } var formattedConditionResults string if len(result.ConditionResults) > 0 { diff --git a/alerting/provider/matrix/matrix_test.go b/alerting/provider/matrix/matrix_test.go index 2d9ae57a..923cae55 100644 --- a/alerting/provider/matrix/matrix_test.go +++ b/alerting/provider/matrix/matrix_test.go @@ -7,7 +7,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/test" ) @@ -149,10 +149,10 @@ func TestAlertProvider_Send(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) { client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) err := scenario.Provider.Send( - &core.Endpoint{Name: "endpoint-name"}, + &endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, - &core.Result{ - ConditionResults: []*core.ConditionResult{ + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved}, }, @@ -197,10 +197,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { for _, scenario := range scenarios { t.Run(scenario.Name, func(t *testing.T) { body := scenario.Provider.buildRequestBody( - &core.Endpoint{Name: "endpoint-name"}, + &endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, - &core.Result{ - ConditionResults: []*core.ConditionResult{ + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved}, }, diff --git a/alerting/provider/mattermost/mattermost.go b/alerting/provider/mattermost/mattermost.go index 41b34542..23899e28 100644 --- a/alerting/provider/mattermost/mattermost.go +++ b/alerting/provider/mattermost/mattermost.go @@ -9,13 +9,16 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" ) // AlertProvider is the configuration necessary for sending an alert using Mattermost type AlertProvider struct { WebhookURL string `yaml:"webhook-url"` + // Channel is the optional setting to override the default webhook's channel + Channel string `yaml:"channel,omitempty"` + // ClientConfig is the configuration of the client used to communicate with the provider's target ClientConfig *client.Config `yaml:"client,omitempty"` @@ -50,9 +53,9 @@ func (provider *AlertProvider) IsValid() bool { } // Send an alert using the provider -func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { - buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved))) - request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer) +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(ep, alert, result, resolved))) + request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer) if err != nil { return err } @@ -70,6 +73,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, } type Body struct { + Channel string `json:"channel,omitempty"` // Optional channel override Text string `json:"text"` Username string `json:"username"` IconURL string `json:"icon_url"` @@ -92,13 +96,13 @@ type Field struct { } // buildRequestBody builds the request body for the provider -func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { +func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { var message, color string if resolved { - message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold) + message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold) color = "#36A64F" } else { - message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) + message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold) color = "#DD0000" } var formattedConditionResults string @@ -118,6 +122,7 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert * description = ":\n> " + alertDescription } body := Body{ + Channel: provider.Channel, Text: "", Username: "gatus", IconURL: "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png", diff --git a/alerting/provider/mattermost/mattermost_test.go b/alerting/provider/mattermost/mattermost_test.go index 016ac406..b476bfc8 100644 --- a/alerting/provider/mattermost/mattermost_test.go +++ b/alerting/provider/mattermost/mattermost_test.go @@ -7,7 +7,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/test" ) @@ -120,10 +120,10 @@ func TestAlertProvider_Send(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) { client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) err := scenario.Provider.Send( - &core.Endpoint{Name: "endpoint-name"}, + &endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, - &core.Result{ - ConditionResults: []*core.ConditionResult{ + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved}, }, @@ -168,10 +168,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { for _, scenario := range scenarios { t.Run(scenario.Name, func(t *testing.T) { body := scenario.Provider.buildRequestBody( - &core.Endpoint{Name: "endpoint-name"}, + &endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, - &core.Result{ - ConditionResults: []*core.ConditionResult{ + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved}, }, diff --git a/alerting/provider/messagebird/messagebird.go b/alerting/provider/messagebird/messagebird.go index eaf93937..235a0a60 100644 --- a/alerting/provider/messagebird/messagebird.go +++ b/alerting/provider/messagebird/messagebird.go @@ -9,7 +9,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" ) const ( @@ -33,8 +33,8 @@ func (provider *AlertProvider) IsValid() bool { // Send an alert using the provider // Reference doc for messagebird: https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms -func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { - buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved)) request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer) if err != nil { return err @@ -60,12 +60,12 @@ type Body struct { } // buildRequestBody builds the request body for the provider -func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { +func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { var message string if resolved { - message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription()) + message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription()) } else { - message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription()) + message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription()) } body, _ := json.Marshal(Body{ Originator: provider.Originator, diff --git a/alerting/provider/messagebird/messagebird_test.go b/alerting/provider/messagebird/messagebird_test.go index a5a7b3b2..22c9a4d6 100644 --- a/alerting/provider/messagebird/messagebird_test.go +++ b/alerting/provider/messagebird/messagebird_test.go @@ -7,7 +7,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/test" ) @@ -83,10 +83,10 @@ func TestAlertProvider_Send(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) { client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) err := scenario.Provider.Send( - &core.Endpoint{Name: "endpoint-name"}, + &endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, - &core.Result{ - ConditionResults: []*core.ConditionResult{ + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved}, }, @@ -131,10 +131,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { for _, scenario := range scenarios { t.Run(scenario.Name, func(t *testing.T) { body := scenario.Provider.buildRequestBody( - &core.Endpoint{Name: "endpoint-name"}, + &endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, - &core.Result{ - ConditionResults: []*core.ConditionResult{ + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved}, }, diff --git a/alerting/provider/ntfy/ntfy.go b/alerting/provider/ntfy/ntfy.go index bd10ef83..784caf39 100644 --- a/alerting/provider/ntfy/ntfy.go +++ b/alerting/provider/ntfy/ntfy.go @@ -11,7 +11,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" ) const ( @@ -21,10 +21,14 @@ const ( // AlertProvider is the configuration necessary for sending an alert using Slack type AlertProvider struct { - Topic string `yaml:"topic"` - URL string `yaml:"url,omitempty"` // Defaults to DefaultURL - Priority int `yaml:"priority,omitempty"` // Defaults to DefaultPriority - Token string `yaml:"token,omitempty"` // Defaults to "" + Topic string `yaml:"topic"` + URL string `yaml:"url,omitempty"` // Defaults to DefaultURL + Priority int `yaml:"priority,omitempty"` // Defaults to DefaultPriority + Token string `yaml:"token,omitempty"` // Defaults to "" + Email string `yaml:"email,omitempty"` // Defaults to "" + Click string `yaml:"click,omitempty"` // Defaults to "" + DisableFirebase bool `yaml:"disable-firebase,omitempty"` // Defaults to false + DisableCache bool `yaml:"disable-cache,omitempty"` // Defaults to false // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` @@ -46,8 +50,8 @@ func (provider *AlertProvider) IsValid() bool { } // Send an alert using the provider -func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { - buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved)) request, err := http.NewRequest(http.MethodPost, provider.URL, buffer) if err != nil { return err @@ -56,6 +60,12 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, if len(provider.Token) > 0 { request.Header.Set("Authorization", "Bearer "+provider.Token) } + if provider.DisableFirebase { + request.Header.Set("Firebase", "no") + } + if provider.DisableCache { + request.Header.Set("Cache", "no") + } response, err := client.GetHTTPClient(nil).Do(request) if err != nil { return err @@ -74,10 +84,12 @@ type Body struct { Message string `json:"message"` Tags []string `json:"tags"` Priority int `json:"priority"` + Email string `json:"email,omitempty"` + Click string `json:"click,omitempty"` } // buildRequestBody builds the request body for the provider -func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { +func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { var message, formattedConditionResults, tag string if resolved { tag = "white_check_mark" @@ -101,10 +113,12 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert * message += formattedConditionResults body, _ := json.Marshal(Body{ Topic: provider.Topic, - Title: "Gatus: " + endpoint.DisplayName(), + Title: "Gatus: " + ep.DisplayName(), Message: message, Tags: []string{tag}, Priority: provider.Priority, + Email: provider.Email, + Click: provider.Click, }) return body } diff --git a/alerting/provider/ntfy/ntfy_test.go b/alerting/provider/ntfy/ntfy_test.go index 6786f3b3..452533f4 100644 --- a/alerting/provider/ntfy/ntfy_test.go +++ b/alerting/provider/ntfy/ntfy_test.go @@ -2,10 +2,13 @@ package ntfy import ( "encoding/json" + "io" + "net/http" + "net/http/httptest" "testing" "github.com/TwiN/gatus/v5/alerting/alert" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" ) func TestAlertDefaultProvider_IsValid(t *testing.T) { @@ -88,14 +91,28 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { Resolved: true, ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\n🟢 [CONNECTED] == true\n🟢 [STATUS] == 200","tags":["white_check_mark"],"priority":2}`, }, + { + Name: "triggered-email", + Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com"}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`, + }, + { + Name: "resolved-email", + Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 2, Email: "test@example.com", Click: "example.com"}, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\n🟢 [CONNECTED] == true\n🟢 [STATUS] == 200","tags":["white_check_mark"],"priority":2,"email":"test@example.com","click":"example.com"}`, + }, } for _, scenario := range scenarios { t.Run(scenario.Name, func(t *testing.T) { body := scenario.Provider.buildRequestBody( - &core.Endpoint{Name: "endpoint-name"}, + &endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, - &core.Result{ - ConditionResults: []*core.ConditionResult{ + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved}, }, @@ -112,3 +129,99 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { }) } } + +func TestAlertProvider_Send(t *testing.T) { + description := "description-1" + scenarios := []struct { + Name string + Provider AlertProvider + Alert alert.Alert + Resolved bool + ExpectedBody string + ExpectedHeaders map[string]string + }{ + { + Name: "triggered", + Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com"}, + Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`, + ExpectedHeaders: map[string]string{ + "Content-Type": "application/json", + }, + }, + { + Name: "no firebase", + Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableFirebase: true}, + Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`, + ExpectedHeaders: map[string]string{ + "Content-Type": "application/json", + "Firebase": "no", + }, + }, + { + Name: "no cache", + Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableCache: true}, + Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`, + ExpectedHeaders: map[string]string{ + "Content-Type": "application/json", + "Cache": "no", + }, + }, + { + Name: "neither firebase & cache", + Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableFirebase: true, DisableCache: true}, + Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`, + ExpectedHeaders: map[string]string{ + "Content-Type": "application/json", + "Firebase": "no", + "Cache": "no", + }, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + // Start a local HTTP server + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + // Test request parameters + for header, value := range scenario.ExpectedHeaders { + if value != req.Header.Get(header) { + t.Errorf("expected: %s, got: %s", value, req.Header.Get(header)) + } + } + body, _ := io.ReadAll(req.Body) + if string(body) != scenario.ExpectedBody { + t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body) + } + // Send response to be tested + rw.Write([]byte(`OK`)) + })) + // Close the server when test finishes + defer server.Close() + + scenario.Provider.URL = server.URL + 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 err != nil { + t.Error("Encountered an error on Send: ", err) + } + + }) + } + +} diff --git a/alerting/provider/opsgenie/opsgenie.go b/alerting/provider/opsgenie/opsgenie.go index 5d105661..a0b4167f 100644 --- a/alerting/provider/opsgenie/opsgenie.go +++ b/alerting/provider/opsgenie/opsgenie.go @@ -11,7 +11,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" ) const ( @@ -59,13 +59,13 @@ func (provider *AlertProvider) IsValid() bool { // Send an alert using the provider // // Relevant: https://docs.opsgenie.com/docs/alert-api -func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { - err := provider.createAlert(endpoint, alert, result, resolved) +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + err := provider.createAlert(ep, alert, result, resolved) if err != nil { return err } if resolved { - err = provider.closeAlert(endpoint, alert) + err = provider.closeAlert(ep, alert) if err != nil { return err } @@ -75,20 +75,20 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, // The alert has been resolved and there's no error, so we can clear the alert's ResolveKey alert.ResolveKey = "" } else { - alert.ResolveKey = provider.alias(buildKey(endpoint)) + alert.ResolveKey = provider.alias(buildKey(ep)) } } return nil } -func (provider *AlertProvider) createAlert(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { - payload := provider.buildCreateRequestBody(endpoint, alert, result, resolved) +func (provider *AlertProvider) createAlert(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + payload := provider.buildCreateRequestBody(ep, alert, result, resolved) return provider.sendRequest(restAPI, http.MethodPost, payload) } -func (provider *AlertProvider) closeAlert(endpoint *core.Endpoint, alert *alert.Alert) error { - payload := provider.buildCloseRequestBody(endpoint, alert) - url := restAPI + "/" + provider.alias(buildKey(endpoint)) + "/close?identifierType=alias" +func (provider *AlertProvider) closeAlert(ep *endpoint.Endpoint, alert *alert.Alert) error { + payload := provider.buildCloseRequestBody(ep, alert) + url := restAPI + "/" + provider.alias(buildKey(ep)) + "/close?identifierType=alias" return provider.sendRequest(url, http.MethodPost, payload) } @@ -115,17 +115,17 @@ func (provider *AlertProvider) sendRequest(url, method string, payload interface return nil } -func (provider *AlertProvider) buildCreateRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) alertCreateRequest { +func (provider *AlertProvider) buildCreateRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) alertCreateRequest { var message, description string if resolved { - message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription()) - description = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold) + message = fmt.Sprintf("RESOLVED: %s - %s", ep.Name, alert.GetDescription()) + description = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold) } else { - message = fmt.Sprintf("%s - %s", endpoint.Name, alert.GetDescription()) - description = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) + message = fmt.Sprintf("%s - %s", ep.Name, alert.GetDescription()) + description = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold) } - if endpoint.Group != "" { - message = fmt.Sprintf("[%s] %s", endpoint.Group, message) + if ep.Group != "" { + message = fmt.Sprintf("[%s] %s", ep.Group, message) } var formattedConditionResults string for _, conditionResult := range result.ConditionResults { @@ -138,10 +138,10 @@ func (provider *AlertProvider) buildCreateRequestBody(endpoint *core.Endpoint, a formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition) } description = description + "\n" + formattedConditionResults - key := buildKey(endpoint) + key := buildKey(ep) details := map[string]string{ - "endpoint:url": endpoint.URL, - "endpoint:group": endpoint.Group, + "endpoint:url": ep.URL, + "endpoint:group": ep.Group, "result:hostname": result.Hostname, "result:ip": result.IP, "result:dns_code": result.DNSRCode, @@ -167,10 +167,10 @@ func (provider *AlertProvider) buildCreateRequestBody(endpoint *core.Endpoint, a } } -func (provider *AlertProvider) buildCloseRequestBody(endpoint *core.Endpoint, alert *alert.Alert) alertCloseRequest { +func (provider *AlertProvider) buildCloseRequestBody(ep *endpoint.Endpoint, alert *alert.Alert) alertCloseRequest { return alertCloseRequest{ - Source: buildKey(endpoint), - Note: fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription()), + Source: buildKey(ep), + Note: fmt.Sprintf("RESOLVED: %s - %s", ep.Name, alert.GetDescription()), } } @@ -211,12 +211,12 @@ func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { return provider.DefaultAlert } -func buildKey(endpoint *core.Endpoint) string { - name := toKebabCase(endpoint.Name) - if endpoint.Group == "" { +func buildKey(ep *endpoint.Endpoint) string { + name := toKebabCase(ep.Name) + if ep.Group == "" { return name } - return toKebabCase(endpoint.Group) + "-" + name + return toKebabCase(ep.Group) + "-" + name } func toKebabCase(val string) string { diff --git a/alerting/provider/opsgenie/opsgenie_test.go b/alerting/provider/opsgenie/opsgenie_test.go index ab054b86..746eb387 100644 --- a/alerting/provider/opsgenie/opsgenie_test.go +++ b/alerting/provider/opsgenie/opsgenie_test.go @@ -7,7 +7,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/test" ) @@ -79,10 +79,10 @@ func TestAlertProvider_Send(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) { client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) err := scenario.Provider.Send( - &core.Endpoint{Name: "endpoint-name"}, + &endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, - &core.Result{ - ConditionResults: []*core.ConditionResult{ + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved}, }, @@ -106,8 +106,8 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) { Name string Provider *AlertProvider Alert *alert.Alert - Endpoint *core.Endpoint - Result *core.Result + Endpoint *endpoint.Endpoint + Result *endpoint.Result Resolved bool want alertCreateRequest }{ @@ -115,8 +115,8 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) { Name: "missing all params (unresolved)", Provider: &AlertProvider{}, Alert: &alert.Alert{}, - Endpoint: &core.Endpoint{}, - Result: &core.Result{}, + Endpoint: &endpoint.Endpoint{}, + Result: &endpoint.Result{}, Resolved: false, want: alertCreateRequest{ Message: " - ", @@ -133,8 +133,8 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) { Name: "missing all params (resolved)", Provider: &AlertProvider{}, Alert: &alert.Alert{}, - Endpoint: &core.Endpoint{}, - Result: &core.Result{}, + Endpoint: &endpoint.Endpoint{}, + Result: &endpoint.Result{}, Resolved: true, want: alertCreateRequest{ Message: "RESOLVED: - ", @@ -154,11 +154,11 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) { Description: &description, FailureThreshold: 3, }, - Endpoint: &core.Endpoint{ + Endpoint: &endpoint.Endpoint{ Name: "my super app", }, - Result: &core.Result{ - ConditionResults: []*core.ConditionResult{ + Result: &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ { Condition: "[STATUS] == 200", Success: true, @@ -194,11 +194,11 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) { Description: &description, SuccessThreshold: 4, }, - Endpoint: &core.Endpoint{ + Endpoint: &endpoint.Endpoint{ Name: "my mega app", }, - Result: &core.Result{ - ConditionResults: []*core.ConditionResult{ + Result: &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ { Condition: "[STATUS] == 200", Success: true, @@ -226,17 +226,17 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) { Description: &description, FailureThreshold: 6, }, - Endpoint: &core.Endpoint{ + Endpoint: &endpoint.Endpoint{ Name: "my app", Group: "end game", URL: "https://my.go/app", }, - Result: &core.Result{ + Result: &endpoint.Result{ HTTPStatus: 400, Hostname: "my.go", Errors: []string{"error 01", "error 02"}, Success: false, - ConditionResults: []*core.ConditionResult{ + ConditionResults: []*endpoint.ConditionResult{ { Condition: "[STATUS] == 200", Success: false, @@ -279,14 +279,14 @@ func TestAlertProvider_buildCloseRequestBody(t *testing.T) { Name string Provider *AlertProvider Alert *alert.Alert - Endpoint *core.Endpoint + Endpoint *endpoint.Endpoint want alertCloseRequest }{ { Name: "Missing all values", Provider: &AlertProvider{}, Alert: &alert.Alert{}, - Endpoint: &core.Endpoint{}, + Endpoint: &endpoint.Endpoint{}, want: alertCloseRequest{ Source: "", Note: "RESOLVED: - ", @@ -298,7 +298,7 @@ func TestAlertProvider_buildCloseRequestBody(t *testing.T) { Alert: &alert.Alert{ Description: &description, }, - Endpoint: &core.Endpoint{ + Endpoint: &endpoint.Endpoint{ Name: "endpoint name", }, want: alertCloseRequest{ diff --git a/alerting/provider/pagerduty/pagerduty.go b/alerting/provider/pagerduty/pagerduty.go index b3ab6e01..cc76751f 100644 --- a/alerting/provider/pagerduty/pagerduty.go +++ b/alerting/provider/pagerduty/pagerduty.go @@ -10,7 +10,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" ) const ( @@ -52,8 +52,8 @@ func (provider *AlertProvider) IsValid() bool { // Send an alert using the provider // // Relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/ -func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { - buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved)) request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer) if err != nil { return err @@ -101,19 +101,19 @@ type Payload struct { } // buildRequestBody builds the request body for the provider -func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { +func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { var message, eventAction, resolveKey string if resolved { - message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription()) + message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription()) eventAction = "resolve" resolveKey = alert.ResolveKey } else { - message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription()) + message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription()) eventAction = "trigger" resolveKey = "" } body, _ := json.Marshal(Body{ - RoutingKey: provider.getIntegrationKeyForGroup(endpoint.Group), + RoutingKey: provider.getIntegrationKeyForGroup(ep.Group), DedupKey: resolveKey, EventAction: eventAction, Payload: Payload{ diff --git a/alerting/provider/pagerduty/pagerduty_test.go b/alerting/provider/pagerduty/pagerduty_test.go index 4523f280..23d0b410 100644 --- a/alerting/provider/pagerduty/pagerduty_test.go +++ b/alerting/provider/pagerduty/pagerduty_test.go @@ -7,7 +7,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/test" ) @@ -115,10 +115,10 @@ func TestAlertProvider_Send(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) { client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) err := scenario.Provider.Send( - &core.Endpoint{Name: "endpoint-name"}, + &endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, - &core.Result{ - ConditionResults: []*core.ConditionResult{ + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved}, }, @@ -161,7 +161,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { } for _, scenario := range scenarios { t.Run(scenario.Name, func(t *testing.T) { - body := scenario.Provider.buildRequestBody(&core.Endpoint{Name: "endpoint-name"}, &scenario.Alert, &core.Result{}, scenario.Resolved) + body := scenario.Provider.buildRequestBody(&endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, &endpoint.Result{}, scenario.Resolved) if string(body) != scenario.ExpectedBody { t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body) } diff --git a/alerting/provider/provider.go b/alerting/provider/provider.go index f8c5257b..5bccc8a4 100644 --- a/alerting/provider/provider.go +++ b/alerting/provider/provider.go @@ -6,6 +6,7 @@ import ( "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" @@ -19,9 +20,11 @@ import ( "github.com/TwiN/gatus/v5/alerting/provider/pushover" "github.com/TwiN/gatus/v5/alerting/provider/slack" "github.com/TwiN/gatus/v5/alerting/provider/teams" + "github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows" "github.com/TwiN/gatus/v5/alerting/provider/telegram" "github.com/TwiN/gatus/v5/alerting/provider/twilio" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/alerting/provider/zulip" + "github.com/TwiN/gatus/v5/config/endpoint" ) // AlertProvider is the interface that each provider should implement @@ -33,7 +36,7 @@ type AlertProvider interface { GetDefaultAlert() *alert.Alert // Send an alert using the provider - Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error + Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error } // ParseWithDefaultAlert parses an Endpoint alert by using the provider's default alert as a baseline @@ -66,6 +69,7 @@ var ( _ AlertProvider = (*email.AlertProvider)(nil) _ AlertProvider = (*github.AlertProvider)(nil) _ AlertProvider = (*gitlab.AlertProvider)(nil) + _ AlertProvider = (*gitea.AlertProvider)(nil) _ AlertProvider = (*googlechat.AlertProvider)(nil) _ AlertProvider = (*jetbrainsspace.AlertProvider)(nil) _ AlertProvider = (*matrix.AlertProvider)(nil) @@ -77,6 +81,8 @@ var ( _ AlertProvider = (*pushover.AlertProvider)(nil) _ AlertProvider = (*slack.AlertProvider)(nil) _ AlertProvider = (*teams.AlertProvider)(nil) + _ AlertProvider = (*teamsworkflows.AlertProvider)(nil) _ AlertProvider = (*telegram.AlertProvider)(nil) _ AlertProvider = (*twilio.AlertProvider)(nil) + _ AlertProvider = (*zulip.AlertProvider)(nil) ) diff --git a/alerting/provider/pushover/pushover.go b/alerting/provider/pushover/pushover.go index 6b5343fd..b90b17a5 100644 --- a/alerting/provider/pushover/pushover.go +++ b/alerting/provider/pushover/pushover.go @@ -9,7 +9,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" ) const ( @@ -52,8 +52,8 @@ func (provider *AlertProvider) IsValid() bool { // Send an alert using the provider // Reference doc for pushover: https://pushover.net/api -func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { - buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved)) request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer) if err != nil { return err @@ -81,12 +81,12 @@ type Body struct { } // buildRequestBody builds the request body for the provider -func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { +func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { var message string if resolved { - message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription()) + message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription()) } else { - message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription()) + message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription()) } body, _ := json.Marshal(Body{ Token: provider.ApplicationToken, diff --git a/alerting/provider/pushover/pushover_test.go b/alerting/provider/pushover/pushover_test.go index 57f03fb5..942a82bf 100644 --- a/alerting/provider/pushover/pushover_test.go +++ b/alerting/provider/pushover/pushover_test.go @@ -7,7 +7,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/test" ) @@ -95,10 +95,10 @@ func TestAlertProvider_Send(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) { client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) err := scenario.Provider.Send( - &core.Endpoint{Name: "endpoint-name"}, + &endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, - &core.Result{ - ConditionResults: []*core.ConditionResult{ + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved}, }, @@ -150,10 +150,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { for _, scenario := range scenarios { t.Run(scenario.Name, func(t *testing.T) { body := scenario.Provider.buildRequestBody( - &core.Endpoint{Name: "endpoint-name"}, + &endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, - &core.Result{ - ConditionResults: []*core.ConditionResult{ + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved}, }, diff --git a/alerting/provider/slack/slack.go b/alerting/provider/slack/slack.go index e498cee1..46d327b2 100644 --- a/alerting/provider/slack/slack.go +++ b/alerting/provider/slack/slack.go @@ -9,7 +9,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" ) // AlertProvider is the configuration necessary for sending an alert using Slack @@ -42,9 +42,9 @@ func (provider *AlertProvider) IsValid() bool { } // Send an alert using the provider -func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { - buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) - request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer) +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved)) + request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer) if err != nil { return err } @@ -81,13 +81,13 @@ type Field struct { } // buildRequestBody builds the request body for the provider -func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { +func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { var message, color string if resolved { - message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold) + message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold) color = "#36A64F" } else { - message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) + message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold) color = "#DD0000" } var formattedConditionResults string diff --git a/alerting/provider/slack/slack_test.go b/alerting/provider/slack/slack_test.go index 4bd10e15..f3e95fcd 100644 --- a/alerting/provider/slack/slack_test.go +++ b/alerting/provider/slack/slack_test.go @@ -7,7 +7,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/test" ) @@ -116,10 +116,10 @@ func TestAlertProvider_Send(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) { client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) err := scenario.Provider.Send( - &core.Endpoint{Name: "endpoint-name"}, + &endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, - &core.Result{ - ConditionResults: []*core.ConditionResult{ + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved}, }, @@ -142,7 +142,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { scenarios := []struct { Name string Provider AlertProvider - Endpoint core.Endpoint + Endpoint endpoint.Endpoint Alert alert.Alert NoConditions bool Resolved bool @@ -151,7 +151,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { { Name: "triggered", Provider: AlertProvider{}, - Endpoint: core.Endpoint{Name: "name"}, + Endpoint: endpoint.Endpoint{Name: "name"}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Resolved: false, ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\"short\":false}]}]}", @@ -159,7 +159,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { { Name: "triggered-with-group", Provider: AlertProvider{}, - Endpoint: core.Endpoint{Name: "name", Group: "group"}, + Endpoint: endpoint.Endpoint{Name: "name", Group: "group"}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Resolved: false, ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *group/name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\"short\":false}]}]}", @@ -168,7 +168,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { Name: "triggered-with-no-conditions", NoConditions: true, Provider: AlertProvider{}, - Endpoint: core.Endpoint{Name: "name"}, + Endpoint: endpoint.Endpoint{Name: "name"}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Resolved: false, ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\"}]}", @@ -176,7 +176,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { { Name: "resolved", Provider: AlertProvider{}, - Endpoint: core.Endpoint{Name: "name"}, + Endpoint: endpoint.Endpoint{Name: "name"}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Resolved: true, ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}", @@ -184,7 +184,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { { Name: "resolved-with-group", Provider: AlertProvider{}, - Endpoint: core.Endpoint{Name: "name", Group: "group"}, + Endpoint: endpoint.Endpoint{Name: "name", Group: "group"}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Resolved: true, ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}", @@ -192,9 +192,9 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { } for _, scenario := range scenarios { t.Run(scenario.Name, func(t *testing.T) { - var conditionResults []*core.ConditionResult + var conditionResults []*endpoint.ConditionResult if !scenario.NoConditions { - conditionResults = []*core.ConditionResult{ + conditionResults = []*endpoint.ConditionResult{ {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved}, } @@ -202,7 +202,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { body := scenario.Provider.buildRequestBody( &scenario.Endpoint, &scenario.Alert, - &core.Result{ + &endpoint.Result{ ConditionResults: conditionResults, }, scenario.Resolved, diff --git a/alerting/provider/teams/teams.go b/alerting/provider/teams/teams.go index 569689bc..e61e2ee4 100644 --- a/alerting/provider/teams/teams.go +++ b/alerting/provider/teams/teams.go @@ -9,7 +9,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" ) // AlertProvider is the configuration necessary for sending an alert using Teams @@ -19,8 +19,14 @@ type AlertProvider struct { // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` + // ClientConfig is the configuration of the client used to communicate with the provider's target + ClientConfig *client.Config `yaml:"client,omitempty"` + // Overrides is a list of Override that may be prioritized over the default configuration Overrides []Override `yaml:"overrides,omitempty"` + + // Title is the title of the message that will be sent + Title string `yaml:"title,omitempty"` } // Override is a case under which the default integration is overridden @@ -44,14 +50,14 @@ func (provider *AlertProvider) IsValid() bool { } // Send an alert using the provider -func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { - buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) - request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(endpoint.Group), buffer) +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved)) + request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer) if err != nil { return err } request.Header.Set("Content-Type", "application/json") - response, err := client.GetHTTPClient(nil).Do(request) + response, err := client.GetHTTPClient(provider.ClientConfig).Do(request) if err != nil { return err } @@ -78,13 +84,13 @@ type Section struct { } // buildRequestBody builds the request body for the provider -func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { +func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { var message, color string if resolved { - message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold) + message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold) color = "#36A64F" } else { - message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) + message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold) color = "#DD0000" } var formattedConditionResults string @@ -105,9 +111,12 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert * Type: "MessageCard", Context: "http://schema.org/extensions", ThemeColor: color, - Title: "🚨 Gatus", + Title: provider.Title, Text: message + description, } + if len(body.Title) == 0 { + body.Title = "🚨 Gatus" + } if len(formattedConditionResults) > 0 { body.Sections = append(body.Sections, Section{ ActivityTitle: "Condition results", diff --git a/alerting/provider/teams/teams_test.go b/alerting/provider/teams/teams_test.go index 7f7cdb21..802a070e 100644 --- a/alerting/provider/teams/teams_test.go +++ b/alerting/provider/teams/teams_test.go @@ -7,7 +7,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/test" ) @@ -116,10 +116,10 @@ func TestAlertProvider_Send(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) { client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) err := scenario.Provider.Send( - &core.Endpoint{Name: "endpoint-name"}, + &endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, - &core.Result{ - ConditionResults: []*core.ConditionResult{ + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved}, }, @@ -172,17 +172,17 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { } for _, scenario := range scenarios { t.Run(scenario.Name, func(t *testing.T) { - var conditionResults []*core.ConditionResult + var conditionResults []*endpoint.ConditionResult if !scenario.NoConditions { - conditionResults = []*core.ConditionResult{ + conditionResults = []*endpoint.ConditionResult{ {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved}, } } body := scenario.Provider.buildRequestBody( - &core.Endpoint{Name: "endpoint-name"}, + &endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, - &core.Result{ConditionResults: conditionResults}, + &endpoint.Result{ConditionResults: conditionResults}, scenario.Resolved, ) if string(body) != scenario.ExpectedBody { diff --git a/alerting/provider/teamsworkflows/teamsworkflows.go b/alerting/provider/teamsworkflows/teamsworkflows.go new file mode 100644 index 00000000..104e2d26 --- /dev/null +++ b/alerting/provider/teamsworkflows/teamsworkflows.go @@ -0,0 +1,182 @@ +package teamsworkflows + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" +) + +// AlertProvider is the configuration necessary for sending an alert using Teams +type AlertProvider struct { + WebhookURL string `yaml:"webhook-url"` + + // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type + DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` + + // Overrides is a list of Override that may be prioritized over the default configuration + Overrides []Override `yaml:"overrides,omitempty"` + + // Title is the title of the message that will be sent + Title string `yaml:"title,omitempty"` +} + +// Override is a case under which the default integration is overridden +type Override struct { + Group string `yaml:"group"` + WebhookURL string `yaml:"webhook-url"` +} + +// IsValid returns whether the provider's configuration is valid +func (provider *AlertProvider) IsValid() bool { + registeredGroups := make(map[string]bool) + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 { + return false + } + registeredGroups[override.Group] = true + } + } + return len(provider.WebhookURL) > 0 +} + +// Send an alert using the provider +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved)) + request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + response, err := client.GetHTTPClient(nil).Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode > 399 { + body, _ := io.ReadAll(response.Body) + return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body)) + } + return err +} + +// AdaptiveCardBody represents the structure of an Adaptive Card +type AdaptiveCardBody struct { + Type string `json:"type"` + Version string `json:"version"` + Body []CardBody `json:"body"` +} + +// CardBody represents the body of the Adaptive Card +type CardBody struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Wrap bool `json:"wrap"` + Separator bool `json:"separator,omitempty"` + Size string `json:"size,omitempty"` + Weight string `json:"weight,omitempty"` + Items []CardBody `json:"items,omitempty"` + Facts []Fact `json:"facts,omitempty"` + FactSet *FactSetBody `json:"factSet,omitempty"` +} + +// FactSetBody represents the FactSet in the Adaptive Card +type FactSetBody struct { + Type string `json:"type"` + Facts []Fact `json:"facts"` +} + +// Fact represents an individual fact in the FactSet +type Fact struct { + Title string `json:"title"` + Value string `json:"value"` +} + +// buildRequestBody builds the request body for the provider +func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { + var message string + if resolved { + message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row.", ep.DisplayName(), alert.SuccessThreshold) + } else { + message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row.", ep.DisplayName(), alert.FailureThreshold) + } + + // Configure default title if it's not provided + title := "⛑ Gatus" + if provider.Title != "" { + title = provider.Title + } + + // Build the facts from the condition results + var facts []Fact + for _, conditionResult := range result.ConditionResults { + var key string + if conditionResult.Success { + key = "✅" + } else { + key = "❌" + } + facts = append(facts, Fact{ + Title: key, + Value: conditionResult.Condition, + }) + } + + cardContent := AdaptiveCardBody{ + Type: "AdaptiveCard", + Version: "1.4", // Version 1.5 and 1.6 doesn't seem to be supported by Teams as of 27/08/2024 + Body: []CardBody{ + { + Type: "TextBlock", + Text: title, + Size: "Medium", + Weight: "Bolder", + }, + { + Type: "TextBlock", + Text: message, + Wrap: true, + }, + { + Type: "FactSet", + Facts: facts, + }, + }, + } + + attachment := map[string]interface{}{ + "contentType": "application/vnd.microsoft.card.adaptive", + "content": cardContent, + } + + payload := map[string]interface{}{ + "type": "message", + "attachments": []interface{}{attachment}, + } + + bodyAsJSON, _ := json.Marshal(payload) + return bodyAsJSON +} + +// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group +func (provider *AlertProvider) getWebhookURLForGroup(group string) string { + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if group == override.Group { + return override.WebhookURL + } + } + } + return provider.WebhookURL +} + +// GetDefaultAlert returns the provider's default alert configuration +func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { + return provider.DefaultAlert +} diff --git a/alerting/provider/teamsworkflows/teamsworkflows_test.go b/alerting/provider/teamsworkflows/teamsworkflows_test.go new file mode 100644 index 00000000..6e4a9940 --- /dev/null +++ b/alerting/provider/teamsworkflows/teamsworkflows_test.go @@ -0,0 +1,269 @@ +package teamsworkflows + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" + "github.com/TwiN/gatus/v5/test" +) + +func TestAlertDefaultProvider_IsValid(t *testing.T) { + invalidProvider := AlertProvider{WebhookURL: ""} + if invalidProvider.IsValid() { + t.Error("provider shouldn't have been valid") + } + validProvider := AlertProvider{WebhookURL: "http://example.com"} + if !validProvider.IsValid() { + t.Error("provider should've been valid") + } +} + +func TestAlertProvider_IsValidWithOverride(t *testing.T) { + providerWithInvalidOverrideGroup := AlertProvider{ + Overrides: []Override{ + { + WebhookURL: "http://example.com", + Group: "", + }, + }, + } + if providerWithInvalidOverrideGroup.IsValid() { + t.Error("provider Group shouldn't have been valid") + } + providerWithInvalidOverrideTo := AlertProvider{ + Overrides: []Override{ + { + WebhookURL: "", + Group: "group", + }, + }, + } + if providerWithInvalidOverrideTo.IsValid() { + t.Error("provider integration key shouldn't have been valid") + } + providerWithValidOverride := AlertProvider{ + WebhookURL: "http://example.com", + Overrides: []Override{ + { + WebhookURL: "http://example.com", + Group: "group", + }, + }, + } + if !providerWithValidOverride.IsValid() { + t.Error("provider should've been valid") + } +} + +func TestAlertProvider_Send(t *testing.T) { + defer client.InjectHTTPClient(nil) + firstDescription := "description-1" + secondDescription := "description-2" + scenarios := []struct { + Name string + Provider AlertProvider + Alert alert.Alert + Resolved bool + MockRoundTripper test.MockRoundTripper + ExpectedError bool + }{ + { + Name: "triggered", + Provider: AlertProvider{}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} + }), + ExpectedError: false, + }, + { + Name: "triggered-error", + Provider: AlertProvider{}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody} + }), + ExpectedError: true, + }, + { + Name: "resolved", + Provider: AlertProvider{}, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} + }), + ExpectedError: false, + }, + { + Name: "resolved-error", + Provider: AlertProvider{}, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { + return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody} + }), + ExpectedError: true, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) + err := scenario.Provider.Send( + &endpoint.Endpoint{Name: "endpoint-name"}, + &scenario.Alert, + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ + {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, + {Condition: "[STATUS] == 200", Success: scenario.Resolved}, + }, + }, + scenario.Resolved, + ) + if scenario.ExpectedError && err == nil { + t.Error("expected error, got none") + } + if !scenario.ExpectedError && err != nil { + t.Error("expected no error, got", err.Error()) + } + }) + } +} + +func TestAlertProvider_buildRequestBody(t *testing.T) { + firstDescription := "description-1" + secondDescription := "description-2" + scenarios := []struct { + Name string + Provider AlertProvider + Alert alert.Alert + NoConditions bool + Resolved bool + ExpectedBody string + }{ + { + Name: "triggered", + Provider: AlertProvider{}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"TextBlock\",\"text\":\"\\u0026#x26D1; Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"\\u0026#x274C;\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"\\u0026#x274C;\",\"value\":\"[STATUS] == 200\"}]}]},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}", + }, + { + Name: "resolved", + Provider: AlertProvider{}, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"TextBlock\",\"text\":\"\\u0026#x26D1; Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"\\u0026#x2705;\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"\\u0026#x2705;\",\"value\":\"[STATUS] == 200\"}]}]},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}", + }, + { + Name: "resolved-with-no-conditions", + NoConditions: true, + Provider: AlertProvider{}, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"TextBlock\",\"text\":\"\\u0026#x26D1; Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false}]},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}", + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + var conditionResults []*endpoint.ConditionResult + if !scenario.NoConditions { + conditionResults = []*endpoint.ConditionResult{ + {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, + {Condition: "[STATUS] == 200", Success: scenario.Resolved}, + } + } + body := scenario.Provider.buildRequestBody( + &endpoint.Endpoint{Name: "endpoint-name"}, + &scenario.Alert, + &endpoint.Result{ConditionResults: conditionResults}, + scenario.Resolved, + ) + if string(body) != scenario.ExpectedBody { + t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body) + } + out := make(map[string]interface{}) + if err := json.Unmarshal(body, &out); err != nil { + t.Error("expected body to be valid JSON, got error:", err.Error()) + } + }) + } +} + +func TestAlertProvider_GetDefaultAlert(t *testing.T) { + if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil { + t.Error("expected default alert to be not nil") + } + if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil { + t.Error("expected default alert to be nil") + } +} + +func TestAlertProvider_getWebhookURLForGroup(t *testing.T) { + tests := []struct { + Name string + Provider AlertProvider + InputGroup string + ExpectedOutput string + }{ + { + Name: "provider-no-override-specify-no-group-should-default", + Provider: AlertProvider{ + WebhookURL: "http://example.com", + Overrides: nil, + }, + InputGroup: "", + ExpectedOutput: "http://example.com", + }, + { + Name: "provider-no-override-specify-group-should-default", + Provider: AlertProvider{ + WebhookURL: "http://example.com", + Overrides: nil, + }, + InputGroup: "group", + ExpectedOutput: "http://example.com", + }, + { + Name: "provider-with-override-specify-no-group-should-default", + Provider: AlertProvider{ + WebhookURL: "http://example.com", + Overrides: []Override{ + { + Group: "group", + WebhookURL: "http://example01.com", + }, + }, + }, + InputGroup: "", + ExpectedOutput: "http://example.com", + }, + { + Name: "provider-with-override-specify-group-should-override", + Provider: AlertProvider{ + WebhookURL: "http://example.com", + Overrides: []Override{ + { + Group: "group", + WebhookURL: "http://example01.com", + }, + }, + }, + InputGroup: "group", + ExpectedOutput: "http://example01.com", + }, + } + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput { + t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput) + } + }) + } +} diff --git a/alerting/provider/telegram/telegram.go b/alerting/provider/telegram/telegram.go index db9ea0a0..ecfd700b 100644 --- a/alerting/provider/telegram/telegram.go +++ b/alerting/provider/telegram/telegram.go @@ -9,7 +9,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" ) const defaultAPIURL = "https://api.telegram.org" @@ -25,6 +25,16 @@ type AlertProvider struct { // 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 Overrid that may be prioritized over the default configuration + Overrides []*Override `yaml:"overrides,omitempty"` +} + +// Override is a configuration that may be prioritized over the default configuration +type Override struct { + group string `yaml:"group"` + token string `yaml:"token"` + id string `yaml:"id"` } // IsValid returns whether the provider's configuration is valid @@ -32,17 +42,29 @@ func (provider *AlertProvider) IsValid() bool { if provider.ClientConfig == nil { provider.ClientConfig = client.GetDefaultConfig() } + + registerGroups := make(map[string]bool) + for _, override := range provider.Overrides { + if len(override.group) == 0 { + return false + } + if _, ok := registerGroups[override.group]; ok { + return false + } + registerGroups[override.group] = true + } + return len(provider.Token) > 0 && len(provider.ID) > 0 } // Send an alert using the provider -func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { - buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved)) apiURL := provider.APIURL if apiURL == "" { apiURL = defaultAPIURL } - request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/bot%s/sendMessage", apiURL, provider.Token), buffer) + request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/bot%s/sendMessage", apiURL, provider.getTokenForGroup(ep.Group)), buffer) if err != nil { return err } @@ -59,6 +81,15 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, return err } +func (provider *AlertProvider) getTokenForGroup(group string) string { + for _, override := range provider.Overrides { + if override.group == group && len(override.token) > 0 { + return override.token + } + } + return provider.Token +} + type Body struct { ChatID string `json:"chat_id"` Text string `json:"text"` @@ -66,12 +97,12 @@ type Body struct { } // buildRequestBody builds the request body for the provider -func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { +func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { var message string if resolved { - message = fmt.Sprintf("An alert for *%s* has been resolved:\n—\n _healthcheck passing successfully %d time(s) in a row_\n— ", endpoint.DisplayName(), alert.SuccessThreshold) + message = fmt.Sprintf("An alert for *%s* has been resolved:\n—\n _healthcheck passing successfully %d time(s) in a row_\n— ", ep.DisplayName(), alert.SuccessThreshold) } else { - message = fmt.Sprintf("An alert for *%s* has been triggered:\n—\n _healthcheck failed %d time(s) in a row_\n— ", endpoint.DisplayName(), alert.FailureThreshold) + message = fmt.Sprintf("An alert for *%s* has been triggered:\n—\n _healthcheck failed %d time(s) in a row_\n— ", ep.DisplayName(), alert.FailureThreshold) } var formattedConditionResults string if len(result.ConditionResults) > 0 { @@ -93,13 +124,22 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert * text = fmt.Sprintf("⛑ *Gatus* \n%s%s", message, formattedConditionResults) } bodyAsJSON, _ := json.Marshal(Body{ - ChatID: provider.ID, + ChatID: provider.getIDForGroup(ep.Group), Text: text, ParseMode: "MARKDOWN", }) return bodyAsJSON } +func (provider *AlertProvider) getIDForGroup(group string) string { + for _, override := range provider.Overrides { + if override.group == group && len(override.id) > 0 { + return override.id + } + } + return provider.ID +} + // GetDefaultAlert returns the provider's default alert configuration func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { return provider.DefaultAlert diff --git a/alerting/provider/telegram/telegram_test.go b/alerting/provider/telegram/telegram_test.go index 3dcebb07..c1f201cb 100644 --- a/alerting/provider/telegram/telegram_test.go +++ b/alerting/provider/telegram/telegram_test.go @@ -7,11 +7,11 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/test" ) -func TestAlertProvider_IsValid(t *testing.T) { +func TestAlertDefaultProvider_IsValid(t *testing.T) { t.Run("invalid-provider", func(t *testing.T) { invalidProvider := AlertProvider{Token: "", ID: ""} if invalidProvider.IsValid() { @@ -32,6 +32,69 @@ func TestAlertProvider_IsValid(t *testing.T) { }) } +func TestAlertProvider_IsValidWithOverrides(t *testing.T) { + t.Run("invalid-provider-override-nonexist-group", func(t *testing.T) { + invalidProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{token: "token", id: "id"}}} + if invalidProvider.IsValid() { + t.Error("provider shouldn't have been valid") + } + }) + t.Run("invalid-provider-override-duplicate-group", func(t *testing.T) { + invalidProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{group: "group1", token: "token", id: "id"}, {group: "group1", id: "id2"}}} + if invalidProvider.IsValid() { + t.Error("provider shouldn't have been valid") + } + }) + t.Run("valid-provider", func(t *testing.T) { + validProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{group: "group", token: "token", id: "id"}}} + if validProvider.ClientConfig != nil { + t.Error("provider client config should have been nil prior to IsValid() being executed") + } + if !validProvider.IsValid() { + t.Error("provider should've been valid") + } + if validProvider.ClientConfig == nil { + t.Error("provider client config should have been set after IsValid() was executed") + } + }) +} + +func TestAlertProvider_getTokenAndIDForGroup(t *testing.T) { + t.Run("get-token-with-override", func(t *testing.T) { + provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{group: "group", token: "overrideToken", id: "overrideID"}}} + token := provider.getTokenForGroup("group") + if token != "overrideToken" { + t.Error("token should have been 'overrideToken'") + } + id := provider.getIDForGroup("group") + if id != "overrideID" { + t.Error("id should have been 'overrideID'") + } + }) + t.Run("get-default-token-with-overridden-id", func(t *testing.T) { + provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{group: "group", id: "overrideID"}}} + token := provider.getTokenForGroup("group") + if token != provider.Token { + t.Error("token should have been the default token") + } + id := provider.getIDForGroup("group") + if id != "overrideID" { + t.Error("id should have been 'overrideID'") + } + }) + t.Run("get-default-token-with-overridden-token", func(t *testing.T) { + provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{group: "group", token: "overrideToken"}}} + token := provider.getTokenForGroup("group") + if token != "overrideToken" { + t.Error("token should have been 'overrideToken'") + } + id := provider.getIDForGroup("group") + if id != provider.ID { + t.Error("id should have been the default id") + } + }) +} + func TestAlertProvider_Send(t *testing.T) { defer client.InjectHTTPClient(nil) firstDescription := "description-1" @@ -89,10 +152,10 @@ func TestAlertProvider_Send(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) { client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) err := scenario.Provider.Send( - &core.Endpoint{Name: "endpoint-name"}, + &endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, - &core.Result{ - ConditionResults: []*core.ConditionResult{ + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved}, }, @@ -145,17 +208,17 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { } for _, scenario := range scenarios { t.Run(scenario.Name, func(t *testing.T) { - var conditionResults []*core.ConditionResult + var conditionResults []*endpoint.ConditionResult if !scenario.NoConditions { - conditionResults = []*core.ConditionResult{ + conditionResults = []*endpoint.ConditionResult{ {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved}, } } body := scenario.Provider.buildRequestBody( - &core.Endpoint{Name: "endpoint-name"}, + &endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, - &core.Result{ConditionResults: conditionResults}, + &endpoint.Result{ConditionResults: conditionResults}, scenario.Resolved, ) if string(body) != scenario.ExpectedBody { diff --git a/alerting/provider/twilio/twilio.go b/alerting/provider/twilio/twilio.go index 8370a298..b2879444 100644 --- a/alerting/provider/twilio/twilio.go +++ b/alerting/provider/twilio/twilio.go @@ -10,7 +10,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" ) // AlertProvider is the configuration necessary for sending an alert using Twilio @@ -30,8 +30,8 @@ func (provider *AlertProvider) IsValid() bool { } // Send an alert using the provider -func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { - buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(endpoint, alert, result, resolved))) +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(ep, alert, result, resolved))) request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", provider.SID), buffer) if err != nil { return err @@ -51,12 +51,12 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, } // buildRequestBody builds the request body for the provider -func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string { +func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string { var message string if resolved { - message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.DisplayName(), alert.GetDescription()) + message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription()) } else { - message = fmt.Sprintf("TRIGGERED: %s - %s", endpoint.DisplayName(), alert.GetDescription()) + message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription()) } return url.Values{ "To": {provider.To}, diff --git a/alerting/provider/twilio/twilio_test.go b/alerting/provider/twilio/twilio_test.go index 31328a2f..66e1737f 100644 --- a/alerting/provider/twilio/twilio_test.go +++ b/alerting/provider/twilio/twilio_test.go @@ -4,7 +4,7 @@ import ( "testing" "github.com/TwiN/gatus/v5/alerting/alert" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" ) func TestTwilioAlertProvider_IsValid(t *testing.T) { @@ -51,10 +51,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { for _, scenario := range scenarios { t.Run(scenario.Name, func(t *testing.T) { body := scenario.Provider.buildRequestBody( - &core.Endpoint{Name: "endpoint-name"}, + &endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, - &core.Result{ - ConditionResults: []*core.ConditionResult{ + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, {Condition: "[STATUS] == 200", Success: scenario.Resolved}, }, diff --git a/alerting/provider/zulip/zulip.go b/alerting/provider/zulip/zulip.go new file mode 100644 index 00000000..5f2a408d --- /dev/null +++ b/alerting/provider/zulip/zulip.go @@ -0,0 +1,132 @@ +package zulip + +import ( + "bytes" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" +) + +type Config struct { + // BotEmail is the email of the bot user + BotEmail string `yaml:"bot-email"` + // BotAPIKey is the API key of the bot user + BotAPIKey string `yaml:"bot-api-key"` + // Domain is the domain of the Zulip server + Domain string `yaml:"domain"` + // ChannelID is the ID of the channel to send the message to + ChannelID string `yaml:"channel-id"` +} + +// AlertProvider is the configuration necessary for sending an alert using Zulip +type AlertProvider struct { + 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 { + Config + Group string `yaml:"group"` +} + +func (provider *AlertProvider) validateConfig(conf *Config) bool { + return len(conf.BotEmail) > 0 && len(conf.BotAPIKey) > 0 && len(conf.Domain) > 0 && len(conf.ChannelID) > 0 +} + +// IsValid returns whether the provider's configuration is valid +func (provider *AlertProvider) IsValid() bool { + registeredGroups := make(map[string]bool) + if provider.Overrides != nil { + for _, override := range provider.Overrides { + isAlreadyRegistered := registeredGroups[override.Group] + if isAlreadyRegistered || override.Group == "" || !provider.validateConfig(&override.Config) { + return false + } + registeredGroups[override.Group] = true + } + } + return provider.validateConfig(&provider.Config) +} + +// getChannelIdForGroup returns the channel ID for the provided group +func (provider *AlertProvider) getChannelIdForGroup(group string) string { + for _, override := range provider.Overrides { + if override.Group == group { + return override.ChannelID + } + } + return provider.ChannelID +} + +// buildRequestBody builds the request body for the provider +func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string { + var message string + if resolved { + message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold) + } else { + message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold) + } + + if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { + message += "\n> " + alertDescription + "\n" + } + + for _, conditionResult := range result.ConditionResults { + var prefix string + if conditionResult.Success { + prefix = ":check:" + } else { + prefix = ":cross_mark:" + } + message += fmt.Sprintf("\n%s - `%s`", prefix, conditionResult.Condition) + } + + postData := map[string]string{ + "type": "channel", + "to": provider.getChannelIdForGroup(ep.Group), + "topic": "Gatus", + "content": message, + } + bodyParams := url.Values{} + for field, value := range postData { + bodyParams.Add(field, value) + } + return bodyParams.Encode() +} + +// Send an alert using the provider +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + buffer := bytes.NewBufferString(provider.buildRequestBody(ep, alert, result, resolved)) + zulipEndpoint := fmt.Sprintf("https://%s/api/v1/messages", provider.Domain) + request, err := http.NewRequest(http.MethodPost, zulipEndpoint, buffer) + if err != nil { + return err + } + request.SetBasicAuth(provider.BotEmail, provider.BotAPIKey) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.Header.Set("User-Agent", "Gatus") + response, err := client.GetHTTPClient(nil).Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode > 399 { + body, _ := io.ReadAll(response.Body) + return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body)) + } + return nil +} + +// GetDefaultAlert returns the provider's default alert configuration +func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { + return provider.DefaultAlert +} diff --git a/alerting/provider/zulip/zulip_test.go b/alerting/provider/zulip/zulip_test.go new file mode 100644 index 00000000..3d481ecd --- /dev/null +++ b/alerting/provider/zulip/zulip_test.go @@ -0,0 +1,488 @@ +package zulip + +import ( + "fmt" + "net/http" + "net/url" + "testing" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" + "github.com/TwiN/gatus/v5/test" +) + +func TestAlertProvider_IsValid(t *testing.T) { + testCase := []struct { + name string + alertProvider AlertProvider + expected bool + }{ + { + name: "Empty provider", + alertProvider: AlertProvider{}, + expected: false, + }, + { + name: "Empty channel id", + alertProvider: AlertProvider{ + Config: Config{ + BotEmail: "something", + BotAPIKey: "something", + Domain: "something", + }, + }, + expected: false, + }, + { + name: "Empty domain", + alertProvider: AlertProvider{ + Config: Config{ + BotEmail: "something", + BotAPIKey: "something", + ChannelID: "something", + }, + }, + expected: false, + }, + { + name: "Empty bot api key", + alertProvider: AlertProvider{ + Config: Config{ + BotEmail: "something", + Domain: "something", + ChannelID: "something", + }, + }, + expected: false, + }, + { + name: "Empty bot email", + alertProvider: AlertProvider{ + Config: Config{ + BotAPIKey: "something", + Domain: "something", + ChannelID: "something", + }, + }, + expected: false, + }, + { + name: "Valid provider", + alertProvider: AlertProvider{ + Config: Config{ + BotEmail: "something", + BotAPIKey: "something", + Domain: "something", + ChannelID: "something", + }, + }, + expected: true, + }, + } + for _, tc := range testCase { + t.Run(tc.name, func(t *testing.T) { + if tc.alertProvider.IsValid() != tc.expected { + t.Errorf("IsValid assertion failed (expected %v, got %v)", tc.expected, !tc.expected) + } + }) + } +} + +func TestAlertProvider_IsValidWithOverride(t *testing.T) { + validConfig := Config{ + BotEmail: "something", + BotAPIKey: "something", + Domain: "something", + ChannelID: "something", + } + + testCase := []struct { + name string + alertProvider AlertProvider + expected bool + }{ + { + name: "Empty group", + alertProvider: AlertProvider{ + Config: validConfig, + Overrides: []Override{ + { + Config: validConfig, + Group: "", + }, + }, + }, + expected: false, + }, + { + name: "Empty override config", + alertProvider: AlertProvider{ + Config: validConfig, + Overrides: []Override{ + { + Group: "something", + }, + }, + }, + expected: false, + }, + { + name: "Empty channel id", + alertProvider: AlertProvider{ + Config: validConfig, + Overrides: []Override{ + { + Group: "something", + Config: Config{ + BotEmail: "something", + BotAPIKey: "something", + Domain: "something", + }, + }, + }, + }, + expected: false, + }, + { + name: "Empty domain", + alertProvider: AlertProvider{ + Config: validConfig, + Overrides: []Override{ + { + Group: "something", + Config: Config{ + BotEmail: "something", + BotAPIKey: "something", + ChannelID: "something", + }, + }, + }, + }, + expected: false, + }, + { + name: "Empty bot api key", + alertProvider: AlertProvider{ + Config: validConfig, + Overrides: []Override{ + { + Group: "something", + Config: Config{ + BotEmail: "something", + Domain: "something", + ChannelID: "something", + }, + }, + }, + }, + expected: false, + }, + { + name: "Empty bot email", + alertProvider: AlertProvider{ + Config: validConfig, + Overrides: []Override{ + { + Group: "something", + Config: Config{ + BotAPIKey: "something", + Domain: "something", + ChannelID: "something", + }, + }, + }, + }, + expected: false, + }, + { + name: "Valid provider", + alertProvider: AlertProvider{ + Config: validConfig, + Overrides: []Override{ + { + Group: "something", + Config: validConfig, + }, + }, + }, + expected: true, + }, + } + for _, tc := range testCase { + t.Run(tc.name, func(t *testing.T) { + if tc.alertProvider.IsValid() != tc.expected { + t.Errorf("IsValid assertion failed (expected %v, got %v)", tc.expected, !tc.expected) + } + }) + } +} + +func TestAlertProvider_GetChannelIdForGroup(t *testing.T) { + provider := AlertProvider{ + Config: Config{ + ChannelID: "default", + }, + Overrides: []Override{ + { + Group: "group1", + Config: Config{ChannelID: "group1"}, + }, + { + Group: "group2", + Config: Config{ChannelID: "group2"}, + }, + }, + } + if provider.getChannelIdForGroup("") != "default" { + t.Error("Expected default channel ID") + } + if provider.getChannelIdForGroup("group2") != "group2" { + t.Error("Expected group2 channel ID") + } +} + +func TestAlertProvider_BuildRequestBody(t *testing.T) { + basicConfig := Config{ + BotEmail: "bot-email", + BotAPIKey: "bot-api-key", + Domain: "domain", + ChannelID: "channel-id", + } + alertDesc := "Description" + basicAlert := alert.Alert{ + SuccessThreshold: 2, + FailureThreshold: 3, + Description: &alertDesc, + } + testCases := []struct { + name string + provider AlertProvider + alert alert.Alert + resolved bool + hasConditions bool + expectedBody url.Values + }{ + { + name: "Resolved alert with no conditions", + provider: AlertProvider{ + Config: basicConfig, + }, + alert: basicAlert, + resolved: true, + hasConditions: false, + expectedBody: url.Values{ + "content": {`An alert for **endpoint-name** has been resolved after passing successfully 2 time(s) in a row +> Description +`}, + "to": {"channel-id"}, + "topic": {"Gatus"}, + "type": {"channel"}, + }, + }, + { + name: "Resolved alert with conditions", + provider: AlertProvider{ + Config: basicConfig, + }, + alert: basicAlert, + resolved: true, + hasConditions: true, + expectedBody: url.Values{ + "content": {`An alert for **endpoint-name** has been resolved after passing successfully 2 time(s) in a row +> Description + +:check: - ` + "`[CONNECTED] == true`" + ` +:check: - ` + "`[STATUS] == 200`" + ` +:check: - ` + "`[BODY] != \"\"`"}, + "to": {"channel-id"}, + "topic": {"Gatus"}, + "type": {"channel"}, + }, + }, + { + name: "Failed alert with no conditions", + provider: AlertProvider{ + Config: basicConfig, + }, + alert: basicAlert, + resolved: false, + hasConditions: false, + expectedBody: url.Values{ + "content": {`An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row +> Description +`}, + "to": {"channel-id"}, + "topic": {"Gatus"}, + "type": {"channel"}, + }, + }, + { + name: "Failed alert with conditions", + provider: AlertProvider{ + Config: basicConfig, + }, + alert: basicAlert, + resolved: false, + hasConditions: true, + expectedBody: url.Values{ + "content": {`An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row +> Description + +:cross_mark: - ` + "`[CONNECTED] == true`" + ` +:cross_mark: - ` + "`[STATUS] == 200`" + ` +:cross_mark: - ` + "`[BODY] != \"\"`"}, + "to": {"channel-id"}, + "topic": {"Gatus"}, + "type": {"channel"}, + }, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var conditionResults []*endpoint.ConditionResult + if tc.hasConditions { + conditionResults = []*endpoint.ConditionResult{ + {Condition: "[CONNECTED] == true", Success: tc.resolved}, + {Condition: "[STATUS] == 200", Success: tc.resolved}, + {Condition: "[BODY] != \"\"", Success: tc.resolved}, + } + } + body := tc.provider.buildRequestBody( + &endpoint.Endpoint{Name: "endpoint-name"}, + &tc.alert, + &endpoint.Result{ + ConditionResults: conditionResults, + }, + tc.resolved, + ) + valuesResult, err := url.ParseQuery(body) + if err != nil { + t.Error(err) + } + if fmt.Sprintf("%v", valuesResult) != fmt.Sprintf("%v", tc.expectedBody) { + t.Errorf("Expected body:\n%v\ngot:\n%v", tc.expectedBody, valuesResult) + } + }) + } +} + +func TestAlertProvider_GetDefaultAlert(t *testing.T) { + if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil { + t.Error("expected default alert to be not nil") + } + if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil { + t.Error("expected default alert to be nil") + } +} + +func TestAlertProvider_Send(t *testing.T) { + defer client.InjectHTTPClient(nil) + validateRequest := func(req *http.Request) { + if req.URL.String() != "https://custom-domain/api/v1/messages" { + t.Errorf("expected url https://custom-domain.zulipchat.com/api/v1/messages, got %s", req.URL.String()) + } + if req.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", req.Method) + } + if req.Header.Get("Content-Type") != "application/x-www-form-urlencoded" { + t.Errorf("expected Content-Type header to be application/x-www-form-urlencoded, got %s", req.Header.Get("Content-Type")) + } + if req.Header.Get("User-Agent") != "Gatus" { + t.Errorf("expected User-Agent header to be Gatus, got %s", req.Header.Get("User-Agent")) + } + } + basicConfig := Config{ + BotEmail: "bot-email", + BotAPIKey: "bot-api-key", + Domain: "custom-domain", + ChannelID: "channel-id", + } + basicAlert := alert.Alert{ + SuccessThreshold: 2, + FailureThreshold: 3, + } + testCases := []struct { + name string + provider AlertProvider + alert alert.Alert + resolved bool + mockRoundTripper test.MockRoundTripper + expectedError bool + }{ + { + name: "resolved", + provider: AlertProvider{ + Config: basicConfig, + }, + alert: basicAlert, + resolved: true, + mockRoundTripper: test.MockRoundTripper(func(req *http.Request) *http.Response { + validateRequest(req) + return &http.Response{StatusCode: http.StatusOK} + }), + expectedError: false, + }, + { + name: "resolved error", + provider: AlertProvider{ + Config: basicConfig, + }, + alert: basicAlert, + resolved: true, + mockRoundTripper: test.MockRoundTripper(func(req *http.Request) *http.Response { + validateRequest(req) + return &http.Response{StatusCode: http.StatusInternalServerError} + }), + expectedError: true, + }, + { + name: "triggered", + provider: AlertProvider{ + Config: basicConfig, + }, + alert: basicAlert, + resolved: false, + mockRoundTripper: test.MockRoundTripper(func(req *http.Request) *http.Response { + validateRequest(req) + return &http.Response{StatusCode: http.StatusOK} + }), + expectedError: false, + }, + { + name: "triggered error", + provider: AlertProvider{ + Config: basicConfig, + }, + alert: basicAlert, + resolved: false, + mockRoundTripper: test.MockRoundTripper(func(req *http.Request) *http.Response { + validateRequest(req) + return &http.Response{StatusCode: http.StatusInternalServerError} + }), + expectedError: true, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + client.InjectHTTPClient(&http.Client{Transport: tc.mockRoundTripper}) + err := tc.provider.Send( + &endpoint.Endpoint{Name: "endpoint-name"}, + &tc.alert, + &endpoint.Result{ + ConditionResults: []*endpoint.ConditionResult{ + {Condition: "[CONNECTED] == true", Success: tc.resolved}, + {Condition: "[STATUS] == 200", Success: tc.resolved}, + }, + }, + tc.resolved, + ) + if tc.expectedError && err == nil { + t.Error("expected error, got none") + } + if !tc.expectedError && err != nil { + t.Errorf("expected no error, got: %v", err) + } + }) + } +} diff --git a/api/badge.go b/api/badge.go index fb04fce3..e2ed3f46 100644 --- a/api/badge.go +++ b/api/badge.go @@ -9,7 +9,7 @@ import ( "time" "github.com/TwiN/gatus/v5/config" - "github.com/TwiN/gatus/v5/core/ui" + "github.com/TwiN/gatus/v5/config/endpoint/ui" "github.com/TwiN/gatus/v5/storage/store" "github.com/TwiN/gatus/v5/storage/store/common" "github.com/TwiN/gatus/v5/storage/store/common/paging" @@ -37,11 +37,13 @@ var ( // UptimeBadge handles the automatic generation of badge based on the group name and endpoint name passed. // -// Valid values for :duration -> 7d, 24h, 1h +// Valid values for :duration -> 30d, 7d, 24h, 1h func UptimeBadge(c *fiber.Ctx) error { duration := c.Params("duration") var from time.Time switch duration { + case "30d": + from = time.Now().Add(-30 * 24 * time.Hour) case "7d": from = time.Now().Add(-7 * 24 * time.Hour) case "24h": @@ -49,7 +51,7 @@ func UptimeBadge(c *fiber.Ctx) error { case "1h": from = time.Now().Add(-2 * time.Hour) // Because uptime metrics are stored by hour, we have to cheat a little default: - return c.Status(400).SendString("Durations supported: 7d, 24h, 1h") + return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h") } key := c.Params("key") uptime, err := store.Get().GetUptimeByKey(key, from, time.Now()) @@ -69,12 +71,14 @@ func UptimeBadge(c *fiber.Ctx) error { // ResponseTimeBadge handles the automatic generation of badge based on the group name and endpoint name passed. // -// Valid values for :duration -> 7d, 24h, 1h +// Valid values for :duration -> 30d, 7d, 24h, 1h func ResponseTimeBadge(cfg *config.Config) fiber.Handler { return func(c *fiber.Ctx) error { duration := c.Params("duration") var from time.Time switch duration { + case "30d": + from = time.Now().Add(-30 * 24 * time.Hour) case "7d": from = time.Now().Add(-7 * 24 * time.Hour) case "24h": @@ -82,7 +86,7 @@ func ResponseTimeBadge(cfg *config.Config) fiber.Handler { case "1h": from = time.Now().Add(-2 * time.Hour) // Because response time metrics are stored by hour, we have to cheat a little default: - return c.Status(400).SendString("Durations supported: 7d, 24h, 1h") + return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h") } key := c.Params("key") averageResponseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now()) @@ -161,6 +165,8 @@ func HealthBadgeShields(c *fiber.Ctx) error { func generateUptimeBadgeSVG(duration string, uptime float64) []byte { var labelWidth, valueWidth, valueWidthAdjustment int switch duration { + case "30d": + labelWidth = 70 case "7d": labelWidth = 65 case "24h": @@ -227,6 +233,8 @@ func getBadgeColorFromUptime(uptime float64) string { func generateResponseTimeBadgeSVG(duration string, averageResponseTime int, key string, cfg *config.Config) []byte { var labelWidth, valueWidth int switch duration { + case "30d": + labelWidth = 110 case "7d": labelWidth = 105 case "24h": diff --git a/api/badge_test.go b/api/badge_test.go index d3da9da6..a5be60d2 100644 --- a/api/badge_test.go +++ b/api/badge_test.go @@ -8,8 +8,8 @@ import ( "time" "github.com/TwiN/gatus/v5/config" - "github.com/TwiN/gatus/v5/core" - "github.com/TwiN/gatus/v5/core/ui" + "github.com/TwiN/gatus/v5/config/endpoint" + "github.com/TwiN/gatus/v5/config/endpoint/ui" "github.com/TwiN/gatus/v5/storage/store" "github.com/TwiN/gatus/v5/watchdog" ) @@ -19,7 +19,7 @@ func TestBadge(t *testing.T) { defer cache.Clear() cfg := &config.Config{ Metrics: true, - Endpoints: []*core.Endpoint{ + Endpoints: []*endpoint.Endpoint{ { Name: "frontend", Group: "core", @@ -34,8 +34,8 @@ func TestBadge(t *testing.T) { cfg.Endpoints[0].UIConfig = ui.GetDefaultConfig() cfg.Endpoints[1].UIConfig = ui.GetDefaultConfig() - watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()}) - watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()}) + watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()}) + watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()}) api := New(cfg) router := api.Router() type Scenario struct { @@ -218,30 +218,30 @@ func TestGetBadgeColorFromResponseTime(t *testing.T) { defer cache.Clear() var ( - firstCondition = core.Condition("[STATUS] == 200") - secondCondition = core.Condition("[RESPONSE_TIME] < 500") - thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h") + firstCondition = endpoint.Condition("[STATUS] == 200") + secondCondition = endpoint.Condition("[RESPONSE_TIME] < 500") + thirdCondition = endpoint.Condition("[CERTIFICATE_EXPIRATION] < 72h") ) - firstTestEndpoint := core.Endpoint{ + firstTestEndpoint := endpoint.Endpoint{ Name: "a", URL: "https://example.org/what/ever", Method: "GET", Body: "body", Interval: 30 * time.Second, - Conditions: []core.Condition{firstCondition, secondCondition, thirdCondition}, + Conditions: []endpoint.Condition{firstCondition, secondCondition, thirdCondition}, Alerts: nil, NumberOfFailuresInARow: 0, NumberOfSuccessesInARow: 0, UIConfig: ui.GetDefaultConfig(), } - secondTestEndpoint := core.Endpoint{ + secondTestEndpoint := endpoint.Endpoint{ Name: "b", URL: "https://example.org/what/ever", Method: "GET", Body: "body", Interval: 30 * time.Second, - Conditions: []core.Condition{firstCondition, secondCondition, thirdCondition}, + Conditions: []endpoint.Condition{firstCondition, secondCondition, thirdCondition}, Alerts: nil, NumberOfFailuresInARow: 0, NumberOfSuccessesInARow: 0, @@ -255,10 +255,10 @@ func TestGetBadgeColorFromResponseTime(t *testing.T) { } cfg := &config.Config{ Metrics: true, - Endpoints: []*core.Endpoint{&firstTestEndpoint, &secondTestEndpoint}, + Endpoints: []*endpoint.Endpoint{&firstTestEndpoint, &secondTestEndpoint}, } - testSuccessfulResult := core.Result{ + testSuccessfulResult := endpoint.Result{ Hostname: "example.org", IP: "127.0.0.1", HTTPStatus: 200, @@ -268,7 +268,7 @@ func TestGetBadgeColorFromResponseTime(t *testing.T) { Timestamp: time.Now(), Duration: 150 * time.Millisecond, CertificateExpiration: 10 * time.Hour, - ConditionResults: []*core.ConditionResult{ + ConditionResults: []*endpoint.ConditionResult{ { Condition: "[STATUS] == 200", Success: true, diff --git a/api/chart.go b/api/chart.go index 3c8a07ae..ff118e1d 100644 --- a/api/chart.go +++ b/api/chart.go @@ -32,14 +32,18 @@ var ( func ResponseTimeChart(c *fiber.Ctx) error { duration := c.Params("duration") + chartTimestampFormatter := chart.TimeValueFormatterWithFormat(timeFormat) var from time.Time switch duration { + case "30d": + from = time.Now().Truncate(time.Hour).Add(-30 * 24 * time.Hour) + chartTimestampFormatter = chart.TimeDateValueFormatter case "7d": - from = time.Now().Truncate(time.Hour).Add(-24 * 7 * time.Hour) + from = time.Now().Truncate(time.Hour).Add(-7 * 24 * time.Hour) case "24h": from = time.Now().Truncate(time.Hour).Add(-24 * time.Hour) default: - return c.Status(400).SendString("Durations supported: 7d, 24h") + return c.Status(400).SendString("Durations supported: 30d, 7d, 24h") } hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(c.Params("key"), from, time.Now()) if err != nil { @@ -88,7 +92,7 @@ func ResponseTimeChart(c *fiber.Ctx) error { Width: 1280, Height: 300, XAxis: chart.XAxis{ - ValueFormatter: chart.TimeValueFormatterWithFormat(timeFormat), + ValueFormatter: chartTimestampFormatter, GridMajorStyle: gridStyle, GridMinorStyle: gridStyle, Style: axisStyle, diff --git a/api/chart_test.go b/api/chart_test.go index 341c6725..2a699a5b 100644 --- a/api/chart_test.go +++ b/api/chart_test.go @@ -7,7 +7,7 @@ import ( "time" "github.com/TwiN/gatus/v5/config" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/storage/store" "github.com/TwiN/gatus/v5/watchdog" ) @@ -17,7 +17,7 @@ func TestResponseTimeChart(t *testing.T) { defer cache.Clear() cfg := &config.Config{ Metrics: true, - Endpoints: []*core.Endpoint{ + Endpoints: []*endpoint.Endpoint{ { Name: "frontend", Group: "core", @@ -28,8 +28,8 @@ func TestResponseTimeChart(t *testing.T) { }, }, } - watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()}) - watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()}) + watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()}) + watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()}) api := New(cfg) router := api.Router() type Scenario struct { @@ -49,6 +49,11 @@ func TestResponseTimeChart(t *testing.T) { Path: "/api/v1/endpoints/core_frontend/response-times/7d/chart.svg", ExpectedCode: http.StatusOK, }, + { + Name: "chart-response-time-30d", + Path: "/api/v1/endpoints/core_frontend/response-times/30d/chart.svg", + ExpectedCode: http.StatusOK, + }, { Name: "chart-response-time-with-invalid-duration", Path: "/api/v1/endpoints/core_backend/response-times/3d/chart.svg", diff --git a/api/endpoint_status.go b/api/endpoint_status.go index ac9e37b5..b97eee11 100644 --- a/api/endpoint_status.go +++ b/api/endpoint_status.go @@ -4,13 +4,12 @@ import ( "encoding/json" "errors" "fmt" - "io" "log" "github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/config" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/remote" - "github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/storage/store" "github.com/TwiN/gatus/v5/storage/store/common" "github.com/TwiN/gatus/v5/storage/store/common/paging" @@ -51,25 +50,19 @@ func EndpointStatuses(cfg *config.Config) fiber.Handler { } } -func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*core.EndpointStatus, error) { +func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*endpoint.Status, error) { if remoteConfig == nil || len(remoteConfig.Instances) == 0 { return nil, nil } - var endpointStatusesFromAllRemotes []*core.EndpointStatus + var endpointStatusesFromAllRemotes []*endpoint.Status httpClient := client.GetHTTPClient(remoteConfig.ClientConfig) for _, instance := range remoteConfig.Instances { response, err := httpClient.Get(instance.URL) if err != nil { return nil, err } - body, err := io.ReadAll(response.Body) - if err != nil { - _ = response.Body.Close() - log.Printf("[api.getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error()) - continue - } - var endpointStatuses []*core.EndpointStatus - if err = json.Unmarshal(body, &endpointStatuses); err != nil { + var endpointStatuses []*endpoint.Status + if err = json.NewDecoder(response.Body).Decode(&endpointStatuses); err != nil { _ = response.Body.Close() log.Printf("[api.getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error()) continue @@ -83,7 +76,7 @@ func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*cor return endpointStatusesFromAllRemotes, nil } -// EndpointStatus retrieves a single core.EndpointStatus by group and endpoint name +// EndpointStatus retrieves a single endpoint.Status by group and endpoint name func EndpointStatus(c *fiber.Ctx) error { page, pageSize := extractPageAndPageSizeFromRequest(c) endpointStatus, err := store.Get().GetEndpointStatusByKey(c.Params("key"), paging.NewEndpointStatusParams().WithResults(page, pageSize).WithEvents(1, common.MaximumNumberOfEvents)) diff --git a/api/endpoint_status_test.go b/api/endpoint_status_test.go index b88a915f..08725393 100644 --- a/api/endpoint_status_test.go +++ b/api/endpoint_status_test.go @@ -8,7 +8,7 @@ import ( "time" "github.com/TwiN/gatus/v5/config" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/storage/store" "github.com/TwiN/gatus/v5/watchdog" ) @@ -16,19 +16,19 @@ import ( var ( timestamp = time.Now() - testEndpoint = core.Endpoint{ + testEndpoint = endpoint.Endpoint{ Name: "name", Group: "group", URL: "https://example.org/what/ever", Method: "GET", Body: "body", Interval: 30 * time.Second, - Conditions: []core.Condition{core.Condition("[STATUS] == 200"), core.Condition("[RESPONSE_TIME] < 500"), core.Condition("[CERTIFICATE_EXPIRATION] < 72h")}, + Conditions: []endpoint.Condition{endpoint.Condition("[STATUS] == 200"), endpoint.Condition("[RESPONSE_TIME] < 500"), endpoint.Condition("[CERTIFICATE_EXPIRATION] < 72h")}, Alerts: nil, NumberOfFailuresInARow: 0, NumberOfSuccessesInARow: 0, } - testSuccessfulResult = core.Result{ + testSuccessfulResult = endpoint.Result{ Hostname: "example.org", IP: "127.0.0.1", HTTPStatus: 200, @@ -38,7 +38,7 @@ var ( Timestamp: timestamp, Duration: 150 * time.Millisecond, CertificateExpiration: 10 * time.Hour, - ConditionResults: []*core.ConditionResult{ + ConditionResults: []*endpoint.ConditionResult{ { Condition: "[STATUS] == 200", Success: true, @@ -53,7 +53,7 @@ var ( }, }, } - testUnsuccessfulResult = core.Result{ + testUnsuccessfulResult = endpoint.Result{ Hostname: "example.org", IP: "127.0.0.1", HTTPStatus: 200, @@ -63,7 +63,7 @@ var ( Timestamp: timestamp, Duration: 750 * time.Millisecond, CertificateExpiration: 10 * time.Hour, - ConditionResults: []*core.ConditionResult{ + ConditionResults: []*endpoint.ConditionResult{ { Condition: "[STATUS] == 200", Success: true, @@ -85,7 +85,7 @@ func TestEndpointStatus(t *testing.T) { defer cache.Clear() cfg := &config.Config{ Metrics: true, - Endpoints: []*core.Endpoint{ + Endpoints: []*endpoint.Endpoint{ { Name: "frontend", Group: "core", @@ -96,8 +96,8 @@ func TestEndpointStatus(t *testing.T) { }, }, } - watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()}) - watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()}) + watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()}) + watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()}) api := New(cfg) router := api.Router() type Scenario struct { diff --git a/api/external_endpoint.go b/api/external_endpoint.go index 93ec5809..6cbe6db3 100644 --- a/api/external_endpoint.go +++ b/api/external_endpoint.go @@ -7,7 +7,7 @@ import ( "time" "github.com/TwiN/gatus/v5/config" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/storage/store" "github.com/TwiN/gatus/v5/storage/store/common" "github.com/TwiN/gatus/v5/watchdog" @@ -41,11 +41,14 @@ func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler { return c.Status(401).SendString("invalid token") } // Persist the result in the storage - result := &core.Result{ + result := &endpoint.Result{ Timestamp: time.Now(), Success: c.QueryBool("success"), Errors: []string{}, } + if !result.Success && c.Query("error") != "" { + result.Errors = append(result.Errors, c.Query("error")) + } convertedEndpoint := externalEndpoint.ToEndpoint() if err := store.Get().Insert(convertedEndpoint, result); err != nil { if errors.Is(err, common.ErrEndpointNotFound) { diff --git a/api/external_endpoint_test.go b/api/external_endpoint_test.go index 72078cb7..d63ab6c0 100644 --- a/api/external_endpoint_test.go +++ b/api/external_endpoint_test.go @@ -9,8 +9,8 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/provider/discord" "github.com/TwiN/gatus/v5/config" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/maintenance" - "github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/storage/store" "github.com/TwiN/gatus/v5/storage/store/common/paging" ) @@ -22,7 +22,7 @@ func TestCreateExternalEndpointResult(t *testing.T) { Alerting: &alerting.Config{ Discord: &discord.AlertProvider{}, }, - ExternalEndpoints: []*core.ExternalEndpoint{ + ExternalEndpoints: []*endpoint.ExternalEndpoint{ { Name: "n", Group: "g", @@ -64,12 +64,24 @@ func TestCreateExternalEndpointResult(t *testing.T) { AuthorizationHeaderBearerToken: "Bearer token", ExpectedCode: 404, }, + { + Name: "bad-success-value", + Path: "/api/v1/endpoints/g_n/external?success=invalid", + AuthorizationHeaderBearerToken: "Bearer token", + ExpectedCode: 400, + }, { Name: "good-token-success-true", Path: "/api/v1/endpoints/g_n/external?success=true", AuthorizationHeaderBearerToken: "Bearer token", ExpectedCode: 200, }, + { + Name: "good-token-success-true-with-ignored-error-because-success-true", + Path: "/api/v1/endpoints/g_n/external?success=true&error=failed", + AuthorizationHeaderBearerToken: "Bearer token", + ExpectedCode: 200, + }, { Name: "good-token-success-false", Path: "/api/v1/endpoints/g_n/external?success=false", @@ -82,6 +94,12 @@ func TestCreateExternalEndpointResult(t *testing.T) { AuthorizationHeaderBearerToken: "Bearer token", ExpectedCode: 200, }, + { + Name: "good-token-success-false-with-error", + Path: "/api/v1/endpoints/g_n/external?success=false&error=failed", + AuthorizationHeaderBearerToken: "Bearer token", + ExpectedCode: 200, + }, } for _, scenario := range scenarios { t.Run(scenario.Name, func(t *testing.T) { @@ -108,21 +126,33 @@ func TestCreateExternalEndpointResult(t *testing.T) { if endpointStatus.Key != "g_n" { t.Errorf("expected key to be g_n but got %s", endpointStatus.Key) } - if len(endpointStatus.Results) != 3 { + if len(endpointStatus.Results) != 5 { t.Errorf("expected 3 results but got %d", len(endpointStatus.Results)) } if !endpointStatus.Results[0].Success { t.Errorf("expected first result to be successful") } - if endpointStatus.Results[1].Success { - t.Errorf("expected second result to be unsuccessful") + if !endpointStatus.Results[1].Success { + t.Errorf("expected second result to be successful") + } + if len(endpointStatus.Results[1].Errors) > 0 { + t.Errorf("expected second result to have no errors") } if endpointStatus.Results[2].Success { t.Errorf("expected third result to be unsuccessful") } + if endpointStatus.Results[3].Success { + t.Errorf("expected fourth result to be unsuccessful") + } + if endpointStatus.Results[4].Success { + t.Errorf("expected fifth result to be unsuccessful") + } + if len(endpointStatus.Results[4].Errors) == 0 || endpointStatus.Results[4].Errors[0] != "failed" { + t.Errorf("expected fifth result to have errors: failed") + } externalEndpointFromConfig := cfg.GetExternalEndpointByKey("g_n") - if externalEndpointFromConfig.NumberOfFailuresInARow != 2 { - t.Errorf("expected 2 failures in a row but got %d", externalEndpointFromConfig.NumberOfFailuresInARow) + if externalEndpointFromConfig.NumberOfFailuresInARow != 3 { + t.Errorf("expected 3 failures in a row but got %d", externalEndpointFromConfig.NumberOfFailuresInARow) } if externalEndpointFromConfig.NumberOfSuccessesInARow != 0 { t.Errorf("expected 0 successes in a row but got %d", externalEndpointFromConfig.NumberOfSuccessesInARow) diff --git a/api/spa_test.go b/api/spa_test.go index 250ecdde..11da2adf 100644 --- a/api/spa_test.go +++ b/api/spa_test.go @@ -9,8 +9,8 @@ import ( "time" "github.com/TwiN/gatus/v5/config" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/ui" - "github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/storage/store" "github.com/TwiN/gatus/v5/watchdog" ) @@ -20,7 +20,7 @@ func TestSinglePageApplication(t *testing.T) { defer cache.Clear() cfg := &config.Config{ Metrics: true, - Endpoints: []*core.Endpoint{ + Endpoints: []*endpoint.Endpoint{ { Name: "frontend", Group: "core", @@ -34,8 +34,8 @@ func TestSinglePageApplication(t *testing.T) { Title: "example-title", }, } - watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()}) - watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()}) + watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()}) + watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()}) api := New(cfg) router := api.Router() type Scenario struct { diff --git a/client/client.go b/client/client.go index 7a00a767..b63fb93b 100644 --- a/client/client.go +++ b/client/client.go @@ -16,11 +16,16 @@ import ( "github.com/TwiN/gocache/v2" "github.com/TwiN/whois" "github.com/ishidawataru/sctp" + "github.com/miekg/dns" ping "github.com/prometheus-community/pro-bing" "golang.org/x/crypto/ssh" "golang.org/x/net/websocket" ) +const ( + dnsPort = 53 +) + var ( // injectedHTTPClient is used for testing purposes injectedHTTPClient *http.Client @@ -291,6 +296,49 @@ func QueryWebSocket(address, body string, config *Config) (bool, []byte, error) return true, msg[:n], nil } +func QueryDNS(queryType, queryName, url string) (connected bool, dnsRcode string, body []byte, err error) { + if !strings.Contains(url, ":") { + url = fmt.Sprintf("%s:%d", url, dnsPort) + } + queryTypeAsUint16 := dns.StringToType[queryType] + c := new(dns.Client) + m := new(dns.Msg) + m.SetQuestion(queryName, queryTypeAsUint16) + r, _, err := c.Exchange(m, url) + if err != nil { + return false, "", nil, err + } + connected = true + dnsRcode = dns.RcodeToString[r.Rcode] + for _, rr := range r.Answer { + switch rr.Header().Rrtype { + case dns.TypeA: + if a, ok := rr.(*dns.A); ok { + body = []byte(a.A.String()) + } + case dns.TypeAAAA: + if aaaa, ok := rr.(*dns.AAAA); ok { + body = []byte(aaaa.AAAA.String()) + } + case dns.TypeCNAME: + if cname, ok := rr.(*dns.CNAME); ok { + body = []byte(cname.Target) + } + case dns.TypeMX: + if mx, ok := rr.(*dns.MX); ok { + body = []byte(mx.Mx) + } + case dns.TypeNS: + if ns, ok := rr.(*dns.NS); ok { + body = []byte(ns.Ns) + } + default: + body = []byte("query type is not supported yet") + } + } + return connected, dnsRcode, body, nil +} + // InjectHTTPClient is used to inject a custom HTTP client for testing purposes func InjectHTTPClient(httpClient *http.Client) { injectedHTTPClient = httpClient diff --git a/client/client_test.go b/client/client_test.go index 10cc9ca4..10392e78 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -8,6 +8,8 @@ import ( "testing" "time" + "github.com/TwiN/gatus/v5/config/endpoint/dns" + "github.com/TwiN/gatus/v5/pattern" "github.com/TwiN/gatus/v5/test" ) @@ -334,3 +336,97 @@ func TestTlsRenegotiation(t *testing.T) { }) } } + +func TestQueryDNS(t *testing.T) { + tests := []struct { + name string + inputDNS dns.Config + inputURL string + expectedDNSCode string + expectedBody string + isErrExpected bool + }{ + { + name: "test Config with type A", + inputDNS: dns.Config{ + QueryType: "A", + QueryName: "example.com.", + }, + inputURL: "8.8.8.8", + expectedDNSCode: "NOERROR", + expectedBody: "93.184.215.14", + }, + { + name: "test Config with type AAAA", + inputDNS: dns.Config{ + QueryType: "AAAA", + QueryName: "example.com.", + }, + inputURL: "8.8.8.8", + expectedDNSCode: "NOERROR", + expectedBody: "2606:2800:21f:cb07:6820:80da:af6b:8b2c", + }, + { + name: "test Config with type CNAME", + inputDNS: dns.Config{ + QueryType: "CNAME", + QueryName: "en.wikipedia.org.", + }, + inputURL: "8.8.8.8", + expectedDNSCode: "NOERROR", + expectedBody: "dyna.wikimedia.org.", + }, + { + name: "test Config with type MX", + inputDNS: dns.Config{ + QueryType: "MX", + QueryName: "example.com.", + }, + inputURL: "8.8.8.8", + expectedDNSCode: "NOERROR", + expectedBody: ".", + }, + { + name: "test Config with type NS", + inputDNS: dns.Config{ + QueryType: "NS", + QueryName: "example.com.", + }, + inputURL: "8.8.8.8", + expectedDNSCode: "NOERROR", + expectedBody: "*.iana-servers.net.", + }, + { + name: "test Config with fake type and retrieve error", + inputDNS: dns.Config{ + QueryType: "B", + QueryName: "example", + }, + inputURL: "8.8.8.8", + isErrExpected: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, dnsRCode, body, err := QueryDNS(test.inputDNS.QueryType, test.inputDNS.QueryName, test.inputURL) + if test.isErrExpected && err == nil { + t.Errorf("there should be an error") + } + if dnsRCode != test.expectedDNSCode { + t.Errorf("expected DNSRCode to be %s, got %s", test.expectedDNSCode, dnsRCode) + } + if test.inputDNS.QueryType == "NS" { + // Because there are often multiple nameservers backing a single domain, we'll only look at the suffix + if !pattern.Match(test.expectedBody, string(body)) { + t.Errorf("got %s, expected result %s,", string(body), test.expectedBody) + } + } else { + if string(body) != test.expectedBody { + t.Errorf("got %s, expected result %s,", string(body), test.expectedBody) + } + } + }) + time.Sleep(5 * time.Millisecond) + } +} diff --git a/config/config.go b/config/config.go index 667be593..8aa61feb 100644 --- a/config/config.go +++ b/config/config.go @@ -15,14 +15,13 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/provider" "github.com/TwiN/gatus/v5/config/connectivity" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/maintenance" "github.com/TwiN/gatus/v5/config/remote" "github.com/TwiN/gatus/v5/config/ui" "github.com/TwiN/gatus/v5/config/web" - "github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/security" "github.com/TwiN/gatus/v5/storage" - "github.com/TwiN/gatus/v5/util" "gopkg.in/yaml.v3" ) @@ -74,10 +73,10 @@ type Config struct { Alerting *alerting.Config `yaml:"alerting,omitempty"` // Endpoints is the list of endpoints to monitor - Endpoints []*core.Endpoint `yaml:"endpoints,omitempty"` + Endpoints []*endpoint.Endpoint `yaml:"endpoints,omitempty"` // ExternalEndpoints is the list of all external endpoints - ExternalEndpoints []*core.ExternalEndpoint `yaml:"external-endpoints,omitempty"` + ExternalEndpoints []*endpoint.ExternalEndpoint `yaml:"external-endpoints,omitempty"` // Storage is the configuration for how the data is stored Storage *storage.Config `yaml:"storage,omitempty"` @@ -102,20 +101,20 @@ type Config struct { lastFileModTime time.Time // last modification time } -func (config *Config) GetEndpointByKey(key string) *core.Endpoint { +func (config *Config) GetEndpointByKey(key string) *endpoint.Endpoint { for i := 0; i < len(config.Endpoints); i++ { ep := config.Endpoints[i] - if util.ConvertGroupAndEndpointNameToKey(ep.Group, ep.Name) == key { + if ep.Key() == key { return ep } } return nil } -func (config *Config) GetExternalEndpointByKey(key string) *core.ExternalEndpoint { +func (config *Config) GetExternalEndpointByKey(key string) *endpoint.ExternalEndpoint { for i := 0; i < len(config.ExternalEndpoints); i++ { ee := config.ExternalEndpoints[i] - if util.ConvertGroupAndEndpointNameToKey(ee.Group, ee.Name) == key { + if ee.Key() == key { return ee } } @@ -189,7 +188,7 @@ func LoadConfiguration(configPath string) (*Config, error) { return nil, fmt.Errorf("error reading configuration from directory %s: %w", usedConfigPath, err) } } else { - log.Printf("[config.LoadConfiguration] Reading configuration from configFile=%s", configPath) + log.Printf("[config.LoadConfiguration] Reading configuration from configFile=%s", usedConfigPath) if data, err := os.ReadFile(usedConfigPath); err != nil { return nil, err } else { @@ -246,16 +245,13 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) { if config == nil || config.Endpoints == nil || len(config.Endpoints) == 0 { err = ErrNoEndpointInConfig } else { - validateAlertingConfig(config.Alerting, config.Endpoints, config.Debug) + validateAlertingConfig(config.Alerting, config.Endpoints, config.ExternalEndpoints, config.Debug) if err := validateSecurityConfig(config); err != nil { return nil, err } if err := validateEndpointsConfig(config); err != nil { return nil, err } - if err := validateExternalEndpointsConfig(config); err != nil { - return nil, err - } if err := validateWebConfig(config); err != nil { return nil, err } @@ -339,28 +335,37 @@ func validateWebConfig(config *Config) error { } func validateEndpointsConfig(config *Config) error { - for _, endpoint := range config.Endpoints { + duplicateValidationMap := make(map[string]bool) + // Validate endpoints + for _, ep := range config.Endpoints { if config.Debug { - log.Printf("[config.validateEndpointsConfig] Validating endpoint '%s'", endpoint.Name) + log.Printf("[config.validateEndpointsConfig] Validating endpoint '%s'", ep.Name) } - if err := endpoint.ValidateAndSetDefaults(); err != nil { - return fmt.Errorf("invalid endpoint %s: %w", endpoint.DisplayName(), err) + if endpointKey := ep.Key(); duplicateValidationMap[endpointKey] { + return fmt.Errorf("invalid endpoint %s: name and group combination must be unique", ep.Key()) + } else { + duplicateValidationMap[endpointKey] = true + } + if err := ep.ValidateAndSetDefaults(); err != nil { + return fmt.Errorf("invalid endpoint %s: %w", ep.Key(), err) } } log.Printf("[config.validateEndpointsConfig] Validated %d endpoints", len(config.Endpoints)) - return nil -} - -func validateExternalEndpointsConfig(config *Config) error { - for _, externalEndpoint := range config.ExternalEndpoints { + // Validate external endpoints + for _, ee := range config.ExternalEndpoints { if config.Debug { - log.Printf("[config.validateExternalEndpointsConfig] Validating external endpoint '%s'", externalEndpoint.Name) + log.Printf("[config.validateEndpointsConfig] Validating external endpoint '%s'", ee.Name) } - if err := externalEndpoint.ValidateAndSetDefaults(); err != nil { - return fmt.Errorf("invalid external endpoint %s: %w", externalEndpoint.DisplayName(), err) + if endpointKey := ee.Key(); duplicateValidationMap[endpointKey] { + return fmt.Errorf("invalid external endpoint %s: name and group combination must be unique", ee.Key()) + } else { + duplicateValidationMap[endpointKey] = true + } + if err := ee.ValidateAndSetDefaults(); err != nil { + return fmt.Errorf("invalid external endpoint %s: %w", ee.Key(), err) } } - log.Printf("[config.validateExternalEndpointsConfig] Validated %d external endpoints", len(config.ExternalEndpoints)) + log.Printf("[config.validateEndpointsConfig] Validated %d external endpoints", len(config.ExternalEndpoints)) return nil } @@ -381,9 +386,9 @@ func validateSecurityConfig(config *Config) error { // validateAlertingConfig validates the alerting configuration // Note that the alerting configuration has to be validated before the endpoint configuration, because the default alert -// returned by provider.AlertProvider.GetDefaultAlert() must be parsed before core.Endpoint.ValidateAndSetDefaults() +// returned by provider.AlertProvider.GetDefaultAlert() must be parsed before endpoint.Endpoint.ValidateAndSetDefaults() // sets the default alert values when none are set. -func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.Endpoint, debug bool) { +func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoint.Endpoint, externalEndpoints []*endpoint.ExternalEndpoint, debug bool) { if alertingConfig == nil { log.Printf("[config.validateAlertingConfig] Alerting is not configured") return @@ -392,12 +397,13 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.E alert.TypeAWSSES, alert.TypeCustom, alert.TypeDiscord, + alert.TypeEmail, alert.TypeGitHub, alert.TypeGitLab, + alert.TypeGitea, alert.TypeGoogleChat, alert.TypeGotify, alert.TypeJetBrainsSpace, - alert.TypeEmail, alert.TypeMatrix, alert.TypeMattermost, alert.TypeMessagebird, @@ -409,6 +415,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.E alert.TypeTeams, alert.TypeTelegram, alert.TypeTwilio, + alert.TypeZulip, } var validProviders, invalidProviders []alert.Type for _, alertType := range alertTypes { @@ -417,11 +424,21 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.E if alertProvider.IsValid() { // Parse alerts with the provider's default alert if alertProvider.GetDefaultAlert() != nil { - for _, endpoint := range endpoints { - for alertIndex, endpointAlert := range endpoint.Alerts { + for _, ep := range endpoints { + for alertIndex, endpointAlert := range ep.Alerts { if alertType == endpointAlert.Type { if debug { - log.Printf("[config.validateAlertingConfig] Parsing alert %d with provider's default alert for provider=%s in endpoint=%s", alertIndex, alertType, endpoint.Name) + log.Printf("[config.validateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ep.Key()) + } + provider.ParseWithDefaultAlert(alertProvider.GetDefaultAlert(), endpointAlert) + } + } + } + for _, ee := range externalEndpoints { + for alertIndex, endpointAlert := range ee.Alerts { + if alertType == endpointAlert.Type { + if debug { + log.Printf("[config.validateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ee.Key()) } provider.ParseWithDefaultAlert(alertProvider.GetDefaultAlert(), endpointAlert) } diff --git a/config/config_test.go b/config/config_test.go index 2f076936..e6f6c4bf 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -16,6 +16,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/provider/email" "github.com/TwiN/gatus/v5/alerting/provider/github" "github.com/TwiN/gatus/v5/alerting/provider/googlechat" + "github.com/TwiN/gatus/v5/alerting/provider/gotify" "github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace" "github.com/TwiN/gatus/v5/alerting/provider/matrix" "github.com/TwiN/gatus/v5/alerting/provider/mattermost" @@ -29,13 +30,14 @@ import ( "github.com/TwiN/gatus/v5/alerting/provider/telegram" "github.com/TwiN/gatus/v5/alerting/provider/twilio" "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/web" - "github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/storage" "gopkg.in/yaml.v3" ) func TestLoadConfiguration(t *testing.T) { + yes := true dir := t.TempDir() scenarios := []struct { name string @@ -65,7 +67,7 @@ func TestLoadConfiguration(t *testing.T) { endpoints: - name: website`, }, - expectedError: core.ErrEndpointWithNoURL, + expectedError: endpoint.ErrEndpointWithNoURL, }, { name: "config-file-with-endpoint-that-has-no-conditions", @@ -76,7 +78,7 @@ endpoints: - name: website url: https://twin.sh/health`, }, - expectedError: core.ErrEndpointWithNoCondition, + expectedError: endpoint.ErrEndpointWithNoCondition, }, { name: "config-file", @@ -90,11 +92,11 @@ endpoints: - "[STATUS] == 200"`, }, expectedConfig: &Config{ - Endpoints: []*core.Endpoint{ + Endpoints: []*endpoint.Endpoint{ { Name: "website", URL: "https://twin.sh/health", - Conditions: []core.Condition{"[STATUS] == 200"}, + Conditions: []endpoint.Condition{"[STATUS] == 200"}, }, }, }, @@ -136,21 +138,21 @@ endpoints: - "[BODY].status == UP"`, }, expectedConfig: &Config{ - Endpoints: []*core.Endpoint{ + Endpoints: []*endpoint.Endpoint{ { Name: "one", URL: "https://example.com", - Conditions: []core.Condition{"[CONNECTED] == true", "[STATUS] == 200"}, + Conditions: []endpoint.Condition{"[CONNECTED] == true", "[STATUS] == 200"}, }, { Name: "two", URL: "https://example.org", - Conditions: []core.Condition{"len([BODY]) > 0"}, + Conditions: []endpoint.Condition{"len([BODY]) > 0"}, }, { Name: "three", URL: "https://twin.sh/health", - Conditions: []core.Condition{"[STATUS] == 200", "[BODY].status == UP"}, + Conditions: []endpoint.Condition{"[STATUS] == 200", "[BODY].status == UP"}, }, }, }, @@ -165,6 +167,8 @@ metrics: true alerting: slack: webhook-url: https://hooks.slack.com/services/xxx/yyy/zzz + default-alert: + enabled: true endpoints: - name: example @@ -179,6 +183,12 @@ alerting: discord: webhook-url: https://discord.com/api/webhooks/xxx/yyy +external-endpoints: + - name: ext-ep-test + token: "potato" + alerts: + - type: slack + endpoints: - name: frontend url: https://example.com @@ -190,19 +200,32 @@ endpoints: Metrics: true, Alerting: &alerting.Config{ Discord: &discord.AlertProvider{WebhookURL: "https://discord.com/api/webhooks/xxx/yyy"}, - Slack: &slack.AlertProvider{WebhookURL: "https://hooks.slack.com/services/xxx/yyy/zzz"}, + Slack: &slack.AlertProvider{WebhookURL: "https://hooks.slack.com/services/xxx/yyy/zzz", DefaultAlert: &alert.Alert{Enabled: &yes}}, }, - Endpoints: []*core.Endpoint{ + ExternalEndpoints: []*endpoint.ExternalEndpoint{ + { + Name: "ext-ep-test", + Token: "potato", + Alerts: []*alert.Alert{ + { + Type: alert.TypeSlack, + FailureThreshold: 3, + SuccessThreshold: 2, + }, + }, + }, + }, + Endpoints: []*endpoint.Endpoint{ { Name: "example", URL: "https://example.org", Interval: 5 * time.Second, - Conditions: []core.Condition{"[STATUS] == 200"}, + Conditions: []endpoint.Condition{"[STATUS] == 200"}, }, { Name: "frontend", URL: "https://example.com", - Conditions: []core.Condition{"[STATUS] == 200"}, + Conditions: []endpoint.Condition{"[STATUS] == 200"}, }, }, }, @@ -325,10 +348,6 @@ external-endpoints: - name: ext-ep-test group: core token: "potato" - alerts: - - type: discord - description: "healthcheck failed" - send-on-resolved: true endpoints: - name: website @@ -382,18 +401,7 @@ endpoints: if config.ExternalEndpoints[0].Token != "potato" { t.Errorf("Token should have been %s", "potato") } - if len(config.ExternalEndpoints[0].Alerts) != 1 { - t.Error("Should have returned one alert") - } - if config.ExternalEndpoints[0].Alerts[0].Type != alert.TypeDiscord { - t.Errorf("Type should have been %s", alert.TypeDiscord) - } - if config.ExternalEndpoints[0].Alerts[0].FailureThreshold != 3 { - t.Errorf("FailureThreshold should have been %d, got %d", 3, config.ExternalEndpoints[0].Alerts[0].FailureThreshold) - } - if config.ExternalEndpoints[0].Alerts[0].SuccessThreshold != 2 { - t.Errorf("SuccessThreshold should have been %d, got %d", 2, config.ExternalEndpoints[0].Alerts[0].SuccessThreshold) - } + if len(config.Endpoints) != 3 { t.Error("Should have returned two endpoints") } @@ -439,7 +447,6 @@ endpoints: if len(config.Endpoints[1].Conditions) != 2 { t.Errorf("There should have been %d conditions", 2) } - if config.Endpoints[2].URL != "https://example.com/" { t.Errorf("URL should have been %s", "https://example.com/") } @@ -689,8 +696,8 @@ endpoints: if config.Endpoints[0].Interval != 60*time.Second { t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second) } - if userAgent := config.Endpoints[0].Headers["User-Agent"]; userAgent != core.GatusUserAgent { - t.Errorf("User-Agent should've been %s because it's the default value, got %s", core.GatusUserAgent, userAgent) + if userAgent := config.Endpoints[0].Headers["User-Agent"]; userAgent != endpoint.GatusUserAgent { + t.Errorf("User-Agent should've been %s because it's the default value, got %s", endpoint.GatusUserAgent, userAgent) } } @@ -924,7 +931,7 @@ alerting: default-alert: enabled: true failure-threshold: 10 - success-threshold: 1 + success-threshold: 15 pagerduty: integration-key: "00000000000000000000000000000000" default-alert: @@ -977,24 +984,49 @@ alerting: enabled: true failure-threshold: 5 success-threshold: 3 + email: + from: "from@example.com" + username: "from@example.com" + password: "hunter2" + host: "mail.example.com" + port: 587 + to: "recipient1@example.com,recipient2@example.com" + client: + insecure: false + default-alert: + enabled: true + gotify: + server-url: "https://gotify.example" + token: "**************" + default-alert: + enabled: true + +external-endpoints: + - name: ext-ep-test + group: core + token: potato + alerts: + - type: discord endpoints: - - name: website - url: https://twin.sh/health - alerts: - - type: slack - - type: pagerduty - - type: mattermost - - type: messagebird - - type: discord - success-threshold: 2 # test endpoint alert override - - type: telegram - - type: twilio - - type: teams - - type: pushover - - type: jetbrainsspace - conditions: - - "[STATUS] == 200" + - name: website + url: https://twin.sh/health + alerts: + - type: slack + - type: pagerduty + - type: mattermost + - type: messagebird + - type: discord + success-threshold: 8 # test endpoint alert override + - type: telegram + - type: twilio + - type: teams + - type: pushover + - type: jetbrainsspace + - type: email + - type: gotify + conditions: + - "[STATUS] == 200" `)) if err != nil { t.Error("expected no error, got", err.Error()) @@ -1071,6 +1103,12 @@ endpoints: if config.Alerting.Discord.GetDefaultAlert() == nil { t.Fatal("Discord.GetDefaultAlert() shouldn't have returned nil") } + if config.Alerting.Discord.GetDefaultAlert().FailureThreshold != 10 { + t.Errorf("Discord default alert failure threshold should've been %d, but was %d", 10, config.Alerting.Discord.GetDefaultAlert().FailureThreshold) + } + if config.Alerting.Discord.GetDefaultAlert().SuccessThreshold != 15 { + t.Errorf("Discord default alert success threshold should've been %d, but was %d", 15, config.Alerting.Discord.GetDefaultAlert().SuccessThreshold) + } if config.Alerting.Discord.WebhookURL != "http://example.org" { t.Errorf("Discord webhook should've been %s, but was %s", "http://example.org", config.Alerting.Discord.WebhookURL) } @@ -1107,6 +1145,7 @@ endpoints: if config.Alerting.JetBrainsSpace == nil || !config.Alerting.JetBrainsSpace.IsValid() { t.Fatal("JetBrainsSpace alerting config should've been valid") } + if config.Alerting.JetBrainsSpace.GetDefaultAlert() == nil { t.Fatal("JetBrainsSpace.GetDefaultAlert() shouldn't have returned nil") } @@ -1120,6 +1159,67 @@ endpoints: t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "baz", config.Alerting.JetBrainsSpace.Token) } + if config.Alerting.Email == nil || !config.Alerting.Email.IsValid() { + t.Fatal("Email alerting config should've been valid") + } + if config.Alerting.Email.GetDefaultAlert() == nil { + t.Fatal("Email.GetDefaultAlert() shouldn't have returned nil") + } + if config.Alerting.Email.From != "from@example.com" { + t.Errorf("Email from should've been %s, but was %s", "from@example.com", config.Alerting.Email.From) + } + if config.Alerting.Email.Username != "from@example.com" { + t.Errorf("Email username should've been %s, but was %s", "from@example.com", config.Alerting.Email.Username) + } + if config.Alerting.Email.Password != "hunter2" { + t.Errorf("Email password should've been %s, but was %s", "hunter2", config.Alerting.Email.Password) + } + if config.Alerting.Email.Host != "mail.example.com" { + t.Errorf("Email host should've been %s, but was %s", "mail.example.com", config.Alerting.Email.Host) + } + if config.Alerting.Email.Port != 587 { + t.Errorf("Email port should've been %d, but was %d", 587, config.Alerting.Email.Port) + } + if config.Alerting.Email.To != "recipient1@example.com,recipient2@example.com" { + t.Errorf("Email to should've been %s, but was %s", "recipient1@example.com,recipient2@example.com", config.Alerting.Email.To) + } + if config.Alerting.Email.ClientConfig == nil { + t.Fatal("Email client config should've been set") + } + if config.Alerting.Email.ClientConfig.Insecure { + t.Error("Email client config should've been secure") + } + + if config.Alerting.Gotify == nil || !config.Alerting.Gotify.IsValid() { + t.Fatal("Gotify alerting config should've been valid") + } + if config.Alerting.Gotify.GetDefaultAlert() == nil { + t.Fatal("Gotify.GetDefaultAlert() shouldn't have returned nil") + } + if config.Alerting.Gotify.ServerURL != "https://gotify.example" { + t.Errorf("Gotify server URL should've been %s, but was %s", "https://gotify.example", config.Alerting.Gotify.ServerURL) + } + if config.Alerting.Gotify.Token != "**************" { + t.Errorf("Gotify token should've been %s, but was %s", "**************", config.Alerting.Gotify.Token) + } + + // External endpoints + if len(config.ExternalEndpoints) != 1 { + t.Error("There should've been 1 external endpoint") + } + if config.ExternalEndpoints[0].Alerts[0].Type != alert.TypeDiscord { + t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeDiscord, config.ExternalEndpoints[0].Alerts[0].Type) + } + if !config.ExternalEndpoints[0].Alerts[0].IsEnabled() { + t.Error("The alert should've been enabled") + } + if config.ExternalEndpoints[0].Alerts[0].FailureThreshold != 10 { + t.Errorf("The failure threshold of the alert should've been %d, but it was %d", 10, config.ExternalEndpoints[0].Alerts[0].FailureThreshold) + } + if config.ExternalEndpoints[0].Alerts[0].SuccessThreshold != 15 { + t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 15, config.ExternalEndpoints[0].Alerts[0].SuccessThreshold) + } + // Endpoints if len(config.Endpoints) != 1 { t.Error("There should've been 1 endpoint") @@ -1130,8 +1230,8 @@ endpoints: if config.Endpoints[0].Interval != 60*time.Second { t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second) } - if len(config.Endpoints[0].Alerts) != 10 { - t.Fatal("There should've been 10 alerts configured") + if len(config.Endpoints[0].Alerts) != 12 { + t.Fatalf("There should've been 12 alerts configured, got %d", len(config.Endpoints[0].Alerts)) } if config.Endpoints[0].Alerts[0].Type != alert.TypeSlack { @@ -1192,8 +1292,8 @@ endpoints: if config.Endpoints[0].Alerts[4].FailureThreshold != 10 { t.Errorf("The failure threshold of the alert should've been %d, but it was %d", 10, config.Endpoints[0].Alerts[4].FailureThreshold) } - if config.Endpoints[0].Alerts[4].SuccessThreshold != 2 { - t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[4].SuccessThreshold) + if config.Endpoints[0].Alerts[4].SuccessThreshold != 8 { + t.Errorf("The default success threshold of the alert should've been %d because it was explicitly overriden, but it was %d", 8, config.Endpoints[0].Alerts[4].SuccessThreshold) } if config.Endpoints[0].Alerts[5].Type != alert.TypeTelegram { @@ -1255,10 +1355,36 @@ endpoints: t.Error("The alert should've been enabled") } if config.Endpoints[0].Alerts[9].FailureThreshold != 5 { - t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[9].FailureThreshold) + t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 5, config.Endpoints[0].Alerts[9].FailureThreshold) } if config.Endpoints[0].Alerts[9].SuccessThreshold != 3 { - t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[9].SuccessThreshold) + t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[9].SuccessThreshold) + } + + if config.Endpoints[0].Alerts[10].Type != alert.TypeEmail { + t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeEmail, config.Endpoints[0].Alerts[10].Type) + } + if !config.Endpoints[0].Alerts[10].IsEnabled() { + t.Error("The alert should've been enabled") + } + if config.Endpoints[0].Alerts[10].FailureThreshold != 3 { + t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[10].FailureThreshold) + } + if config.Endpoints[0].Alerts[10].SuccessThreshold != 2 { + t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[10].SuccessThreshold) + } + + if config.Endpoints[0].Alerts[11].Type != alert.TypeGotify { + t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeGotify, config.Endpoints[0].Alerts[11].Type) + } + if !config.Endpoints[0].Alerts[11].IsEnabled() { + t.Error("The alert should've been enabled") + } + if config.Endpoints[0].Alerts[11].FailureThreshold != 3 { + t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[11].FailureThreshold) + } + if config.Endpoints[0].Alerts[11].SuccessThreshold != 2 { + t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[11].SuccessThreshold) } } @@ -1532,6 +1658,99 @@ endpoints: } } +func TestParseAndValidateConfigBytesWithDuplicateEndpointName(t *testing.T) { + scenarios := []struct { + name string + shouldError bool + config string + }{ + { + name: "same-name-no-group", + shouldError: true, + config: ` +endpoints: + - name: ep1 + url: https://twin.sh/health + conditions: + - "[STATUS] == 200" + - name: ep1 + url: https://twin.sh/health + conditions: + - "[STATUS] == 200"`, + }, + { + name: "same-name-different-group", + shouldError: false, + config: ` +endpoints: + - name: ep1 + url: https://twin.sh/health + conditions: + - "[STATUS] == 200" + - name: ep1 + group: g1 + url: https://twin.sh/health + conditions: + - "[STATUS] == 200"`, + }, + { + name: "same-name-same-group", + shouldError: true, + config: ` +endpoints: + - name: ep1 + group: g1 + url: https://twin.sh/health + conditions: + - "[STATUS] == 200" + - name: ep1 + group: g1 + url: https://twin.sh/health + conditions: + - "[STATUS] == 200"`, + }, + { + name: "same-name-different-endpoint-type", + shouldError: true, + config: ` +external-endpoints: + - name: ep1 + token: "12345678" + +endpoints: + - name: ep1 + url: https://twin.sh/health + conditions: + - "[STATUS] == 200"`, + }, + { + name: "same-name-different-group-different-endpoint-type", + shouldError: false, + config: ` +external-endpoints: + - name: ep1 + group: gr1 + token: "12345678" + +endpoints: + - name: ep1 + url: https://twin.sh/health + conditions: + - "[STATUS] == 200"`, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + _, err := parseAndValidateConfigBytes([]byte(scenario.config)) + if scenario.shouldError && err == nil { + t.Error("should've returned an error") + } else if !scenario.shouldError && err != nil { + t.Error("shouldn't have returned an error") + } + }) + } +} + func TestParseAndValidateConfigBytesWithInvalidStorageConfig(t *testing.T) { _, err := parseAndValidateConfigBytes([]byte(` storage: @@ -1645,7 +1864,7 @@ endpoints: func TestParseAndValidateConfigBytesWithNoEndpoints(t *testing.T) { _, err := parseAndValidateConfigBytes([]byte(``)) - if err != ErrNoEndpointInConfig { + if !errors.Is(err, ErrNoEndpointInConfig) { t.Error("The error returned should have been of type ErrNoEndpointInConfig") } } @@ -1657,6 +1876,7 @@ func TestGetAlertingProviderByAlertType(t *testing.T) { Email: &email.AlertProvider{}, GitHub: &github.AlertProvider{}, GoogleChat: &googlechat.AlertProvider{}, + Gotify: &gotify.AlertProvider{}, JetBrainsSpace: &jetbrainsspace.AlertProvider{}, Matrix: &matrix.AlertProvider{}, Mattermost: &mattermost.AlertProvider{}, @@ -1679,6 +1899,7 @@ func TestGetAlertingProviderByAlertType(t *testing.T) { {alertType: alert.TypeEmail, expected: alertingConfig.Email}, {alertType: alert.TypeGitHub, expected: alertingConfig.GitHub}, {alertType: alert.TypeGoogleChat, expected: alertingConfig.GoogleChat}, + {alertType: alert.TypeGotify, expected: alertingConfig.Gotify}, {alertType: alert.TypeJetBrainsSpace, expected: alertingConfig.JetBrainsSpace}, {alertType: alert.TypeMatrix, expected: alertingConfig.Matrix}, {alertType: alert.TypeMattermost, expected: alertingConfig.Mattermost}, diff --git a/core/endpoint_common.go b/config/endpoint/common.go similarity index 98% rename from core/endpoint_common.go rename to config/endpoint/common.go index f99e5501..f3da8bf8 100644 --- a/core/endpoint_common.go +++ b/config/endpoint/common.go @@ -1,4 +1,4 @@ -package core +package endpoint import ( "errors" diff --git a/core/endpoint_common_test.go b/config/endpoint/common_test.go similarity index 98% rename from core/endpoint_common_test.go rename to config/endpoint/common_test.go index e1c63178..ab4fc9ef 100644 --- a/core/endpoint_common_test.go +++ b/config/endpoint/common_test.go @@ -1,4 +1,4 @@ -package core +package endpoint import ( "errors" diff --git a/core/condition.go b/config/endpoint/condition.go similarity index 99% rename from core/condition.go rename to config/endpoint/condition.go index 42f70d92..6d36babd 100644 --- a/core/condition.go +++ b/config/endpoint/condition.go @@ -1,4 +1,4 @@ -package core +package endpoint import ( "errors" diff --git a/core/condition_bench_test.go b/config/endpoint/condition_bench_test.go similarity index 98% rename from core/condition_bench_test.go rename to config/endpoint/condition_bench_test.go index 0126871f..82061f69 100644 --- a/core/condition_bench_test.go +++ b/config/endpoint/condition_bench_test.go @@ -1,6 +1,8 @@ -package core +package endpoint -import "testing" +import ( + "testing" +) func BenchmarkCondition_evaluateWithBodyStringAny(b *testing.B) { condition := Condition("[BODY].name == any(john.doe, jane.doe)") diff --git a/core/condition_result.go b/config/endpoint/condition_result.go similarity index 93% rename from core/condition_result.go rename to config/endpoint/condition_result.go index d8bdc1e9..00af8620 100644 --- a/core/condition_result.go +++ b/config/endpoint/condition_result.go @@ -1,4 +1,4 @@ -package core +package endpoint // ConditionResult result of a Condition type ConditionResult struct { diff --git a/core/condition_test.go b/config/endpoint/condition_test.go similarity index 99% rename from core/condition_test.go rename to config/endpoint/condition_test.go index ef2e79ed..3e98912f 100644 --- a/core/condition_test.go +++ b/config/endpoint/condition_test.go @@ -1,4 +1,4 @@ -package core +package endpoint import ( "errors" diff --git a/config/endpoint/dns/dns.go b/config/endpoint/dns/dns.go new file mode 100644 index 00000000..47a29bdc --- /dev/null +++ b/config/endpoint/dns/dns.go @@ -0,0 +1,38 @@ +package dns + +import ( + "errors" + "strings" + + "github.com/miekg/dns" +) + +var ( + // ErrDNSWithNoQueryName is the error with which gatus will panic if a dns is configured without query name + ErrDNSWithNoQueryName = errors.New("you must specify a query name in the DNS configuration") + + // ErrDNSWithInvalidQueryType is the error with which gatus will panic if a dns is configured with invalid query type + ErrDNSWithInvalidQueryType = errors.New("invalid query type in the DNS configuration") +) + +// Config for an Endpoint of type DNS +type Config struct { + // QueryType is the type for the DNS records like A, AAAA, CNAME... + QueryType string `yaml:"query-type"` + + // QueryName is the query for DNS + QueryName string `yaml:"query-name"` +} + +func (d *Config) ValidateAndSetDefault() error { + if len(d.QueryName) == 0 { + return ErrDNSWithNoQueryName + } + if !strings.HasSuffix(d.QueryName, ".") { + d.QueryName += "." + } + if _, ok := dns.StringToType[d.QueryType]; !ok { + return ErrDNSWithInvalidQueryType + } + return nil +} diff --git a/config/endpoint/dns/dns_test.go b/config/endpoint/dns/dns_test.go new file mode 100644 index 00000000..57e96296 --- /dev/null +++ b/config/endpoint/dns/dns_test.go @@ -0,0 +1,27 @@ +package dns + +import ( + "testing" +) + +func TestConfig_ValidateAndSetDefault(t *testing.T) { + dns := &Config{ + QueryType: "A", + QueryName: "", + } + err := dns.ValidateAndSetDefault() + if err == nil { + t.Error("Should've returned an error because endpoint's dns didn't have a query name, which is a mandatory field for dns") + } +} + +func TestConfig_ValidateAndSetDefaultsWithInvalidDNSQueryType(t *testing.T) { + dns := &Config{ + QueryType: "B", + QueryName: "example.com", + } + err := dns.ValidateAndSetDefault() + if err == nil { + t.Error("Should've returned an error because endpoint's dns query type is invalid, it needs to be a valid query name like A, AAAA, CNAME...") + } +} diff --git a/core/endpoint.go b/config/endpoint/endpoint.go similarity index 60% rename from core/endpoint.go rename to config/endpoint/endpoint.go index e0ccbff0..b522ec44 100644 --- a/core/endpoint.go +++ b/config/endpoint/endpoint.go @@ -1,4 +1,4 @@ -package core +package endpoint import ( "bytes" @@ -16,12 +16,13 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" - "github.com/TwiN/gatus/v5/core/ui" - "github.com/TwiN/gatus/v5/util" + "github.com/TwiN/gatus/v5/config/endpoint/dns" + sshconfig "github.com/TwiN/gatus/v5/config/endpoint/ssh" + "github.com/TwiN/gatus/v5/config/endpoint/ui" "golang.org/x/crypto/ssh" ) -type EndpointType string +type Type string const ( // HostHeader is the name of the header used to specify the host @@ -36,17 +37,17 @@ const ( // GatusUserAgent is the default user agent that Gatus uses to send requests. GatusUserAgent = "Gatus/1.0" - EndpointTypeDNS EndpointType = "DNS" - EndpointTypeTCP EndpointType = "TCP" - EndpointTypeSCTP EndpointType = "SCTP" - EndpointTypeUDP EndpointType = "UDP" - EndpointTypeICMP EndpointType = "ICMP" - EndpointTypeSTARTTLS EndpointType = "STARTTLS" - EndpointTypeTLS EndpointType = "TLS" - EndpointTypeHTTP EndpointType = "HTTP" - EndpointTypeWS EndpointType = "WEBSOCKET" - EndpointTypeSSH EndpointType = "SSH" - EndpointTypeUNKNOWN EndpointType = "UNKNOWN" + TypeDNS Type = "DNS" + TypeTCP Type = "TCP" + TypeSCTP Type = "SCTP" + TypeUDP Type = "UDP" + TypeICMP Type = "ICMP" + TypeSTARTTLS Type = "STARTTLS" + TypeTLS Type = "TLS" + TypeHTTP Type = "HTTP" + TypeWS Type = "WEBSOCKET" + TypeSSH Type = "SSH" + TypeUNKNOWN Type = "UNKNOWN" ) var ( @@ -83,12 +84,6 @@ type Endpoint struct { // URL to send the request to URL string `yaml:"url"` - // DNS is the configuration of DNS monitoring - DNS *DNS `yaml:"dns,omitempty"` - - // SSH is the configuration of SSH monitoring. - SSH *SSH `yaml:"ssh,omitempty"` - // Method of the request made to the url of the endpoint Method string `yaml:"method,omitempty"` @@ -110,6 +105,12 @@ type Endpoint struct { // Alerts is the alerting configuration for the endpoint in case of failure Alerts []*alert.Alert `yaml:"alerts,omitempty"` + // DNSConfig is the configuration for DNS monitoring + DNSConfig *dns.Config `yaml:"dns,omitempty"` + + // SSH is the configuration for SSH monitoring + SSHConfig *sshconfig.Config `yaml:"ssh,omitempty"` + // ClientConfig is the configuration of the client used to communicate with the endpoint's target ClientConfig *client.Config `yaml:"client,omitempty"` @@ -127,103 +128,103 @@ type Endpoint struct { } // IsEnabled returns whether the endpoint is enabled or not -func (endpoint *Endpoint) IsEnabled() bool { - if endpoint.Enabled == nil { +func (e *Endpoint) IsEnabled() bool { + if e.Enabled == nil { return true } - return *endpoint.Enabled + return *e.Enabled } // Type returns the endpoint type -func (endpoint *Endpoint) Type() EndpointType { +func (e *Endpoint) Type() Type { switch { - case endpoint.DNS != nil: - return EndpointTypeDNS - case strings.HasPrefix(endpoint.URL, "tcp://"): - return EndpointTypeTCP - case strings.HasPrefix(endpoint.URL, "sctp://"): - return EndpointTypeSCTP - case strings.HasPrefix(endpoint.URL, "udp://"): - return EndpointTypeUDP - case strings.HasPrefix(endpoint.URL, "icmp://"): - return EndpointTypeICMP - case strings.HasPrefix(endpoint.URL, "starttls://"): - return EndpointTypeSTARTTLS - case strings.HasPrefix(endpoint.URL, "tls://"): - return EndpointTypeTLS - case strings.HasPrefix(endpoint.URL, "http://") || strings.HasPrefix(endpoint.URL, "https://"): - return EndpointTypeHTTP - case strings.HasPrefix(endpoint.URL, "ws://") || strings.HasPrefix(endpoint.URL, "wss://"): - return EndpointTypeWS - case strings.HasPrefix(endpoint.URL, "ssh://"): - return EndpointTypeSSH + case e.DNSConfig != nil: + return TypeDNS + case strings.HasPrefix(e.URL, "tcp://"): + return TypeTCP + case strings.HasPrefix(e.URL, "sctp://"): + return TypeSCTP + case strings.HasPrefix(e.URL, "udp://"): + return TypeUDP + case strings.HasPrefix(e.URL, "icmp://"): + return TypeICMP + case strings.HasPrefix(e.URL, "starttls://"): + return TypeSTARTTLS + case strings.HasPrefix(e.URL, "tls://"): + return TypeTLS + case strings.HasPrefix(e.URL, "http://") || strings.HasPrefix(e.URL, "https://"): + return TypeHTTP + case strings.HasPrefix(e.URL, "ws://") || strings.HasPrefix(e.URL, "wss://"): + return TypeWS + case strings.HasPrefix(e.URL, "ssh://"): + return TypeSSH default: - return EndpointTypeUNKNOWN + return TypeUNKNOWN } } // ValidateAndSetDefaults validates the endpoint's configuration and sets the default value of args that have one -func (endpoint *Endpoint) ValidateAndSetDefaults() error { - if err := validateEndpointNameGroupAndAlerts(endpoint.Name, endpoint.Group, endpoint.Alerts); err != nil { +func (e *Endpoint) ValidateAndSetDefaults() error { + if err := validateEndpointNameGroupAndAlerts(e.Name, e.Group, e.Alerts); err != nil { return err } - if len(endpoint.URL) == 0 { + if len(e.URL) == 0 { return ErrEndpointWithNoURL } - if endpoint.ClientConfig == nil { - endpoint.ClientConfig = client.GetDefaultConfig() + if e.ClientConfig == nil { + e.ClientConfig = client.GetDefaultConfig() } else { - if err := endpoint.ClientConfig.ValidateAndSetDefaults(); err != nil { + if err := e.ClientConfig.ValidateAndSetDefaults(); err != nil { return err } } - if endpoint.UIConfig == nil { - endpoint.UIConfig = ui.GetDefaultConfig() + if e.UIConfig == nil { + e.UIConfig = ui.GetDefaultConfig() } else { - if err := endpoint.UIConfig.ValidateAndSetDefaults(); err != nil { + if err := e.UIConfig.ValidateAndSetDefaults(); err != nil { return err } } - if endpoint.Interval == 0 { - endpoint.Interval = 1 * time.Minute + if e.Interval == 0 { + e.Interval = 1 * time.Minute } - if len(endpoint.Method) == 0 { - endpoint.Method = http.MethodGet + if len(e.Method) == 0 { + e.Method = http.MethodGet } - if len(endpoint.Headers) == 0 { - endpoint.Headers = make(map[string]string) + if len(e.Headers) == 0 { + e.Headers = make(map[string]string) } // Automatically add user agent header if there isn't one specified in the endpoint configuration - if _, userAgentHeaderExists := endpoint.Headers[UserAgentHeader]; !userAgentHeaderExists { - endpoint.Headers[UserAgentHeader] = GatusUserAgent + if _, userAgentHeaderExists := e.Headers[UserAgentHeader]; !userAgentHeaderExists { + e.Headers[UserAgentHeader] = GatusUserAgent } // Automatically add "Content-Type: application/json" header if there's no Content-Type set // and endpoint.GraphQL is set to true - if _, contentTypeHeaderExists := endpoint.Headers[ContentTypeHeader]; !contentTypeHeaderExists && endpoint.GraphQL { - endpoint.Headers[ContentTypeHeader] = "application/json" + if _, contentTypeHeaderExists := e.Headers[ContentTypeHeader]; !contentTypeHeaderExists && e.GraphQL { + e.Headers[ContentTypeHeader] = "application/json" } - if len(endpoint.Conditions) == 0 { + if len(e.Conditions) == 0 { return ErrEndpointWithNoCondition } - for _, c := range endpoint.Conditions { - if endpoint.Interval < 5*time.Minute && c.hasDomainExpirationPlaceholder() { + for _, c := range e.Conditions { + if e.Interval < 5*time.Minute && c.hasDomainExpirationPlaceholder() { return ErrInvalidEndpointIntervalForDomainExpirationPlaceholder } if err := c.Validate(); err != nil { return fmt.Errorf("%v: %w", ErrInvalidConditionFormat, err) } } - if endpoint.DNS != nil { - return endpoint.DNS.validateAndSetDefault() + if e.DNSConfig != nil { + return e.DNSConfig.ValidateAndSetDefault() } - if endpoint.SSH != nil { - return endpoint.SSH.validate() + if e.SSHConfig != nil { + return e.SSHConfig.Validate() } - if endpoint.Type() == EndpointTypeUNKNOWN { + if e.Type() == TypeUNKNOWN { return ErrUnknownEndpointType } // Make sure that the request can be created - _, err := http.NewRequest(endpoint.Method, endpoint.URL, bytes.NewBuffer([]byte(endpoint.Body))) + _, err := http.NewRequest(e.Method, e.URL, bytes.NewBuffer([]byte(e.Body))) if err != nil { return err } @@ -231,35 +232,35 @@ func (endpoint *Endpoint) ValidateAndSetDefaults() error { } // DisplayName returns an identifier made up of the Name and, if not empty, the Group. -func (endpoint *Endpoint) DisplayName() string { - if len(endpoint.Group) > 0 { - return endpoint.Group + "/" + endpoint.Name +func (e *Endpoint) DisplayName() string { + if len(e.Group) > 0 { + return e.Group + "/" + e.Name } - return endpoint.Name + return e.Name } // Key returns the unique key for the Endpoint -func (endpoint *Endpoint) Key() string { - return util.ConvertGroupAndEndpointNameToKey(endpoint.Group, endpoint.Name) +func (e *Endpoint) Key() string { + return ConvertGroupAndEndpointNameToKey(e.Group, e.Name) } // Close HTTP connections between watchdog and endpoints to avoid dangling socket file descriptors // on configuration reload. // More context on https://github.com/TwiN/gatus/issues/536 -func (endpoint *Endpoint) Close() { - if endpoint.Type() == EndpointTypeHTTP { - client.GetHTTPClient(endpoint.ClientConfig).CloseIdleConnections() +func (e *Endpoint) Close() { + if e.Type() == TypeHTTP { + client.GetHTTPClient(e.ClientConfig).CloseIdleConnections() } } // EvaluateHealth sends a request to the endpoint's URL and evaluates the conditions of the endpoint. -func (endpoint *Endpoint) EvaluateHealth() *Result { +func (e *Endpoint) EvaluateHealth() *Result { result := &Result{Success: true, Errors: []string{}} // Parse or extract hostname from URL - if endpoint.DNS != nil { - result.Hostname = strings.TrimSuffix(endpoint.URL, ":53") + if e.DNSConfig != nil { + result.Hostname = strings.TrimSuffix(e.URL, ":53") } else { - urlObject, err := url.Parse(endpoint.URL) + urlObject, err := url.Parse(e.URL) if err != nil { result.AddError(err.Error()) } else { @@ -267,11 +268,11 @@ func (endpoint *Endpoint) EvaluateHealth() *Result { } } // Retrieve IP if necessary - if endpoint.needsToRetrieveIP() { - endpoint.getIP(result) + if e.needsToRetrieveIP() { + e.getIP(result) } // Retrieve domain expiration if necessary - if endpoint.needsToRetrieveDomainExpiration() && len(result.Hostname) > 0 { + if e.needsToRetrieveDomainExpiration() && len(result.Hostname) > 0 { var err error if result.DomainExpiration, err = client.GetDomainExpiration(result.Hostname); err != nil { result.AddError(err.Error()) @@ -279,31 +280,31 @@ func (endpoint *Endpoint) EvaluateHealth() *Result { } // Call the endpoint (if there's no errors) if len(result.Errors) == 0 { - endpoint.call(result) + e.call(result) } else { result.Success = false } // Evaluate the conditions - for _, condition := range endpoint.Conditions { - success := condition.evaluate(result, endpoint.UIConfig.DontResolveFailedConditions) + for _, condition := range e.Conditions { + success := condition.evaluate(result, e.UIConfig.DontResolveFailedConditions) if !success { result.Success = false } } result.Timestamp = time.Now() // Clean up parameters that we don't need to keep in the results - if endpoint.UIConfig.HideURL { + if e.UIConfig.HideURL { for errIdx, errorString := range result.Errors { - result.Errors[errIdx] = strings.ReplaceAll(errorString, endpoint.URL, "") + result.Errors[errIdx] = strings.ReplaceAll(errorString, e.URL, "") } } - if endpoint.UIConfig.HideHostname { + if e.UIConfig.HideHostname { for errIdx, errorString := range result.Errors { result.Errors[errIdx] = strings.ReplaceAll(errorString, result.Hostname, "") } result.Hostname = "" } - if endpoint.UIConfig.HideConditions { + if e.UIConfig.HideConditions { result.ConditionResults = nil } @@ -328,7 +329,7 @@ func (endpoint *Endpoint) EvaluateHealth() *Result { return result } -func (endpoint *Endpoint) getIP(result *Result) { +func (e *Endpoint) getIP(result *Result) { if ips, err := net.LookupIP(result.Hostname); err != nil { result.AddError(err.Error()) return @@ -337,24 +338,28 @@ func (endpoint *Endpoint) getIP(result *Result) { } } -func (endpoint *Endpoint) call(result *Result) { +func (e *Endpoint) call(result *Result) { var request *http.Request var response *http.Response var err error var certificate *x509.Certificate - endpointType := endpoint.Type() - if endpointType == EndpointTypeHTTP { - request = endpoint.buildHTTPRequest() + endpointType := e.Type() + if endpointType == TypeHTTP { + request = e.buildHTTPRequest() } startTime := time.Now() - if endpointType == EndpointTypeDNS { - endpoint.DNS.query(endpoint.URL, result) + if endpointType == TypeDNS { + result.Connected, result.DNSRCode, result.Body, err = client.QueryDNS(e.DNSConfig.QueryType, e.DNSConfig.QueryName, e.URL) + if err != nil { + result.AddError(err.Error()) + return + } result.Duration = time.Since(startTime) - } else if endpointType == EndpointTypeSTARTTLS || endpointType == EndpointTypeTLS { - if endpointType == EndpointTypeSTARTTLS { - result.Connected, certificate, err = client.CanPerformStartTLS(strings.TrimPrefix(endpoint.URL, "starttls://"), endpoint.ClientConfig) + } else if endpointType == TypeSTARTTLS || endpointType == TypeTLS { + if endpointType == TypeSTARTTLS { + result.Connected, certificate, err = client.CanPerformStartTLS(strings.TrimPrefix(e.URL, "starttls://"), e.ClientConfig) } else { - result.Connected, certificate, err = client.CanPerformTLS(strings.TrimPrefix(endpoint.URL, "tls://"), endpoint.ClientConfig) + result.Connected, certificate, err = client.CanPerformTLS(strings.TrimPrefix(e.URL, "tls://"), e.ClientConfig) } if err != nil { result.AddError(err.Error()) @@ -362,39 +367,39 @@ func (endpoint *Endpoint) call(result *Result) { } result.Duration = time.Since(startTime) result.CertificateExpiration = time.Until(certificate.NotAfter) - } else if endpointType == EndpointTypeTCP { - result.Connected = client.CanCreateTCPConnection(strings.TrimPrefix(endpoint.URL, "tcp://"), endpoint.ClientConfig) + } else if endpointType == TypeTCP { + result.Connected = client.CanCreateTCPConnection(strings.TrimPrefix(e.URL, "tcp://"), e.ClientConfig) result.Duration = time.Since(startTime) - } else if endpointType == EndpointTypeUDP { - result.Connected = client.CanCreateUDPConnection(strings.TrimPrefix(endpoint.URL, "udp://"), endpoint.ClientConfig) + } else if endpointType == TypeUDP { + result.Connected = client.CanCreateUDPConnection(strings.TrimPrefix(e.URL, "udp://"), e.ClientConfig) result.Duration = time.Since(startTime) - } else if endpointType == EndpointTypeSCTP { - result.Connected = client.CanCreateSCTPConnection(strings.TrimPrefix(endpoint.URL, "sctp://"), endpoint.ClientConfig) + } else if endpointType == TypeSCTP { + result.Connected = client.CanCreateSCTPConnection(strings.TrimPrefix(e.URL, "sctp://"), e.ClientConfig) result.Duration = time.Since(startTime) - } else if endpointType == EndpointTypeICMP { - result.Connected, result.Duration = client.Ping(strings.TrimPrefix(endpoint.URL, "icmp://"), endpoint.ClientConfig) - } else if endpointType == EndpointTypeWS { - result.Connected, result.Body, err = client.QueryWebSocket(endpoint.URL, endpoint.Body, endpoint.ClientConfig) + } else if endpointType == TypeICMP { + result.Connected, result.Duration = client.Ping(strings.TrimPrefix(e.URL, "icmp://"), e.ClientConfig) + } else if endpointType == TypeWS { + result.Connected, result.Body, err = client.QueryWebSocket(e.URL, e.Body, e.ClientConfig) if err != nil { result.AddError(err.Error()) return } result.Duration = time.Since(startTime) - } else if endpointType == EndpointTypeSSH { + } else if endpointType == TypeSSH { var cli *ssh.Client - result.Connected, cli, err = client.CanCreateSSHConnection(strings.TrimPrefix(endpoint.URL, "ssh://"), endpoint.SSH.Username, endpoint.SSH.Password, endpoint.ClientConfig) + result.Connected, cli, err = client.CanCreateSSHConnection(strings.TrimPrefix(e.URL, "ssh://"), e.SSHConfig.Username, e.SSHConfig.Password, e.ClientConfig) if err != nil { result.AddError(err.Error()) return } - result.Success, result.HTTPStatus, err = client.ExecuteSSHCommand(cli, endpoint.Body, endpoint.ClientConfig) + result.Success, result.HTTPStatus, err = client.ExecuteSSHCommand(cli, e.Body, e.ClientConfig) if err != nil { result.AddError(err.Error()) return } result.Duration = time.Since(startTime) } else { - response, err = client.GetHTTPClient(endpoint.ClientConfig).Do(request) + response, err = client.GetHTTPClient(e.ClientConfig).Do(request) result.Duration = time.Since(startTime) if err != nil { result.AddError(err.Error()) @@ -408,7 +413,7 @@ func (endpoint *Endpoint) call(result *Result) { result.HTTPStatus = response.StatusCode result.Connected = response.StatusCode > 0 // Only read the Body if there's a condition that uses the BodyPlaceholder - if endpoint.needsToReadBody() { + if e.needsToReadBody() { result.Body, err = io.ReadAll(response.Body) if err != nil { result.AddError("error reading response body:" + err.Error()) @@ -417,19 +422,19 @@ func (endpoint *Endpoint) call(result *Result) { } } -func (endpoint *Endpoint) buildHTTPRequest() *http.Request { +func (e *Endpoint) buildHTTPRequest() *http.Request { var bodyBuffer *bytes.Buffer - if endpoint.GraphQL { + if e.GraphQL { graphQlBody := map[string]string{ - "query": endpoint.Body, + "query": e.Body, } body, _ := json.Marshal(graphQlBody) bodyBuffer = bytes.NewBuffer(body) } else { - bodyBuffer = bytes.NewBuffer([]byte(endpoint.Body)) + bodyBuffer = bytes.NewBuffer([]byte(e.Body)) } - request, _ := http.NewRequest(endpoint.Method, endpoint.URL, bodyBuffer) - for k, v := range endpoint.Headers { + request, _ := http.NewRequest(e.Method, e.URL, bodyBuffer) + for k, v := range e.Headers { request.Header.Set(k, v) if k == HostHeader { request.Host = v @@ -439,11 +444,11 @@ func (endpoint *Endpoint) buildHTTPRequest() *http.Request { } // needsToReadBody checks if there's any condition that requires the response Body to be read -func (endpoint *Endpoint) needsToReadBody() bool { - if strings.Contains(endpoint.OnErrorAdd, BodyPlaceholder) { +func (e *Endpoint) needsToReadBody() bool { + if strings.Contains(e.OnErrorAdd, BodyPlaceholder) { return true } - for _, condition := range endpoint.Conditions { + for _, condition := range e.Conditions { if condition.hasBodyPlaceholder() { return true } @@ -452,8 +457,8 @@ func (endpoint *Endpoint) needsToReadBody() bool { } // needsToRetrieveDomainExpiration checks if there's any condition that requires a whois query to be performed -func (endpoint *Endpoint) needsToRetrieveDomainExpiration() bool { - for _, condition := range endpoint.Conditions { +func (e *Endpoint) needsToRetrieveDomainExpiration() bool { + for _, condition := range e.Conditions { if condition.hasDomainExpirationPlaceholder() { return true } @@ -462,8 +467,8 @@ func (endpoint *Endpoint) needsToRetrieveDomainExpiration() bool { } // needsToRetrieveIP checks if there's any condition that requires an IP lookup -func (endpoint *Endpoint) needsToRetrieveIP() bool { - for _, condition := range endpoint.Conditions { +func (e *Endpoint) needsToRetrieveIP() bool { + for _, condition := range e.Conditions { if condition.hasIPPlaceholder() { return true } diff --git a/core/endpoint_test.go b/config/endpoint/endpoint_test.go similarity index 95% rename from core/endpoint_test.go rename to config/endpoint/endpoint_test.go index ad7af6c0..267160d8 100644 --- a/core/endpoint_test.go +++ b/config/endpoint/endpoint_test.go @@ -1,4 +1,4 @@ -package core +package endpoint import ( "bytes" @@ -13,7 +13,9 @@ import ( "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" - "github.com/TwiN/gatus/v5/core/ui" + "github.com/TwiN/gatus/v5/config/endpoint/dns" + "github.com/TwiN/gatus/v5/config/endpoint/ssh" + "github.com/TwiN/gatus/v5/config/endpoint/ui" "github.com/TwiN/gatus/v5/test" ) @@ -302,105 +304,105 @@ func TestEndpoint_IsEnabled(t *testing.T) { func TestEndpoint_Type(t *testing.T) { type args struct { URL string - DNS *DNS - SSH *SSH + DNS *dns.Config + SSH *ssh.Config } tests := []struct { args args - want EndpointType + want Type }{ { args: args{ URL: "8.8.8.8", - DNS: &DNS{ + DNS: &dns.Config{ QueryType: "A", QueryName: "example.com", }, }, - want: EndpointTypeDNS, + want: TypeDNS, }, { args: args{ URL: "tcp://127.0.0.1:6379", }, - want: EndpointTypeTCP, + want: TypeTCP, }, { args: args{ URL: "icmp://example.com", }, - want: EndpointTypeICMP, + want: TypeICMP, }, { args: args{ URL: "sctp://example.com", }, - want: EndpointTypeSCTP, + want: TypeSCTP, }, { args: args{ URL: "udp://example.com", }, - want: EndpointTypeUDP, + want: TypeUDP, }, { args: args{ URL: "starttls://smtp.gmail.com:587", }, - want: EndpointTypeSTARTTLS, + want: TypeSTARTTLS, }, { args: args{ URL: "tls://example.com:443", }, - want: EndpointTypeTLS, + want: TypeTLS, }, { args: args{ URL: "https://twin.sh/health", }, - want: EndpointTypeHTTP, + want: TypeHTTP, }, { args: args{ URL: "wss://example.com/", }, - want: EndpointTypeWS, + want: TypeWS, }, { args: args{ URL: "ws://example.com/", }, - want: EndpointTypeWS, + want: TypeWS, }, { args: args{ URL: "ssh://example.com:22", - SSH: &SSH{ + SSH: &ssh.Config{ Username: "root", Password: "password", }, }, - want: EndpointTypeSSH, + want: TypeSSH, }, { args: args{ URL: "invalid://example.org", }, - want: EndpointTypeUNKNOWN, + want: TypeUNKNOWN, }, { args: args{ URL: "no-scheme", }, - want: EndpointTypeUNKNOWN, + want: TypeUNKNOWN, }, } for _, tt := range tests { t.Run(string(tt.want), func(t *testing.T) { endpoint := Endpoint{ - URL: tt.args.URL, - DNS: tt.args.DNS, + URL: tt.args.URL, + DNSConfig: tt.args.DNS, } if got := endpoint.Type(); got != tt.want { t.Errorf("Endpoint.Type() = %v, want %v", got, tt.want) @@ -500,7 +502,7 @@ func TestEndpoint_ValidateAndSetDefaultsWithDNS(t *testing.T) { endpoint := &Endpoint{ Name: "dns-test", URL: "https://example.com", - DNS: &DNS{ + DNSConfig: &dns.Config{ QueryType: "A", QueryName: "example.com", }, @@ -510,7 +512,7 @@ func TestEndpoint_ValidateAndSetDefaultsWithDNS(t *testing.T) { if err != nil { t.Error("did not expect an error, got", err) } - if endpoint.DNS.QueryName != "example.com." { + if endpoint.DNSConfig.QueryName != "example.com." { t.Error("Endpoint.dns.query-name should be formatted with . suffix") } } @@ -526,13 +528,13 @@ func TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) { name: "fail when has no user", username: "", password: "password", - expectedErr: ErrEndpointWithoutSSHUsername, + expectedErr: ssh.ErrEndpointWithoutSSHUsername, }, { name: "fail when has no password", username: "username", password: "", - expectedErr: ErrEndpointWithoutSSHPassword, + expectedErr: ssh.ErrEndpointWithoutSSHPassword, }, { name: "success when all fields are set", @@ -547,7 +549,7 @@ func TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) { endpoint := &Endpoint{ Name: "ssh-test", URL: "https://example.com", - SSH: &SSH{ + SSHConfig: &ssh.Config{ Username: scenario.username, Password: scenario.password, }, @@ -786,7 +788,7 @@ func TestIntegrationEvaluateHealthForDNS(t *testing.T) { endpoint := Endpoint{ Name: "example", URL: "8.8.8.8", - DNS: &DNS{ + DNSConfig: &dns.Config{ QueryType: "A", QueryName: "example.com.", }, @@ -809,7 +811,7 @@ func TestIntegrationEvaluateHealthForDNS(t *testing.T) { } func TestIntegrationEvaluateHealthForSSH(t *testing.T) { - tests := []struct { + scenarios := []struct { name string endpoint Endpoint conditions []Condition @@ -820,9 +822,9 @@ func TestIntegrationEvaluateHealthForSSH(t *testing.T) { endpoint: Endpoint{ Name: "ssh-success", URL: "ssh://localhost", - SSH: &SSH{ - Username: "test", - Password: "test", + SSHConfig: &ssh.Config{ + Username: "scenario", + Password: "scenario", }, Body: "{ \"command\": \"uptime\" }", }, @@ -834,9 +836,9 @@ func TestIntegrationEvaluateHealthForSSH(t *testing.T) { endpoint: Endpoint{ Name: "ssh-failure", URL: "ssh://localhost", - SSH: &SSH{ - Username: "test", - Password: "test", + SSHConfig: &ssh.Config{ + Username: "scenario", + Password: "scenario", }, Body: "{ \"command\": \"uptime\" }", }, @@ -845,13 +847,13 @@ func TestIntegrationEvaluateHealthForSSH(t *testing.T) { }, } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - test.endpoint.ValidateAndSetDefaults() - test.endpoint.Conditions = test.conditions - result := test.endpoint.EvaluateHealth() - if result.Success != test.success { - t.Errorf("Expected success to be %v, but was %v", test.success, result.Success) + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + scenario.endpoint.ValidateAndSetDefaults() + scenario.endpoint.Conditions = scenario.conditions + result := scenario.endpoint.EvaluateHealth() + if result.Success != scenario.success { + t.Errorf("Expected success to be %v, but was %v", scenario.success, result.Success) } }) } diff --git a/core/event.go b/config/endpoint/event.go similarity index 96% rename from core/event.go rename to config/endpoint/event.go index 43f9cf76..da8d2275 100644 --- a/core/event.go +++ b/config/endpoint/event.go @@ -1,6 +1,8 @@ -package core +package endpoint -import "time" +import ( + "time" +) // Event is something that happens at a specific time type Event struct { diff --git a/core/event_test.go b/config/endpoint/event_test.go similarity index 89% rename from core/event_test.go rename to config/endpoint/event_test.go index 2269178a..bdfe07a2 100644 --- a/core/event_test.go +++ b/config/endpoint/event_test.go @@ -1,6 +1,8 @@ -package core +package endpoint -import "testing" +import ( + "testing" +) func TestNewEventFromResult(t *testing.T) { if event := NewEventFromResult(&Result{Success: true}); event.Type != EventHealthy { diff --git a/core/external_endpoint.go b/config/endpoint/external_endpoint.go similarity index 90% rename from core/external_endpoint.go rename to config/endpoint/external_endpoint.go index 709deea5..58f37fed 100644 --- a/core/external_endpoint.go +++ b/config/endpoint/external_endpoint.go @@ -1,10 +1,9 @@ -package core +package endpoint import ( "errors" "github.com/TwiN/gatus/v5/alerting/alert" - "github.com/TwiN/gatus/v5/util" ) var ( @@ -46,11 +45,6 @@ func (externalEndpoint *ExternalEndpoint) ValidateAndSetDefaults() error { if len(externalEndpoint.Token) == 0 { return ErrExternalEndpointWithNoToken } - for _, externalEndpointAlert := range externalEndpoint.Alerts { - if err := externalEndpointAlert.ValidateAndSetDefaults(); err != nil { - return err - } - } return nil } @@ -72,7 +66,7 @@ func (externalEndpoint *ExternalEndpoint) DisplayName() string { // Key returns the unique key for the Endpoint func (externalEndpoint *ExternalEndpoint) Key() string { - return util.ConvertGroupAndEndpointNameToKey(externalEndpoint.Group, externalEndpoint.Name) + return ConvertGroupAndEndpointNameToKey(externalEndpoint.Group, externalEndpoint.Name) } // ToEndpoint converts the ExternalEndpoint to an Endpoint diff --git a/core/external_endpoint_test.go b/config/endpoint/external_endpoint_test.go similarity index 97% rename from core/external_endpoint_test.go rename to config/endpoint/external_endpoint_test.go index a79456c3..4ead308b 100644 --- a/core/external_endpoint_test.go +++ b/config/endpoint/external_endpoint_test.go @@ -1,4 +1,4 @@ -package core +package endpoint import ( "testing" diff --git a/util/key.go b/config/endpoint/key.go similarity index 96% rename from util/key.go rename to config/endpoint/key.go index a56a21ec..89c35488 100644 --- a/util/key.go +++ b/config/endpoint/key.go @@ -1,4 +1,4 @@ -package util +package endpoint import "strings" diff --git a/util/key_bench_test.go b/config/endpoint/key_bench_test.go similarity index 91% rename from util/key_bench_test.go rename to config/endpoint/key_bench_test.go index 7f974f41..cfec8c8b 100644 --- a/util/key_bench_test.go +++ b/config/endpoint/key_bench_test.go @@ -1,4 +1,4 @@ -package util +package endpoint import ( "testing" diff --git a/util/key_test.go b/config/endpoint/key_test.go similarity index 98% rename from util/key_test.go rename to config/endpoint/key_test.go index 03341770..c693ba5d 100644 --- a/util/key_test.go +++ b/config/endpoint/key_test.go @@ -1,4 +1,4 @@ -package util +package endpoint import "testing" diff --git a/core/result.go b/config/endpoint/result.go similarity index 95% rename from core/result.go rename to config/endpoint/result.go index 884493d5..1d20a5c6 100644 --- a/core/result.go +++ b/config/endpoint/result.go @@ -1,4 +1,4 @@ -package core +package endpoint import ( "time" @@ -29,7 +29,7 @@ type Result struct { // Errors encountered during the evaluation of the Endpoint's health Errors []string `json:"errors,omitempty"` - // ConditionResults results of the Endpoint's conditions + // ConditionResults are the results of each of the Endpoint's Condition ConditionResults []*ConditionResult `json:"conditionResults,omitempty"` // Success whether the result signifies a success or not diff --git a/core/result_test.go b/config/endpoint/result_test.go similarity index 96% rename from core/result_test.go rename to config/endpoint/result_test.go index 899ec7f9..1e521c8f 100644 --- a/core/result_test.go +++ b/config/endpoint/result_test.go @@ -1,4 +1,4 @@ -package core +package endpoint import ( "testing" diff --git a/core/ssh.go b/config/endpoint/ssh/ssh.go similarity index 81% rename from core/ssh.go rename to config/endpoint/ssh/ssh.go index b0349bac..88636473 100644 --- a/core/ssh.go +++ b/config/endpoint/ssh/ssh.go @@ -1,4 +1,4 @@ -package core +package ssh import ( "errors" @@ -12,17 +12,17 @@ var ( ErrEndpointWithoutSSHPassword = errors.New("you must specify a password for each SSH endpoint") ) -type SSH struct { +type Config struct { Username string `yaml:"username,omitempty"` Password string `yaml:"password,omitempty"` } -// validate validates the endpoint -func (s *SSH) validate() error { - if len(s.Username) == 0 { +// Validate the SSH configuration +func (cfg *Config) Validate() error { + if len(cfg.Username) == 0 { return ErrEndpointWithoutSSHUsername } - if len(s.Password) == 0 { + if len(cfg.Password) == 0 { return ErrEndpointWithoutSSHPassword } return nil diff --git a/core/ssh_test.go b/config/endpoint/ssh/ssh_test.go similarity index 70% rename from core/ssh_test.go rename to config/endpoint/ssh/ssh_test.go index 15e70433..ed563028 100644 --- a/core/ssh_test.go +++ b/config/endpoint/ssh/ssh_test.go @@ -1,4 +1,4 @@ -package core +package ssh import ( "errors" @@ -6,20 +6,20 @@ import ( ) func TestSSH_validate(t *testing.T) { - ssh := &SSH{} - if err := ssh.validate(); err == nil { + cfg := &Config{} + if err := cfg.Validate(); err == nil { t.Error("expected an error") } else if !errors.Is(err, ErrEndpointWithoutSSHUsername) { t.Errorf("expected error to be '%v', got '%v'", ErrEndpointWithoutSSHUsername, err) } - ssh.Username = "username" - if err := ssh.validate(); err == nil { + cfg.Username = "username" + if err := cfg.Validate(); err == nil { t.Error("expected an error") } else if !errors.Is(err, ErrEndpointWithoutSSHPassword) { t.Errorf("expected error to be '%v', got '%v'", ErrEndpointWithoutSSHPassword, err) } - ssh.Password = "password" - if err := ssh.validate(); err != nil { + cfg.Password = "password" + if err := cfg.Validate(); err != nil { t.Errorf("expected no error, got '%v'", err) } } diff --git a/core/endpoint_status.go b/config/endpoint/status.go similarity index 64% rename from core/endpoint_status.go rename to config/endpoint/status.go index db1ccd78..f1a725ba 100644 --- a/core/endpoint_status.go +++ b/config/endpoint/status.go @@ -1,16 +1,14 @@ -package core +package endpoint -import "github.com/TwiN/gatus/v5/util" - -// EndpointStatus contains the evaluation Results of an Endpoint -type EndpointStatus struct { +// Status contains the evaluation Results of an Endpoint +type Status struct { // Name of the endpoint Name string `json:"name,omitempty"` // Group the endpoint is a part of. Used for grouping multiple endpoints together on the front end. Group string `json:"group,omitempty"` - // Key is the key representing the EndpointStatus + // Key of the Endpoint Key string `json:"key"` // Results is the list of endpoint evaluation results @@ -27,12 +25,12 @@ type EndpointStatus struct { Uptime *Uptime `json:"-"` } -// NewEndpointStatus creates a new EndpointStatus -func NewEndpointStatus(group, name string) *EndpointStatus { - return &EndpointStatus{ +// NewStatus creates a new Status +func NewStatus(group, name string) *Status { + return &Status{ Name: name, Group: group, - Key: util.ConvertGroupAndEndpointNameToKey(group, name), + Key: ConvertGroupAndEndpointNameToKey(group, name), Results: make([]*Result, 0), Events: make([]*Event, 0), Uptime: NewUptime(), diff --git a/config/endpoint/status_test.go b/config/endpoint/status_test.go new file mode 100644 index 00000000..510f9b51 --- /dev/null +++ b/config/endpoint/status_test.go @@ -0,0 +1,19 @@ +package endpoint + +import ( + "testing" +) + +func TestNewEndpointStatus(t *testing.T) { + ep := &Endpoint{Name: "name", Group: "group"} + status := NewStatus(ep.Group, ep.Name) + if status.Name != ep.Name { + t.Errorf("expected %s, got %s", ep.Name, status.Name) + } + if status.Group != ep.Group { + t.Errorf("expected %s, got %s", ep.Group, status.Group) + } + if status.Key != "group_name" { + t.Errorf("expected %s, got %s", "group_name", status.Key) + } +} diff --git a/core/ui/ui.go b/config/endpoint/ui/ui.go similarity index 97% rename from core/ui/ui.go rename to config/endpoint/ui/ui.go index 7ea6eeec..5a13ccdc 100644 --- a/core/ui/ui.go +++ b/config/endpoint/ui/ui.go @@ -2,7 +2,7 @@ package ui import "errors" -// Config is the UI configuration for core.Endpoint +// Config is the UI configuration for endpoint.Endpoint type Config struct { // HideConditions whether to hide the condition results on the UI HideConditions bool `yaml:"hide-conditions"` diff --git a/core/ui/ui_test.go b/config/endpoint/ui/ui_test.go similarity index 100% rename from core/ui/ui_test.go rename to config/endpoint/ui/ui_test.go diff --git a/core/uptime.go b/config/endpoint/uptime.go similarity index 98% rename from core/uptime.go rename to config/endpoint/uptime.go index 514b4a77..da4d2139 100644 --- a/core/uptime.go +++ b/config/endpoint/uptime.go @@ -1,4 +1,4 @@ -package core +package endpoint // Uptime is the struct that contains the relevant data for calculating the uptime as well as the uptime itself // and some other statistics diff --git a/config/endpoints/README.md b/config/endpoints/README.md deleted file mode 100644 index 9416ef98..00000000 --- a/config/endpoints/README.md +++ /dev/null @@ -1 +0,0 @@ -TODO: move files from core to here. \ No newline at end of file diff --git a/config/maintenance/maintenance.go b/config/maintenance/maintenance.go index dc66fefe..967443db 100644 --- a/config/maintenance/maintenance.go +++ b/config/maintenance/maintenance.go @@ -6,12 +6,14 @@ import ( "strconv" "strings" "time" + _ "time/tzdata" // Required for IANA timezone support ) var ( errInvalidMaintenanceStartFormat = errors.New("invalid maintenance start format: must be hh:mm, between 00:00 and 23:59 inclusively (e.g. 23:00)") errInvalidMaintenanceDuration = errors.New("invalid maintenance duration: must be bigger than 0 (e.g. 30m)") errInvalidDayName = fmt.Errorf("invalid value specified for 'on'. supported values are %s", longDayNames) + errInvalidTimezone = errors.New("invalid timezone specified or format not supported. Use IANA timezone format (e.g. America/Sao_Paulo)") longDayNames = []string{ "Sunday", @@ -27,17 +29,19 @@ var ( // Config allows for the configuration of a maintenance period. // During this maintenance period, no alerts will be sent. // -// Uses UTC. +// Uses UTC by default. type Config struct { Enabled *bool `yaml:"enabled"` // Whether the maintenance period is enabled. Enabled by default if nil. Start string `yaml:"start"` // Time at which the maintenance period starts (e.g. 23:00) Duration time.Duration `yaml:"duration"` // Duration of the maintenance period (e.g. 4h) + Timezone string `yaml:"timezone"` // Timezone in string format which the maintenance period is configured (e.g. America/Sao_Paulo) // Every is a list of days of the week during which maintenance period applies. // See longDayNames for list of valid values. // Every day if empty. Every []string `yaml:"every"` + TimezoneLocation *time.Location // Timezone in location format which the maintenance period is configured durationToStartFromMidnight time.Duration } @@ -49,7 +53,7 @@ func GetDefaultConfig() *Config { } // IsEnabled returns whether maintenance is enabled or not -func (c Config) IsEnabled() bool { +func (c *Config) IsEnabled() bool { if c.Enabled == nil { return true } @@ -85,15 +89,27 @@ func (c *Config) ValidateAndSetDefaults() error { if c.Duration <= 0 || c.Duration > 24*time.Hour { return errInvalidMaintenanceDuration } + if c.Timezone != "" { + c.TimezoneLocation, err = time.LoadLocation(c.Timezone) + if err != nil { + return fmt.Errorf("%w: %w", errInvalidTimezone, err) + } + } else { + c.Timezone = "UTC" + c.TimezoneLocation = time.UTC + } return nil } // IsUnderMaintenance checks whether the endpoints that Gatus monitors are within the configured maintenance window -func (c Config) IsUnderMaintenance() bool { +func (c *Config) IsUnderMaintenance() bool { if !c.IsEnabled() { return false } - now := time.Now().UTC() + now := time.Now() + if c.TimezoneLocation != nil { + now = now.In(c.TimezoneLocation) + } var dayWhereMaintenancePeriodWouldStart time.Time if now.Hour() >= int(c.durationToStartFromMidnight.Hours()) { dayWhereMaintenancePeriodWouldStart = now.Truncate(24 * time.Hour) @@ -112,7 +128,7 @@ func (c Config) IsUnderMaintenance() bool { return now.After(startOfMaintenancePeriod) && now.Before(endOfMaintenancePeriod) } -func (c Config) hasDay(day string) bool { +func (c *Config) hasDay(day string) bool { for _, d := range c.Every { if d == day { return true diff --git a/config/maintenance/maintenance_test.go b/config/maintenance/maintenance_test.go index 76ef9eb2..edbdad37 100644 --- a/config/maintenance/maintenance_test.go +++ b/config/maintenance/maintenance_test.go @@ -90,6 +90,15 @@ func TestConfig_ValidateAndSetDefaults(t *testing.T) { }, expectedError: errInvalidMaintenanceDuration, }, + { + name: "invalid-timezone", + cfg: &Config{ + Start: "23:00", + Duration: time.Hour, + Timezone: "invalid-timezone", + }, + expectedError: errInvalidTimezone, + }, { name: "every-day-at-2300", cfg: &Config{ @@ -126,6 +135,33 @@ func TestConfig_ValidateAndSetDefaults(t *testing.T) { }, expectedError: nil, }, + { + name: "timezone-amsterdam", + cfg: &Config{ + Start: "23:00", + Duration: time.Hour, + Timezone: "Europe/Amsterdam", + }, + expectedError: nil, + }, + { + name: "timezone-cet", + cfg: &Config{ + Start: "23:00", + Duration: time.Hour, + Timezone: "CET", + }, + expectedError: nil, + }, + { + name: "timezone-etc-plus-5", + cfg: &Config{ + Start: "23:00", + Duration: time.Hour, + Timezone: "Etc/GMT+5", + }, + expectedError: nil, + }, } for _, scenario := range scenarios { t.Run(scenario.name, func(t *testing.T) { @@ -220,7 +256,25 @@ func TestConfig_IsUnderMaintenance(t *testing.T) { expected: true, }, { - name: "under-maintenance-starting-4h-ago-for-3h", + name: "under-maintenance-amsterdam-timezone-starting-now-for-2h", + cfg: &Config{ + Start: fmt.Sprintf("%02d:00", now.Hour()), + Duration: 2 * time.Hour, + Timezone: "Europe/Amsterdam", + }, + expected: true, + }, + { + name: "under-maintenance-utc-timezone-starting-now-for-2h", + cfg: &Config{ + Start: fmt.Sprintf("%02d:00", now.Hour()), + Duration: 2 * time.Hour, + Timezone: "UTC", + }, + expected: true, + }, + { + name: "not-under-maintenance-starting-4h-ago-for-3h", cfg: &Config{ Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-4)), Duration: 3 * time.Hour, @@ -228,7 +282,7 @@ func TestConfig_IsUnderMaintenance(t *testing.T) { expected: false, }, { - name: "under-maintenance-starting-5h-ago-for-1h", + name: "not-under-maintenance-starting-5h-ago-for-1h", cfg: &Config{ Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-5)), Duration: time.Hour, @@ -253,6 +307,16 @@ func TestConfig_IsUnderMaintenance(t *testing.T) { }, expected: false, }, + { + name: "not-under-maintenance-los-angeles-timezone-starting-now-for-2h-today", + cfg: &Config{ + Start: fmt.Sprintf("%02d:00", now.Hour()), + Duration: 2 * time.Hour, + Timezone: "America/Los_Angeles", + Every: []string{now.Weekday().String()}, + }, + expected: false, + }, } for _, scenario := range scenarios { t.Run(scenario.name, func(t *testing.T) { diff --git a/controller/controller_test.go b/controller/controller_test.go index 62a28297..aaefcbdc 100644 --- a/controller/controller_test.go +++ b/controller/controller_test.go @@ -8,8 +8,8 @@ import ( "testing" "github.com/TwiN/gatus/v5/config" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/web" - "github.com/TwiN/gatus/v5/core" "github.com/gofiber/fiber/v2" ) @@ -19,7 +19,7 @@ func TestHandle(t *testing.T) { Address: "0.0.0.0", Port: rand.Intn(65534), }, - Endpoints: []*core.Endpoint{ + Endpoints: []*endpoint.Endpoint{ { Name: "frontend", Group: "core", @@ -64,7 +64,7 @@ func TestHandleTLS(t *testing.T) { t.Run(scenario.name, func(t *testing.T) { cfg := &config.Config{ Web: &web.Config{Address: "0.0.0.0", Port: rand.Intn(65534), TLS: scenario.tls}, - Endpoints: []*core.Endpoint{ + Endpoints: []*endpoint.Endpoint{ {Name: "frontend", Group: "core"}, {Name: "backend", Group: "core"}, }, diff --git a/core/dns.go b/core/dns.go deleted file mode 100644 index 3cae0eb9..00000000 --- a/core/dns.go +++ /dev/null @@ -1,86 +0,0 @@ -package core - -import ( - "errors" - "fmt" - "strings" - - "github.com/miekg/dns" -) - -var ( - // ErrDNSWithNoQueryName is the error with which gatus will panic if a dns is configured without query name - ErrDNSWithNoQueryName = errors.New("you must specify a query name for DNS") - - // ErrDNSWithInvalidQueryType is the error with which gatus will panic if a dns is configured with invalid query type - ErrDNSWithInvalidQueryType = errors.New("invalid query type") -) - -const ( - dnsPort = 53 -) - -// DNS is the configuration for a Endpoint of type DNS -type DNS struct { - // QueryType is the type for the DNS records like A, AAAA, CNAME... - QueryType string `yaml:"query-type"` - - // QueryName is the query for DNS - QueryName string `yaml:"query-name"` -} - -func (d *DNS) validateAndSetDefault() error { - if len(d.QueryName) == 0 { - return ErrDNSWithNoQueryName - } - if !strings.HasSuffix(d.QueryName, ".") { - d.QueryName += "." - } - if _, ok := dns.StringToType[d.QueryType]; !ok { - return ErrDNSWithInvalidQueryType - } - return nil -} - -func (d *DNS) query(url string, result *Result) { - if !strings.Contains(url, ":") { - url = fmt.Sprintf("%s:%d", url, dnsPort) - } - queryType := dns.StringToType[d.QueryType] - c := new(dns.Client) - m := new(dns.Msg) - m.SetQuestion(d.QueryName, queryType) - r, _, err := c.Exchange(m, url) - if err != nil { - result.AddError(err.Error()) - return - } - result.Connected = true - result.DNSRCode = dns.RcodeToString[r.Rcode] - for _, rr := range r.Answer { - switch rr.Header().Rrtype { - case dns.TypeA: - if a, ok := rr.(*dns.A); ok { - result.Body = []byte(a.A.String()) - } - case dns.TypeAAAA: - if aaaa, ok := rr.(*dns.AAAA); ok { - result.Body = []byte(aaaa.AAAA.String()) - } - case dns.TypeCNAME: - if cname, ok := rr.(*dns.CNAME); ok { - result.Body = []byte(cname.Target) - } - case dns.TypeMX: - if mx, ok := rr.(*dns.MX); ok { - result.Body = []byte(mx.Mx) - } - case dns.TypeNS: - if ns, ok := rr.(*dns.NS); ok { - result.Body = []byte(ns.Ns) - } - default: - result.Body = []byte("query type is not supported yet") - } - } -} diff --git a/core/dns_test.go b/core/dns_test.go deleted file mode 100644 index 29e57da1..00000000 --- a/core/dns_test.go +++ /dev/null @@ -1,126 +0,0 @@ -package core - -import ( - "testing" - "time" - - "github.com/TwiN/gatus/v5/pattern" -) - -func TestIntegrationQuery(t *testing.T) { - tests := []struct { - name string - inputDNS DNS - inputURL string - expectedDNSCode string - expectedBody string - isErrExpected bool - }{ - { - name: "test DNS with type A", - inputDNS: DNS{ - QueryType: "A", - QueryName: "example.com.", - }, - inputURL: "8.8.8.8", - expectedDNSCode: "NOERROR", - expectedBody: "93.184.215.14", - }, - { - name: "test DNS with type AAAA", - inputDNS: DNS{ - QueryType: "AAAA", - QueryName: "example.com.", - }, - inputURL: "8.8.8.8", - expectedDNSCode: "NOERROR", - expectedBody: "2606:2800:21f:cb07:6820:80da:af6b:8b2c", - }, - { - name: "test DNS with type CNAME", - inputDNS: DNS{ - QueryType: "CNAME", - QueryName: "en.wikipedia.org.", - }, - inputURL: "8.8.8.8", - expectedDNSCode: "NOERROR", - expectedBody: "dyna.wikimedia.org.", - }, - { - name: "test DNS with type MX", - inputDNS: DNS{ - QueryType: "MX", - QueryName: "example.com.", - }, - inputURL: "8.8.8.8", - expectedDNSCode: "NOERROR", - expectedBody: ".", - }, - { - name: "test DNS with type NS", - inputDNS: DNS{ - QueryType: "NS", - QueryName: "example.com.", - }, - inputURL: "8.8.8.8", - expectedDNSCode: "NOERROR", - expectedBody: "*.iana-servers.net.", - }, - { - name: "test DNS with fake type and retrieve error", - inputDNS: DNS{ - QueryType: "B", - QueryName: "example", - }, - inputURL: "8.8.8.8", - isErrExpected: true, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - dns := test.inputDNS - result := &Result{} - dns.query(test.inputURL, result) - if test.isErrExpected && len(result.Errors) == 0 { - t.Errorf("there should be errors") - } - if result.DNSRCode != test.expectedDNSCode { - t.Errorf("expected DNSRCode to be %s, got %s", test.expectedDNSCode, result.DNSRCode) - } - if test.inputDNS.QueryType == "NS" { - // Because there are often multiple nameservers backing a single domain, we'll only look at the suffix - if !pattern.Match(test.expectedBody, string(result.Body)) { - t.Errorf("got %s, expected result %s,", string(result.Body), test.expectedBody) - } - } else { - if string(result.Body) != test.expectedBody { - t.Errorf("got %s, expected result %s,", string(result.Body), test.expectedBody) - } - } - }) - time.Sleep(5 * time.Millisecond) - } -} - -func TestDNS_validateAndSetDefault(t *testing.T) { - dns := &DNS{ - QueryType: "A", - QueryName: "", - } - err := dns.validateAndSetDefault() - if err == nil { - t.Error("Should've returned an error because endpoint's dns didn't have a query name, which is a mandatory field for dns") - } -} - -func TestEndpoint_ValidateAndSetDefaultsWithInvalidDNSQueryType(t *testing.T) { - dns := &DNS{ - QueryType: "B", - QueryName: "example.com", - } - err := dns.validateAndSetDefault() - if err == nil { - t.Error("Should've returned an error because endpoint's dns query type is invalid, it needs to be a valid query name like A, AAAA, CNAME...") - } -} diff --git a/core/endpoint_status_test.go b/core/endpoint_status_test.go deleted file mode 100644 index 0eed7d29..00000000 --- a/core/endpoint_status_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package core - -import ( - "testing" -) - -func TestNewEndpointStatus(t *testing.T) { - endpoint := &Endpoint{Name: "name", Group: "group"} - status := NewEndpointStatus(endpoint.Group, endpoint.Name) - if status.Name != endpoint.Name { - t.Errorf("expected %s, got %s", endpoint.Name, status.Name) - } - if status.Group != endpoint.Group { - t.Errorf("expected %s, got %s", endpoint.Group, status.Group) - } - if status.Key != "group_name" { - t.Errorf("expected %s, got %s", "group_name", status.Key) - } -} diff --git a/go.mod b/go.mod index cd5cdb5e..f08bab4a 100644 --- a/go.mod +++ b/go.mod @@ -1,85 +1,93 @@ module github.com/TwiN/gatus/v5 -go 1.21 +go 1.22.2 require ( + code.gitea.io/sdk/gitea v0.19.0 github.com/TwiN/deepmerge v0.2.1 github.com/TwiN/g8/v2 v2.0.0 - github.com/TwiN/gocache/v2 v2.2.0 + github.com/TwiN/gocache/v2 v2.2.2 github.com/TwiN/health v1.6.0 - github.com/TwiN/whois v1.1.7 - github.com/aws/aws-sdk-go v1.47.9 - github.com/coreos/go-oidc/v3 v3.7.0 - github.com/gofiber/fiber/v2 v2.52.1 + github.com/TwiN/whois v1.1.9 + github.com/aws/aws-sdk-go v1.54.10 + github.com/coreos/go-oidc/v3 v3.10.0 + github.com/gofiber/fiber/v2 v2.52.4 github.com/google/go-github/v48 v48.2.0 github.com/google/uuid v1.6.0 - github.com/ishidawataru/sctp v0.0.0-20210707070123-9a39160e9062 + github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2 github.com/lib/pq v1.10.9 - github.com/miekg/dns v1.1.56 - github.com/prometheus-community/pro-bing v0.3.0 - github.com/prometheus/client_golang v1.18.0 - github.com/valyala/fasthttp v1.51.0 - github.com/wcharczuk/go-chart/v2 v2.1.1 - golang.org/x/crypto v0.21.0 - golang.org/x/net v0.22.0 - golang.org/x/oauth2 v0.18.0 - google.golang.org/api v0.148.0 + github.com/miekg/dns v1.1.62 + github.com/prometheus-community/pro-bing v0.4.0 + github.com/prometheus/client_golang v1.20.4 + github.com/valyala/fasthttp v1.56.0 + github.com/wcharczuk/go-chart/v2 v2.1.2 + golang.org/x/crypto v0.27.0 + golang.org/x/net v0.29.0 + golang.org/x/oauth2 v0.21.0 + google.golang.org/api v0.183.0 gopkg.in/mail.v2 v2.3.1 gopkg.in/yaml.v3 v3.0.1 - modernc.org/sqlite v1.28.0 + modernc.org/sqlite v1.33.1 ) require ( - cloud.google.com/go/compute v1.23.0 // indirect - cloud.google.com/go/compute/metadata v0.2.3 // indirect - github.com/andybalholm/brotli v1.0.5 // indirect + cloud.google.com/go/auth v0.5.1 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect + cloud.google.com/go/compute/metadata v0.3.0 // indirect + github.com/andybalholm/brotli v1.1.0 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/blend/go-sdk v1.20220411.3 // indirect - github.com/cespare/xxhash/v2 v2.2.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davidmz/go-pageant v1.0.2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/go-jose/go-jose/v3 v3.0.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-fed/httpsig v1.1.0 // indirect + github.com/go-jose/go-jose/v4 v4.0.2 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/golang/protobuf v1.5.3 // indirect + github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/s2a-go v0.1.7 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.1 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect + github.com/hashicorp/go-version v1.6.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect - github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect - github.com/klauspost/compress v1.17.0 // indirect + github.com/klauspost/compress v1.17.9 // indirect github.com/kr/text v0.2.0 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect - github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect - github.com/prometheus/client_model v0.5.0 // indirect - github.com/prometheus/common v0.45.0 // indirect - github.com/prometheus/procfs v0.12.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.55.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/rivo/uniseg v0.4.4 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.11.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect go.opencensus.io v0.24.0 // indirect - golang.org/x/image v0.11.0 // indirect - golang.org/x/mod v0.12.0 // indirect - golang.org/x/sync v0.4.0 // indirect - golang.org/x/sys v0.18.0 // indirect - golang.org/x/text v0.14.0 // indirect - golang.org/x/tools v0.13.0 // indirect - google.golang.org/appengine v1.6.8 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20231012201019-e917dd12ba7a // indirect - google.golang.org/grpc v1.58.3 // indirect - google.golang.org/protobuf v1.31.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 // indirect + go.opentelemetry.io/otel v1.27.0 // indirect + go.opentelemetry.io/otel/metric v1.27.0 // indirect + go.opentelemetry.io/otel/trace v1.27.0 // indirect + golang.org/x/image v0.18.0 // indirect + golang.org/x/mod v0.18.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.25.0 // indirect + golang.org/x/text v0.18.0 // indirect + golang.org/x/tools v0.22.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 // indirect + google.golang.org/grpc v1.64.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect - lukechampine.com/uint128 v1.2.0 // indirect - modernc.org/cc/v3 v3.40.0 // indirect - modernc.org/ccgo/v3 v3.16.13 // indirect - modernc.org/libc v1.29.0 // indirect + modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect + modernc.org/libc v1.55.3 // indirect modernc.org/mathutil v1.6.0 // indirect - modernc.org/memory v1.7.2 // indirect - modernc.org/opt v0.1.3 // indirect - modernc.org/strutil v1.1.3 // indirect - modernc.org/token v1.0.1 // indirect + modernc.org/memory v1.8.0 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect ) diff --git a/go.sum b/go.sum index ff2b26fa..dc74b9eb 100644 --- a/go.sum +++ b/go.sum @@ -1,48 +1,61 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go/compute v1.23.0 h1:tP41Zoavr8ptEqaW6j+LQOnyBBhO7OkOMAGrgLopTwY= -cloud.google.com/go/compute v1.23.0/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM= -cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= -cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= +cloud.google.com/go/auth v0.5.1 h1:0QNO7VThG54LUzKiQxv8C6x1YX7lUrzlAa1nVLF8CIw= +cloud.google.com/go/auth v0.5.1/go.mod h1:vbZT8GjzDf3AVqCcQmqeeM32U9HBFc32vVVAbwDsa6s= +cloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4= +cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q= +cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +code.gitea.io/sdk/gitea v0.19.0 h1:8I6s1s4RHgzxiPHhOQdgim1RWIRcr0LVMbHBjBFXq4Y= +code.gitea.io/sdk/gitea v0.19.0/go.mod h1:IG9xZJoltDNeDSW0qiF2Vqx5orMWa7OhVWrjvrd5NpI= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/TwiN/deepmerge v0.2.1 h1:GowJr9O4THTVW4awX63x1BVg1hgr4q+35XKKCYbwsSs= github.com/TwiN/deepmerge v0.2.1/go.mod h1:LVBmCEBQvibYSF8Gyl/NqhHXH7yIiT7Ozqf9dHxGPW0= github.com/TwiN/g8/v2 v2.0.0 h1:+hwIbRLMhDd2iwHzkZUPp2FkX7yTx8ddYOnS91HkDqQ= github.com/TwiN/g8/v2 v2.0.0/go.mod h1:4sVAF27q8T8ISggRa/Fb0drw7wpB22B6eWd+/+SGMqE= -github.com/TwiN/gocache/v2 v2.2.0 h1:M3B36KyH24BntxLrLaUb2kgTdq8DzCnfod0IekLG57w= -github.com/TwiN/gocache/v2 v2.2.0/go.mod h1:SnUuBsrwGQeNcDG6vhkOMJnqErZM0JGjgIkuKryokYA= +github.com/TwiN/gocache/v2 v2.2.2 h1:4HToPfDV8FSbaYO5kkbhLpEllUYse5rAf+hVU/mSsuI= +github.com/TwiN/gocache/v2 v2.2.2/go.mod h1:WfIuwd7GR82/7EfQqEtmLFC3a2vqaKbs4Pe6neB7Gyc= github.com/TwiN/health v1.6.0 h1:L2ks575JhRgQqWWOfKjw9B0ec172hx7GdToqkYUycQM= github.com/TwiN/health v1.6.0/go.mod h1:Z6TszwQPMvtSiVx1QMidVRgvVr4KZGfiwqcD7/Z+3iw= -github.com/TwiN/whois v1.1.7 h1:eGzLOrWhpYLAGXD8boXh0bBKllN/EmuBsLqTJT4tC/U= -github.com/TwiN/whois v1.1.7/go.mod h1:VOJAH4+3chAik5gva5zxJNXv2voEHjMNCf1y07sqj9w= -github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= -github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= -github.com/aws/aws-sdk-go v1.47.9 h1:rarTsos0mA16q+huicGx0e560aYRtOucV5z2Mw23JRY= -github.com/aws/aws-sdk-go v1.47.9/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= +github.com/TwiN/whois v1.1.9 h1:m20+m1CXnrstie+tW2ZmAJkfcT9zgwpVRUFsKeMw+ng= +github.com/TwiN/whois v1.1.9/go.mod h1:TjipCMpJRAJYKmtz/rXQBU6UGxMh6bk8SHazu7OMnQE= +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/aws/aws-sdk-go v1.54.10 h1:dvkMlAttUsyacKj2L4poIQBLzOSWL2JG2ty+yWrqets= +github.com/aws/aws-sdk-go v1.54.10/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/blend/go-sdk v1.20220411.3 h1:GFV4/FQX5UzXLPwWV03gP811pj7B8J2sbuq+GJQofXc= -github.com/blend/go-sdk v1.20220411.3/go.mod h1:7lnH8fTi6U4i1fArEXRyOIY2E1X4MALg09qsQqY1+ak= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= -github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/coreos/go-oidc/v3 v3.7.0 h1:FTdj0uexT4diYIPlF4yoFVI5MRO1r5+SEcIpEw9vC0o= -github.com/coreos/go-oidc/v3 v3.7.0/go.mod h1:yQzSCqBnK3e6Fs5l+f5i0F8Kwf0zpH9bPEsbY00KanM= +github.com/coreos/go-oidc/v3 v3.10.0 h1:tDnXHnLyiTVyT/2zLDGj09pFPkhND8Gl8lnTRhoEaJU= +github.com/coreos/go-oidc/v3 v3.10.0/go.mod h1:5j11xcw0D3+SGxn6Z/WFADsgcWVMyNAlSQupk0KK3ac= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= +github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo= -github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= -github.com/gofiber/fiber/v2 v2.52.1 h1:1RoU2NS+b98o1L77sdl5mboGPiW+0Ypsi5oLmcYlgHI= -github.com/gofiber/fiber/v2 v2.52.1/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= +github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= +github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk= +github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gofiber/fiber/v2 v2.52.4 h1:P+T+4iK7VaqUsq2PALYEfBBo6bJZ4q3FP8cZ84EggTM= +github.com/gofiber/fiber/v2 v2.52.4/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= @@ -59,10 +72,8 @@ github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:W github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -70,38 +81,41 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-github/v48 v48.2.0 h1:68puzySE6WqUY9KWmpOsDEQfDZsso98rT6pZcz9HqcE= github.com/google/go-github/v48 v48.2.0/go.mod h1:dDlehKBDo850ZPvCTK0sEqTCVWcrGl2LcDiajkYi89Y= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= -github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= -github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.1 h1:SBWmZhjUDRorQxrN0nwzf+AHBxnbFjViHQS4P0yVpmQ= -github.com/googleapis/enterprise-certificate-proxy v0.3.1/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= -github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas= -github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU= -github.com/ishidawataru/sctp v0.0.0-20210707070123-9a39160e9062 h1:G1+wBT0dwjIrBdLy0MIG0i+E4CQxEnedHXdauJEIH6g= -github.com/ishidawataru/sctp v0.0.0-20210707070123-9a39160e9062/go.mod h1:co9pwDoBCm1kGxawmb4sPq0cSIOOWNPT4KnHotMP1Zg= +github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= +github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= +github.com/googleapis/gax-go/v2 v2.12.4 h1:9gWcmF85Wvq4ryPFvGFaOgPIs1AQX0d0bcbGw4Z96qg= +github.com/googleapis/gax-go/v2 v2.12.4/go.mod h1:KYEYLorsnIGDi/rPC8b5TdlB9kbKoFubselGIoBMCwI= +github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= +github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2 h1:i2fYnDurfLlJH8AyyMOnkLHnHeP8Ff/DDpuZA/D3bPo= +github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2/go.mod h1:co9pwDoBCm1kGxawmb4sPq0cSIOOWNPT4KnHotMP1Zg= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= -github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= -github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= -github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= -github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= @@ -111,67 +125,83 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= -github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= -github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= -github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= -github.com/miekg/dns v1.1.56 h1:5imZaSeoRNvpM9SzWNhEcP9QliKiz20/dA2QabIGVnE= -github.com/miekg/dns v1.1.56/go.mod h1:cRm6Oo2C8TY9ZS/TqsSrseAcncm74lfK5G+ikN2SWWY= +github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ= +github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus-community/pro-bing v0.3.0 h1:SFT6gHqXwbItEDJhTkzPWVqU6CLEtqEfNAPp47RUON4= -github.com/prometheus-community/pro-bing v0.3.0/go.mod h1:p9dLb9zdmv+eLxWfCT6jESWuDrS+YzpPkQBgysQF8a0= -github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk= -github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA= +github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4= +github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4= +github.com/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI= +github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= -github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= -github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM= -github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= -github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= -github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= +github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= -github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= -github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/fasthttp v1.56.0 h1:bEZdJev/6LCBlpdORfrLu/WOZXXxvrUQSiyniuaoW8U= +github.com/valyala/fasthttp v1.56.0/go.mod h1:sReBt3XZVnudxuLOx4J/fMrJVorWRiWY2koQKgABiVI= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= -github.com/wcharczuk/go-chart/v2 v2.1.1 h1:2u7na789qiD5WzccZsFz4MJWOJP72G+2kUuJoSNqWnE= -github.com/wcharczuk/go-chart/v2 v2.1.1/go.mod h1:CyCAUt2oqvfhCl6Q5ZvAZwItgpQKZOkCJGb+VGv6l14= +github.com/wcharczuk/go-chart/v2 v2.1.2 h1:Y17/oYNuXwZg6TFag06qe8sBajwwsuvPiJJXcUcLL6E= +github.com/wcharczuk/go-chart/v2 v2.1.2/go.mod h1:Zi4hbaqlWpYajnXB2K22IUYVXRXaLfSGNNR7P4ukyyQ= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 h1:9l89oX4ba9kHbBol3Xin3leYJ+252h0zszDtBwyKe2A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0/go.mod h1:XLZfZboOJWHNKUv7eH0inh0E9VV6eWDFB/9yJyTLPp0= +go.opentelemetry.io/otel v1.27.0 h1:9BZoF3yMK/O1AafMiQTVu0YDj5Ea4hPhxCs7sGva+cg= +go.opentelemetry.io/otel v1.27.0/go.mod h1:DMpAK8fzYRzs+bi3rS5REupisuqTheUlSZJ1WnZaPAQ= +go.opentelemetry.io/otel/metric v1.27.0 h1:hvj3vdEKyeCi4YaYfNjv2NUje8FqKqUY8IlF0FxV/ik= +go.opentelemetry.io/otel/metric v1.27.0/go.mod h1:mVFgmRlhljgBiuk/MP/oKylr4hs85GZAylncepAX/ak= +go.opentelemetry.io/otel/trace v1.27.0 h1:IqYb813p7cmbHk0a5y6pD5JPakbVfftRXABGt5/Rscw= +go.opentelemetry.io/otel/trace v1.27.0/go.mod h1:6RiD1hkAprV4/q+yd2ln1HG9GoPx39SuvvstaLBl+l4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= -golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= +golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/image v0.11.0 h1:ds2RoQvBvYTiJkwpSFDwCcDFNX7DqjL2WsUgTNk0Ooo= -golang.org/x/image v0.11.0/go.mod h1:bglhjqbqVuEb9e9+eNR45Jfu7D+T4Qan+NhQk8Ck2P8= +golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= +golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= +golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -182,18 +212,25 @@ golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= -golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= +golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI= -golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= +golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= -golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -205,21 +242,35 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= -golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM= +golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -228,28 +279,28 @@ golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBn golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= +golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.148.0 h1:HBq4TZlN4/1pNcu0geJZ/Q50vIwIXT532UIMYoo0vOs= -google.golang.org/api v0.148.0/go.mod h1:8/TBgwaKjfqTdacOJrOv2+2Q6fBDU1uHKK06oGSkxzU= +google.golang.org/api v0.183.0 h1:PNMeRDwo1pJdgNcFQ9GstuLe/noWKIc89pRWRLMvLwE= +google.golang.org/api v0.183.0/go.mod h1:q43adC5/pHoSZTx5h2mSmdF7NcyfW9JuDyIOJAgS9ZQ= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= -google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto/googleapis/rpc v0.0.0-20231012201019-e917dd12ba7a h1:a2MQQVoTo96JC9PMGtGBymLp7+/RzpFc2yX/9WfFg1c= -google.golang.org/genproto/googleapis/rpc v0.0.0-20231012201019-e917dd12ba7a/go.mod h1:4cYg8o5yUbm77w8ZX00LhMVNl/YVBFJRYWDc0uYWMs0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 h1:1GBuWVLM/KMVUv1t1En5Gs+gFZCNd360GGb4sSxtrhU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.58.3 h1:BjnpXut1btbtgN/6sp+brB2Kbm2LjNXnidYujAVbSoQ= -google.golang.org/grpc v1.58.3/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0= +google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= +google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -259,10 +310,8 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= -google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -278,31 +327,29 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI= -lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= -modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw= -modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0= -modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw= -modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY= -modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk= -modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ= -modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM= -modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM= -modernc.org/libc v1.29.0 h1:tTFRFq69YKCF2QyGNuRUQxKBm1uZZLubf6Cjh/pVHXs= -modernc.org/libc v1.29.0/go.mod h1:DaG/4Q3LRRdqpiLyP0C2m1B8ZMGkQ+cCgOIjEtQlYhQ= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b h1:BnN1t+pb1cy61zbvSUV7SeI0PwosMhlAEi/vBY4qxp8= +modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= -modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= -modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= -modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ= -modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0= -modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY= -modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= -modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY= -modernc.org/tcl v1.15.2/go.mod h1:3+k/ZaEbKrC8ePv8zJWPtBSW0V7Gg9g8rkmhI1Kfs3c= -modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg= -modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= -modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY= -modernc.org/z v1.7.3/go.mod h1:Ipv4tsdxZRbQyLq9Q1M6gdbkxYzdlrciF2Hi/lS7nWE= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM= +modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/main.go b/main.go index c5994ac6..31de744c 100644 --- a/main.go +++ b/main.go @@ -80,16 +80,70 @@ func initializeStorage(cfg *config.Config) { } // Remove all EndpointStatus that represent endpoints which no longer exist in the configuration var keys []string - for _, endpoint := range cfg.Endpoints { - keys = append(keys, endpoint.Key()) + for _, ep := range cfg.Endpoints { + keys = append(keys, ep.Key()) } - for _, externalEndpoint := range cfg.ExternalEndpoints { - keys = append(keys, externalEndpoint.Key()) + for _, ee := range cfg.ExternalEndpoints { + keys = append(keys, ee.Key()) } numberOfEndpointStatusesDeleted := store.Get().DeleteAllEndpointStatusesNotInKeys(keys) if numberOfEndpointStatusesDeleted > 0 { log.Printf("[main.initializeStorage] Deleted %d endpoint statuses because their matching endpoints no longer existed", numberOfEndpointStatusesDeleted) } + // Clean up the triggered alerts from the storage provider and load valid triggered endpoint alerts + numberOfPersistedTriggeredAlertsLoaded := 0 + for _, ep := range cfg.Endpoints { + var checksums []string + for _, alert := range ep.Alerts { + if alert.IsEnabled() { + checksums = append(checksums, alert.Checksum()) + } + } + numberOfTriggeredAlertsDeleted := store.Get().DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(ep, checksums) + if cfg.Debug && numberOfTriggeredAlertsDeleted > 0 { + log.Printf("[main.initializeStorage] Deleted %d triggered alerts for endpoint with key=%s because their configurations have been changed or deleted", numberOfTriggeredAlertsDeleted, ep.Key()) + } + for _, alert := range ep.Alerts { + exists, resolveKey, numberOfSuccessesInARow, err := store.Get().GetTriggeredEndpointAlert(ep, alert) + if err != nil { + log.Printf("[main.initializeStorage] Failed to get triggered alert for endpoint with key=%s: %s", ep.Key(), err.Error()) + continue + } + if exists { + alert.Triggered, alert.ResolveKey = true, resolveKey + ep.NumberOfSuccessesInARow, ep.NumberOfFailuresInARow = numberOfSuccessesInARow, alert.FailureThreshold + numberOfPersistedTriggeredAlertsLoaded++ + } + } + } + for _, ee := range cfg.ExternalEndpoints { + var checksums []string + for _, alert := range ee.Alerts { + if alert.IsEnabled() { + checksums = append(checksums, alert.Checksum()) + } + } + convertedEndpoint := ee.ToEndpoint() + numberOfTriggeredAlertsDeleted := store.Get().DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(convertedEndpoint, checksums) + if cfg.Debug && numberOfTriggeredAlertsDeleted > 0 { + log.Printf("[main.initializeStorage] Deleted %d triggered alerts for endpoint with key=%s because their configurations have been changed or deleted", numberOfTriggeredAlertsDeleted, ee.Key()) + } + for _, alert := range ee.Alerts { + exists, resolveKey, numberOfSuccessesInARow, err := store.Get().GetTriggeredEndpointAlert(convertedEndpoint, alert) + if err != nil { + log.Printf("[main.initializeStorage] Failed to get triggered alert for endpoint with key=%s: %s", ee.Key(), err.Error()) + continue + } + if exists { + alert.Triggered, alert.ResolveKey = true, resolveKey + ee.NumberOfSuccessesInARow, ee.NumberOfFailuresInARow = numberOfSuccessesInARow, alert.FailureThreshold + numberOfPersistedTriggeredAlertsLoaded++ + } + } + } + if numberOfPersistedTriggeredAlertsLoaded > 0 { + log.Printf("[main.initializeStorage] Loaded %d persisted triggered alerts", numberOfPersistedTriggeredAlertsLoaded) + } } func listenToConfigurationFileChanges(cfg *config.Config) { diff --git a/metrics/metrics.go b/metrics/metrics.go index 37027f24..42600c4b 100644 --- a/metrics/metrics.go +++ b/metrics/metrics.go @@ -3,7 +3,7 @@ package metrics import ( "strconv" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" ) @@ -50,24 +50,24 @@ func initializePrometheusMetrics() { // PublishMetricsForEndpoint publishes metrics for the given endpoint and its result. // These metrics will be exposed at /metrics if the metrics are enabled -func PublishMetricsForEndpoint(endpoint *core.Endpoint, result *core.Result) { +func PublishMetricsForEndpoint(ep *endpoint.Endpoint, result *endpoint.Result) { if !initializedMetrics { initializePrometheusMetrics() initializedMetrics = true } - endpointType := endpoint.Type() - resultTotal.WithLabelValues(endpoint.Key(), endpoint.Group, endpoint.Name, string(endpointType), strconv.FormatBool(result.Success)).Inc() - resultDurationSeconds.WithLabelValues(endpoint.Key(), endpoint.Group, endpoint.Name, string(endpointType)).Set(result.Duration.Seconds()) + endpointType := ep.Type() + resultTotal.WithLabelValues(ep.Key(), ep.Group, ep.Name, string(endpointType), strconv.FormatBool(result.Success)).Inc() + resultDurationSeconds.WithLabelValues(ep.Key(), ep.Group, ep.Name, string(endpointType)).Set(result.Duration.Seconds()) if result.Connected { - resultConnectedTotal.WithLabelValues(endpoint.Key(), endpoint.Group, endpoint.Name, string(endpointType)).Inc() + resultConnectedTotal.WithLabelValues(ep.Key(), ep.Group, ep.Name, string(endpointType)).Inc() } if result.DNSRCode != "" { - resultCodeTotal.WithLabelValues(endpoint.Key(), endpoint.Group, endpoint.Name, string(endpointType), result.DNSRCode).Inc() + resultCodeTotal.WithLabelValues(ep.Key(), ep.Group, ep.Name, string(endpointType), result.DNSRCode).Inc() } if result.HTTPStatus != 0 { - resultCodeTotal.WithLabelValues(endpoint.Key(), endpoint.Group, endpoint.Name, string(endpointType), strconv.Itoa(result.HTTPStatus)).Inc() + resultCodeTotal.WithLabelValues(ep.Key(), ep.Group, ep.Name, string(endpointType), strconv.Itoa(result.HTTPStatus)).Inc() } if result.CertificateExpiration != 0 { - resultCertificateExpirationSeconds.WithLabelValues(endpoint.Key(), endpoint.Group, endpoint.Name, string(endpointType)).Set(result.CertificateExpiration.Seconds()) + resultCertificateExpirationSeconds.WithLabelValues(ep.Key(), ep.Group, ep.Name, string(endpointType)).Set(result.CertificateExpiration.Seconds()) } } diff --git a/metrics/metrics_test.go b/metrics/metrics_test.go index 4a1bb35e..ecc1602a 100644 --- a/metrics/metrics_test.go +++ b/metrics/metrics_test.go @@ -5,18 +5,19 @@ import ( "testing" "time" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" + "github.com/TwiN/gatus/v5/config/endpoint/dns" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/testutil" ) func TestPublishMetricsForEndpoint(t *testing.T) { - httpEndpoint := &core.Endpoint{Name: "http-ep-name", Group: "http-ep-group", URL: "https://example.org"} - PublishMetricsForEndpoint(httpEndpoint, &core.Result{ + httpEndpoint := &endpoint.Endpoint{Name: "http-ep-name", Group: "http-ep-group", URL: "https://example.org"} + PublishMetricsForEndpoint(httpEndpoint, &endpoint.Result{ HTTPStatus: 200, Connected: true, Duration: 123 * time.Millisecond, - ConditionResults: []*core.ConditionResult{ + ConditionResults: []*endpoint.ConditionResult{ {Condition: "[STATUS] == 200", Success: true}, {Condition: "[CERTIFICATE_EXPIRATION] > 48h", Success: true}, }, @@ -43,11 +44,11 @@ gatus_results_total{group="http-ep-group",key="http-ep-group_http-ep-name",name= if err != nil { t.Errorf("Expected no errors but got: %v", err) } - PublishMetricsForEndpoint(httpEndpoint, &core.Result{ + PublishMetricsForEndpoint(httpEndpoint, &endpoint.Result{ HTTPStatus: 200, Connected: true, Duration: 125 * time.Millisecond, - ConditionResults: []*core.ConditionResult{ + ConditionResults: []*endpoint.ConditionResult{ {Condition: "[STATUS] == 200", Success: true}, {Condition: "[CERTIFICATE_EXPIRATION] > 47h", Success: false}, }, @@ -75,15 +76,15 @@ gatus_results_total{group="http-ep-group",key="http-ep-group_http-ep-name",name= if err != nil { t.Errorf("Expected no errors but got: %v", err) } - dnsEndpoint := &core.Endpoint{Name: "dns-ep-name", Group: "dns-ep-group", URL: "8.8.8.8", DNS: &core.DNS{ + dnsEndpoint := &endpoint.Endpoint{Name: "dns-ep-name", Group: "dns-ep-group", URL: "8.8.8.8", DNSConfig: &dns.Config{ QueryType: "A", QueryName: "example.com.", }} - PublishMetricsForEndpoint(dnsEndpoint, &core.Result{ + PublishMetricsForEndpoint(dnsEndpoint, &endpoint.Result{ DNSRCode: "NOERROR", Connected: true, Duration: 50 * time.Millisecond, - ConditionResults: []*core.ConditionResult{ + ConditionResults: []*endpoint.ConditionResult{ {Condition: "[DNS_RCODE] == NOERROR", Success: true}, }, Success: true, diff --git a/storage/store/memory/memory.go b/storage/store/memory/memory.go index 281e4a59..6451381d 100644 --- a/storage/store/memory/memory.go +++ b/storage/store/memory/memory.go @@ -5,10 +5,10 @@ import ( "sync" "time" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/storage/store/common" "github.com/TwiN/gatus/v5/storage/store/common/paging" - "github.com/TwiN/gatus/v5/util" "github.com/TwiN/gocache/v2" ) @@ -30,13 +30,13 @@ func NewStore() (*Store, error) { return store, nil } -// GetAllEndpointStatuses returns all monitored core.EndpointStatus -// with a subset of core.Result defined by the page and pageSize parameters -func (s *Store) GetAllEndpointStatuses(params *paging.EndpointStatusParams) ([]*core.EndpointStatus, error) { +// GetAllEndpointStatuses returns all monitored endpoint.Status +// with a subset of endpoint.Result defined by the page and pageSize parameters +func (s *Store) GetAllEndpointStatuses(params *paging.EndpointStatusParams) ([]*endpoint.Status, error) { endpointStatuses := s.cache.GetAll() - pagedEndpointStatuses := make([]*core.EndpointStatus, 0, len(endpointStatuses)) + pagedEndpointStatuses := make([]*endpoint.Status, 0, len(endpointStatuses)) for _, v := range endpointStatuses { - pagedEndpointStatuses = append(pagedEndpointStatuses, ShallowCopyEndpointStatus(v.(*core.EndpointStatus), params)) + pagedEndpointStatuses = append(pagedEndpointStatuses, ShallowCopyEndpointStatus(v.(*endpoint.Status), params)) } sort.Slice(pagedEndpointStatuses, func(i, j int) bool { return pagedEndpointStatuses[i].Key < pagedEndpointStatuses[j].Key @@ -45,17 +45,17 @@ func (s *Store) GetAllEndpointStatuses(params *paging.EndpointStatusParams) ([]* } // GetEndpointStatus returns the endpoint status for a given endpoint name in the given group -func (s *Store) GetEndpointStatus(groupName, endpointName string, params *paging.EndpointStatusParams) (*core.EndpointStatus, error) { - return s.GetEndpointStatusByKey(util.ConvertGroupAndEndpointNameToKey(groupName, endpointName), params) +func (s *Store) GetEndpointStatus(groupName, endpointName string, params *paging.EndpointStatusParams) (*endpoint.Status, error) { + return s.GetEndpointStatusByKey(endpoint.ConvertGroupAndEndpointNameToKey(groupName, endpointName), params) } // GetEndpointStatusByKey returns the endpoint status for a given key -func (s *Store) GetEndpointStatusByKey(key string, params *paging.EndpointStatusParams) (*core.EndpointStatus, error) { +func (s *Store) GetEndpointStatusByKey(key string, params *paging.EndpointStatusParams) (*endpoint.Status, error) { endpointStatus := s.cache.GetValue(key) if endpointStatus == nil { return nil, common.ErrEndpointNotFound } - return ShallowCopyEndpointStatus(endpointStatus.(*core.EndpointStatus), params), nil + return ShallowCopyEndpointStatus(endpointStatus.(*endpoint.Status), params), nil } // GetUptimeByKey returns the uptime percentage during a time range @@ -64,7 +64,7 @@ func (s *Store) GetUptimeByKey(key string, from, to time.Time) (float64, error) return 0, common.ErrInvalidTimeRange } endpointStatus := s.cache.GetValue(key) - if endpointStatus == nil || endpointStatus.(*core.EndpointStatus).Uptime == nil { + if endpointStatus == nil || endpointStatus.(*endpoint.Status).Uptime == nil { return 0, common.ErrEndpointNotFound } successfulExecutions := uint64(0) @@ -72,7 +72,7 @@ func (s *Store) GetUptimeByKey(key string, from, to time.Time) (float64, error) current := from for to.Sub(current) >= 0 { hourlyUnixTimestamp := current.Truncate(time.Hour).Unix() - hourlyStats := endpointStatus.(*core.EndpointStatus).Uptime.HourlyStatistics[hourlyUnixTimestamp] + hourlyStats := endpointStatus.(*endpoint.Status).Uptime.HourlyStatistics[hourlyUnixTimestamp] if hourlyStats == nil || hourlyStats.TotalExecutions == 0 { current = current.Add(time.Hour) continue @@ -93,14 +93,14 @@ func (s *Store) GetAverageResponseTimeByKey(key string, from, to time.Time) (int return 0, common.ErrInvalidTimeRange } endpointStatus := s.cache.GetValue(key) - if endpointStatus == nil || endpointStatus.(*core.EndpointStatus).Uptime == nil { + if endpointStatus == nil || endpointStatus.(*endpoint.Status).Uptime == nil { return 0, common.ErrEndpointNotFound } current := from var totalExecutions, totalResponseTime uint64 for to.Sub(current) >= 0 { hourlyUnixTimestamp := current.Truncate(time.Hour).Unix() - hourlyStats := endpointStatus.(*core.EndpointStatus).Uptime.HourlyStatistics[hourlyUnixTimestamp] + hourlyStats := endpointStatus.(*endpoint.Status).Uptime.HourlyStatistics[hourlyUnixTimestamp] if hourlyStats == nil || hourlyStats.TotalExecutions == 0 { current = current.Add(time.Hour) continue @@ -121,14 +121,14 @@ func (s *Store) GetHourlyAverageResponseTimeByKey(key string, from, to time.Time return nil, common.ErrInvalidTimeRange } endpointStatus := s.cache.GetValue(key) - if endpointStatus == nil || endpointStatus.(*core.EndpointStatus).Uptime == nil { + if endpointStatus == nil || endpointStatus.(*endpoint.Status).Uptime == nil { return nil, common.ErrEndpointNotFound } hourlyAverageResponseTimes := make(map[int64]int) current := from for to.Sub(current) >= 0 { hourlyUnixTimestamp := current.Truncate(time.Hour).Unix() - hourlyStats := endpointStatus.(*core.EndpointStatus).Uptime.HourlyStatistics[hourlyUnixTimestamp] + hourlyStats := endpointStatus.(*endpoint.Status).Uptime.HourlyStatistics[hourlyUnixTimestamp] if hourlyStats == nil || hourlyStats.TotalExecutions == 0 { current = current.Add(time.Hour) continue @@ -140,24 +140,24 @@ func (s *Store) GetHourlyAverageResponseTimeByKey(key string, from, to time.Time } // Insert adds the observed result for the specified endpoint into the store -func (s *Store) Insert(endpoint *core.Endpoint, result *core.Result) error { - key := endpoint.Key() +func (s *Store) Insert(ep *endpoint.Endpoint, result *endpoint.Result) error { + key := ep.Key() s.Lock() status, exists := s.cache.Get(key) if !exists { - status = core.NewEndpointStatus(endpoint.Group, endpoint.Name) - status.(*core.EndpointStatus).Events = append(status.(*core.EndpointStatus).Events, &core.Event{ - Type: core.EventStart, + status = endpoint.NewStatus(ep.Group, ep.Name) + status.(*endpoint.Status).Events = append(status.(*endpoint.Status).Events, &endpoint.Event{ + Type: endpoint.EventStart, Timestamp: time.Now(), }) } - AddResult(status.(*core.EndpointStatus), result) + AddResult(status.(*endpoint.Status), result) s.cache.Set(key, status) s.Unlock() return nil } -// DeleteAllEndpointStatusesNotInKeys removes all EndpointStatus that are not within the keys provided +// DeleteAllEndpointStatusesNotInKeys removes all Status that are not within the keys provided func (s *Store) DeleteAllEndpointStatusesNotInKeys(keys []string) int { var keysToDelete []string for _, existingKey := range s.cache.GetKeysByPattern("*", 0) { @@ -175,6 +175,37 @@ func (s *Store) DeleteAllEndpointStatusesNotInKeys(keys []string) int { return s.cache.DeleteAll(keysToDelete) } +// GetTriggeredEndpointAlert returns whether the triggered alert for the specified endpoint as well as the necessary information to resolve it +// +// Always returns that the alert does not exist for the in-memory store since it does not support persistence across restarts +func (s *Store) GetTriggeredEndpointAlert(ep *endpoint.Endpoint, alert *alert.Alert) (exists bool, resolveKey string, numberOfSuccessesInARow int, err error) { + return false, "", 0, nil +} + +// UpsertTriggeredEndpointAlert inserts/updates a triggered alert for an endpoint +// Used for persistence of triggered alerts across application restarts +// +// Does nothing for the in-memory store since it does not support persistence across restarts +func (s *Store) UpsertTriggeredEndpointAlert(ep *endpoint.Endpoint, triggeredAlert *alert.Alert) error { + return nil +} + +// DeleteTriggeredEndpointAlert deletes a triggered alert for an endpoint +// +// Does nothing for the in-memory store since it does not support persistence across restarts +func (s *Store) DeleteTriggeredEndpointAlert(ep *endpoint.Endpoint, triggeredAlert *alert.Alert) error { + return nil +} + +// DeleteAllTriggeredAlertsNotInChecksumsByEndpoint removes all triggered alerts owned by an endpoint whose alert +// configurations are not provided in the checksums list. +// This prevents triggered alerts that have been removed or modified from lingering in the database. +// +// Does nothing for the in-memory store since it does not support persistence across restarts +func (s *Store) DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(ep *endpoint.Endpoint, checksums []string) int { + return 0 +} + // Clear deletes everything from the store func (s *Store) Clear() { s.cache.Clear() diff --git a/storage/store/memory/memory_test.go b/storage/store/memory/memory_test.go index ea0bd1cd..5d489677 100644 --- a/storage/store/memory/memory_test.go +++ b/storage/store/memory/memory_test.go @@ -4,30 +4,30 @@ import ( "testing" "time" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/storage/store/common/paging" ) var ( - firstCondition = core.Condition("[STATUS] == 200") - secondCondition = core.Condition("[RESPONSE_TIME] < 500") - thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h") + firstCondition = endpoint.Condition("[STATUS] == 200") + secondCondition = endpoint.Condition("[RESPONSE_TIME] < 500") + thirdCondition = endpoint.Condition("[CERTIFICATE_EXPIRATION] < 72h") now = time.Now() - testEndpoint = core.Endpoint{ + testEndpoint = endpoint.Endpoint{ Name: "name", Group: "group", URL: "https://example.org/what/ever", Method: "GET", Body: "body", Interval: 30 * time.Second, - Conditions: []core.Condition{firstCondition, secondCondition, thirdCondition}, + Conditions: []endpoint.Condition{firstCondition, secondCondition, thirdCondition}, Alerts: nil, NumberOfFailuresInARow: 0, NumberOfSuccessesInARow: 0, } - testSuccessfulResult = core.Result{ + testSuccessfulResult = endpoint.Result{ Hostname: "example.org", IP: "127.0.0.1", HTTPStatus: 200, @@ -37,7 +37,7 @@ var ( Timestamp: now, Duration: 150 * time.Millisecond, CertificateExpiration: 10 * time.Hour, - ConditionResults: []*core.ConditionResult{ + ConditionResults: []*endpoint.ConditionResult{ { Condition: "[STATUS] == 200", Success: true, @@ -52,7 +52,7 @@ var ( }, }, } - testUnsuccessfulResult = core.Result{ + testUnsuccessfulResult = endpoint.Result{ Hostname: "example.org", IP: "127.0.0.1", HTTPStatus: 200, @@ -62,7 +62,7 @@ var ( Timestamp: now, Duration: 750 * time.Millisecond, CertificateExpiration: 10 * time.Hour, - ConditionResults: []*core.ConditionResult{ + ConditionResults: []*endpoint.ConditionResult{ { Condition: "[STATUS] == 200", Success: true, diff --git a/storage/store/memory/uptime.go b/storage/store/memory/uptime.go index ffb4863b..93322359 100644 --- a/storage/store/memory/uptime.go +++ b/storage/store/memory/uptime.go @@ -3,24 +3,24 @@ package memory import ( "time" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" ) const ( - numberOfHoursInTenDays = 10 * 24 - sevenDays = 7 * 24 * time.Hour + uptimeCleanUpThreshold = 32 * 24 + uptimeRetention = 30 * 24 * time.Hour ) // processUptimeAfterResult processes the result by extracting the relevant from the result and recalculating the uptime // if necessary -func processUptimeAfterResult(uptime *core.Uptime, result *core.Result) { +func processUptimeAfterResult(uptime *endpoint.Uptime, result *endpoint.Result) { if uptime.HourlyStatistics == nil { - uptime.HourlyStatistics = make(map[int64]*core.HourlyUptimeStatistics) + uptime.HourlyStatistics = make(map[int64]*endpoint.HourlyUptimeStatistics) } unixTimestampFlooredAtHour := result.Timestamp.Truncate(time.Hour).Unix() hourlyStats, _ := uptime.HourlyStatistics[unixTimestampFlooredAtHour] if hourlyStats == nil { - hourlyStats = &core.HourlyUptimeStatistics{} + hourlyStats = &endpoint.HourlyUptimeStatistics{} uptime.HourlyStatistics[unixTimestampFlooredAtHour] = hourlyStats } if result.Success { @@ -30,10 +30,10 @@ func processUptimeAfterResult(uptime *core.Uptime, result *core.Result) { hourlyStats.TotalExecutionsResponseTime += uint64(result.Duration.Milliseconds()) // Clean up only when we're starting to have too many useless keys // Note that this is only triggered when there are more entries than there should be after - // 10 days, despite the fact that we are deleting everything that's older than 7 days. - // This is to prevent re-iterating on every `processUptimeAfterResult` as soon as the uptime has been logged for 7 days. - if len(uptime.HourlyStatistics) > numberOfHoursInTenDays { - sevenDaysAgo := time.Now().Add(-(sevenDays + time.Hour)).Unix() + // 32 days, despite the fact that we are deleting everything that's older than 30 days. + // This is to prevent re-iterating on every `processUptimeAfterResult` as soon as the uptime has been logged for 30 days. + if len(uptime.HourlyStatistics) > uptimeCleanUpThreshold { + sevenDaysAgo := time.Now().Add(-(uptimeRetention + time.Hour)).Unix() for hourlyUnixTimestamp := range uptime.HourlyStatistics { if sevenDaysAgo > hourlyUnixTimestamp { delete(uptime.HourlyStatistics, hourlyUnixTimestamp) diff --git a/storage/store/memory/uptime_bench_test.go b/storage/store/memory/uptime_bench_test.go index 886ba4df..f055339a 100644 --- a/storage/store/memory/uptime_bench_test.go +++ b/storage/store/memory/uptime_bench_test.go @@ -4,17 +4,17 @@ import ( "testing" "time" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" ) func BenchmarkProcessUptimeAfterResult(b *testing.B) { - uptime := core.NewUptime() + uptime := endpoint.NewUptime() now := time.Now() now = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location()) // Start 12000 days ago timestamp := now.Add(-12000 * 24 * time.Hour) for n := 0; n < b.N; n++ { - processUptimeAfterResult(uptime, &core.Result{ + processUptimeAfterResult(uptime, &endpoint.Result{ Duration: 18 * time.Millisecond, Success: n%15 == 0, Timestamp: timestamp, diff --git a/storage/store/memory/uptime_test.go b/storage/store/memory/uptime_test.go index 071bb4ce..3d9f36c1 100644 --- a/storage/store/memory/uptime_test.go +++ b/storage/store/memory/uptime_test.go @@ -4,62 +4,62 @@ import ( "testing" "time" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" ) func TestProcessUptimeAfterResult(t *testing.T) { - endpoint := &core.Endpoint{Name: "name", Group: "group"} - status := core.NewEndpointStatus(endpoint.Group, endpoint.Name) + ep := &endpoint.Endpoint{Name: "name", Group: "group"} + status := endpoint.NewStatus(ep.Group, ep.Name) uptime := status.Uptime now := time.Now() now = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location()) - processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-7 * 24 * time.Hour), Success: true}) + processUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-7 * 24 * time.Hour), Success: true}) - processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-6 * 24 * time.Hour), Success: false}) + processUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-6 * 24 * time.Hour), Success: false}) - processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-8 * 24 * time.Hour), Success: true}) + processUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-8 * 24 * time.Hour), Success: true}) - processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-24 * time.Hour), Success: true}) - processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-12 * time.Hour), Success: true}) + processUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-24 * time.Hour), Success: true}) + processUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-12 * time.Hour), Success: true}) - processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-1 * time.Hour), Success: true, Duration: 10 * time.Millisecond}) + processUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-1 * time.Hour), Success: true, Duration: 10 * time.Millisecond}) checkHourlyStatistics(t, uptime.HourlyStatistics[now.Unix()-now.Unix()%3600-3600], 10, 1, 1) - processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-30 * time.Minute), Success: false, Duration: 500 * time.Millisecond}) + processUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-30 * time.Minute), Success: false, Duration: 500 * time.Millisecond}) checkHourlyStatistics(t, uptime.HourlyStatistics[now.Unix()-now.Unix()%3600-3600], 510, 2, 1) - processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-15 * time.Minute), Success: false, Duration: 25 * time.Millisecond}) + processUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-15 * time.Minute), Success: false, Duration: 25 * time.Millisecond}) checkHourlyStatistics(t, uptime.HourlyStatistics[now.Unix()-now.Unix()%3600-3600], 535, 3, 1) - processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-10 * time.Minute), Success: false}) + processUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-10 * time.Minute), Success: false}) - processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-120 * time.Hour), Success: true}) - processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-119 * time.Hour), Success: true}) - processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-118 * time.Hour), Success: true}) - processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-117 * time.Hour), Success: true}) - processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-10 * time.Hour), Success: true}) - processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-8 * time.Hour), Success: true}) - processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-30 * time.Minute), Success: true}) - processUptimeAfterResult(uptime, &core.Result{Timestamp: now.Add(-25 * time.Minute), Success: true}) + processUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-120 * time.Hour), Success: true}) + processUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-119 * time.Hour), Success: true}) + processUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-118 * time.Hour), Success: true}) + processUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-117 * time.Hour), Success: true}) + processUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-10 * time.Hour), Success: true}) + processUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-8 * time.Hour), Success: true}) + processUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-30 * time.Minute), Success: true}) + processUptimeAfterResult(uptime, &endpoint.Result{Timestamp: now.Add(-25 * time.Minute), Success: true}) } func TestAddResultUptimeIsCleaningUpAfterItself(t *testing.T) { - endpoint := &core.Endpoint{Name: "name", Group: "group"} - status := core.NewEndpointStatus(endpoint.Group, endpoint.Name) + ep := &endpoint.Endpoint{Name: "name", Group: "group"} + status := endpoint.NewStatus(ep.Group, ep.Name) now := time.Now() now = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location()) // Start 12 days ago timestamp := now.Add(-12 * 24 * time.Hour) for timestamp.Unix() <= now.Unix() { - AddResult(status, &core.Result{Timestamp: timestamp, Success: true}) - if len(status.Uptime.HourlyStatistics) > numberOfHoursInTenDays { - t.Errorf("At no point in time should there be more than %d entries in status.SuccessfulExecutionsPerHour, but there are %d", numberOfHoursInTenDays, len(status.Uptime.HourlyStatistics)) + AddResult(status, &endpoint.Result{Timestamp: timestamp, Success: true}) + if len(status.Uptime.HourlyStatistics) > uptimeCleanUpThreshold { + t.Errorf("At no point in time should there be more than %d entries in status.SuccessfulExecutionsPerHour, but there are %d", uptimeCleanUpThreshold, len(status.Uptime.HourlyStatistics)) } // Simulate endpoint with an interval of 3 minutes timestamp = timestamp.Add(3 * time.Minute) } } -func checkHourlyStatistics(t *testing.T, hourlyUptimeStatistics *core.HourlyUptimeStatistics, expectedTotalExecutionsResponseTime uint64, expectedTotalExecutions uint64, expectedSuccessfulExecutions uint64) { +func checkHourlyStatistics(t *testing.T, hourlyUptimeStatistics *endpoint.HourlyUptimeStatistics, expectedTotalExecutionsResponseTime uint64, expectedTotalExecutions uint64, expectedSuccessfulExecutions uint64) { if hourlyUptimeStatistics.TotalExecutionsResponseTime != expectedTotalExecutionsResponseTime { t.Error("TotalExecutionsResponseTime should've been", expectedTotalExecutionsResponseTime, "got", hourlyUptimeStatistics.TotalExecutionsResponseTime) } diff --git a/storage/store/memory/util.go b/storage/store/memory/util.go index 7ba757e1..961f740a 100644 --- a/storage/store/memory/util.go +++ b/storage/store/memory/util.go @@ -1,31 +1,31 @@ package memory import ( - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/storage/store/common" "github.com/TwiN/gatus/v5/storage/store/common/paging" ) -// ShallowCopyEndpointStatus returns a shallow copy of a EndpointStatus with only the results +// ShallowCopyEndpointStatus returns a shallow copy of a Status with only the results // within the range defined by the page and pageSize parameters -func ShallowCopyEndpointStatus(ss *core.EndpointStatus, params *paging.EndpointStatusParams) *core.EndpointStatus { - shallowCopy := &core.EndpointStatus{ +func ShallowCopyEndpointStatus(ss *endpoint.Status, params *paging.EndpointStatusParams) *endpoint.Status { + shallowCopy := &endpoint.Status{ Name: ss.Name, Group: ss.Group, Key: ss.Key, - Uptime: core.NewUptime(), + Uptime: endpoint.NewUptime(), } numberOfResults := len(ss.Results) resultsStart, resultsEnd := getStartAndEndIndex(numberOfResults, params.ResultsPage, params.ResultsPageSize) if resultsStart < 0 || resultsEnd < 0 { - shallowCopy.Results = []*core.Result{} + shallowCopy.Results = []*endpoint.Result{} } else { shallowCopy.Results = ss.Results[resultsStart:resultsEnd] } numberOfEvents := len(ss.Events) eventsStart, eventsEnd := getStartAndEndIndex(numberOfEvents, params.EventsPage, params.EventsPageSize) if eventsStart < 0 || eventsEnd < 0 { - shallowCopy.Events = []*core.Event{} + shallowCopy.Events = []*endpoint.Event{} } else { shallowCopy.Events = ss.Events[eventsStart:eventsEnd] } @@ -49,16 +49,16 @@ func getStartAndEndIndex(numberOfResults int, page, pageSize int) (int, int) { return start, end } -// AddResult adds a Result to EndpointStatus.Results and makes sure that there are +// AddResult adds a Result to Status.Results and makes sure that there are // no more than MaximumNumberOfResults results in the Results slice -func AddResult(ss *core.EndpointStatus, result *core.Result) { +func AddResult(ss *endpoint.Status, result *endpoint.Result) { if ss == nil { return } if len(ss.Results) > 0 { // Check if there's any change since the last result if ss.Results[len(ss.Results)-1].Success != result.Success { - ss.Events = append(ss.Events, core.NewEventFromResult(result)) + ss.Events = append(ss.Events, endpoint.NewEventFromResult(result)) if len(ss.Events) > common.MaximumNumberOfEvents { // Doing ss.Events[1:] would usually be sufficient, but in the case where for some reason, the slice has // more than one extra element, we can get rid of all of them at once and thus returning the slice to a @@ -68,7 +68,7 @@ func AddResult(ss *core.EndpointStatus, result *core.Result) { } } else { // This is the first result, so we need to add the first healthy/unhealthy event - ss.Events = append(ss.Events, core.NewEventFromResult(result)) + ss.Events = append(ss.Events, endpoint.NewEventFromResult(result)) } ss.Results = append(ss.Results, result) if len(ss.Results) > common.MaximumNumberOfResults { diff --git a/storage/store/memory/util_bench_test.go b/storage/store/memory/util_bench_test.go index e832f149..36252829 100644 --- a/storage/store/memory/util_bench_test.go +++ b/storage/store/memory/util_bench_test.go @@ -3,14 +3,14 @@ package memory import ( "testing" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/storage/store/common" "github.com/TwiN/gatus/v5/storage/store/common/paging" ) func BenchmarkShallowCopyEndpointStatus(b *testing.B) { - endpoint := &testEndpoint - status := core.NewEndpointStatus(endpoint.Group, endpoint.Name) + ep := &testEndpoint + status := endpoint.NewStatus(ep.Group, ep.Name) for i := 0; i < common.MaximumNumberOfResults; i++ { AddResult(status, &testSuccessfulResult) } diff --git a/storage/store/memory/util_test.go b/storage/store/memory/util_test.go index 8f88299e..1de445e7 100644 --- a/storage/store/memory/util_test.go +++ b/storage/store/memory/util_test.go @@ -4,16 +4,16 @@ import ( "testing" "time" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/storage/store/common" "github.com/TwiN/gatus/v5/storage/store/common/paging" ) func TestAddResult(t *testing.T) { - endpoint := &core.Endpoint{Name: "name", Group: "group"} - endpointStatus := core.NewEndpointStatus(endpoint.Group, endpoint.Name) + ep := &endpoint.Endpoint{Name: "name", Group: "group"} + endpointStatus := endpoint.NewStatus(ep.Group, ep.Name) for i := 0; i < (common.MaximumNumberOfResults+common.MaximumNumberOfEvents)*2; i++ { - AddResult(endpointStatus, &core.Result{Success: i%2 == 0, Timestamp: time.Now()}) + AddResult(endpointStatus, &endpoint.Result{Success: i%2 == 0, Timestamp: time.Now()}) } if len(endpointStatus.Results) != common.MaximumNumberOfResults { t.Errorf("expected endpointStatus.Results to not exceed a length of %d", common.MaximumNumberOfResults) @@ -22,15 +22,15 @@ func TestAddResult(t *testing.T) { t.Errorf("expected endpointStatus.Events to not exceed a length of %d", common.MaximumNumberOfEvents) } // Try to add nil endpointStatus - AddResult(nil, &core.Result{Timestamp: time.Now()}) + AddResult(nil, &endpoint.Result{Timestamp: time.Now()}) } func TestShallowCopyEndpointStatus(t *testing.T) { - endpoint := &core.Endpoint{Name: "name", Group: "group"} - endpointStatus := core.NewEndpointStatus(endpoint.Group, endpoint.Name) + ep := &endpoint.Endpoint{Name: "name", Group: "group"} + endpointStatus := endpoint.NewStatus(ep.Group, ep.Name) ts := time.Now().Add(-25 * time.Hour) for i := 0; i < 25; i++ { - AddResult(endpointStatus, &core.Result{Success: i%2 == 0, Timestamp: ts}) + AddResult(endpointStatus, &endpoint.Result{Success: i%2 == 0, Timestamp: ts}) ts = ts.Add(time.Hour) } if len(ShallowCopyEndpointStatus(endpointStatus, paging.NewEndpointStatusParams().WithResults(-1, -1)).Results) != 0 { diff --git a/storage/store/sql/specific_postgres.go b/storage/store/sql/specific_postgres.go index ea00e839..aa8b5d5f 100644 --- a/storage/store/sql/specific_postgres.go +++ b/storage/store/sql/specific_postgres.go @@ -16,7 +16,7 @@ func (s *Store) createPostgresSchema() error { _, err = s.db.Exec(` CREATE TABLE IF NOT EXISTS endpoint_events ( endpoint_event_id BIGSERIAL PRIMARY KEY, - endpoint_id INTEGER NOT NULL REFERENCES endpoints(endpoint_id) ON DELETE CASCADE, + endpoint_id BIGINT NOT NULL REFERENCES endpoints(endpoint_id) ON DELETE CASCADE, event_type TEXT NOT NULL, event_timestamp TIMESTAMP NOT NULL ) @@ -66,7 +66,20 @@ func (s *Store) createPostgresSchema() error { UNIQUE(endpoint_id, hour_unix_timestamp) ) `) - // Silent table modifications + if err != nil { + return err + } + _, err = s.db.Exec(` + CREATE TABLE IF NOT EXISTS endpoint_alerts_triggered ( + endpoint_alert_trigger_id BIGSERIAL PRIMARY KEY, + endpoint_id BIGINT NOT NULL REFERENCES endpoints(endpoint_id) ON DELETE CASCADE, + configuration_checksum TEXT NOT NULL, + resolve_key TEXT NOT NULL, + number_of_successes_in_a_row INTEGER NOT NULL, + UNIQUE(endpoint_id, configuration_checksum) + ) + `) + // Silent table modifications TODO: Remove this in v6.0.0 _, _ = s.db.Exec(`ALTER TABLE endpoint_results ADD IF NOT EXISTS domain_expiration BIGINT NOT NULL DEFAULT 0`) return err } diff --git a/storage/store/sql/specific_sqlite.go b/storage/store/sql/specific_sqlite.go index 66b6eff2..0fe7fa54 100644 --- a/storage/store/sql/specific_sqlite.go +++ b/storage/store/sql/specific_sqlite.go @@ -66,7 +66,20 @@ func (s *Store) createSQLiteSchema() error { UNIQUE(endpoint_id, hour_unix_timestamp) ) `) - // Silent table modifications TODO: Remove this + if err != nil { + return err + } + _, err = s.db.Exec(` + CREATE TABLE IF NOT EXISTS endpoint_alerts_triggered ( + endpoint_alert_trigger_id INTEGER PRIMARY KEY, + endpoint_id INTEGER NOT NULL REFERENCES endpoints(endpoint_id) ON DELETE CASCADE, + configuration_checksum TEXT NOT NULL, + resolve_key TEXT NOT NULL, + number_of_successes_in_a_row INTEGER NOT NULL, + UNIQUE(endpoint_id, configuration_checksum) + ) + `) + // Silent table modifications TODO: Remove this in v6.0.0 _, _ = s.db.Exec(`ALTER TABLE endpoint_results ADD domain_expiration INTEGER NOT NULL DEFAULT 0`) return err } diff --git a/storage/store/sql/sql.go b/storage/store/sql/sql.go index cd94c824..a2a6791d 100644 --- a/storage/store/sql/sql.go +++ b/storage/store/sql/sql.go @@ -9,10 +9,10 @@ import ( "strings" "time" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/storage/store/common" "github.com/TwiN/gatus/v5/storage/store/common/paging" - "github.com/TwiN/gatus/v5/util" "github.com/TwiN/gocache/v2" _ "github.com/lib/pq" _ "modernc.org/sqlite" @@ -28,11 +28,13 @@ const ( // for aesthetic purposes, I deemed it wasn't worth the performance impact of yet another one-to-many table. arraySeparator = "|~|" - uptimeCleanUpThreshold = 10 * 24 * time.Hour // Maximum uptime age before triggering a clean up - eventsCleanUpThreshold = common.MaximumNumberOfEvents + 10 // Maximum number of events before triggering a clean up - resultsCleanUpThreshold = common.MaximumNumberOfResults + 10 // Maximum number of results before triggering a clean up + eventsCleanUpThreshold = common.MaximumNumberOfEvents + 10 // Maximum number of events before triggering a cleanup + resultsCleanUpThreshold = common.MaximumNumberOfResults + 10 // Maximum number of results before triggering a cleanup - uptimeRetention = 7 * 24 * time.Hour + uptimeTotalEntriesMergeThreshold = 100 // Maximum number of uptime entries before triggering a merge + uptimeAgeCleanUpThreshold = 32 * 24 * time.Hour // Maximum uptime age before triggering a cleanup + uptimeRetention = 30 * 24 * time.Hour // Minimum duration that must be kept to operate as intended + uptimeHourlyBuffer = 48 * time.Hour // Number of hours to buffer from now when determining which hourly uptime entries can be merged into daily uptime entries cacheTTL = 10 * time.Minute ) @@ -100,9 +102,9 @@ func (s *Store) createSchema() error { return s.createPostgresSchema() } -// GetAllEndpointStatuses returns all monitored core.EndpointStatus -// with a subset of core.Result defined by the page and pageSize parameters -func (s *Store) GetAllEndpointStatuses(params *paging.EndpointStatusParams) ([]*core.EndpointStatus, error) { +// GetAllEndpointStatuses returns all monitored endpoint.Status +// with a subset of endpoint.Result defined by the page and pageSize parameters +func (s *Store) GetAllEndpointStatuses(params *paging.EndpointStatusParams) ([]*endpoint.Status, error) { tx, err := s.db.Begin() if err != nil { return nil, err @@ -112,7 +114,7 @@ func (s *Store) GetAllEndpointStatuses(params *paging.EndpointStatusParams) ([]* _ = tx.Rollback() return nil, err } - endpointStatuses := make([]*core.EndpointStatus, 0, len(keys)) + endpointStatuses := make([]*endpoint.Status, 0, len(keys)) for _, key := range keys { endpointStatus, err := s.getEndpointStatusByKey(tx, key, params) if err != nil { @@ -127,12 +129,12 @@ func (s *Store) GetAllEndpointStatuses(params *paging.EndpointStatusParams) ([]* } // GetEndpointStatus returns the endpoint status for a given endpoint name in the given group -func (s *Store) GetEndpointStatus(groupName, endpointName string, params *paging.EndpointStatusParams) (*core.EndpointStatus, error) { - return s.GetEndpointStatusByKey(util.ConvertGroupAndEndpointNameToKey(groupName, endpointName), params) +func (s *Store) GetEndpointStatus(groupName, endpointName string, params *paging.EndpointStatusParams) (*endpoint.Status, error) { + return s.GetEndpointStatusByKey(endpoint.ConvertGroupAndEndpointNameToKey(groupName, endpointName), params) } // GetEndpointStatusByKey returns the endpoint status for a given key -func (s *Store) GetEndpointStatusByKey(key string, params *paging.EndpointStatusParams) (*core.EndpointStatus, error) { +func (s *Store) GetEndpointStatusByKey(key string, params *paging.EndpointStatusParams) (*endpoint.Status, error) { tx, err := s.db.Begin() if err != nil { return nil, err @@ -224,30 +226,30 @@ func (s *Store) GetHourlyAverageResponseTimeByKey(key string, from, to time.Time } // Insert adds the observed result for the specified endpoint into the store -func (s *Store) Insert(endpoint *core.Endpoint, result *core.Result) error { +func (s *Store) Insert(ep *endpoint.Endpoint, result *endpoint.Result) error { tx, err := s.db.Begin() if err != nil { return err } - endpointID, err := s.getEndpointID(tx, endpoint) + endpointID, err := s.getEndpointID(tx, ep) if err != nil { if errors.Is(err, common.ErrEndpointNotFound) { // Endpoint doesn't exist in the database, insert it - if endpointID, err = s.insertEndpoint(tx, endpoint); err != nil { + if endpointID, err = s.insertEndpoint(tx, ep); err != nil { _ = tx.Rollback() - log.Printf("[sql.Insert] Failed to create endpoint with group=%s; endpoint=%s: %s", endpoint.Group, endpoint.Name, err.Error()) + log.Printf("[sql.Insert] Failed to create endpoint with key=%s: %s", ep.Key(), err.Error()) return err } } else { _ = tx.Rollback() - log.Printf("[sql.Insert] Failed to retrieve id of endpoint with group=%s; endpoint=%s: %s", endpoint.Group, endpoint.Name, err.Error()) + log.Printf("[sql.Insert] Failed to retrieve id of endpoint with key=%s: %s", ep.Key(), err.Error()) return err } } // First, we need to check if we need to insert a new event. // // A new event must be added if either of the following cases happen: - // 1. There is only 1 event. The total number of events for a endpoint can only be 1 if the only existing event is + // 1. There is only 1 event. The total number of events for an endpoint can only be 1 if the only existing event is // of type EventStart, in which case we will have to create a new event of type EventHealthy or EventUnhealthy // based on result.Success. // 2. The lastResult.Success != result.Success. This implies that the endpoint went from healthy to unhealthy or @@ -256,38 +258,38 @@ func (s *Store) Insert(endpoint *core.Endpoint, result *core.Result) error { numberOfEvents, err := s.getNumberOfEventsByEndpointID(tx, endpointID) if err != nil { // Silently fail - log.Printf("[sql.Insert] Failed to retrieve total number of events for group=%s; endpoint=%s: %s", endpoint.Group, endpoint.Name, err.Error()) + log.Printf("[sql.Insert] Failed to retrieve total number of events for endpoint with key=%s: %s", ep.Key(), err.Error()) } if numberOfEvents == 0 { // There's no events yet, which means we need to add the EventStart and the first healthy/unhealthy event - err = s.insertEndpointEvent(tx, endpointID, &core.Event{ - Type: core.EventStart, + err = s.insertEndpointEvent(tx, endpointID, &endpoint.Event{ + Type: endpoint.EventStart, Timestamp: result.Timestamp.Add(-50 * time.Millisecond), }) if err != nil { // Silently fail - log.Printf("[sql.Insert] Failed to insert event=%s for group=%s; endpoint=%s: %s", core.EventStart, endpoint.Group, endpoint.Name, err.Error()) + log.Printf("[sql.Insert] Failed to insert event=%s for endpoint with key=%s: %s", endpoint.EventStart, ep.Key(), err.Error()) } - event := core.NewEventFromResult(result) + event := endpoint.NewEventFromResult(result) if err = s.insertEndpointEvent(tx, endpointID, event); err != nil { // Silently fail - log.Printf("[sql.Insert] Failed to insert event=%s for group=%s; endpoint=%s: %s", event.Type, endpoint.Group, endpoint.Name, err.Error()) + log.Printf("[sql.Insert] Failed to insert event=%s for endpoint with key=%s: %s", event.Type, ep.Key(), err.Error()) } } else { // Get the success value of the previous result var lastResultSuccess bool if lastResultSuccess, err = s.getLastEndpointResultSuccessValue(tx, endpointID); err != nil { - log.Printf("[sql.Insert] Failed to retrieve outcome of previous result for group=%s; endpoint=%s: %s", endpoint.Group, endpoint.Name, err.Error()) + log.Printf("[sql.Insert] Failed to retrieve outcome of previous result for endpoint with key=%s: %s", ep.Key(), err.Error()) } else { // If we managed to retrieve the outcome of the previous result, we'll compare it with the new result. // If the final outcome (success or failure) of the previous and the new result aren't the same, it means // that the endpoint either went from Healthy to Unhealthy or Unhealthy -> Healthy, therefore, we'll add // an event to mark the change in state if lastResultSuccess != result.Success { - event := core.NewEventFromResult(result) + event := endpoint.NewEventFromResult(result) if err = s.insertEndpointEvent(tx, endpointID, event); err != nil { // Silently fail - log.Printf("[sql.Insert] Failed to insert event=%s for group=%s; endpoint=%s: %s", event.Type, endpoint.Group, endpoint.Name, err.Error()) + log.Printf("[sql.Insert] Failed to insert event=%s for endpoint with key=%s: %s", event.Type, ep.Key(), err.Error()) } } } @@ -296,45 +298,60 @@ func (s *Store) Insert(endpoint *core.Endpoint, result *core.Result) error { // (since we're only deleting MaximumNumberOfEvents at a time instead of 1) if numberOfEvents > eventsCleanUpThreshold { if err = s.deleteOldEndpointEvents(tx, endpointID); err != nil { - log.Printf("[sql.Insert] Failed to delete old events for group=%s; endpoint=%s: %s", endpoint.Group, endpoint.Name, err.Error()) + log.Printf("[sql.Insert] Failed to delete old events for endpoint with key=%s: %s", ep.Key(), err.Error()) } } } // Second, we need to insert the result. if err = s.insertEndpointResult(tx, endpointID, result); err != nil { - log.Printf("[sql.Insert] Failed to insert result for group=%s; endpoint=%s: %s", endpoint.Group, endpoint.Name, err.Error()) + log.Printf("[sql.Insert] Failed to insert result for endpoint with key=%s: %s", ep.Key(), err.Error()) _ = tx.Rollback() // If we can't insert the result, we'll rollback now since there's no point continuing return err } // Clean up old results numberOfResults, err := s.getNumberOfResultsByEndpointID(tx, endpointID) if err != nil { - log.Printf("[sql.Insert] Failed to retrieve total number of results for group=%s; endpoint=%s: %s", endpoint.Group, endpoint.Name, err.Error()) + log.Printf("[sql.Insert] Failed to retrieve total number of results for endpoint with key=%s: %s", ep.Key(), err.Error()) } else { if numberOfResults > resultsCleanUpThreshold { if err = s.deleteOldEndpointResults(tx, endpointID); err != nil { - log.Printf("[sql.Insert] Failed to delete old results for group=%s; endpoint=%s: %s", endpoint.Group, endpoint.Name, err.Error()) + log.Printf("[sql.Insert] Failed to delete old results for endpoint with key=%s: %s", ep.Key(), err.Error()) } } } // Finally, we need to insert the uptime data. // Because the uptime data significantly outlives the results, we can't rely on the results for determining the uptime if err = s.updateEndpointUptime(tx, endpointID, result); err != nil { - log.Printf("[sql.Insert] Failed to update uptime for group=%s; endpoint=%s: %s", endpoint.Group, endpoint.Name, err.Error()) + log.Printf("[sql.Insert] Failed to update uptime for endpoint with key=%s: %s", ep.Key(), err.Error()) } - // Clean up old uptime entries + // Merge hourly uptime entries that can be merged into daily entries and clean up old uptime entries + numberOfUptimeEntries, err := s.getNumberOfUptimeEntriesByEndpointID(tx, endpointID) + if err != nil { + log.Printf("[sql.Insert] Failed to retrieve total number of uptime entries for endpoint with key=%s: %s", ep.Key(), err.Error()) + } else { + // Merge older hourly uptime entries into daily uptime entries if we have more than uptimeTotalEntriesMergeThreshold + if numberOfUptimeEntries >= uptimeTotalEntriesMergeThreshold { + log.Printf("[sql.Insert] Merging hourly uptime entries for endpoint with key=%s; This is a lot of work, it shouldn't happen too often", ep.Key()) + if err = s.mergeHourlyUptimeEntriesOlderThanMergeThresholdIntoDailyUptimeEntries(tx, endpointID); err != nil { + log.Printf("[sql.Insert] Failed to merge hourly uptime entries for endpoint with key=%s: %s", ep.Key(), err.Error()) + } + } + } + // Clean up outdated uptime entries + // In most cases, this would be handled by mergeHourlyUptimeEntriesOlderThanMergeThresholdIntoDailyUptimeEntries, + // but if Gatus was temporarily shut down, we might have some old entries that need to be cleaned up ageOfOldestUptimeEntry, err := s.getAgeOfOldestEndpointUptimeEntry(tx, endpointID) if err != nil { - log.Printf("[sql.Insert] Failed to retrieve oldest endpoint uptime entry for group=%s; endpoint=%s: %s", endpoint.Group, endpoint.Name, err.Error()) + log.Printf("[sql.Insert] Failed to retrieve oldest endpoint uptime entry for endpoint with key=%s: %s", ep.Key(), err.Error()) } else { - if ageOfOldestUptimeEntry > uptimeCleanUpThreshold { + if ageOfOldestUptimeEntry > uptimeAgeCleanUpThreshold { if err = s.deleteOldUptimeEntries(tx, endpointID, time.Now().Add(-(uptimeRetention + time.Hour))); err != nil { - log.Printf("[sql.Insert] Failed to delete old uptime entries for group=%s; endpoint=%s: %s", endpoint.Group, endpoint.Name, err.Error()) + log.Printf("[sql.Insert] Failed to delete old uptime entries for endpoint with key=%s: %s", ep.Key(), err.Error()) } } } if s.writeThroughCache != nil { - cacheKeysToRefresh := s.writeThroughCache.GetKeysByPattern(endpoint.Key()+"*", 0) + cacheKeysToRefresh := s.writeThroughCache.GetKeysByPattern(ep.Key()+"*", 0) for _, cacheKey := range cacheKeysToRefresh { s.writeThroughCache.Delete(cacheKey) endpointKey, params, err := extractKeyAndParamsFromCacheKey(cacheKey) @@ -375,6 +392,8 @@ func (s *Store) DeleteAllEndpointStatusesNotInKeys(keys []string) int { } if s.writeThroughCache != nil { // It's easier to just wipe out the entire cache than to try to find all keys that are not in the keys list + // This only happens on start and during tests, so it's fine for us to just clear the cache without worrying + // about performance _ = s.writeThroughCache.DeleteKeysByPattern("*") } // Return number of rows deleted @@ -382,6 +401,111 @@ func (s *Store) DeleteAllEndpointStatusesNotInKeys(keys []string) int { return int(rowsAffects) } +// GetTriggeredEndpointAlert returns whether the triggered alert for the specified endpoint as well as the necessary information to resolve it +func (s *Store) GetTriggeredEndpointAlert(ep *endpoint.Endpoint, alert *alert.Alert) (exists bool, resolveKey string, numberOfSuccessesInARow int, err error) { + //log.Printf("[sql.GetTriggeredEndpointAlert] Getting triggered alert with checksum=%s for endpoint with key=%s", alert.Checksum(), ep.Key()) + err = s.db.QueryRow( + "SELECT resolve_key, number_of_successes_in_a_row FROM endpoint_alerts_triggered WHERE endpoint_id = (SELECT endpoint_id FROM endpoints WHERE endpoint_key = $1 LIMIT 1) AND configuration_checksum = $2", + ep.Key(), + alert.Checksum(), + ).Scan(&resolveKey, &numberOfSuccessesInARow) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, "", 0, nil + } + return false, "", 0, err + } + return true, resolveKey, numberOfSuccessesInARow, nil +} + +// UpsertTriggeredEndpointAlert inserts/updates a triggered alert for an endpoint +// Used for persistence of triggered alerts across application restarts +func (s *Store) UpsertTriggeredEndpointAlert(ep *endpoint.Endpoint, triggeredAlert *alert.Alert) error { + //log.Printf("[sql.UpsertTriggeredEndpointAlert] Upserting triggered alert with checksum=%s for endpoint with key=%s", triggeredAlert.Checksum(), ep.Key()) + tx, err := s.db.Begin() + if err != nil { + return err + } + endpointID, err := s.getEndpointID(tx, ep) + if err != nil { + if errors.Is(err, common.ErrEndpointNotFound) { + // Endpoint doesn't exist in the database, insert it + // This shouldn't happen, but we'll handle it anyway + if endpointID, err = s.insertEndpoint(tx, ep); err != nil { + _ = tx.Rollback() + log.Printf("[sql.UpsertTriggeredEndpointAlert] Failed to create endpoint with key=%s: %s", ep.Key(), err.Error()) + return err + } + } else { + _ = tx.Rollback() + log.Printf("[sql.UpsertTriggeredEndpointAlert] Failed to retrieve id of endpoint with key=%s: %s", ep.Key(), err.Error()) + return err + } + } + _, err = tx.Exec( + ` + INSERT INTO endpoint_alerts_triggered (endpoint_id, configuration_checksum, resolve_key, number_of_successes_in_a_row) + VALUES ($1, $2, $3, $4) + ON CONFLICT(endpoint_id, configuration_checksum) DO UPDATE SET + resolve_key = $3, + number_of_successes_in_a_row = $4 + `, + endpointID, + triggeredAlert.Checksum(), + triggeredAlert.ResolveKey, + ep.NumberOfSuccessesInARow, // We only persist NumberOfSuccessesInARow, because all alerts in this table are already triggered + ) + if err != nil { + _ = tx.Rollback() + log.Printf("[sql.UpsertTriggeredEndpointAlert] Failed to persist triggered alert for endpoint with key=%s: %s", ep.Key(), err.Error()) + return err + } + if err = tx.Commit(); err != nil { + _ = tx.Rollback() + } + return nil +} + +// DeleteTriggeredEndpointAlert deletes a triggered alert for an endpoint +func (s *Store) DeleteTriggeredEndpointAlert(ep *endpoint.Endpoint, triggeredAlert *alert.Alert) error { + //log.Printf("[sql.DeleteTriggeredEndpointAlert] Deleting triggered alert with checksum=%s for endpoint with key=%s", triggeredAlert.Checksum(), ep.Key()) + _, err := s.db.Exec("DELETE FROM endpoint_alerts_triggered WHERE configuration_checksum = $1 AND endpoint_id = (SELECT endpoint_id FROM endpoints WHERE endpoint_key = $2 LIMIT 1)", triggeredAlert.Checksum(), ep.Key()) + return err +} + +// DeleteAllTriggeredAlertsNotInChecksumsByEndpoint removes all triggered alerts owned by an endpoint whose alert +// configurations are not provided in the checksums list. +// This prevents triggered alerts that have been removed or modified from lingering in the database. +func (s *Store) DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(ep *endpoint.Endpoint, checksums []string) int { + //log.Printf("[sql.DeleteAllTriggeredAlertsNotInChecksumsByEndpoint] Deleting triggered alerts for endpoint with key=%s that do not belong to any of checksums=%v", ep.Key(), checksums) + var err error + var result sql.Result + if len(checksums) == 0 { + // No checksums? Then it means there are no (enabled) alerts configured for that endpoint, so we can get rid of all + // persisted triggered alerts for that endpoint + result, err = s.db.Exec("DELETE FROM endpoint_alerts_triggered WHERE endpoint_id = (SELECT endpoint_id FROM endpoints WHERE endpoint_key = $1 LIMIT 1)", ep.Key()) + } else { + args := make([]interface{}, 0, len(checksums)+1) + args = append(args, ep.Key()) + query := `DELETE FROM endpoint_alerts_triggered + WHERE endpoint_id = (SELECT endpoint_id FROM endpoints WHERE endpoint_key = $1 LIMIT 1) + AND configuration_checksum NOT IN (` + for i := range checksums { + query += fmt.Sprintf("$%d,", i+2) + args = append(args, checksums[i]) + } + query = query[:len(query)-1] + ")" // Remove the last comma and add the closing parenthesis + result, err = s.db.Exec(query, args...) + } + if err != nil { + log.Printf("[sql.DeleteAllTriggeredAlertsNotInChecksumsByEndpoint] Failed to delete rows for endpoint with key=%s that do not belong to any of checksums=%v: %s", ep.Key(), checksums, err.Error()) + return 0 + } + // Return number of rows deleted + rowsAffects, _ := result.RowsAffected() + return int(rowsAffects) +} + // Clear deletes everything from the store func (s *Store) Clear() { _, _ = s.db.Exec("DELETE FROM endpoints") @@ -405,14 +529,14 @@ func (s *Store) Close() { } // insertEndpoint inserts an endpoint in the store and returns the generated id of said endpoint -func (s *Store) insertEndpoint(tx *sql.Tx, endpoint *core.Endpoint) (int64, error) { - //log.Printf("[sql.insertEndpoint] Inserting endpoint with group=%s and name=%s", endpoint.Group, endpoint.Name) +func (s *Store) insertEndpoint(tx *sql.Tx, ep *endpoint.Endpoint) (int64, error) { + //log.Printf("[sql.insertEndpoint] Inserting endpoint with group=%s and name=%s", ep.Group, ep.Name) var id int64 err := tx.QueryRow( "INSERT INTO endpoints (endpoint_key, endpoint_name, endpoint_group) VALUES ($1, $2, $3) RETURNING endpoint_id", - endpoint.Key(), - endpoint.Name, - endpoint.Group, + ep.Key(), + ep.Name, + ep.Group, ).Scan(&id) if err != nil { return 0, err @@ -421,7 +545,7 @@ func (s *Store) insertEndpoint(tx *sql.Tx, endpoint *core.Endpoint) (int64, erro } // insertEndpointEvent inserts en event in the store -func (s *Store) insertEndpointEvent(tx *sql.Tx, endpointID int64, event *core.Event) error { +func (s *Store) insertEndpointEvent(tx *sql.Tx, endpointID int64, event *endpoint.Event) error { _, err := tx.Exec( "INSERT INTO endpoint_events (endpoint_id, event_type, event_timestamp) VALUES ($1, $2, $3)", endpointID, @@ -435,7 +559,7 @@ func (s *Store) insertEndpointEvent(tx *sql.Tx, endpointID int64, event *core.Ev } // insertEndpointResult inserts a result in the store -func (s *Store) insertEndpointResult(tx *sql.Tx, endpointID int64, result *core.Result) error { +func (s *Store) insertEndpointResult(tx *sql.Tx, endpointID int64, result *endpoint.Result) error { var endpointResultID int64 err := tx.QueryRow( ` @@ -462,7 +586,7 @@ func (s *Store) insertEndpointResult(tx *sql.Tx, endpointID int64, result *core. return s.insertConditionResults(tx, endpointResultID, result.ConditionResults) } -func (s *Store) insertConditionResults(tx *sql.Tx, endpointResultID int64, conditionResults []*core.ConditionResult) error { +func (s *Store) insertConditionResults(tx *sql.Tx, endpointResultID int64, conditionResults []*endpoint.ConditionResult) error { var err error for _, cr := range conditionResults { _, err = tx.Exec("INSERT INTO endpoint_result_conditions (endpoint_result_id, condition, success) VALUES ($1, $2, $3)", @@ -477,7 +601,7 @@ func (s *Store) insertConditionResults(tx *sql.Tx, endpointResultID int64, condi return nil } -func (s *Store) updateEndpointUptime(tx *sql.Tx, endpointID int64, result *core.Result) error { +func (s *Store) updateEndpointUptime(tx *sql.Tx, endpointID int64, result *endpoint.Result) error { unixTimestampFlooredAtHour := result.Timestamp.Truncate(time.Hour).Unix() var successfulExecutions int if result.Success { @@ -514,12 +638,12 @@ func (s *Store) getAllEndpointKeys(tx *sql.Tx) (keys []string, err error) { return } -func (s *Store) getEndpointStatusByKey(tx *sql.Tx, key string, parameters *paging.EndpointStatusParams) (*core.EndpointStatus, error) { +func (s *Store) getEndpointStatusByKey(tx *sql.Tx, key string, parameters *paging.EndpointStatusParams) (*endpoint.Status, error) { var cacheKey string if s.writeThroughCache != nil { cacheKey = generateCacheKey(key, parameters) if cachedEndpointStatus, exists := s.writeThroughCache.Get(cacheKey); exists { - if castedCachedEndpointStatus, ok := cachedEndpointStatus.(*core.EndpointStatus); ok { + if castedCachedEndpointStatus, ok := cachedEndpointStatus.(*endpoint.Status); ok { return castedCachedEndpointStatus, nil } } @@ -528,7 +652,7 @@ func (s *Store) getEndpointStatusByKey(tx *sql.Tx, key string, parameters *pagin if err != nil { return nil, err } - endpointStatus := core.NewEndpointStatus(group, endpointName) + endpointStatus := endpoint.NewStatus(group, endpointName) if parameters.EventsPageSize > 0 { if endpointStatus.Events, err = s.getEndpointEventsByEndpointID(tx, endpointID, parameters.EventsPage, parameters.EventsPageSize); err != nil { log.Printf("[sql.getEndpointStatusByKey] Failed to retrieve events for key=%s: %s", key, err.Error()) @@ -564,7 +688,7 @@ func (s *Store) getEndpointIDGroupAndNameByKey(tx *sql.Tx, key string) (id int64 return } -func (s *Store) getEndpointEventsByEndpointID(tx *sql.Tx, endpointID int64, page, pageSize int) (events []*core.Event, err error) { +func (s *Store) getEndpointEventsByEndpointID(tx *sql.Tx, endpointID int64, page, pageSize int) (events []*endpoint.Event, err error) { rows, err := tx.Query( ` SELECT event_type, event_timestamp @@ -581,14 +705,14 @@ func (s *Store) getEndpointEventsByEndpointID(tx *sql.Tx, endpointID int64, page return nil, err } for rows.Next() { - event := &core.Event{} + event := &endpoint.Event{} _ = rows.Scan(&event.Type, &event.Timestamp) events = append(events, event) } return } -func (s *Store) getEndpointResultsByEndpointID(tx *sql.Tx, endpointID int64, page, pageSize int) (results []*core.Result, err error) { +func (s *Store) getEndpointResultsByEndpointID(tx *sql.Tx, endpointID int64, page, pageSize int) (results []*endpoint.Result, err error) { rows, err := tx.Query( ` SELECT endpoint_result_id, success, errors, connected, status, dns_rcode, certificate_expiration, domain_expiration, hostname, ip, duration, timestamp @@ -604,9 +728,9 @@ func (s *Store) getEndpointResultsByEndpointID(tx *sql.Tx, endpointID int64, pag if err != nil { return nil, err } - idResultMap := make(map[int64]*core.Result) + idResultMap := make(map[int64]*endpoint.Result) for rows.Next() { - result := &core.Result{} + result := &endpoint.Result{} var id int64 var joinedErrors string err = rows.Scan(&id, &result.Success, &joinedErrors, &result.Connected, &result.HTTPStatus, &result.DNSRCode, &result.CertificateExpiration, &result.DomainExpiration, &result.Hostname, &result.IP, &result.Duration, &result.Timestamp) @@ -618,7 +742,7 @@ func (s *Store) getEndpointResultsByEndpointID(tx *sql.Tx, endpointID int64, pag result.Errors = strings.Split(joinedErrors, arraySeparator) } // This is faster than using a subselect - results = append([]*core.Result{result}, results...) + results = append([]*endpoint.Result{result}, results...) idResultMap[id] = result } if len(idResultMap) == 0 { @@ -643,7 +767,7 @@ func (s *Store) getEndpointResultsByEndpointID(tx *sql.Tx, endpointID int64, pag } defer rows.Close() // explicitly defer the close in case an error happens during the scan for rows.Next() { - conditionResult := &core.ConditionResult{} + conditionResult := &endpoint.ConditionResult{} var endpointResultID int64 if err = rows.Scan(&endpointResultID, &conditionResult.Condition, &conditionResult.Success); err != nil { return @@ -734,9 +858,9 @@ func (s *Store) getEndpointHourlyAverageResponseTimes(tx *sql.Tx, endpointID int return hourlyAverageResponseTimes, nil } -func (s *Store) getEndpointID(tx *sql.Tx, endpoint *core.Endpoint) (int64, error) { +func (s *Store) getEndpointID(tx *sql.Tx, ep *endpoint.Endpoint) (int64, error) { var id int64 - err := tx.QueryRow("SELECT endpoint_id FROM endpoints WHERE endpoint_key = $1", endpoint.Key()).Scan(&id) + err := tx.QueryRow("SELECT endpoint_id FROM endpoints WHERE endpoint_key = $1", ep.Key()).Scan(&id) if err != nil { if errors.Is(err, sql.ErrNoRows) { return 0, common.ErrEndpointNotFound @@ -758,6 +882,12 @@ func (s *Store) getNumberOfResultsByEndpointID(tx *sql.Tx, endpointID int64) (in return numberOfResults, err } +func (s *Store) getNumberOfUptimeEntriesByEndpointID(tx *sql.Tx, endpointID int64) (int64, error) { + var numberOfUptimeEntries int64 + err := tx.QueryRow("SELECT COUNT(1) FROM endpoint_uptimes WHERE endpoint_id = $1", endpointID).Scan(&numberOfUptimeEntries) + return numberOfUptimeEntries, err +} + func (s *Store) getAgeOfOldestEndpointUptimeEntry(tx *sql.Tx, endpointID int64) (time.Duration, error) { rows, err := tx.Query( ` @@ -841,6 +971,92 @@ func (s *Store) deleteOldUptimeEntries(tx *sql.Tx, endpointID int64, maxAge time return err } +// mergeHourlyUptimeEntriesOlderThanMergeThresholdIntoDailyUptimeEntries merges all hourly uptime entries older than +// uptimeHourlyMergeThreshold from now into daily uptime entries by summing all hourly entries of the same day into a +// single entry. +// +// This effectively limits the number of uptime entries to (48+(n-2)) where 48 is for the first 48 entries with hourly +// entries (defined by uptimeHourlyBuffer) and n is the number of days for all entries older than 48 hours. +// Supporting 30d of entries would then result in far less than 24*30=720 entries. +func (s *Store) mergeHourlyUptimeEntriesOlderThanMergeThresholdIntoDailyUptimeEntries(tx *sql.Tx, endpointID int64) error { + // Calculate timestamp of the first full day of uptime entries that would not impact the uptime calculation for 24h badges + // The logic is that once at least 48 hours passed, we: + // - No longer need to worry about keeping hourly entries + // - Don't have to worry about new hourly entries being inserted, as the day has already passed + // which implies that no matter at what hour of the day we are, any timestamp + 48h floored to the current day + // will never impact the 24h uptime badge calculation + now := time.Now() + minThreshold := now.Add(-uptimeHourlyBuffer) + minThreshold = time.Date(minThreshold.Year(), minThreshold.Month(), minThreshold.Day(), 0, 0, 0, 0, minThreshold.Location()) + maxThreshold := now.Add(-uptimeRetention) + // Get all uptime entries older than uptimeHourlyMergeThreshold + rows, err := tx.Query( + ` + SELECT hour_unix_timestamp, total_executions, successful_executions, total_response_time + FROM endpoint_uptimes + WHERE endpoint_id = $1 + AND hour_unix_timestamp < $2 + AND hour_unix_timestamp >= $3 + `, + endpointID, + minThreshold.Unix(), + maxThreshold.Unix(), + ) + if err != nil { + return err + } + type Entry struct { + totalExecutions int + successfulExecutions int + totalResponseTime int + } + dailyEntries := make(map[int64]*Entry) + for rows.Next() { + var unixTimestamp int64 + entry := Entry{} + if err = rows.Scan(&unixTimestamp, &entry.totalExecutions, &entry.successfulExecutions, &entry.totalResponseTime); err != nil { + return err + } + timestamp := time.Unix(unixTimestamp, 0) + unixTimestampFlooredAtDay := time.Date(timestamp.Year(), timestamp.Month(), timestamp.Day(), 0, 0, 0, 0, timestamp.Location()).Unix() + if dailyEntry := dailyEntries[unixTimestampFlooredAtDay]; dailyEntry == nil { + dailyEntries[unixTimestampFlooredAtDay] = &entry + } else { + dailyEntries[unixTimestampFlooredAtDay].totalExecutions += entry.totalExecutions + dailyEntries[unixTimestampFlooredAtDay].successfulExecutions += entry.successfulExecutions + dailyEntries[unixTimestampFlooredAtDay].totalResponseTime += entry.totalResponseTime + } + } + // Delete older hourly uptime entries + _, err = tx.Exec("DELETE FROM endpoint_uptimes WHERE endpoint_id = $1 AND hour_unix_timestamp < $2", endpointID, minThreshold.Unix()) + if err != nil { + return err + } + // Insert new daily uptime entries + for unixTimestamp, entry := range dailyEntries { + _, err = tx.Exec( + ` + INSERT INTO endpoint_uptimes (endpoint_id, hour_unix_timestamp, total_executions, successful_executions, total_response_time) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT(endpoint_id, hour_unix_timestamp) DO UPDATE SET + total_executions = $3, + successful_executions = $4, + total_response_time = $5 + `, + endpointID, + unixTimestamp, + entry.totalExecutions, + entry.successfulExecutions, + entry.totalResponseTime, + ) + if err != nil { + return err + } + } + // TODO: Find a way to ignore entries that were already merged? + return nil +} + func generateCacheKey(endpointKey string, p *paging.EndpointStatusParams) string { return fmt.Sprintf("%s-%d-%d-%d-%d", endpointKey, p.EventsPage, p.EventsPageSize, p.ResultsPage, p.ResultsPageSize) } diff --git a/storage/store/sql/sql_test.go b/storage/store/sql/sql_test.go index cfd3c8bd..7261c8fa 100644 --- a/storage/store/sql/sql_test.go +++ b/storage/store/sql/sql_test.go @@ -1,34 +1,37 @@ package sql import ( + "errors" + "fmt" "testing" "time" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/storage/store/common" "github.com/TwiN/gatus/v5/storage/store/common/paging" ) var ( - firstCondition = core.Condition("[STATUS] == 200") - secondCondition = core.Condition("[RESPONSE_TIME] < 500") - thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h") + firstCondition = endpoint.Condition("[STATUS] == 200") + secondCondition = endpoint.Condition("[RESPONSE_TIME] < 500") + thirdCondition = endpoint.Condition("[CERTIFICATE_EXPIRATION] < 72h") now = time.Now() - testEndpoint = core.Endpoint{ + testEndpoint = endpoint.Endpoint{ Name: "name", Group: "group", URL: "https://example.org/what/ever", Method: "GET", Body: "body", Interval: 30 * time.Second, - Conditions: []core.Condition{firstCondition, secondCondition, thirdCondition}, + Conditions: []endpoint.Condition{firstCondition, secondCondition, thirdCondition}, Alerts: nil, NumberOfFailuresInARow: 0, NumberOfSuccessesInARow: 0, } - testSuccessfulResult = core.Result{ + testSuccessfulResult = endpoint.Result{ Hostname: "example.org", IP: "127.0.0.1", HTTPStatus: 200, @@ -38,7 +41,7 @@ var ( Timestamp: now, Duration: 150 * time.Millisecond, CertificateExpiration: 10 * time.Hour, - ConditionResults: []*core.ConditionResult{ + ConditionResults: []*endpoint.ConditionResult{ { Condition: "[STATUS] == 200", Success: true, @@ -53,7 +56,7 @@ var ( }, }, } - testUnsuccessfulResult = core.Result{ + testUnsuccessfulResult = endpoint.Result{ Hostname: "example.org", IP: "127.0.0.1", HTTPStatus: 200, @@ -63,7 +66,7 @@ var ( Timestamp: now, Duration: 750 * time.Millisecond, CertificateExpiration: 10 * time.Hour, - ConditionResults: []*core.ConditionResult{ + ConditionResults: []*endpoint.ConditionResult{ { Condition: "[STATUS] == 200", Success: true, @@ -81,13 +84,13 @@ var ( ) func TestNewStore(t *testing.T) { - if _, err := NewStore("", "TestNewStore.db", false); err != ErrDatabaseDriverNotSpecified { + if _, err := NewStore("", t.TempDir()+"/TestNewStore.db", false); !errors.Is(err, ErrDatabaseDriverNotSpecified) { t.Error("expected error due to blank driver parameter") } - if _, err := NewStore("sqlite", "", false); err != ErrPathNotSpecified { + if _, err := NewStore("sqlite", "", false); !errors.Is(err, ErrPathNotSpecified) { t.Error("expected error due to blank path parameter") } - if store, err := NewStore("sqlite", t.TempDir()+"/TestNewStore.db", false); err != nil { + if store, err := NewStore("sqlite", t.TempDir()+"/TestNewStore.db", true); err != nil { t.Error("shouldn't have returned any error, got", err.Error()) } else { _ = store.db.Close() @@ -100,7 +103,7 @@ func TestStore_InsertCleansUpOldUptimeEntriesProperly(t *testing.T) { now := time.Now().Truncate(time.Hour) now = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location()) - store.Insert(&testEndpoint, &core.Result{Timestamp: now.Add(-5 * time.Hour), Success: true}) + store.Insert(&testEndpoint, &endpoint.Result{Timestamp: now.Add(-5 * time.Hour), Success: true}) tx, _ := store.db.Begin() oldest, _ := store.getAgeOfOldestEndpointUptimeEntry(tx, 1) @@ -110,7 +113,7 @@ func TestStore_InsertCleansUpOldUptimeEntriesProperly(t *testing.T) { } // The oldest cache entry should remain at ~5 hours old, because this entry is more recent - store.Insert(&testEndpoint, &core.Result{Timestamp: now.Add(-3 * time.Hour), Success: true}) + store.Insert(&testEndpoint, &endpoint.Result{Timestamp: now.Add(-3 * time.Hour), Success: true}) tx, _ = store.db.Begin() oldest, _ = store.getAgeOfOldestEndpointUptimeEntry(tx, 1) @@ -120,7 +123,7 @@ func TestStore_InsertCleansUpOldUptimeEntriesProperly(t *testing.T) { } // The oldest cache entry should now become at ~8 hours old, because this entry is older - store.Insert(&testEndpoint, &core.Result{Timestamp: now.Add(-8 * time.Hour), Success: true}) + store.Insert(&testEndpoint, &endpoint.Result{Timestamp: now.Add(-8 * time.Hour), Success: true}) tx, _ = store.db.Begin() oldest, _ = store.getAgeOfOldestEndpointUptimeEntry(tx, 1) @@ -130,18 +133,18 @@ func TestStore_InsertCleansUpOldUptimeEntriesProperly(t *testing.T) { } // Since this is one hour before reaching the clean up threshold, the oldest entry should now be this one - store.Insert(&testEndpoint, &core.Result{Timestamp: now.Add(-(uptimeCleanUpThreshold - time.Hour)), Success: true}) + store.Insert(&testEndpoint, &endpoint.Result{Timestamp: now.Add(-(uptimeAgeCleanUpThreshold - time.Hour)), Success: true}) tx, _ = store.db.Begin() oldest, _ = store.getAgeOfOldestEndpointUptimeEntry(tx, 1) _ = tx.Commit() - if oldest.Truncate(time.Hour) != uptimeCleanUpThreshold-time.Hour { - t.Errorf("oldest endpoint uptime entry should've been ~%s hours old, was %s", uptimeCleanUpThreshold-time.Hour, oldest) + if oldest.Truncate(time.Hour) != uptimeAgeCleanUpThreshold-time.Hour { + t.Errorf("oldest endpoint uptime entry should've been ~%s hours old, was %s", uptimeAgeCleanUpThreshold-time.Hour, oldest) } - // Since this entry is after the uptimeCleanUpThreshold, both this entry as well as the previous + // Since this entry is after the uptimeAgeCleanUpThreshold, both this entry as well as the previous // one should be deleted since they both surpass uptimeRetention - store.Insert(&testEndpoint, &core.Result{Timestamp: now.Add(-(uptimeCleanUpThreshold + time.Hour)), Success: true}) + store.Insert(&testEndpoint, &endpoint.Result{Timestamp: now.Add(-(uptimeAgeCleanUpThreshold + time.Hour)), Success: true}) tx, _ = store.db.Begin() oldest, _ = store.getAgeOfOldestEndpointUptimeEntry(tx, 1) @@ -151,8 +154,128 @@ func TestStore_InsertCleansUpOldUptimeEntriesProperly(t *testing.T) { } } +func TestStore_HourlyUptimeEntriesAreMergedIntoDailyUptimeEntriesProperly(t *testing.T) { + store, _ := NewStore("sqlite", t.TempDir()+"/TestStore_HourlyUptimeEntriesAreMergedIntoDailyUptimeEntriesProperly.db", false) + defer store.Close() + now := time.Now().Truncate(time.Hour) + now = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location()) + + scenarios := []struct { + numberOfHours int + expectedMaxUptimeEntries int64 + }{ + {numberOfHours: 1, expectedMaxUptimeEntries: 1}, + {numberOfHours: 10, expectedMaxUptimeEntries: 10}, + {numberOfHours: 50, expectedMaxUptimeEntries: 50}, + {numberOfHours: 75, expectedMaxUptimeEntries: 75}, + {numberOfHours: 99, expectedMaxUptimeEntries: 99}, + {numberOfHours: 150, expectedMaxUptimeEntries: 100}, + {numberOfHours: 300, expectedMaxUptimeEntries: 100}, + {numberOfHours: 768, expectedMaxUptimeEntries: 100}, // 32 days (in hours), which means anything beyond that won't be persisted anyway + {numberOfHours: 1000, expectedMaxUptimeEntries: 100}, + } + // Note that is not technically an accurate real world representation, because uptime entries are always added in + // the present, while this test is inserting results from the past to simulate long term uptime entries. + // Since we want to test the behavior and not the test itself, this is a "best effort" approach. + for _, scenario := range scenarios { + t.Run(fmt.Sprintf("num-hours-%d-expected-max-entries-%d", scenario.numberOfHours, scenario.expectedMaxUptimeEntries), func(t *testing.T) { + for i := scenario.numberOfHours; i > 0; i-- { + //fmt.Printf("i: %d (%s)\n", i, now.Add(-time.Duration(i)*time.Hour)) + // Create an uptime entry + err := store.Insert(&testEndpoint, &endpoint.Result{Timestamp: now.Add(-time.Duration(i) * time.Hour), Success: true}) + if err != nil { + t.Log(err) + } + //// DEBUGGING: check number of uptime entries for endpoint + //tx, _ := store.db.Begin() + //numberOfUptimeEntriesForEndpoint, err := store.getNumberOfUptimeEntriesByEndpointID(tx, 1) + //if err != nil { + // t.Log(err) + //} + //_ = tx.Commit() + //t.Logf("i=%d; numberOfHours=%d; There are currently %d uptime entries for endpointID=%d", i, scenario.numberOfHours, numberOfUptimeEntriesForEndpoint, 1) + } + // check number of uptime entries for endpoint + tx, _ := store.db.Begin() + numberOfUptimeEntriesForEndpoint, err := store.getNumberOfUptimeEntriesByEndpointID(tx, 1) + if err != nil { + t.Log(err) + } + _ = tx.Commit() + //t.Logf("numberOfHours=%d; There are currently %d uptime entries for endpointID=%d", scenario.numberOfHours, numberOfUptimeEntriesForEndpoint, 1) + if scenario.expectedMaxUptimeEntries < numberOfUptimeEntriesForEndpoint { + t.Errorf("expected %d (uptime entries) to be smaller than %d", numberOfUptimeEntriesForEndpoint, scenario.expectedMaxUptimeEntries) + } + store.Clear() + }) + } +} + +func TestStore_getEndpointUptime(t *testing.T) { + store, _ := NewStore("sqlite", t.TempDir()+"/TestStore_InsertCleansUpEventsAndResultsProperly.db", false) + defer store.Clear() + defer store.Close() + // Add 768 hourly entries (32 days) + // Daily entries should be merged from hourly entries automatically + for i := 768; i > 0; i-- { + err := store.Insert(&testEndpoint, &endpoint.Result{Timestamp: time.Now().Add(-time.Duration(i) * time.Hour), Duration: time.Second, Success: true}) + if err != nil { + t.Log(err) + } + } + // Check the number of uptime entries + tx, _ := store.db.Begin() + numberOfUptimeEntriesForEndpoint, err := store.getNumberOfUptimeEntriesByEndpointID(tx, 1) + if err != nil { + t.Log(err) + } + if numberOfUptimeEntriesForEndpoint < 20 || numberOfUptimeEntriesForEndpoint > 200 { + t.Errorf("expected number of uptime entries to be between 20 and 200, got %d", numberOfUptimeEntriesForEndpoint) + } + // Retrieve uptime for the past 30d + uptime, avgResponseTime, err := store.getEndpointUptime(tx, 1, time.Now().Add(-(30 * 24 * time.Hour)), time.Now()) + if err != nil { + t.Log(err) + } + _ = tx.Commit() + if avgResponseTime != time.Second { + t.Errorf("expected average response time to be %s, got %s", time.Second, avgResponseTime) + } + if uptime != 1 { + t.Errorf("expected uptime to be 1, got %f", uptime) + } + // Add a new unsuccessful result, which should impact the uptime + err = store.Insert(&testEndpoint, &endpoint.Result{Timestamp: time.Now(), Duration: time.Second, Success: false}) + if err != nil { + t.Log(err) + } + // Retrieve uptime for the past 30d + tx, _ = store.db.Begin() + uptime, _, err = store.getEndpointUptime(tx, 1, time.Now().Add(-(30 * 24 * time.Hour)), time.Now()) + if err != nil { + t.Log(err) + } + _ = tx.Commit() + if uptime == 1 { + t.Errorf("expected uptime to be less than 1, got %f", uptime) + } + // Retrieve uptime for the past 30d, but excluding the last 24h + // This is not a real use case as there is no way for users to exclude the last 24h, but this is a great way + // to ensure that hourly merging works as intended + tx, _ = store.db.Begin() + uptimeExcludingLast24h, _, err := store.getEndpointUptime(tx, 1, time.Now().Add(-(30 * 24 * time.Hour)), time.Now().Add(-24*time.Hour)) + if err != nil { + t.Log(err) + } + _ = tx.Commit() + if uptimeExcludingLast24h == uptime { + t.Error("expected uptimeExcludingLast24h to to be different from uptime, got") + } +} + func TestStore_InsertCleansUpEventsAndResultsProperly(t *testing.T) { store, _ := NewStore("sqlite", t.TempDir()+"/TestStore_InsertCleansUpEventsAndResultsProperly.db", false) + defer store.Clear() defer store.Close() for i := 0; i < resultsCleanUpThreshold+eventsCleanUpThreshold; i++ { store.Insert(&testEndpoint, &testSuccessfulResult) @@ -165,7 +288,40 @@ func TestStore_InsertCleansUpEventsAndResultsProperly(t *testing.T) { t.Errorf("number of events shouldn't have exceeded %d, reached %d", eventsCleanUpThreshold, len(ss.Events)) } } +} + +func TestStore_InsertWithCaching(t *testing.T) { + store, _ := NewStore("sqlite", t.TempDir()+"/TestStore_InsertWithCaching.db", true) + defer store.Close() + // Add 2 results + store.Insert(&testEndpoint, &testSuccessfulResult) + store.Insert(&testEndpoint, &testSuccessfulResult) + // Verify that they exist + endpointStatuses, _ := store.GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(1, 20)) + if numberOfEndpointStatuses := len(endpointStatuses); numberOfEndpointStatuses != 1 { + t.Fatalf("expected 1 EndpointStatus, got %d", numberOfEndpointStatuses) + } + if len(endpointStatuses[0].Results) != 2 { + t.Fatalf("expected 2 results, got %d", len(endpointStatuses[0].Results)) + } + // Add 2 more results + store.Insert(&testEndpoint, &testUnsuccessfulResult) + store.Insert(&testEndpoint, &testUnsuccessfulResult) + // Verify that they exist + endpointStatuses, _ = store.GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(1, 20)) + if numberOfEndpointStatuses := len(endpointStatuses); numberOfEndpointStatuses != 1 { + t.Fatalf("expected 1 EndpointStatus, got %d", numberOfEndpointStatuses) + } + if len(endpointStatuses[0].Results) != 4 { + t.Fatalf("expected 4 results, got %d", len(endpointStatuses[0].Results)) + } + // Clear the store, which should also clear the cache store.Clear() + // Verify that they no longer exist + endpointStatuses, _ = store.GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(1, 20)) + if numberOfEndpointStatuses := len(endpointStatuses); numberOfEndpointStatuses != 0 { + t.Fatalf("expected 0 EndpointStatus, got %d", numberOfEndpointStatuses) + } } func TestStore_Persistence(t *testing.T) { @@ -182,6 +338,9 @@ func TestStore_Persistence(t *testing.T) { if uptime, _ := store.GetUptimeByKey(testEndpoint.Key(), time.Now().Add(-time.Hour*24*7), time.Now()); uptime != 0.5 { t.Errorf("the uptime over the past 7d should've been 0.5, got %f", uptime) } + if uptime, _ := store.GetUptimeByKey(testEndpoint.Key(), time.Now().Add(-time.Hour*24*30), time.Now()); uptime != 0.5 { + t.Errorf("the uptime over the past 30d should've been 0.5, got %f", uptime) + } ssFromOldStore, _ := store.GetEndpointStatus(testEndpoint.Group, testEndpoint.Name, paging.NewEndpointStatusParams().WithResults(1, common.MaximumNumberOfResults).WithEvents(1, common.MaximumNumberOfEvents)) if ssFromOldStore == nil || ssFromOldStore.Group != "group" || ssFromOldStore.Name != "name" || len(ssFromOldStore.Events) != 3 || len(ssFromOldStore.Results) != 2 { store.Close() @@ -313,7 +472,7 @@ func TestStore_InvalidTransaction(t *testing.T) { if _, err := store.insertEndpoint(tx, &testEndpoint); err == nil { t.Error("should've returned an error, because the transaction was already committed") } - if err := store.insertEndpointEvent(tx, 1, core.NewEventFromResult(&testSuccessfulResult)); err == nil { + if err := store.insertEndpointEvent(tx, 1, endpoint.NewEventFromResult(&testSuccessfulResult)); err == nil { t.Error("should've returned an error, because the transaction was already committed") } if err := store.insertEndpointResult(tx, 1, &testSuccessfulResult); err == nil { @@ -368,10 +527,10 @@ func TestStore_NoRows(t *testing.T) { defer store.Close() tx, _ := store.db.Begin() defer tx.Rollback() - if _, err := store.getLastEndpointResultSuccessValue(tx, 1); err != errNoRowsReturned { + if _, err := store.getLastEndpointResultSuccessValue(tx, 1); !errors.Is(err, errNoRowsReturned) { t.Errorf("should've %v, got %v", errNoRowsReturned, err) } - if _, err := store.getAgeOfOldestEndpointUptimeEntry(tx, 1); err != errNoRowsReturned { + if _, err := store.getAgeOfOldestEndpointUptimeEntry(tx, 1); !errors.Is(err, errNoRowsReturned) { t.Errorf("should've %v, got %v", errNoRowsReturned, err) } } @@ -564,3 +723,131 @@ func TestCacheKey(t *testing.T) { }) } } + +func TestTriggeredEndpointAlertsPersistence(t *testing.T) { + store, _ := NewStore("sqlite", t.TempDir()+"/TestTriggeredEndpointAlertsPersistence.db", false) + defer store.Close() + yes, desc := false, "description" + ep := testEndpoint + ep.NumberOfSuccessesInARow = 0 + alrt := &alert.Alert{ + Type: alert.TypePagerDuty, + Enabled: &yes, + FailureThreshold: 4, + SuccessThreshold: 2, + Description: &desc, + SendOnResolved: &yes, + Triggered: true, + ResolveKey: "1234567", + } + // Alert just triggered, so NumberOfSuccessesInARow is 0 + if err := store.UpsertTriggeredEndpointAlert(&ep, alrt); err != nil { + t.Fatal("expected no error, got", err.Error()) + } + exists, resolveKey, numberOfSuccessesInARow, err := store.GetTriggeredEndpointAlert(&ep, alrt) + if err != nil { + t.Fatal("expected no error, got", err.Error()) + } + if !exists { + t.Error("expected triggered alert to exist") + } + if resolveKey != alrt.ResolveKey { + t.Errorf("expected resolveKey %s, got %s", alrt.ResolveKey, resolveKey) + } + if numberOfSuccessesInARow != ep.NumberOfSuccessesInARow { + t.Errorf("expected persisted NumberOfSuccessesInARow to be %d, got %d", ep.NumberOfSuccessesInARow, numberOfSuccessesInARow) + } + // Endpoint just had a successful evaluation, so NumberOfSuccessesInARow is now 1 + ep.NumberOfSuccessesInARow++ + if err := store.UpsertTriggeredEndpointAlert(&ep, alrt); err != nil { + t.Fatal("expected no error, got", err.Error()) + } + exists, resolveKey, numberOfSuccessesInARow, err = store.GetTriggeredEndpointAlert(&ep, alrt) + if err != nil { + t.Error("expected no error, got", err.Error()) + } + if !exists { + t.Error("expected triggered alert to exist") + } + if resolveKey != alrt.ResolveKey { + t.Errorf("expected resolveKey %s, got %s", alrt.ResolveKey, resolveKey) + } + if numberOfSuccessesInARow != ep.NumberOfSuccessesInARow { + t.Errorf("expected persisted NumberOfSuccessesInARow to be %d, got %d", ep.NumberOfSuccessesInARow, numberOfSuccessesInARow) + } + // Simulate the endpoint having another successful evaluation, which means the alert is now resolved, + // and we should delete the triggered alert from the store + ep.NumberOfSuccessesInARow++ + if err := store.DeleteTriggeredEndpointAlert(&ep, alrt); err != nil { + t.Fatal("expected no error, got", err.Error()) + } + exists, _, _, err = store.GetTriggeredEndpointAlert(&ep, alrt) + if err != nil { + t.Error("expected no error, got", err.Error()) + } + if exists { + t.Error("expected triggered alert to no longer exist as it has been deleted") + } +} + +func TestStore_DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(t *testing.T) { + store, _ := NewStore("sqlite", t.TempDir()+"/TestStore_DeleteAllTriggeredAlertsNotInChecksumsByEndpoint.db", false) + defer store.Close() + yes, desc := false, "description" + ep1 := testEndpoint + ep1.Name = "ep1" + ep2 := testEndpoint + ep2.Name = "ep2" + alert1 := alert.Alert{ + Type: alert.TypePagerDuty, + Enabled: &yes, + FailureThreshold: 4, + SuccessThreshold: 2, + Description: &desc, + SendOnResolved: &yes, + Triggered: true, + ResolveKey: "1234567", + } + alert2 := alert1 + alert2.Type, alert2.ResolveKey = alert.TypeSlack, "" + alert3 := alert2 + if err := store.UpsertTriggeredEndpointAlert(&ep1, &alert1); err != nil { + t.Fatal("expected no error, got", err.Error()) + } + if err := store.UpsertTriggeredEndpointAlert(&ep1, &alert2); err != nil { + t.Fatal("expected no error, got", err.Error()) + } + if err := store.UpsertTriggeredEndpointAlert(&ep2, &alert3); err != nil { + t.Fatal("expected no error, got", err.Error()) + } + if exists, _, _, _ := store.GetTriggeredEndpointAlert(&ep1, &alert1); !exists { + t.Error("expected alert1 to have been deleted") + } + if exists, _, _, _ := store.GetTriggeredEndpointAlert(&ep1, &alert2); !exists { + t.Error("expected alert2 to exist for ep1") + } + if exists, _, _, _ := store.GetTriggeredEndpointAlert(&ep2, &alert3); !exists { + t.Error("expected alert3 to exist for ep2") + } + // Now we simulate the alert configuration being updated, and the alert being resolved + if deleted := store.DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(&ep1, []string{alert2.Checksum()}); deleted != 1 { + t.Errorf("expected 1 triggered alert to be deleted, got %d", deleted) + } + if exists, _, _, _ := store.GetTriggeredEndpointAlert(&ep1, &alert1); exists { + t.Error("expected alert1 to have been deleted") + } + if exists, _, _, _ := store.GetTriggeredEndpointAlert(&ep1, &alert2); !exists { + t.Error("expected alert2 to exist for ep1") + } + if exists, _, _, _ := store.GetTriggeredEndpointAlert(&ep2, &alert3); !exists { + t.Error("expected alert3 to exist for ep2") + } + // Now let's just assume all alerts for ep1 were removed + if deleted := store.DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(&ep1, []string{}); deleted != 1 { + t.Errorf("expected 1 triggered alert to be deleted, got %d", deleted) + } + // Make sure the alert for ep2 still exists + if exists, _, _, _ := store.GetTriggeredEndpointAlert(&ep2, &alert3); !exists { + t.Error("expected alert3 to exist for ep2") + } +} diff --git a/storage/store/store.go b/storage/store/store.go index f4d1d986..5cb20e36 100644 --- a/storage/store/store.go +++ b/storage/store/store.go @@ -5,7 +5,8 @@ import ( "log" "time" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/storage" "github.com/TwiN/gatus/v5/storage/store/common/paging" "github.com/TwiN/gatus/v5/storage/store/memory" @@ -14,15 +15,15 @@ import ( // Store is the interface that each store should implement type Store interface { - // GetAllEndpointStatuses returns the JSON encoding of all monitored core.EndpointStatus - // with a subset of core.Result defined by the page and pageSize parameters - GetAllEndpointStatuses(params *paging.EndpointStatusParams) ([]*core.EndpointStatus, error) + // GetAllEndpointStatuses returns the JSON encoding of all monitored endpoint.Status + // with a subset of endpoint.Result defined by the page and pageSize parameters + GetAllEndpointStatuses(params *paging.EndpointStatusParams) ([]*endpoint.Status, error) // GetEndpointStatus returns the endpoint status for a given endpoint name in the given group - GetEndpointStatus(groupName, endpointName string, params *paging.EndpointStatusParams) (*core.EndpointStatus, error) + GetEndpointStatus(groupName, endpointName string, params *paging.EndpointStatusParams) (*endpoint.Status, error) // GetEndpointStatusByKey returns the endpoint status for a given key - GetEndpointStatusByKey(key string, params *paging.EndpointStatusParams) (*core.EndpointStatus, error) + GetEndpointStatusByKey(key string, params *paging.EndpointStatusParams) (*endpoint.Status, error) // GetUptimeByKey returns the uptime percentage during a time range GetUptimeByKey(key string, from, to time.Time) (float64, error) @@ -34,13 +35,28 @@ type Store interface { GetHourlyAverageResponseTimeByKey(key string, from, to time.Time) (map[int64]int, error) // Insert adds the observed result for the specified endpoint into the store - Insert(endpoint *core.Endpoint, result *core.Result) error + Insert(ep *endpoint.Endpoint, result *endpoint.Result) error - // DeleteAllEndpointStatusesNotInKeys removes all EndpointStatus that are not within the keys provided + // DeleteAllEndpointStatusesNotInKeys removes all Status that are not within the keys provided // // Used to delete endpoints that have been persisted but are no longer part of the configured endpoints DeleteAllEndpointStatusesNotInKeys(keys []string) int + // GetTriggeredEndpointAlert returns whether the triggered alert for the specified endpoint as well as the necessary information to resolve it + GetTriggeredEndpointAlert(ep *endpoint.Endpoint, alert *alert.Alert) (exists bool, resolveKey string, numberOfSuccessesInARow int, err error) + + // UpsertTriggeredEndpointAlert inserts/updates a triggered alert for an endpoint + // Used for persistence of triggered alerts across application restarts + UpsertTriggeredEndpointAlert(ep *endpoint.Endpoint, triggeredAlert *alert.Alert) error + + // DeleteTriggeredEndpointAlert deletes a triggered alert for an endpoint + DeleteTriggeredEndpointAlert(ep *endpoint.Endpoint, triggeredAlert *alert.Alert) error + + // DeleteAllTriggeredAlertsNotInChecksumsByEndpoint removes all triggered alerts owned by an endpoint whose alert + // configurations are not provided in the checksums list. + // This prevents triggered alerts that have been removed or modified from lingering in the database. + DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(ep *endpoint.Endpoint, checksums []string) int + // Clear deletes everything from the store Clear() diff --git a/storage/store/store_bench_test.go b/storage/store/store_bench_test.go index 67a8e06c..0ed4c2db 100644 --- a/storage/store/store_bench_test.go +++ b/storage/store/store_bench_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/storage/store/common/paging" "github.com/TwiN/gatus/v5/storage/store/memory" "github.com/TwiN/gatus/v5/storage/store/sql" @@ -53,11 +53,11 @@ func BenchmarkStore_GetAllEndpointStatuses(b *testing.B) { for _, numberOfEndpointsToCreate := range numberOfEndpoints { // Create endpoints and insert results for i := 0; i < numberOfEndpointsToCreate; i++ { - endpoint := testEndpoint - endpoint.Name = "endpoint" + strconv.Itoa(i) + ep := testEndpoint + ep.Name = "endpoint" + strconv.Itoa(i) // Insert 20 results for each endpoint for j := 0; j < 20; j++ { - scenario.Store.Insert(&endpoint, &testSuccessfulResult) + scenario.Store.Insert(&ep, &testSuccessfulResult) } } // Run the scenarios @@ -123,7 +123,7 @@ func BenchmarkStore_Insert(b *testing.B) { b.RunParallel(func(pb *testing.PB) { n := 0 for pb.Next() { - var result core.Result + var result endpoint.Result if n%10 == 0 { result = testUnsuccessfulResult } else { @@ -136,7 +136,7 @@ func BenchmarkStore_Insert(b *testing.B) { }) } else { for n := 0; n < b.N; n++ { - var result core.Result + var result endpoint.Result if n%10 == 0 { result = testUnsuccessfulResult } else { diff --git a/storage/store/store_test.go b/storage/store/store_test.go index 8aec8182..6f18f446 100644 --- a/storage/store/store_test.go +++ b/storage/store/store_test.go @@ -1,11 +1,12 @@ package store import ( + "errors" "path/filepath" "testing" "time" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/storage" "github.com/TwiN/gatus/v5/storage/store/common" "github.com/TwiN/gatus/v5/storage/store/common/paging" @@ -14,25 +15,25 @@ import ( ) var ( - firstCondition = core.Condition("[STATUS] == 200") - secondCondition = core.Condition("[RESPONSE_TIME] < 500") - thirdCondition = core.Condition("[CERTIFICATE_EXPIRATION] < 72h") + firstCondition = endpoint.Condition("[STATUS] == 200") + secondCondition = endpoint.Condition("[RESPONSE_TIME] < 500") + thirdCondition = endpoint.Condition("[CERTIFICATE_EXPIRATION] < 72h") now = time.Now().Truncate(time.Hour) - testEndpoint = core.Endpoint{ + testEndpoint = endpoint.Endpoint{ Name: "name", Group: "group", URL: "https://example.org/what/ever", Method: "GET", Body: "body", Interval: 30 * time.Second, - Conditions: []core.Condition{firstCondition, secondCondition, thirdCondition}, + Conditions: []endpoint.Condition{firstCondition, secondCondition, thirdCondition}, Alerts: nil, NumberOfFailuresInARow: 0, NumberOfSuccessesInARow: 0, } - testSuccessfulResult = core.Result{ + testSuccessfulResult = endpoint.Result{ Timestamp: now, Success: true, Hostname: "example.org", @@ -42,7 +43,7 @@ var ( Connected: true, Duration: 150 * time.Millisecond, CertificateExpiration: 10 * time.Hour, - ConditionResults: []*core.ConditionResult{ + ConditionResults: []*endpoint.ConditionResult{ { Condition: "[STATUS] == 200", Success: true, @@ -57,7 +58,7 @@ var ( }, }, } - testUnsuccessfulResult = core.Result{ + testUnsuccessfulResult = endpoint.Result{ Timestamp: now, Success: false, Hostname: "example.org", @@ -67,7 +68,7 @@ var ( Connected: true, Duration: 750 * time.Millisecond, CertificateExpiration: 10 * time.Hour, - ConditionResults: []*core.ConditionResult{ + ConditionResults: []*endpoint.ConditionResult{ { Condition: "[STATUS] == 200", Success: true, @@ -176,21 +177,21 @@ func TestStore_GetEndpointStatusForMissingStatusReturnsNil(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) { scenario.Store.Insert(&testEndpoint, &testSuccessfulResult) endpointStatus, err := scenario.Store.GetEndpointStatus("nonexistantgroup", "nonexistantname", paging.NewEndpointStatusParams().WithEvents(1, common.MaximumNumberOfEvents).WithResults(1, common.MaximumNumberOfResults)) - if err != common.ErrEndpointNotFound { + if !errors.Is(err, common.ErrEndpointNotFound) { t.Error("should've returned ErrEndpointNotFound, got", err) } if endpointStatus != nil { t.Errorf("Returned endpoint status for group '%s' and name '%s' not nil after inserting the endpoint into the store", testEndpoint.Group, testEndpoint.Name) } endpointStatus, err = scenario.Store.GetEndpointStatus(testEndpoint.Group, "nonexistantname", paging.NewEndpointStatusParams().WithEvents(1, common.MaximumNumberOfEvents).WithResults(1, common.MaximumNumberOfResults)) - if err != common.ErrEndpointNotFound { + if !errors.Is(err, common.ErrEndpointNotFound) { t.Error("should've returned ErrEndpointNotFound, got", err) } if endpointStatus != nil { t.Errorf("Returned endpoint status for group '%s' and name '%s' not nil after inserting the endpoint into the store", testEndpoint.Group, "nonexistantname") } endpointStatus, err = scenario.Store.GetEndpointStatus("nonexistantgroup", testEndpoint.Name, paging.NewEndpointStatusParams().WithEvents(1, common.MaximumNumberOfEvents).WithResults(1, common.MaximumNumberOfResults)) - if err != common.ErrEndpointNotFound { + if !errors.Is(err, common.ErrEndpointNotFound) { t.Error("should've returned ErrEndpointNotFound, got", err) } if endpointStatus != nil { @@ -482,7 +483,7 @@ func TestStore_Insert(t *testing.T) { if len(ss.Results) != 2 { t.Fatalf("Endpoint '%s' should've had 2 results, got %d", ss.Name, len(ss.Results)) } - for i, expectedResult := range []core.Result{firstResult, secondResult} { + for i, expectedResult := range []endpoint.Result{firstResult, secondResult} { if expectedResult.HTTPStatus != ss.Results[i].HTTPStatus { t.Errorf("Result at index %d should've had a HTTPStatus of %d, got %d", i, ss.Results[i].HTTPStatus, expectedResult.HTTPStatus) } @@ -539,13 +540,13 @@ func TestStore_Insert(t *testing.T) { func TestStore_DeleteAllEndpointStatusesNotInKeys(t *testing.T) { scenarios := initStoresAndBaseScenarios(t, "TestStore_DeleteAllEndpointStatusesNotInKeys") defer cleanUp(scenarios) - firstEndpoint := core.Endpoint{Name: "endpoint-1", Group: "group"} - secondEndpoint := core.Endpoint{Name: "endpoint-2", Group: "group"} - result := &testSuccessfulResult + firstEndpoint := endpoint.Endpoint{Name: "endpoint-1", Group: "group"} + secondEndpoint := endpoint.Endpoint{Name: "endpoint-2", Group: "group"} + r := &testSuccessfulResult for _, scenario := range scenarios { t.Run(scenario.Name, func(t *testing.T) { - scenario.Store.Insert(&firstEndpoint, result) - scenario.Store.Insert(&secondEndpoint, result) + scenario.Store.Insert(&firstEndpoint, r) + scenario.Store.Insert(&secondEndpoint, r) if ss, _ := scenario.Store.GetEndpointStatusByKey(firstEndpoint.Key(), paging.NewEndpointStatusParams()); ss == nil { t.Fatal("firstEndpoint should exist, got", ss) } @@ -631,7 +632,7 @@ func TestInitialize(t *testing.T) { store.Close() // Try to initialize it again err = Initialize(scenario.Cfg) - if err != scenario.ExpectedErr { + if !errors.Is(err, scenario.ExpectedErr) { t.Errorf("expected %v, got %v", scenario.ExpectedErr, err) return } diff --git a/watchdog/alerting.go b/watchdog/alerting.go index 29898016..6fd7a246 100644 --- a/watchdog/alerting.go +++ b/watchdog/alerting.go @@ -6,79 +6,93 @@ import ( "os" "github.com/TwiN/gatus/v5/alerting" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" + "github.com/TwiN/gatus/v5/storage/store" ) // HandleAlerting takes care of alerts to resolve and alerts to trigger based on result success or failure -func HandleAlerting(endpoint *core.Endpoint, result *core.Result, alertingConfig *alerting.Config, debug bool) { +func HandleAlerting(ep *endpoint.Endpoint, result *endpoint.Result, alertingConfig *alerting.Config, debug bool) { if alertingConfig == nil { return } if result.Success { - handleAlertsToResolve(endpoint, result, alertingConfig, debug) + handleAlertsToResolve(ep, result, alertingConfig, debug) } else { - handleAlertsToTrigger(endpoint, result, alertingConfig, debug) + handleAlertsToTrigger(ep, result, alertingConfig, debug) } } -func handleAlertsToTrigger(endpoint *core.Endpoint, result *core.Result, alertingConfig *alerting.Config, debug bool) { - endpoint.NumberOfSuccessesInARow = 0 - endpoint.NumberOfFailuresInARow++ - for _, endpointAlert := range endpoint.Alerts { +func handleAlertsToTrigger(ep *endpoint.Endpoint, result *endpoint.Result, alertingConfig *alerting.Config, debug bool) { + ep.NumberOfSuccessesInARow = 0 + ep.NumberOfFailuresInARow++ + for _, endpointAlert := range ep.Alerts { // If the alert hasn't been triggered, move to the next one - if !endpointAlert.IsEnabled() || endpointAlert.FailureThreshold > endpoint.NumberOfFailuresInARow { + if !endpointAlert.IsEnabled() || endpointAlert.FailureThreshold > ep.NumberOfFailuresInARow { continue } if endpointAlert.Triggered { if debug { - log.Printf("[watchdog.handleAlertsToTrigger] Alert for endpoint=%s with description='%s' has already been TRIGGERED, skipping", endpoint.Name, endpointAlert.GetDescription()) + log.Printf("[watchdog.handleAlertsToTrigger] Alert for endpoint=%s with description='%s' has already been TRIGGERED, skipping", ep.Name, endpointAlert.GetDescription()) } continue } alertProvider := alertingConfig.GetAlertingProviderByAlertType(endpointAlert.Type) if alertProvider != nil { - log.Printf("[watchdog.handleAlertsToTrigger] Sending %s alert because alert for endpoint=%s with description='%s' has been TRIGGERED", endpointAlert.Type, endpoint.Name, endpointAlert.GetDescription()) + log.Printf("[watchdog.handleAlertsToTrigger] Sending %s alert because alert for endpoint=%s with description='%s' has been TRIGGERED", endpointAlert.Type, ep.Name, endpointAlert.GetDescription()) var err error if os.Getenv("MOCK_ALERT_PROVIDER") == "true" { if os.Getenv("MOCK_ALERT_PROVIDER_ERROR") == "true" { err = errors.New("error") } } else { - err = alertProvider.Send(endpoint, endpointAlert, result, false) + err = alertProvider.Send(ep, endpointAlert, result, false) } if err != nil { - log.Printf("[watchdog.handleAlertsToTrigger] Failed to send an alert for endpoint=%s: %s", endpoint.Name, err.Error()) + log.Printf("[watchdog.handleAlertsToTrigger] Failed to send an alert for endpoint=%s: %s", ep.Name, err.Error()) } else { endpointAlert.Triggered = true + if err := store.Get().UpsertTriggeredEndpointAlert(ep, endpointAlert); err != nil { + log.Printf("[watchdog.handleAlertsToTrigger] Failed to persist triggered endpoint alert for endpoint with key=%s: %s", ep.Key(), err.Error()) + } } } else { - log.Printf("[watchdog.handleAlertsToResolve] Not sending alert of type=%s despite being TRIGGERED, because the provider wasn't configured properly", endpointAlert.Type) + log.Printf("[watchdog.handleAlertsToTrigger] Not sending alert of type=%s despite being TRIGGERED, because the provider wasn't configured properly", endpointAlert.Type) } } } -func handleAlertsToResolve(endpoint *core.Endpoint, result *core.Result, alertingConfig *alerting.Config, debug bool) { - endpoint.NumberOfSuccessesInARow++ - for _, endpointAlert := range endpoint.Alerts { - if !endpointAlert.IsEnabled() || !endpointAlert.Triggered || endpointAlert.SuccessThreshold > endpoint.NumberOfSuccessesInARow { +func handleAlertsToResolve(ep *endpoint.Endpoint, result *endpoint.Result, alertingConfig *alerting.Config, debug bool) { + ep.NumberOfSuccessesInARow++ + for _, endpointAlert := range ep.Alerts { + isStillBelowSuccessThreshold := endpointAlert.SuccessThreshold > ep.NumberOfSuccessesInARow + if isStillBelowSuccessThreshold && endpointAlert.IsEnabled() && endpointAlert.Triggered { + // Persist NumberOfSuccessesInARow + if err := store.Get().UpsertTriggeredEndpointAlert(ep, endpointAlert); err != nil { + log.Printf("[watchdog.handleAlertsToResolve] Failed to update triggered endpoint alert for endpoint with key=%s: %s", ep.Key(), err.Error()) + } + } + if !endpointAlert.IsEnabled() || !endpointAlert.Triggered || isStillBelowSuccessThreshold { continue } // Even if the alert provider returns an error, we still set the alert's Triggered variable to false. // Further explanation can be found on Alert's Triggered field. endpointAlert.Triggered = false + if err := store.Get().DeleteTriggeredEndpointAlert(ep, endpointAlert); err != nil { + log.Printf("[watchdog.handleAlertsToResolve] Failed to delete persisted triggered endpoint alert for endpoint with key=%s: %s", ep.Key(), err.Error()) + } if !endpointAlert.IsSendingOnResolved() { continue } alertProvider := alertingConfig.GetAlertingProviderByAlertType(endpointAlert.Type) if alertProvider != nil { - log.Printf("[watchdog.handleAlertsToResolve] Sending %s alert because alert for endpoint=%s with description='%s' has been RESOLVED", endpointAlert.Type, endpoint.Name, endpointAlert.GetDescription()) - err := alertProvider.Send(endpoint, endpointAlert, result, true) + log.Printf("[watchdog.handleAlertsToResolve] Sending %s alert because alert for endpoint with key=%s with description='%s' has been RESOLVED", endpointAlert.Type, ep.Key(), endpointAlert.GetDescription()) + err := alertProvider.Send(ep, endpointAlert, result, true) if err != nil { - log.Printf("[watchdog.handleAlertsToResolve] Failed to send an alert for endpoint=%s: %s", endpoint.Name, err.Error()) + log.Printf("[watchdog.handleAlertsToResolve] Failed to send an alert for endpoint with key=%s: %s", ep.Key(), err.Error()) } } else { log.Printf("[watchdog.handleAlertsToResolve] Not sending alert of type=%s despite being RESOLVED, because the provider wasn't configured properly", endpointAlert.Type) } } - endpoint.NumberOfFailuresInARow = 0 + ep.NumberOfFailuresInARow = 0 } diff --git a/watchdog/alerting_test.go b/watchdog/alerting_test.go index 358412d8..914355e4 100644 --- a/watchdog/alerting_test.go +++ b/watchdog/alerting_test.go @@ -20,7 +20,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/provider/telegram" "github.com/TwiN/gatus/v5/alerting/provider/twilio" "github.com/TwiN/gatus/v5/config" - "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/config/endpoint" ) func TestHandleAlerting(t *testing.T) { @@ -37,7 +37,7 @@ func TestHandleAlerting(t *testing.T) { }, } enabled := true - endpoint := &core.Endpoint{ + ep := &endpoint.Endpoint{ URL: "https://example.com", Alerts: []*alert.Alert{ { @@ -51,23 +51,23 @@ func TestHandleAlerting(t *testing.T) { }, } - verify(t, endpoint, 0, 0, false, "The alert shouldn't start triggered") - HandleAlerting(endpoint, &core.Result{Success: false}, cfg.Alerting, cfg.Debug) - verify(t, endpoint, 1, 0, false, "The alert shouldn't have triggered") - HandleAlerting(endpoint, &core.Result{Success: false}, cfg.Alerting, cfg.Debug) - verify(t, endpoint, 2, 0, true, "The alert should've triggered") - HandleAlerting(endpoint, &core.Result{Success: false}, cfg.Alerting, cfg.Debug) - verify(t, endpoint, 3, 0, true, "The alert should still be triggered") - HandleAlerting(endpoint, &core.Result{Success: false}, cfg.Alerting, cfg.Debug) - verify(t, endpoint, 4, 0, true, "The alert should still be triggered") - HandleAlerting(endpoint, &core.Result{Success: true}, cfg.Alerting, cfg.Debug) - verify(t, endpoint, 0, 1, true, "The alert should still be triggered (because endpoint.Alerts[0].SuccessThreshold is 3)") - HandleAlerting(endpoint, &core.Result{Success: true}, cfg.Alerting, cfg.Debug) - verify(t, endpoint, 0, 2, true, "The alert should still be triggered (because endpoint.Alerts[0].SuccessThreshold is 3)") - HandleAlerting(endpoint, &core.Result{Success: true}, cfg.Alerting, cfg.Debug) - verify(t, endpoint, 0, 3, false, "The alert should've been resolved") - HandleAlerting(endpoint, &core.Result{Success: true}, cfg.Alerting, cfg.Debug) - verify(t, endpoint, 0, 4, false, "The alert should no longer be triggered") + verify(t, ep, 0, 0, false, "The alert shouldn't start triggered") + HandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting, cfg.Debug) + verify(t, ep, 1, 0, false, "The alert shouldn't have triggered") + HandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting, cfg.Debug) + verify(t, ep, 2, 0, true, "The alert should've triggered") + HandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting, cfg.Debug) + verify(t, ep, 3, 0, true, "The alert should still be triggered") + HandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting, cfg.Debug) + verify(t, ep, 4, 0, true, "The alert should still be triggered") + HandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting, cfg.Debug) + verify(t, ep, 0, 1, true, "The alert should still be triggered (because endpoint.Alerts[0].SuccessThreshold is 3)") + HandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting, cfg.Debug) + verify(t, ep, 0, 2, true, "The alert should still be triggered (because endpoint.Alerts[0].SuccessThreshold is 3)") + HandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting, cfg.Debug) + verify(t, ep, 0, 3, false, "The alert should've been resolved") + HandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting, cfg.Debug) + verify(t, ep, 0, 4, false, "The alert should no longer be triggered") } func TestHandleAlertingWhenAlertingConfigIsNil(t *testing.T) { @@ -81,7 +81,7 @@ func TestHandleAlertingWithBadAlertProvider(t *testing.T) { defer os.Clearenv() enabled := true - endpoint := &core.Endpoint{ + ep := &endpoint.Endpoint{ URL: "http://example.com", Alerts: []*alert.Alert{ { @@ -95,11 +95,11 @@ func TestHandleAlertingWithBadAlertProvider(t *testing.T) { }, } - verify(t, endpoint, 0, 0, false, "The alert shouldn't start triggered") - HandleAlerting(endpoint, &core.Result{Success: false}, &alerting.Config{}, false) - verify(t, endpoint, 1, 0, false, "The alert shouldn't have triggered") - HandleAlerting(endpoint, &core.Result{Success: false}, &alerting.Config{}, false) - verify(t, endpoint, 2, 0, false, "The alert shouldn't have triggered, because the provider wasn't configured properly") + verify(t, ep, 0, 0, false, "The alert shouldn't start triggered") + HandleAlerting(ep, &endpoint.Result{Success: false}, &alerting.Config{}, false) + verify(t, ep, 1, 0, false, "The alert shouldn't have triggered") + HandleAlerting(ep, &endpoint.Result{Success: false}, &alerting.Config{}, false) + verify(t, ep, 2, 0, false, "The alert shouldn't have triggered, because the provider wasn't configured properly") } func TestHandleAlertingWhenTriggeredAlertIsAlmostResolvedButendpointStartFailingAgain(t *testing.T) { @@ -116,7 +116,7 @@ func TestHandleAlertingWhenTriggeredAlertIsAlmostResolvedButendpointStartFailing }, } enabled := true - endpoint := &core.Endpoint{ + ep := &endpoint.Endpoint{ URL: "https://example.com", Alerts: []*alert.Alert{ { @@ -132,8 +132,8 @@ func TestHandleAlertingWhenTriggeredAlertIsAlmostResolvedButendpointStartFailing } // This test simulate an alert that was already triggered - HandleAlerting(endpoint, &core.Result{Success: false}, cfg.Alerting, cfg.Debug) - verify(t, endpoint, 2, 0, true, "The alert was already triggered at the beginning of this test") + HandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting, cfg.Debug) + verify(t, ep, 2, 0, true, "The alert was already triggered at the beginning of this test") } func TestHandleAlertingWhenTriggeredAlertIsResolvedButSendOnResolvedIsFalse(t *testing.T) { @@ -151,7 +151,7 @@ func TestHandleAlertingWhenTriggeredAlertIsResolvedButSendOnResolvedIsFalse(t *t } enabled := true disabled := false - endpoint := &core.Endpoint{ + ep := &endpoint.Endpoint{ URL: "https://example.com", Alerts: []*alert.Alert{ { @@ -166,8 +166,8 @@ func TestHandleAlertingWhenTriggeredAlertIsResolvedButSendOnResolvedIsFalse(t *t NumberOfFailuresInARow: 1, } - HandleAlerting(endpoint, &core.Result{Success: true}, cfg.Alerting, cfg.Debug) - verify(t, endpoint, 0, 1, false, "The alert should've been resolved") + HandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting, cfg.Debug) + verify(t, ep, 0, 1, false, "The alert should've been resolved") } func TestHandleAlertingWhenTriggeredAlertIsResolvedPagerDuty(t *testing.T) { @@ -183,7 +183,7 @@ func TestHandleAlertingWhenTriggeredAlertIsResolvedPagerDuty(t *testing.T) { }, } enabled := true - endpoint := &core.Endpoint{ + ep := &endpoint.Endpoint{ URL: "https://example.com", Alerts: []*alert.Alert{ { @@ -198,11 +198,11 @@ func TestHandleAlertingWhenTriggeredAlertIsResolvedPagerDuty(t *testing.T) { NumberOfFailuresInARow: 0, } - HandleAlerting(endpoint, &core.Result{Success: false}, cfg.Alerting, cfg.Debug) - verify(t, endpoint, 1, 0, true, "") + HandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting, cfg.Debug) + verify(t, ep, 1, 0, true, "") - HandleAlerting(endpoint, &core.Result{Success: true}, cfg.Alerting, cfg.Debug) - verify(t, endpoint, 0, 1, false, "The alert should've been resolved") + HandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting, cfg.Debug) + verify(t, ep, 0, 1, false, "The alert should've been resolved") } func TestHandleAlertingWhenTriggeredAlertIsResolvedPushover(t *testing.T) { @@ -219,7 +219,7 @@ func TestHandleAlertingWhenTriggeredAlertIsResolvedPushover(t *testing.T) { }, } enabled := true - endpoint := &core.Endpoint{ + ep := &endpoint.Endpoint{ URL: "https://example.com", Alerts: []*alert.Alert{ { @@ -234,11 +234,11 @@ func TestHandleAlertingWhenTriggeredAlertIsResolvedPushover(t *testing.T) { NumberOfFailuresInARow: 0, } - HandleAlerting(endpoint, &core.Result{Success: false}, cfg.Alerting, cfg.Debug) - verify(t, endpoint, 1, 0, true, "") + HandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting, cfg.Debug) + verify(t, ep, 1, 0, true, "") - HandleAlerting(endpoint, &core.Result{Success: true}, cfg.Alerting, cfg.Debug) - verify(t, endpoint, 0, 1, false, "The alert should've been resolved") + HandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting, cfg.Debug) + verify(t, ep, 0, 1, false, "The alert should've been resolved") } func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) { @@ -389,7 +389,7 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) { for _, scenario := range scenarios { t.Run(scenario.Name, func(t *testing.T) { - endpoint := &core.Endpoint{ + ep := &endpoint.Endpoint{ URL: "https://example.com", Alerts: []*alert.Alert{ { @@ -403,33 +403,33 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) { }, } _ = os.Setenv("MOCK_ALERT_PROVIDER_ERROR", "true") - HandleAlerting(endpoint, &core.Result{Success: false}, scenario.AlertingConfig, true) - verify(t, endpoint, 1, 0, false, "") - HandleAlerting(endpoint, &core.Result{Success: false}, scenario.AlertingConfig, true) - verify(t, endpoint, 2, 0, false, "The alert should have failed to trigger, because the alert provider is returning an error") - HandleAlerting(endpoint, &core.Result{Success: false}, scenario.AlertingConfig, true) - verify(t, endpoint, 3, 0, false, "The alert should still not be triggered, because the alert provider is still returning an error") - HandleAlerting(endpoint, &core.Result{Success: false}, scenario.AlertingConfig, true) - verify(t, endpoint, 4, 0, false, "The alert should still not be triggered, because the alert provider is still returning an error") + HandleAlerting(ep, &endpoint.Result{Success: false}, scenario.AlertingConfig, true) + verify(t, ep, 1, 0, false, "") + HandleAlerting(ep, &endpoint.Result{Success: false}, scenario.AlertingConfig, true) + verify(t, ep, 2, 0, false, "The alert should have failed to trigger, because the alert provider is returning an error") + HandleAlerting(ep, &endpoint.Result{Success: false}, scenario.AlertingConfig, true) + verify(t, ep, 3, 0, false, "The alert should still not be triggered, because the alert provider is still returning an error") + HandleAlerting(ep, &endpoint.Result{Success: false}, scenario.AlertingConfig, true) + verify(t, ep, 4, 0, false, "The alert should still not be triggered, because the alert provider is still returning an error") _ = os.Setenv("MOCK_ALERT_PROVIDER_ERROR", "false") - HandleAlerting(endpoint, &core.Result{Success: false}, scenario.AlertingConfig, true) - verify(t, endpoint, 5, 0, true, "The alert should've been triggered because the alert provider is no longer returning an error") - HandleAlerting(endpoint, &core.Result{Success: true}, scenario.AlertingConfig, true) - verify(t, endpoint, 0, 1, true, "The alert should've still been triggered") + HandleAlerting(ep, &endpoint.Result{Success: false}, scenario.AlertingConfig, true) + verify(t, ep, 5, 0, true, "The alert should've been triggered because the alert provider is no longer returning an error") + HandleAlerting(ep, &endpoint.Result{Success: true}, scenario.AlertingConfig, true) + verify(t, ep, 0, 1, true, "The alert should've still been triggered") _ = os.Setenv("MOCK_ALERT_PROVIDER_ERROR", "true") - HandleAlerting(endpoint, &core.Result{Success: true}, scenario.AlertingConfig, true) - verify(t, endpoint, 0, 2, false, "The alert should've been resolved DESPITE THE ALERT PROVIDER RETURNING AN ERROR. See Alert.Triggered for further explanation.") + HandleAlerting(ep, &endpoint.Result{Success: true}, scenario.AlertingConfig, true) + verify(t, ep, 0, 2, false, "The alert should've been resolved DESPITE THE ALERT PROVIDER RETURNING AN ERROR. See Alert.Triggered for further explanation.") _ = os.Setenv("MOCK_ALERT_PROVIDER_ERROR", "false") // Make sure that everything's working as expected after a rough patch - HandleAlerting(endpoint, &core.Result{Success: false}, scenario.AlertingConfig, true) - verify(t, endpoint, 1, 0, false, "") - HandleAlerting(endpoint, &core.Result{Success: false}, scenario.AlertingConfig, true) - verify(t, endpoint, 2, 0, true, "The alert should have triggered") - HandleAlerting(endpoint, &core.Result{Success: true}, scenario.AlertingConfig, true) - verify(t, endpoint, 0, 1, true, "The alert should still be triggered") - HandleAlerting(endpoint, &core.Result{Success: true}, scenario.AlertingConfig, true) - verify(t, endpoint, 0, 2, false, "The alert should have been resolved") + HandleAlerting(ep, &endpoint.Result{Success: false}, scenario.AlertingConfig, true) + verify(t, ep, 1, 0, false, "") + HandleAlerting(ep, &endpoint.Result{Success: false}, scenario.AlertingConfig, true) + verify(t, ep, 2, 0, true, "The alert should have triggered") + HandleAlerting(ep, &endpoint.Result{Success: true}, scenario.AlertingConfig, true) + verify(t, ep, 0, 1, true, "The alert should still be triggered") + HandleAlerting(ep, &endpoint.Result{Success: true}, scenario.AlertingConfig, true) + verify(t, ep, 0, 2, false, "The alert should have been resolved") }) } @@ -449,7 +449,7 @@ func TestHandleAlertingWithProviderThatOnlyReturnsErrorOnResolve(t *testing.T) { }, } enabled := true - endpoint := &core.Endpoint{ + ep := &endpoint.Endpoint{ URL: "https://example.com", Alerts: []*alert.Alert{ { @@ -463,38 +463,38 @@ func TestHandleAlertingWithProviderThatOnlyReturnsErrorOnResolve(t *testing.T) { }, } - HandleAlerting(endpoint, &core.Result{Success: false}, cfg.Alerting, cfg.Debug) - verify(t, endpoint, 1, 0, true, "") + HandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting, cfg.Debug) + verify(t, ep, 1, 0, true, "") _ = os.Setenv("MOCK_ALERT_PROVIDER_ERROR", "true") - HandleAlerting(endpoint, &core.Result{Success: true}, cfg.Alerting, cfg.Debug) - verify(t, endpoint, 0, 1, false, "") + HandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting, cfg.Debug) + verify(t, ep, 0, 1, false, "") _ = os.Setenv("MOCK_ALERT_PROVIDER_ERROR", "false") - HandleAlerting(endpoint, &core.Result{Success: false}, cfg.Alerting, cfg.Debug) - verify(t, endpoint, 1, 0, true, "") + HandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting, cfg.Debug) + verify(t, ep, 1, 0, true, "") _ = os.Setenv("MOCK_ALERT_PROVIDER_ERROR", "true") - HandleAlerting(endpoint, &core.Result{Success: true}, cfg.Alerting, cfg.Debug) - verify(t, endpoint, 0, 1, false, "") + HandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting, cfg.Debug) + verify(t, ep, 0, 1, false, "") _ = os.Setenv("MOCK_ALERT_PROVIDER_ERROR", "false") // Make sure that everything's working as expected after a rough patch - HandleAlerting(endpoint, &core.Result{Success: false}, cfg.Alerting, cfg.Debug) - verify(t, endpoint, 1, 0, true, "") - HandleAlerting(endpoint, &core.Result{Success: false}, cfg.Alerting, cfg.Debug) - verify(t, endpoint, 2, 0, true, "") - HandleAlerting(endpoint, &core.Result{Success: true}, cfg.Alerting, cfg.Debug) - verify(t, endpoint, 0, 1, false, "") - HandleAlerting(endpoint, &core.Result{Success: true}, cfg.Alerting, cfg.Debug) - verify(t, endpoint, 0, 2, false, "") + HandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting, cfg.Debug) + verify(t, ep, 1, 0, true, "") + HandleAlerting(ep, &endpoint.Result{Success: false}, cfg.Alerting, cfg.Debug) + verify(t, ep, 2, 0, true, "") + HandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting, cfg.Debug) + verify(t, ep, 0, 1, false, "") + HandleAlerting(ep, &endpoint.Result{Success: true}, cfg.Alerting, cfg.Debug) + verify(t, ep, 0, 2, false, "") } -func verify(t *testing.T, endpoint *core.Endpoint, expectedNumberOfFailuresInARow, expectedNumberOfSuccessInARow int, expectedTriggered bool, expectedTriggeredReason string) { - if endpoint.NumberOfFailuresInARow != expectedNumberOfFailuresInARow { - t.Errorf("endpoint.NumberOfFailuresInARow should've been %d, got %d", expectedNumberOfFailuresInARow, endpoint.NumberOfFailuresInARow) +func verify(t *testing.T, ep *endpoint.Endpoint, expectedNumberOfFailuresInARow, expectedNumberOfSuccessInARow int, expectedTriggered bool, expectedTriggeredReason string) { + if ep.NumberOfFailuresInARow != expectedNumberOfFailuresInARow { + t.Errorf("endpoint.NumberOfFailuresInARow should've been %d, got %d", expectedNumberOfFailuresInARow, ep.NumberOfFailuresInARow) } - if endpoint.NumberOfSuccessesInARow != expectedNumberOfSuccessInARow { - t.Errorf("endpoint.NumberOfSuccessesInARow should've been %d, got %d", expectedNumberOfSuccessInARow, endpoint.NumberOfSuccessesInARow) + if ep.NumberOfSuccessesInARow != expectedNumberOfSuccessInARow { + t.Errorf("endpoint.NumberOfSuccessesInARow should've been %d, got %d", expectedNumberOfSuccessInARow, ep.NumberOfSuccessesInARow) } - if endpoint.Alerts[0].Triggered != expectedTriggered { + if ep.Alerts[0].Triggered != expectedTriggered { if len(expectedTriggeredReason) != 0 { t.Error(expectedTriggeredReason) } else { diff --git a/watchdog/watchdog.go b/watchdog/watchdog.go index 2557b413..3fcb977a 100644 --- a/watchdog/watchdog.go +++ b/watchdog/watchdog.go @@ -9,8 +9,8 @@ import ( "github.com/TwiN/gatus/v5/alerting" "github.com/TwiN/gatus/v5/config" "github.com/TwiN/gatus/v5/config/connectivity" + "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/maintenance" - "github.com/TwiN/gatus/v5/core" "github.com/TwiN/gatus/v5/metrics" "github.com/TwiN/gatus/v5/storage/store" ) @@ -37,17 +37,17 @@ func Monitor(cfg *config.Config) { } // monitor a single endpoint in a loop -func monitor(endpoint *core.Endpoint, alertingConfig *alerting.Config, maintenanceConfig *maintenance.Config, connectivityConfig *connectivity.Config, disableMonitoringLock, enabledMetrics, debug bool, ctx context.Context) { +func monitor(ep *endpoint.Endpoint, alertingConfig *alerting.Config, maintenanceConfig *maintenance.Config, connectivityConfig *connectivity.Config, disableMonitoringLock, enabledMetrics, debug bool, ctx context.Context) { // Run it immediately on start - execute(endpoint, alertingConfig, maintenanceConfig, connectivityConfig, disableMonitoringLock, enabledMetrics, debug) + execute(ep, alertingConfig, maintenanceConfig, connectivityConfig, disableMonitoringLock, enabledMetrics, debug) // Loop for the next executions for { select { case <-ctx.Done(): - log.Printf("[watchdog.monitor] Canceling current execution of group=%s; endpoint=%s", endpoint.Group, endpoint.Name) + log.Printf("[watchdog.monitor] Canceling current execution of group=%s; endpoint=%s", ep.Group, ep.Name) return - case <-time.After(endpoint.Interval): - execute(endpoint, alertingConfig, maintenanceConfig, connectivityConfig, disableMonitoringLock, enabledMetrics, debug) + case <-time.After(ep.Interval): + execute(ep, alertingConfig, maintenanceConfig, connectivityConfig, disableMonitoringLock, enabledMetrics, debug) } } // Just in case somebody wandered all the way to here and wonders, "what about ExternalEndpoints?" @@ -55,7 +55,7 @@ func monitor(endpoint *core.Endpoint, alertingConfig *alerting.Config, maintenan // periodically like they are for normal endpoints. } -func execute(endpoint *core.Endpoint, alertingConfig *alerting.Config, maintenanceConfig *maintenance.Config, connectivityConfig *connectivity.Config, disableMonitoringLock, enabledMetrics, debug bool) { +func execute(ep *endpoint.Endpoint, alertingConfig *alerting.Config, maintenanceConfig *maintenance.Config, connectivityConfig *connectivity.Config, disableMonitoringLock, enabledMetrics, debug bool) { if !disableMonitoringLock { // By placing the lock here, we prevent multiple endpoints from being monitored at the exact same time, which // could cause performance issues and return inaccurate results @@ -68,32 +68,32 @@ func execute(endpoint *core.Endpoint, alertingConfig *alerting.Config, maintenan return } if debug { - log.Printf("[watchdog.execute] Monitoring group=%s; endpoint=%s", endpoint.Group, endpoint.Name) + log.Printf("[watchdog.execute] Monitoring group=%s; endpoint=%s", ep.Group, ep.Name) } - result := endpoint.EvaluateHealth() + result := ep.EvaluateHealth() if enabledMetrics { - metrics.PublishMetricsForEndpoint(endpoint, result) + metrics.PublishMetricsForEndpoint(ep, result) } - UpdateEndpointStatuses(endpoint, result) + UpdateEndpointStatuses(ep, result) if debug && !result.Success { - log.Printf("[watchdog.execute] Monitored group=%s; endpoint=%s; success=%v; errors=%d; duration=%s; body=%s", endpoint.Group, endpoint.Name, result.Success, len(result.Errors), result.Duration.Round(time.Millisecond), result.Body) + log.Printf("[watchdog.execute] Monitored group=%s; endpoint=%s; success=%v; errors=%d; duration=%s; body=%s", ep.Group, ep.Name, result.Success, len(result.Errors), result.Duration.Round(time.Millisecond), result.Body) } else { - log.Printf("[watchdog.execute] Monitored group=%s; endpoint=%s; success=%v; errors=%d; duration=%s", endpoint.Group, endpoint.Name, result.Success, len(result.Errors), result.Duration.Round(time.Millisecond)) + log.Printf("[watchdog.execute] Monitored group=%s; endpoint=%s; success=%v; errors=%d; duration=%s", ep.Group, ep.Name, result.Success, len(result.Errors), result.Duration.Round(time.Millisecond)) } if !maintenanceConfig.IsUnderMaintenance() { // TODO: Consider moving this after the monitoring lock is unlocked? I mean, how much noise can a single alerting provider cause... - HandleAlerting(endpoint, result, alertingConfig, debug) + HandleAlerting(ep, result, alertingConfig, debug) } else if debug { log.Println("[watchdog.execute] Not handling alerting because currently in the maintenance window") } if debug { - log.Printf("[watchdog.execute] Waiting for interval=%s before monitoring group=%s endpoint=%s again", endpoint.Interval, endpoint.Group, endpoint.Name) + log.Printf("[watchdog.execute] Waiting for interval=%s before monitoring group=%s endpoint=%s again", ep.Interval, ep.Group, ep.Name) } } // UpdateEndpointStatuses updates the slice of endpoint statuses -func UpdateEndpointStatuses(endpoint *core.Endpoint, result *core.Result) { - if err := store.Get().Insert(endpoint, result); err != nil { +func UpdateEndpointStatuses(ep *endpoint.Endpoint, result *endpoint.Result) { + if err := store.Get().Insert(ep, result); err != nil { log.Println("[watchdog.UpdateEndpointStatuses] Failed to insert result in storage:", err.Error()) } } @@ -101,8 +101,8 @@ func UpdateEndpointStatuses(endpoint *core.Endpoint, result *core.Result) { // Shutdown stops monitoring all endpoints func Shutdown(cfg *config.Config) { // Disable all the old HTTP connections - for _, endpoint := range cfg.Endpoints { - endpoint.Close() + for _, ep := range cfg.Endpoints { + ep.Close() } cancelFunc() }