Compare commits
245 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7f0a32135 | ||
|
|
405c15f756 | ||
|
|
6f1312dfcf | ||
|
|
bd296c75da | ||
|
|
f007725140 | ||
|
|
40345a03d3 | ||
|
|
97a2be3504 | ||
|
|
15a4133502 | ||
|
|
64a5043655 | ||
|
|
5a06a74cc3 | ||
|
|
d6fa2c955b | ||
|
|
e6576e9080 | ||
|
|
cd10b31ab5 | ||
|
|
d1ef0b72a4 | ||
|
|
327a39964d | ||
|
|
c87c651ff0 | ||
|
|
1658825525 | ||
|
|
3a95e32210 | ||
|
|
bd793305e9 | ||
|
|
0d2a55cf11 | ||
|
|
565831aa46 | ||
|
|
8238a42a55 | ||
|
|
83c4fac217 | ||
|
|
37ba305c34 | ||
|
|
39ace63224 | ||
|
|
412b6d30a4 | ||
|
|
0f2b486623 | ||
|
|
347394b38a | ||
|
|
daf6ff60f8 | ||
|
|
f4001d0d80 | ||
|
|
65af0c9377 | ||
|
|
af4fbac84d | ||
|
|
39bfc51ce4 | ||
|
|
c006b35871 | ||
|
|
e3cae4637c | ||
|
|
3d61f5fe60 | ||
|
|
d559990162 | ||
|
|
f7fe56efa1 | ||
|
|
d668a14703 | ||
|
|
10cabb9dde | ||
|
|
3580bbb41b | ||
|
|
3a47d64610 | ||
|
|
2fa197d5bf | ||
|
|
d41cfc0d16 | ||
|
|
a49b9145d2 | ||
|
|
6e888430fa | ||
|
|
7dac2cc3f5 | ||
|
|
b875ba4dfe | ||
|
|
3e713dfee3 | ||
|
|
2f99eccf5f | ||
|
|
d37f71eee7 | ||
|
|
6d579a4b48 | ||
|
|
2c42aa8087 | ||
|
|
12825a2b6f | ||
|
|
e1edc15337 | ||
|
|
d17f51a1a1 | ||
|
|
a9fb48b26c | ||
|
|
131447f702 | ||
|
|
609a634df3 | ||
|
|
6c28de6950 | ||
|
|
440b732c71 | ||
|
|
8d63462fcd | ||
|
|
daf67dc1e6 | ||
|
|
3ebed01b4c | ||
|
|
a2f5516b06 | ||
|
|
a68e7e39bd | ||
|
|
f9d7320a2a | ||
|
|
c374649019 | ||
|
|
f6e938746f | ||
|
|
2c6fede468 | ||
|
|
9205cb2890 | ||
|
|
6a9cbb1728 | ||
|
|
4667fdbc15 | ||
|
|
501b71cab5 | ||
|
|
196be2b89c | ||
|
|
d27c63ded7 | ||
|
|
8c5ad54e71 | ||
|
|
6f9a2c7c32 | ||
|
|
aa08321239 | ||
|
|
ad5197f037 | ||
|
|
bdaffbca77 | ||
|
|
f4a667549e | ||
|
|
00419a4b4a | ||
|
|
7c27fcb895 | ||
|
|
3db5894e90 | ||
|
|
9b1d15c9e0 | ||
|
|
1855718e46 | ||
|
|
d5f2d92e8e | ||
|
|
20d1011a20 | ||
|
|
0888094fdb | ||
|
|
3f51536eaf | ||
|
|
d8a1da81f0 | ||
|
|
25b178bf94 | ||
|
|
e8e0b0f71c | ||
|
|
439ccaa372 | ||
|
|
1bb490e068 | ||
|
|
b78f3f85b0 | ||
|
|
f0034f88b7 | ||
|
|
659b81663e | ||
|
|
2f12088823 | ||
|
|
5b666f924c | ||
|
|
b296d4bf4c | ||
|
|
2b80b80769 | ||
|
|
40c274d36a | ||
|
|
65db65e052 | ||
|
|
0a9f5d8838 | ||
|
|
c449738844 | ||
|
|
35985017a8 | ||
|
|
d9d5815488 | ||
|
|
04692d15ba | ||
|
|
c411b001eb | ||
|
|
ce1777c680 | ||
|
|
f3a346d91c | ||
|
|
fca4e2170a | ||
|
|
b388cc87aa | ||
|
|
fc8868d996 | ||
|
|
021a28a8d9 | ||
|
|
fe214e9e25 | ||
|
|
493c7165fe | ||
|
|
ac44c1f2d6 | ||
|
|
5212b656a2 | ||
|
|
8f0a11a9e4 | ||
|
|
d576b3d72c | ||
|
|
33120bee52 | ||
|
|
53b785b581 | ||
|
|
0d11b0ef82 | ||
|
|
8a62eb0dcc | ||
|
|
c9c2639f67 | ||
|
|
76a8710e0b | ||
|
|
d5fe682f9a | ||
|
|
35a3238bc9 | ||
|
|
216dffa1a6 | ||
|
|
3191552343 | ||
|
|
fb98e853d4 | ||
|
|
e608950f98 | ||
|
|
39a623a349 | ||
|
|
75b99d3072 | ||
|
|
55d7bcdf2e | ||
|
|
b79fb09fe5 | ||
|
|
46499e13a0 | ||
|
|
43b0772e7d | ||
|
|
6d807e322e | ||
|
|
efa25c3498 | ||
|
|
ee96ba8721 | ||
|
|
e0bdda5225 | ||
|
|
fc07f15b67 | ||
|
|
168dafe5bb | ||
|
|
ca2c6899ed | ||
|
|
da3607106c | ||
|
|
e3e951aec8 | ||
|
|
a4cb2acd24 | ||
|
|
467e811fd5 | ||
|
|
11292b5f1e | ||
|
|
4cd35ef51d | ||
|
|
bb62a400f1 | ||
|
|
b750fcd209 | ||
|
|
62af9e842f | ||
|
|
2049601be5 | ||
|
|
abf5fbee9e | ||
|
|
8ad8a05535 | ||
|
|
3f94e16950 | ||
|
|
46b24b849f | ||
|
|
a1f7bd7b73 | ||
|
|
7e122a9fd9 | ||
|
|
52c142ec81 | ||
|
|
18e90d0e63 | ||
|
|
64b4c53b4e | ||
|
|
9fd134ca9c | ||
|
|
d9e0ee04f9 | ||
|
|
5227da3407 | ||
|
|
541a70584d | ||
|
|
ea2b7c4bdf | ||
|
|
9d8928dee0 | ||
|
|
41085757f9 | ||
|
|
5b3e0c8074 | ||
|
|
975ac3592e | ||
|
|
dd839be918 | ||
|
|
1c99386807 | ||
|
|
fa3e5dcc6e | ||
|
|
0bba77ab2b | ||
|
|
31073365ed | ||
|
|
69dbe4fa23 | ||
|
|
9157b5bf67 | ||
|
|
1ddaf5f3e5 | ||
|
|
e1675cc747 | ||
|
|
47246ddcf7 | ||
|
|
c259364edf | ||
|
|
b650518ccc | ||
|
|
d69844dc13 | ||
|
|
8aae277113 | ||
|
|
c44d998fb3 | ||
|
|
e88f47f0f4 | ||
|
|
19713c2d79 | ||
|
|
6eca0201ce | ||
|
|
f2a82e0681 | ||
|
|
9a12adbaf5 | ||
|
|
efbb739a44 | ||
|
|
78c9a1bd41 | ||
|
|
0440deb6b5 | ||
|
|
83a555e334 | ||
|
|
fcb4c71cda | ||
|
|
5169c1a91a | ||
|
|
126b78fc0e | ||
|
|
8b2cd2e4b3 | ||
|
|
79c9f24c15 | ||
|
|
be9ae6f55d | ||
|
|
539c7635a1 | ||
|
|
d56a8487eb | ||
|
|
a1bbc41b62 | ||
|
|
cdac5790cb | ||
|
|
ff318efb67 | ||
|
|
504bd8f66c | ||
|
|
917ad59306 | ||
|
|
f04dbfa516 | ||
|
|
4e6e57747f | ||
|
|
778019590d | ||
|
|
29cbff6774 | ||
|
|
2fe991319e | ||
|
|
52a73125f9 | ||
|
|
01131755bc | ||
|
|
8060a77b1f | ||
|
|
92bb42d444 | ||
|
|
c758af8791 | ||
|
|
0d38132a15 | ||
|
|
177feba75b | ||
|
|
c6ff6ec583 | ||
|
|
e061c788de | ||
|
|
e537758590 | ||
|
|
1c78f659c2 | ||
|
|
40cbb4b1d4 | ||
|
|
7103d0a9ae | ||
|
|
ff4b09dff8 | ||
|
|
29072da23e | ||
|
|
bb973979d2 | ||
|
|
3a7be5caff | ||
|
|
bd1a544cf7 | ||
|
|
aa51b0ad70 | ||
|
|
fc09981779 | ||
|
|
de2d41ed88 | ||
|
|
59842d5e88 | ||
|
|
b0c2f1eba9 | ||
|
|
f55f39fa33 | ||
|
|
1a0c0af403 | ||
|
|
12b04802ef | ||
|
|
d947a6b6f5 |
@@ -1,5 +1,4 @@
|
|||||||
metrics: true
|
metrics: true
|
||||||
debug: false
|
|
||||||
ui:
|
ui:
|
||||||
header: Example Company
|
header: Example Company
|
||||||
link: https://example.org
|
link: https://example.org
|
||||||
|
|||||||
4
.examples/nixos/README.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# NixOS
|
||||||
|
|
||||||
|
Gatus is implemented as a NixOS module. See [gatus.nix](./gatus.nix) for example
|
||||||
|
usage.
|
||||||
23
.examples/nixos/gatus.nix
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
services.gatus = {
|
||||||
|
enable = true;
|
||||||
|
|
||||||
|
settings = {
|
||||||
|
web.port = 8080;
|
||||||
|
|
||||||
|
endpoints = [
|
||||||
|
{
|
||||||
|
name = "website";
|
||||||
|
url = "https://twin.sh/health";
|
||||||
|
interval = "5m";
|
||||||
|
|
||||||
|
conditions = [
|
||||||
|
"[STATUS] == 200"
|
||||||
|
"[BODY].status == UP"
|
||||||
|
"[RESPONSE_TIME] < 300"
|
||||||
|
];
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
BIN
.github/assets/dashboard-conditions.jpg
vendored
Normal file
|
After Width: | Height: | Size: 290 KiB |
BIN
.github/assets/dashboard-conditions.png
vendored
|
Before Width: | Height: | Size: 43 KiB |
BIN
.github/assets/dashboard-dark.jpg
vendored
Normal file
|
After Width: | Height: | Size: 536 KiB |
BIN
.github/assets/dashboard-dark.png
vendored
|
Before Width: | Height: | Size: 90 KiB |
BIN
.github/assets/endpoint-groups.jpg
vendored
Normal file
|
After Width: | Height: | Size: 375 KiB |
BIN
.github/assets/endpoint-groups.png
vendored
|
Before Width: | Height: | Size: 39 KiB |
BIN
.github/assets/example.jpg
vendored
Normal file
|
After Width: | Height: | Size: 207 KiB |
BIN
.github/assets/example.png
vendored
|
Before Width: | Height: | Size: 43 KiB |
BIN
.github/assets/teams-workflows-alerts.png
vendored
Normal file
|
After Width: | Height: | Size: 12 KiB |
9
.github/codecov.yml
vendored
@@ -1,12 +1,15 @@
|
|||||||
ignore:
|
ignore:
|
||||||
- "watchdog/watchdog.go"
|
|
||||||
- "storage/store/sql/specific_postgres.go" # Can't test for postgres
|
- "storage/store/sql/specific_postgres.go" # Can't test for postgres
|
||||||
|
- "watchdog/endpoint.go"
|
||||||
|
- "watchdog/external_endpoint.go"
|
||||||
|
- "watchdog/suite.go"
|
||||||
|
- "watchdog/watchdog.go"
|
||||||
|
comment: false
|
||||||
coverage:
|
coverage:
|
||||||
status:
|
status:
|
||||||
patch: off
|
patch: off
|
||||||
project:
|
project:
|
||||||
default:
|
default:
|
||||||
target: 75%
|
target: 70%
|
||||||
threshold: null
|
threshold: null
|
||||||
|
|
||||||
|
|||||||
2
.github/dependabot.yml
vendored
@@ -7,7 +7,7 @@ updates:
|
|||||||
interval: "daily"
|
interval: "daily"
|
||||||
- package-ecosystem: "gomod"
|
- package-ecosystem: "gomod"
|
||||||
directory: "/"
|
directory: "/"
|
||||||
open-pull-requests-limit: 1
|
open-pull-requests-limit: 3
|
||||||
labels: ["dependencies"]
|
labels: ["dependencies"]
|
||||||
schedule:
|
schedule:
|
||||||
interval: "daily"
|
interval: "daily"
|
||||||
|
|||||||
8
.github/workflows/benchmark.yml
vendored
@@ -18,13 +18,13 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
name: benchmark
|
name: benchmark
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: 1.22.2
|
go-version: 1.24.4
|
||||||
repository: "${{ github.event.inputs.repository || 'TwiN/gatus' }}"
|
repository: "${{ github.event.inputs.repository || 'TwiN/gatus' }}"
|
||||||
ref: "${{ github.event.inputs.ref || 'master' }}"
|
ref: "${{ github.event.inputs.ref || 'master' }}"
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- name: Benchmark
|
- name: Benchmark
|
||||||
run: go test -bench=. ./storage/store
|
run: go test -bench=. ./storage/store
|
||||||
|
|||||||
46
.github/workflows/labeler.yml
vendored
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
name: labeler
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
issues:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
jobs:
|
||||||
|
labeler:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- name: Label
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
TITLE: ${{ github.event.issue.title }}${{ github.event.pull_request.title }}
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
GH_REPO: ${{ github.repository }}
|
||||||
|
NUMBER: ${{ github.event.issue.number }}${{ github.event.pull_request.number }}
|
||||||
|
run: |
|
||||||
|
if [[ $TITLE == "feat"* ]]; then
|
||||||
|
gh issue edit "$NUMBER" --add-label "feature"
|
||||||
|
elif [[ $TITLE == "fix"* ]]; then
|
||||||
|
gh issue edit "$NUMBER" --add-label "bug"
|
||||||
|
elif [[ $TITLE == "docs"* ]]; then
|
||||||
|
gh issue edit "$NUMBER" --add-label "documentation"
|
||||||
|
fi
|
||||||
|
if [[ $TITLE == *"alerting"* || $TITLE == *"provider"* || $TITLE == *"alert"* ]]; then
|
||||||
|
gh issue edit "$NUMBER" --add-label "area/alerting"
|
||||||
|
fi
|
||||||
|
if [[ $TITLE == *"(ui)"* || $TITLE == *"ui:"* ]]; then
|
||||||
|
gh issue edit "$NUMBER" --add-label "area/ui"
|
||||||
|
fi
|
||||||
|
if [[ $TITLE == *"storage"* || $TITLE == *"postgres"* || $TITLE == *"sqlite"* ]]; then
|
||||||
|
gh issue edit "$NUMBER" --add-label "area/storage"
|
||||||
|
fi
|
||||||
|
if [[ $TITLE == *"security"* || $TITLE == *"oidc"* || $TITLE == *"oauth2"* ]]; then
|
||||||
|
gh issue edit "$NUMBER" --add-label "area/security"
|
||||||
|
fi
|
||||||
|
if [[ $TITLE == *"metric"* || $TITLE == *"prometheus"* ]]; then
|
||||||
|
gh issue edit "$NUMBER" --add-label "area/metrics"
|
||||||
|
fi
|
||||||
40
.github/workflows/publish-custom.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
name: publish-custom
|
||||||
|
run-name: "${{ inputs.tag }}"
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: Custom tag to publish
|
||||||
|
jobs:
|
||||||
|
publish-custom:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Get image repository
|
||||||
|
run: echo GHCR_IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.GHCR_IMAGE_REPOSITORY }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=${{ inputs.tag }}
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
platforms: linux/amd64
|
||||||
|
pull: true
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
14
.github/workflows/publish-experimental.yml
vendored
@@ -3,9 +3,9 @@ on: [workflow_dispatch]
|
|||||||
jobs:
|
jobs:
|
||||||
publish-experimental:
|
publish-experimental:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 60
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
@@ -17,10 +17,18 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
- name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.IMAGE_REPOSITORY }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=experimental
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
pull: true
|
pull: true
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ env.IMAGE_REPOSITORY }}:experimental
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|||||||
38
.github/workflows/publish-latest-to-ghcr.yml
vendored
@@ -1,38 +0,0 @@
|
|||||||
name: publish-latest-to-ghcr
|
|
||||||
on:
|
|
||||||
workflow_run:
|
|
||||||
workflows: [test]
|
|
||||||
branches: [master]
|
|
||||||
types: [completed]
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.event.workflow_run.head_repository.full_name }}::${{ github.event.workflow_run.head_branch }}::${{ github.workflow }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
jobs:
|
|
||||||
publish-latest-to-ghcr:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: ${{ (github.event.workflow_run.conclusion == 'success') && (github.event.workflow_run.head_repository.full_name == github.repository) }}
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
timeout-minutes: 60
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
- name: Get image repository
|
|
||||||
run: echo IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
|
||||||
- name: Login to Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
- name: Build and push Docker image
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
|
||||||
pull: true
|
|
||||||
push: true
|
|
||||||
tags: ${{ env.IMAGE_REPOSITORY }}:latest
|
|
||||||
26
.github/workflows/publish-latest.yml
vendored
@@ -11,24 +11,42 @@ jobs:
|
|||||||
publish-latest:
|
publish-latest:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: ${{ (github.event.workflow_run.conclusion == 'success') && (github.event.workflow_run.head_repository.full_name == github.repository) }}
|
if: ${{ (github.event.workflow_run.conclusion == 'success') && (github.event.workflow_run.head_repository.full_name == github.repository) }}
|
||||||
timeout-minutes: 60
|
timeout-minutes: 240
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
- name: Get image repository
|
- name: Get image repository
|
||||||
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
run: |
|
||||||
|
echo DOCKER_IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||||
|
echo GHCR_IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||||
- name: Login to Docker Registry
|
- name: Login to Docker Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
${{ env.DOCKER_IMAGE_REPOSITORY }}
|
||||||
|
${{ env.GHCR_IMAGE_REPOSITORY }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||||
pull: true
|
pull: true
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ env.IMAGE_REPOSITORY }}:latest
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|||||||
37
.github/workflows/publish-release-to-ghcr.yml
vendored
@@ -1,37 +0,0 @@
|
|||||||
name: publish-release-to-ghcr
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types: [published]
|
|
||||||
jobs:
|
|
||||||
publish-release-to-ghcr:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
timeout-minutes: 60
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
- name: Get image repository
|
|
||||||
run: echo IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
|
||||||
- name: Get the release
|
|
||||||
run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV
|
|
||||||
- name: Login to Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
- name: Build and push Docker image
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
|
||||||
pull: true
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ env.IMAGE_REPOSITORY }}:${{ env.RELEASE }}
|
|
||||||
${{ env.IMAGE_REPOSITORY }}:stable
|
|
||||||
${{ env.IMAGE_REPOSITORY }}:latest
|
|
||||||
31
.github/workflows/publish-release.yml
vendored
@@ -6,15 +6,17 @@ jobs:
|
|||||||
publish-release:
|
publish-release:
|
||||||
name: publish-release
|
name: publish-release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
timeout-minutes: 240
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
- name: Get image repository
|
- name: Get image repository
|
||||||
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
run: |
|
||||||
|
echo DOCKER_IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||||
|
echo GHCR_IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||||
- name: Get the release
|
- name: Get the release
|
||||||
run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV
|
run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV
|
||||||
- name: Login to Docker Registry
|
- name: Login to Docker Registry
|
||||||
@@ -22,13 +24,28 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
${{ env.DOCKER_IMAGE_REPOSITORY }}
|
||||||
|
${{ env.GHCR_IMAGE_REPOSITORY }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=${{ env.RELEASE }}
|
||||||
|
type=raw,value=stable
|
||||||
|
type=raw,value=latest
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||||
pull: true
|
pull: true
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
${{ env.IMAGE_REPOSITORY }}:${{ env.RELEASE }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
${{ env.IMAGE_REPOSITORY }}:stable
|
|
||||||
${{ env.IMAGE_REPOSITORY }}:latest
|
|
||||||
|
|||||||
18
.github/workflows/test-ui.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
name: test-ui
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'web/**'
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
paths:
|
||||||
|
- 'web/**'
|
||||||
|
jobs:
|
||||||
|
test-ui:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- run: make frontend-install-dependencies
|
||||||
|
- run: make frontend-build
|
||||||
10
.github/workflows/test.yml
vendored
@@ -14,12 +14,12 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: 1.22.2
|
go-version: 1.24.4
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- name: Build binary to make sure it works
|
- name: Build binary to make sure it works
|
||||||
run: go build
|
run: go build
|
||||||
- name: Test
|
- name: Test
|
||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
# was configured by the "Set up Go" step (otherwise, it'd use sudo's "go" executable)
|
# was configured by the "Set up Go" step (otherwise, it'd use sudo's "go" executable)
|
||||||
run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test ./... -race -coverprofile=coverage.txt -covermode=atomic
|
run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test ./... -race -coverprofile=coverage.txt -covermode=atomic
|
||||||
- name: Codecov
|
- name: Codecov
|
||||||
uses: codecov/codecov-action@v4.5.0
|
uses: codecov/codecov-action@v5.5.1
|
||||||
with:
|
with:
|
||||||
files: ./coverage.txt
|
files: ./coverage.txt
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|||||||
3
.gitignore
vendored
@@ -17,4 +17,5 @@ node_modules
|
|||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
gatus
|
gatus
|
||||||
config/config.yml
|
config/config.yml
|
||||||
|
config.yaml
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# Build the go application into a binary
|
# Build the go application into a binary
|
||||||
FROM golang:alpine as builder
|
FROM golang:alpine AS builder
|
||||||
RUN apk --update add ca-certificates
|
RUN apk --update add ca-certificates
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . ./
|
COPY . ./
|
||||||
@@ -15,6 +15,8 @@ FROM scratch
|
|||||||
COPY --from=builder /app/gatus .
|
COPY --from=builder /app/gatus .
|
||||||
COPY --from=builder /app/config.yaml ./config/config.yaml
|
COPY --from=builder /app/config.yaml ./config/config.yaml
|
||||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||||
ENV PORT=8080
|
ENV GATUS_CONFIG_PATH=""
|
||||||
|
ENV GATUS_LOG_LEVEL="INFO"
|
||||||
|
ENV PORT="8080"
|
||||||
EXPOSE ${PORT}
|
EXPOSE ${PORT}
|
||||||
ENTRYPOINT ["/gatus"]
|
ENTRYPOINT ["/gatus"]
|
||||||
|
|||||||
9
Makefile
@@ -6,7 +6,11 @@ install:
|
|||||||
|
|
||||||
.PHONY: run
|
.PHONY: run
|
||||||
run:
|
run:
|
||||||
GATUS_CONFIG_PATH=./config.yaml ./$(BINARY)
|
ENVIRONMENT=dev GATUS_CONFIG_PATH=./config.yaml go run main.go
|
||||||
|
|
||||||
|
.PHONY: run-binary
|
||||||
|
run-binary:
|
||||||
|
ENVIRONMENT=dev GATUS_CONFIG_PATH=./config.yaml ./$(BINARY)
|
||||||
|
|
||||||
.PHONY: clean
|
.PHONY: clean
|
||||||
clean:
|
clean:
|
||||||
@@ -34,6 +38,9 @@ docker-build-and-run: docker-build docker-run
|
|||||||
# Front end #
|
# Front end #
|
||||||
#############
|
#############
|
||||||
|
|
||||||
|
frontend-install-dependencies:
|
||||||
|
npm --prefix web/app install
|
||||||
|
|
||||||
frontend-build:
|
frontend-build:
|
||||||
npm --prefix web/app run build
|
npm --prefix web/app run build
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,20 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/TwiN/logr"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// ErrAlertWithInvalidDescription is the error with which Gatus will panic if an alert has an invalid character
|
// ErrAlertWithInvalidDescription is the error with which Gatus will panic if an alert has an invalid character
|
||||||
ErrAlertWithInvalidDescription = errors.New("alert description must not have \" or \\")
|
ErrAlertWithInvalidDescription = errors.New("alert description must not have \" or \\")
|
||||||
|
|
||||||
|
ErrAlertWithInvalidMinimumReminderInterval = errors.New("minimum-reminder-interval must be either omitted or be at least 5m")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Alert is a endpoint.Endpoint's alert configuration
|
// Alert is endpoint.Endpoint's alert configuration
|
||||||
type Alert struct {
|
type Alert struct {
|
||||||
// Type of alert (required)
|
// Type of alert (required)
|
||||||
Type Type `yaml:"type"`
|
Type Type `yaml:"type"`
|
||||||
@@ -32,17 +38,24 @@ type Alert struct {
|
|||||||
// SuccessThreshold defines how many successful executions must happen in a row before an ongoing incident is marked as 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"`
|
SuccessThreshold int `yaml:"success-threshold"`
|
||||||
|
|
||||||
|
// MinimumReminderInterval is the interval between reminders
|
||||||
|
MinimumReminderInterval time.Duration `yaml:"minimum-reminder-interval,omitempty"`
|
||||||
|
|
||||||
// Description of the alert. Will be included in the alert sent.
|
// Description of the alert. Will be included in the alert sent.
|
||||||
//
|
//
|
||||||
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
|
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
|
||||||
// or not for provider.ParseWithDefaultAlert to work.
|
// or not for provider.ParseWithDefaultAlert to work.
|
||||||
Description *string `yaml:"description"`
|
Description *string `yaml:"description,omitempty"`
|
||||||
|
|
||||||
// SendOnResolved defines whether to send a second notification when the issue has been resolved
|
// SendOnResolved defines whether to send a second notification when the issue has been resolved
|
||||||
//
|
//
|
||||||
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
|
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
|
||||||
// or not for provider.ParseWithDefaultAlert to work. Use Alert.IsSendingOnResolved() for a non-pointer
|
// or not for provider.ParseWithDefaultAlert to work. Use Alert.IsSendingOnResolved() for a non-pointer
|
||||||
SendOnResolved *bool `yaml:"send-on-resolved"`
|
SendOnResolved *bool `yaml:"send-on-resolved,omitempty"`
|
||||||
|
|
||||||
|
// ProviderOverride is an optional field that can be used to override the provider's configuration
|
||||||
|
// It is freeform so that it can be used for any provider-specific configuration.
|
||||||
|
ProviderOverride map[string]any `yaml:"provider-override,omitempty"`
|
||||||
|
|
||||||
// ResolveKey is an optional field that is used by some providers (i.e. PagerDuty's dedup_key) to resolve
|
// ResolveKey is an optional field that is used by some providers (i.e. PagerDuty's dedup_key) to resolve
|
||||||
// ongoing/triggered incidents
|
// ongoing/triggered incidents
|
||||||
@@ -67,6 +80,9 @@ func (alert *Alert) ValidateAndSetDefaults() error {
|
|||||||
if alert.SuccessThreshold <= 0 {
|
if alert.SuccessThreshold <= 0 {
|
||||||
alert.SuccessThreshold = 2
|
alert.SuccessThreshold = 2
|
||||||
}
|
}
|
||||||
|
if alert.MinimumReminderInterval != 0 && alert.MinimumReminderInterval < 5*time.Minute {
|
||||||
|
return ErrAlertWithInvalidMinimumReminderInterval
|
||||||
|
}
|
||||||
if strings.ContainsAny(alert.GetDescription(), "\"\\") {
|
if strings.ContainsAny(alert.GetDescription(), "\"\\") {
|
||||||
return ErrAlertWithInvalidDescription
|
return ErrAlertWithInvalidDescription
|
||||||
}
|
}
|
||||||
@@ -111,3 +127,11 @@ func (alert *Alert) Checksum() string {
|
|||||||
)
|
)
|
||||||
return hex.EncodeToString(hash.Sum(nil))
|
return hex.EncodeToString(hash.Sum(nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (alert *Alert) ProviderOverrideAsBytes() []byte {
|
||||||
|
yamlBytes, err := yaml.Marshal(alert.ProviderOverride)
|
||||||
|
if err != nil {
|
||||||
|
logr.Warnf("[alert.ProviderOverrideAsBytes] Failed to marshal alert override of type=%s as bytes: %v", alert.Type, err)
|
||||||
|
}
|
||||||
|
return yamlBytes
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package alert
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlert_ValidateAndSetDefaults(t *testing.T) {
|
func TestAlert_ValidateAndSetDefaults(t *testing.T) {
|
||||||
@@ -36,6 +37,61 @@ func TestAlert_ValidateAndSetDefaults(t *testing.T) {
|
|||||||
expectedFailureThreshold: 10,
|
expectedFailureThreshold: 10,
|
||||||
expectedSuccessThreshold: 5,
|
expectedSuccessThreshold: 5,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "valid-minimum-reminder-interval-0",
|
||||||
|
alert: Alert{
|
||||||
|
MinimumReminderInterval: 0,
|
||||||
|
FailureThreshold: 10,
|
||||||
|
SuccessThreshold: 5,
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
expectedFailureThreshold: 10,
|
||||||
|
expectedSuccessThreshold: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid-minimum-reminder-interval-5m",
|
||||||
|
alert: Alert{
|
||||||
|
MinimumReminderInterval: 5 * time.Minute,
|
||||||
|
FailureThreshold: 10,
|
||||||
|
SuccessThreshold: 5,
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
expectedFailureThreshold: 10,
|
||||||
|
expectedSuccessThreshold: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid-minimum-reminder-interval-10m",
|
||||||
|
alert: Alert{
|
||||||
|
MinimumReminderInterval: 10 * time.Minute,
|
||||||
|
FailureThreshold: 10,
|
||||||
|
SuccessThreshold: 5,
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
expectedFailureThreshold: 10,
|
||||||
|
expectedSuccessThreshold: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-minimum-reminder-interval-1m",
|
||||||
|
alert: Alert{
|
||||||
|
MinimumReminderInterval: 1 * time.Minute,
|
||||||
|
FailureThreshold: 10,
|
||||||
|
SuccessThreshold: 5,
|
||||||
|
},
|
||||||
|
expectedError: ErrAlertWithInvalidMinimumReminderInterval,
|
||||||
|
expectedFailureThreshold: 10,
|
||||||
|
expectedSuccessThreshold: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-minimum-reminder-interval-1s",
|
||||||
|
alert: Alert{
|
||||||
|
MinimumReminderInterval: 1 * time.Second,
|
||||||
|
FailureThreshold: 10,
|
||||||
|
SuccessThreshold: 5,
|
||||||
|
},
|
||||||
|
expectedError: ErrAlertWithInvalidMinimumReminderInterval,
|
||||||
|
expectedFailureThreshold: 10,
|
||||||
|
expectedSuccessThreshold: 5,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.name, func(t *testing.T) {
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ const (
|
|||||||
// TypeCustom is the Type for the custom alerting provider
|
// TypeCustom is the Type for the custom alerting provider
|
||||||
TypeCustom Type = "custom"
|
TypeCustom Type = "custom"
|
||||||
|
|
||||||
|
// TypeDatadog is the Type for the datadog alerting provider
|
||||||
|
TypeDatadog Type = "datadog"
|
||||||
|
|
||||||
// TypeDiscord is the Type for the discord alerting provider
|
// TypeDiscord is the Type for the discord alerting provider
|
||||||
TypeDiscord Type = "discord"
|
TypeDiscord Type = "discord"
|
||||||
|
|
||||||
@@ -32,9 +35,24 @@ const (
|
|||||||
// TypeGotify is the Type for the gotify alerting provider
|
// TypeGotify is the Type for the gotify alerting provider
|
||||||
TypeGotify Type = "gotify"
|
TypeGotify Type = "gotify"
|
||||||
|
|
||||||
|
// TypeHomeAssistant is the Type for the homeassistant alerting provider
|
||||||
|
TypeHomeAssistant Type = "homeassistant"
|
||||||
|
|
||||||
|
// TypeIFTTT is the Type for the ifttt alerting provider
|
||||||
|
TypeIFTTT Type = "ifttt"
|
||||||
|
|
||||||
|
// TypeIlert is the Type for the ilert alerting provider
|
||||||
|
TypeIlert Type = "ilert"
|
||||||
|
|
||||||
|
// TypeIncidentIO is the Type for the incident-io alerting provider
|
||||||
|
TypeIncidentIO Type = "incident-io"
|
||||||
|
|
||||||
// TypeJetBrainsSpace is the Type for the jetbrains alerting provider
|
// TypeJetBrainsSpace is the Type for the jetbrains alerting provider
|
||||||
TypeJetBrainsSpace Type = "jetbrainsspace"
|
TypeJetBrainsSpace Type = "jetbrainsspace"
|
||||||
|
|
||||||
|
// TypeLine is the Type for the line alerting provider
|
||||||
|
TypeLine Type = "line"
|
||||||
|
|
||||||
// TypeMatrix is the Type for the matrix alerting provider
|
// TypeMatrix is the Type for the matrix alerting provider
|
||||||
TypeMatrix Type = "matrix"
|
TypeMatrix Type = "matrix"
|
||||||
|
|
||||||
@@ -44,6 +62,9 @@ const (
|
|||||||
// TypeMessagebird is the Type for the messagebird alerting provider
|
// TypeMessagebird is the Type for the messagebird alerting provider
|
||||||
TypeMessagebird Type = "messagebird"
|
TypeMessagebird Type = "messagebird"
|
||||||
|
|
||||||
|
// TypeNewRelic is the Type for the newrelic alerting provider
|
||||||
|
TypeNewRelic Type = "newrelic"
|
||||||
|
|
||||||
// TypeNtfy is the Type for the ntfy alerting provider
|
// TypeNtfy is the Type for the ntfy alerting provider
|
||||||
TypeNtfy Type = "ntfy"
|
TypeNtfy Type = "ntfy"
|
||||||
|
|
||||||
@@ -53,18 +74,54 @@ const (
|
|||||||
// TypePagerDuty is the Type for the pagerduty alerting provider
|
// TypePagerDuty is the Type for the pagerduty alerting provider
|
||||||
TypePagerDuty Type = "pagerduty"
|
TypePagerDuty Type = "pagerduty"
|
||||||
|
|
||||||
|
// TypePlivo is the Type for the plivo alerting provider
|
||||||
|
TypePlivo Type = "plivo"
|
||||||
|
|
||||||
// TypePushover is the Type for the pushover alerting provider
|
// TypePushover is the Type for the pushover alerting provider
|
||||||
TypePushover Type = "pushover"
|
TypePushover Type = "pushover"
|
||||||
|
|
||||||
|
// TypeRocketChat is the Type for the rocketchat alerting provider
|
||||||
|
TypeRocketChat Type = "rocketchat"
|
||||||
|
|
||||||
|
// TypeSendGrid is the Type for the sendgrid alerting provider
|
||||||
|
TypeSendGrid Type = "sendgrid"
|
||||||
|
|
||||||
|
// TypeSignal is the Type for the signal alerting provider
|
||||||
|
TypeSignal Type = "signal"
|
||||||
|
|
||||||
|
// TypeSIGNL4 is the Type for the signl4 alerting provider
|
||||||
|
TypeSIGNL4 Type = "signl4"
|
||||||
|
|
||||||
// TypeSlack is the Type for the slack alerting provider
|
// TypeSlack is the Type for the slack alerting provider
|
||||||
TypeSlack Type = "slack"
|
TypeSlack Type = "slack"
|
||||||
|
|
||||||
|
// TypeSplunk is the Type for the splunk alerting provider
|
||||||
|
TypeSplunk Type = "splunk"
|
||||||
|
|
||||||
|
// TypeSquadcast is the Type for the squadcast alerting provider
|
||||||
|
TypeSquadcast Type = "squadcast"
|
||||||
|
|
||||||
// TypeTeams is the Type for the teams alerting provider
|
// TypeTeams is the Type for the teams alerting provider
|
||||||
TypeTeams Type = "teams"
|
TypeTeams Type = "teams"
|
||||||
|
|
||||||
|
// TypeTeamsWorkflows is the Type for the teams-workflows alerting provider
|
||||||
|
TypeTeamsWorkflows Type = "teams-workflows"
|
||||||
|
|
||||||
// TypeTelegram is the Type for the telegram alerting provider
|
// TypeTelegram is the Type for the telegram alerting provider
|
||||||
TypeTelegram Type = "telegram"
|
TypeTelegram Type = "telegram"
|
||||||
|
|
||||||
// TypeTwilio is the Type for the twilio alerting provider
|
// TypeTwilio is the Type for the twilio alerting provider
|
||||||
TypeTwilio Type = "twilio"
|
TypeTwilio Type = "twilio"
|
||||||
|
|
||||||
|
// TypeVonage is the Type for the vonage alerting provider
|
||||||
|
TypeVonage Type = "vonage"
|
||||||
|
|
||||||
|
// TypeWebex is the Type for the webex alerting provider
|
||||||
|
TypeWebex Type = "webex"
|
||||||
|
|
||||||
|
// TypeZapier is the Type for the zapier alerting provider
|
||||||
|
TypeZapier Type = "zapier"
|
||||||
|
|
||||||
|
// TypeZulip is the Type for the Zulip alerting provider
|
||||||
|
TypeZulip Type = "zulip"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package alerting
|
package alerting
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -9,6 +8,7 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/alerting/provider"
|
"github.com/TwiN/gatus/v5/alerting/provider"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/awsses"
|
"github.com/TwiN/gatus/v5/alerting/provider/awsses"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/custom"
|
"github.com/TwiN/gatus/v5/alerting/provider/custom"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/datadog"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/discord"
|
"github.com/TwiN/gatus/v5/alerting/provider/discord"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/email"
|
"github.com/TwiN/gatus/v5/alerting/provider/email"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/gitea"
|
"github.com/TwiN/gatus/v5/alerting/provider/gitea"
|
||||||
@@ -16,18 +16,37 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
|
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
|
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/gotify"
|
"github.com/TwiN/gatus/v5/alerting/provider/gotify"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/homeassistant"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/ifttt"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/ilert"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
|
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/line"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
|
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
|
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/newrelic"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/ntfy"
|
"github.com/TwiN/gatus/v5/alerting/provider/ntfy"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
|
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/pagerduty"
|
"github.com/TwiN/gatus/v5/alerting/provider/pagerduty"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/plivo"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
|
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/rocketchat"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/sendgrid"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/signal"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/signl4"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/slack"
|
"github.com/TwiN/gatus/v5/alerting/provider/slack"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/splunk"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/squadcast"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/teams"
|
"github.com/TwiN/gatus/v5/alerting/provider/teams"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
|
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
|
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/vonage"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/webex"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/zapier"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/zulip"
|
||||||
|
"github.com/TwiN/logr"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config is the configuration for alerting providers
|
// Config is the configuration for alerting providers
|
||||||
@@ -38,12 +57,16 @@ type Config struct {
|
|||||||
// Custom is the configuration for the custom alerting provider
|
// Custom is the configuration for the custom alerting provider
|
||||||
Custom *custom.AlertProvider `yaml:"custom,omitempty"`
|
Custom *custom.AlertProvider `yaml:"custom,omitempty"`
|
||||||
|
|
||||||
|
// Datadog is the configuration for the datadog alerting provider
|
||||||
|
Datadog *datadog.AlertProvider `yaml:"datadog,omitempty"`
|
||||||
|
|
||||||
// Discord is the configuration for the discord alerting provider
|
// Discord is the configuration for the discord alerting provider
|
||||||
Discord *discord.AlertProvider `yaml:"discord,omitempty"`
|
Discord *discord.AlertProvider `yaml:"discord,omitempty"`
|
||||||
|
|
||||||
// Email is the configuration for the email alerting provider
|
// Email is the configuration for the email alerting provider
|
||||||
Email *email.AlertProvider `yaml:"email,omitempty"`
|
Email *email.AlertProvider `yaml:"email,omitempty"`
|
||||||
|
|
||||||
|
|
||||||
// GitHub is the configuration for the github alerting provider
|
// GitHub is the configuration for the github alerting provider
|
||||||
GitHub *github.AlertProvider `yaml:"github,omitempty"`
|
GitHub *github.AlertProvider `yaml:"github,omitempty"`
|
||||||
|
|
||||||
@@ -58,10 +81,25 @@ type Config struct {
|
|||||||
|
|
||||||
// Gotify is the configuration for the gotify alerting provider
|
// Gotify is the configuration for the gotify alerting provider
|
||||||
Gotify *gotify.AlertProvider `yaml:"gotify,omitempty"`
|
Gotify *gotify.AlertProvider `yaml:"gotify,omitempty"`
|
||||||
|
|
||||||
|
// HomeAssistant is the configuration for the homeassistant alerting provider
|
||||||
|
HomeAssistant *homeassistant.AlertProvider `yaml:"homeassistant,omitempty"`
|
||||||
|
|
||||||
|
// IFTTT is the configuration for the ifttt alerting provider
|
||||||
|
IFTTT *ifttt.AlertProvider `yaml:"ifttt,omitempty"`
|
||||||
|
|
||||||
|
// Ilert is the configuration for the ilert alerting provider
|
||||||
|
Ilert *ilert.AlertProvider `yaml:"ilert,omitempty"`
|
||||||
|
|
||||||
|
// IncidentIO is the configuration for the incident-io alerting provider
|
||||||
|
IncidentIO *incidentio.AlertProvider `yaml:"incident-io,omitempty"`
|
||||||
|
|
||||||
// JetBrainsSpace is the configuration for the jetbrains space alerting provider
|
// JetBrainsSpace is the configuration for the jetbrains space alerting provider
|
||||||
JetBrainsSpace *jetbrainsspace.AlertProvider `yaml:"jetbrainsspace,omitempty"`
|
JetBrainsSpace *jetbrainsspace.AlertProvider `yaml:"jetbrainsspace,omitempty"`
|
||||||
|
|
||||||
|
// Line is the configuration for the line alerting provider
|
||||||
|
Line *line.AlertProvider `yaml:"line,omitempty"`
|
||||||
|
|
||||||
// Matrix is the configuration for the matrix alerting provider
|
// Matrix is the configuration for the matrix alerting provider
|
||||||
Matrix *matrix.AlertProvider `yaml:"matrix,omitempty"`
|
Matrix *matrix.AlertProvider `yaml:"matrix,omitempty"`
|
||||||
|
|
||||||
@@ -71,6 +109,9 @@ type Config struct {
|
|||||||
// Messagebird is the configuration for the messagebird alerting provider
|
// Messagebird is the configuration for the messagebird alerting provider
|
||||||
Messagebird *messagebird.AlertProvider `yaml:"messagebird,omitempty"`
|
Messagebird *messagebird.AlertProvider `yaml:"messagebird,omitempty"`
|
||||||
|
|
||||||
|
// NewRelic is the configuration for the newrelic alerting provider
|
||||||
|
NewRelic *newrelic.AlertProvider `yaml:"newrelic,omitempty"`
|
||||||
|
|
||||||
// Ntfy is the configuration for the ntfy alerting provider
|
// Ntfy is the configuration for the ntfy alerting provider
|
||||||
Ntfy *ntfy.AlertProvider `yaml:"ntfy,omitempty"`
|
Ntfy *ntfy.AlertProvider `yaml:"ntfy,omitempty"`
|
||||||
|
|
||||||
@@ -80,20 +121,56 @@ type Config struct {
|
|||||||
// PagerDuty is the configuration for the pagerduty alerting provider
|
// PagerDuty is the configuration for the pagerduty alerting provider
|
||||||
PagerDuty *pagerduty.AlertProvider `yaml:"pagerduty,omitempty"`
|
PagerDuty *pagerduty.AlertProvider `yaml:"pagerduty,omitempty"`
|
||||||
|
|
||||||
|
// Plivo is the configuration for the plivo alerting provider
|
||||||
|
Plivo *plivo.AlertProvider `yaml:"plivo,omitempty"`
|
||||||
|
|
||||||
// Pushover is the configuration for the pushover alerting provider
|
// Pushover is the configuration for the pushover alerting provider
|
||||||
Pushover *pushover.AlertProvider `yaml:"pushover,omitempty"`
|
Pushover *pushover.AlertProvider `yaml:"pushover,omitempty"`
|
||||||
|
|
||||||
|
// RocketChat is the configuration for the rocketchat alerting provider
|
||||||
|
RocketChat *rocketchat.AlertProvider `yaml:"rocketchat,omitempty"`
|
||||||
|
|
||||||
|
// SendGrid is the configuration for the sendgrid alerting provider
|
||||||
|
SendGrid *sendgrid.AlertProvider `yaml:"sendgrid,omitempty"`
|
||||||
|
|
||||||
|
// Signal is the configuration for the signal alerting provider
|
||||||
|
Signal *signal.AlertProvider `yaml:"signal,omitempty"`
|
||||||
|
|
||||||
|
// SIGNL4 is the configuration for the signl4 alerting provider
|
||||||
|
SIGNL4 *signl4.AlertProvider `yaml:"signl4,omitempty"`
|
||||||
|
|
||||||
// Slack is the configuration for the slack alerting provider
|
// Slack is the configuration for the slack alerting provider
|
||||||
Slack *slack.AlertProvider `yaml:"slack,omitempty"`
|
Slack *slack.AlertProvider `yaml:"slack,omitempty"`
|
||||||
|
|
||||||
|
// Splunk is the configuration for the splunk alerting provider
|
||||||
|
Splunk *splunk.AlertProvider `yaml:"splunk,omitempty"`
|
||||||
|
|
||||||
|
// Squadcast is the configuration for the squadcast alerting provider
|
||||||
|
Squadcast *squadcast.AlertProvider `yaml:"squadcast,omitempty"`
|
||||||
|
|
||||||
// Teams is the configuration for the teams alerting provider
|
// Teams is the configuration for the teams alerting provider
|
||||||
Teams *teams.AlertProvider `yaml:"teams,omitempty"`
|
Teams *teams.AlertProvider `yaml:"teams,omitempty"`
|
||||||
|
|
||||||
|
// TeamsWorkflows is the configuration for the teams alerting provider using the new Workflow App Webhook Connector
|
||||||
|
TeamsWorkflows *teamsworkflows.AlertProvider `yaml:"teams-workflows,omitempty"`
|
||||||
|
|
||||||
// Telegram is the configuration for the telegram alerting provider
|
// Telegram is the configuration for the telegram alerting provider
|
||||||
Telegram *telegram.AlertProvider `yaml:"telegram,omitempty"`
|
Telegram *telegram.AlertProvider `yaml:"telegram,omitempty"`
|
||||||
|
|
||||||
// Twilio is the configuration for the twilio alerting provider
|
// Twilio is the configuration for the twilio alerting provider
|
||||||
Twilio *twilio.AlertProvider `yaml:"twilio,omitempty"`
|
Twilio *twilio.AlertProvider `yaml:"twilio,omitempty"`
|
||||||
|
|
||||||
|
// Vonage is the configuration for the vonage alerting provider
|
||||||
|
Vonage *vonage.AlertProvider `yaml:"vonage,omitempty"`
|
||||||
|
|
||||||
|
// Webex is the configuration for the webex alerting provider
|
||||||
|
Webex *webex.AlertProvider `yaml:"webex,omitempty"`
|
||||||
|
|
||||||
|
// Zapier is the configuration for the zapier alerting provider
|
||||||
|
Zapier *zapier.AlertProvider `yaml:"zapier,omitempty"`
|
||||||
|
|
||||||
|
// Zulip is the configuration for the zulip alerting provider
|
||||||
|
Zulip *zulip.AlertProvider `yaml:"zulip,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding alert.Type
|
// GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding alert.Type
|
||||||
@@ -110,7 +187,7 @@ func (config *Config) GetAlertingProviderByAlertType(alertType alert.Type) provi
|
|||||||
return fieldValue.Interface().(provider.AlertProvider)
|
return fieldValue.Interface().(provider.AlertProvider)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.Printf("[alerting.GetAlertingProviderByAlertType] No alerting provider found for alert type %s", alertType)
|
logr.Infof("[alerting.GetAlertingProviderByAlertType] No alerting provider found for alert type %s", alertType)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,73 @@
|
|||||||
package awsses
|
package awsses
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/TwiN/logr"
|
||||||
"github.com/aws/aws-sdk-go/aws"
|
"github.com/aws/aws-sdk-go/aws"
|
||||||
"github.com/aws/aws-sdk-go/aws/awserr"
|
"github.com/aws/aws-sdk-go/aws/awserr"
|
||||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||||
"github.com/aws/aws-sdk-go/aws/session"
|
"github.com/aws/aws-sdk-go/aws/session"
|
||||||
"github.com/aws/aws-sdk-go/service/ses"
|
"github.com/aws/aws-sdk-go/service/ses"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
CharSet = "UTF-8"
|
CharSet = "UTF-8"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using AWS Simple Email Service
|
var (
|
||||||
type AlertProvider struct {
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
ErrMissingFromOrToFields = errors.New("from and to fields are required")
|
||||||
|
ErrInvalidAWSAuthConfig = errors.New("either both or neither of access-key-id and secret-access-key must be specified")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
AccessKeyID string `yaml:"access-key-id"`
|
AccessKeyID string `yaml:"access-key-id"`
|
||||||
SecretAccessKey string `yaml:"secret-access-key"`
|
SecretAccessKey string `yaml:"secret-access-key"`
|
||||||
Region string `yaml:"region"`
|
Region string `yaml:"region"`
|
||||||
|
|
||||||
From string `yaml:"from"`
|
From string `yaml:"from"`
|
||||||
To string `yaml:"to"`
|
To string `yaml:"to"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.From) == 0 || len(cfg.To) == 0 {
|
||||||
|
return ErrMissingFromOrToFields
|
||||||
|
}
|
||||||
|
if !((len(cfg.AccessKeyID) == 0 && len(cfg.SecretAccessKey) == 0) || (len(cfg.AccessKeyID) > 0 && len(cfg.SecretAccessKey) > 0)) {
|
||||||
|
// if both AccessKeyID and SecretAccessKey are specified, we'll use these to authenticate,
|
||||||
|
// otherwise if neither are specified, then we'll fall back on IAM authentication.
|
||||||
|
return ErrInvalidAWSAuthConfig
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.AccessKeyID) > 0 {
|
||||||
|
cfg.AccessKeyID = override.AccessKeyID
|
||||||
|
}
|
||||||
|
if len(override.SecretAccessKey) > 0 {
|
||||||
|
cfg.SecretAccessKey = override.SecretAccessKey
|
||||||
|
}
|
||||||
|
if len(override.Region) > 0 {
|
||||||
|
cfg.Region = override.Region
|
||||||
|
}
|
||||||
|
if len(override.From) > 0 {
|
||||||
|
cfg.From = override.From
|
||||||
|
}
|
||||||
|
if len(override.To) > 0 {
|
||||||
|
cfg.To = override.To
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using AWS Simple Email Service
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
@@ -35,36 +78,37 @@ type AlertProvider struct {
|
|||||||
|
|
||||||
// Override is a case under which the default integration is overridden
|
// Override is a case under which the default integration is overridden
|
||||||
type Override struct {
|
type Override struct {
|
||||||
Group string `yaml:"group"`
|
Group string `yaml:"group"`
|
||||||
To string `yaml:"to"`
|
Config `yaml:",inline"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// Validate the provider's configuration
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
func (provider *AlertProvider) Validate() error {
|
||||||
registeredGroups := make(map[string]bool)
|
registeredGroups := make(map[string]bool)
|
||||||
if provider.Overrides != nil {
|
if provider.Overrides != nil {
|
||||||
for _, override := range provider.Overrides {
|
for _, override := range provider.Overrides {
|
||||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.To) == 0 {
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.To) == 0 {
|
||||||
return false
|
return ErrDuplicateGroupOverride
|
||||||
}
|
}
|
||||||
registeredGroups[override.Group] = true
|
registeredGroups[override.Group] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// if both AccessKeyID and SecretAccessKey are specified, we'll use these to authenticate,
|
return provider.DefaultConfig.Validate()
|
||||||
// otherwise if neither are specified, then we'll fall back on IAM authentication.
|
|
||||||
return len(provider.From) > 0 && len(provider.To) > 0 &&
|
|
||||||
((len(provider.AccessKeyID) == 0 && len(provider.SecretAccessKey) == 0) || (len(provider.AccessKeyID) > 0 && len(provider.SecretAccessKey) > 0))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
sess, err := provider.createSession()
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
svc := ses.New(sess)
|
awsSession, err := provider.createSession(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
svc := ses.New(awsSession)
|
||||||
subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
|
subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
|
||||||
emails := strings.Split(provider.getToForGroup(ep.Group), ",")
|
emails := strings.Split(cfg.To, ",")
|
||||||
|
|
||||||
input := &ses.SendEmailInput{
|
input := &ses.SendEmailInput{
|
||||||
Destination: &ses.Destination{
|
Destination: &ses.Destination{
|
||||||
@@ -82,26 +126,24 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
|||||||
Data: aws.String(subject),
|
Data: aws.String(subject),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Source: aws.String(provider.From),
|
Source: aws.String(cfg.From),
|
||||||
}
|
}
|
||||||
_, err = svc.SendEmail(input)
|
if _, err = svc.SendEmail(input); err != nil {
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
if aerr, ok := err.(awserr.Error); ok {
|
if aerr, ok := err.(awserr.Error); ok {
|
||||||
switch aerr.Code() {
|
switch aerr.Code() {
|
||||||
case ses.ErrCodeMessageRejected:
|
case ses.ErrCodeMessageRejected:
|
||||||
fmt.Println(ses.ErrCodeMessageRejected, aerr.Error())
|
logr.Error(ses.ErrCodeMessageRejected + ": " + aerr.Error())
|
||||||
case ses.ErrCodeMailFromDomainNotVerifiedException:
|
case ses.ErrCodeMailFromDomainNotVerifiedException:
|
||||||
fmt.Println(ses.ErrCodeMailFromDomainNotVerifiedException, aerr.Error())
|
logr.Error(ses.ErrCodeMailFromDomainNotVerifiedException + ": " + aerr.Error())
|
||||||
case ses.ErrCodeConfigurationSetDoesNotExistException:
|
case ses.ErrCodeConfigurationSetDoesNotExistException:
|
||||||
fmt.Println(ses.ErrCodeConfigurationSetDoesNotExistException, aerr.Error())
|
logr.Error(ses.ErrCodeConfigurationSetDoesNotExistException + ": " + aerr.Error())
|
||||||
default:
|
default:
|
||||||
fmt.Println(aerr.Error())
|
logr.Error(aerr.Error())
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Print the error, cast err to awserr.Error to get the Code and
|
// Print the error, cast err to awserr.Error to get the Code and
|
||||||
// Message from an error.
|
// Message from an error.
|
||||||
fmt.Println(err.Error())
|
logr.Error(err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
@@ -109,6 +151,16 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) createSession(cfg *Config) (*session.Session, error) {
|
||||||
|
awsConfig := &aws.Config{
|
||||||
|
Region: aws.String(cfg.Region),
|
||||||
|
}
|
||||||
|
if len(cfg.AccessKeyID) > 0 && len(cfg.SecretAccessKey) > 0 {
|
||||||
|
awsConfig.Credentials = credentials.NewStaticCredentials(cfg.AccessKeyID, cfg.SecretAccessKey, "")
|
||||||
|
}
|
||||||
|
return session.NewSession(awsConfig)
|
||||||
|
}
|
||||||
|
|
||||||
// buildMessageSubjectAndBody builds the message subject and body
|
// buildMessageSubjectAndBody builds the message subject and body
|
||||||
func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.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
|
var subject, message string
|
||||||
@@ -139,29 +191,38 @@ func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint,
|
|||||||
return subject, message + description + formattedConditionResults
|
return subject, message + description + formattedConditionResults
|
||||||
}
|
}
|
||||||
|
|
||||||
// getToForGroup returns the appropriate email integration to for a given group
|
|
||||||
func (provider *AlertProvider) getToForGroup(group string) string {
|
|
||||||
if provider.Overrides != nil {
|
|
||||||
for _, override := range provider.Overrides {
|
|
||||||
if group == override.Group {
|
|
||||||
return override.To
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return provider.To
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *AlertProvider) createSession() (*session.Session, error) {
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
config := &aws.Config{
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
Region: aws.String(provider.Region),
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if len(provider.AccessKeyID) > 0 && len(provider.SecretAccessKey) > 0 {
|
// Handle alert overrides
|
||||||
config.Credentials = credentials.NewStaticCredentials(provider.AccessKeyID, provider.SecretAccessKey, "")
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
}
|
}
|
||||||
return session.NewSession(config)
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,59 +7,61 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
invalidProvider := AlertProvider{}
|
invalidProvider := AlertProvider{}
|
||||||
if invalidProvider.IsValid() {
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
t.Error("provider shouldn't have been valid")
|
t.Error("provider shouldn't have been valid")
|
||||||
}
|
}
|
||||||
invalidProviderWithOneKey := AlertProvider{From: "from@example.com", To: "to@example.com", AccessKeyID: "1"}
|
invalidProviderWithOneKey := AlertProvider{DefaultConfig: Config{From: "from@example.com", To: "to@example.com", AccessKeyID: "1"}}
|
||||||
if invalidProviderWithOneKey.IsValid() {
|
if err := invalidProviderWithOneKey.Validate(); err == nil {
|
||||||
t.Error("provider shouldn't have been valid")
|
t.Error("provider shouldn't have been valid")
|
||||||
}
|
}
|
||||||
validProvider := AlertProvider{From: "from@example.com", To: "to@example.com"}
|
validProvider := AlertProvider{DefaultConfig: Config{From: "from@example.com", To: "to@example.com"}}
|
||||||
if !validProvider.IsValid() {
|
if err := validProvider.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
validProviderWithKeys := AlertProvider{From: "from@example.com", To: "to@example.com", AccessKeyID: "1", SecretAccessKey: "1"}
|
validProviderWithKeys := AlertProvider{DefaultConfig: Config{From: "from@example.com", To: "to@example.com", AccessKeyID: "1", SecretAccessKey: "1"}}
|
||||||
if !validProviderWithKeys.IsValid() {
|
if err := validProviderWithKeys.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||||
providerWithInvalidOverrideGroup := AlertProvider{
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
To: "to@example.com",
|
Config: Config{To: "to@example.com"},
|
||||||
Group: "",
|
Group: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if providerWithInvalidOverrideGroup.IsValid() {
|
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||||
t.Error("provider Group shouldn't have been valid")
|
t.Error("provider Group shouldn't have been valid")
|
||||||
}
|
}
|
||||||
providerWithInvalidOverrideTo := AlertProvider{
|
providerWithInvalidOverrideTo := AlertProvider{
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
To: "",
|
Config: Config{To: ""},
|
||||||
Group: "group",
|
Group: "group",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if providerWithInvalidOverrideTo.IsValid() {
|
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
|
||||||
t.Error("provider integration key shouldn't have been valid")
|
t.Error("provider integration key shouldn't have been valid")
|
||||||
}
|
}
|
||||||
providerWithValidOverride := AlertProvider{
|
providerWithValidOverride := AlertProvider{
|
||||||
From: "from@example.com",
|
DefaultConfig: Config{
|
||||||
To: "to@example.com",
|
From: "from@example.com",
|
||||||
|
To: "to@example.com",
|
||||||
|
},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
To: "to@example.com",
|
Config: Config{To: "to@example.com"},
|
||||||
Group: "group",
|
Group: "group",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if !providerWithValidOverride.IsValid() {
|
if err := providerWithValidOverride.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,64 +126,124 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_getToForGroup(t *testing.T) {
|
func TestAlertProvider_getConfigWithOverrides(t *testing.T) {
|
||||||
tests := []struct {
|
scenarios := []struct {
|
||||||
Name string
|
Name string
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
InputGroup string
|
InputGroup string
|
||||||
ExpectedOutput string
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "provider-no-override-specify-no-group-should-default",
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
To: "to@example.com",
|
DefaultConfig: Config{
|
||||||
|
From: "from@example.com",
|
||||||
|
To: "to@example.com",
|
||||||
|
},
|
||||||
Overrides: nil,
|
Overrides: nil,
|
||||||
},
|
},
|
||||||
InputGroup: "",
|
InputGroup: "",
|
||||||
ExpectedOutput: "to@example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-no-override-specify-group-should-default",
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
To: "to@example.com",
|
DefaultConfig: Config{
|
||||||
|
From: "from@example.com",
|
||||||
|
To: "to@example.com",
|
||||||
|
},
|
||||||
Overrides: nil,
|
Overrides: nil,
|
||||||
},
|
},
|
||||||
InputGroup: "group",
|
InputGroup: "group",
|
||||||
ExpectedOutput: "to@example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-with-override-specify-no-group-should-default",
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
To: "to@example.com",
|
DefaultConfig: Config{
|
||||||
|
From: "from@example.com",
|
||||||
|
To: "to@example.com",
|
||||||
|
},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
To: "to01@example.com",
|
Config: Config{To: "groupto@example.com"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InputGroup: "",
|
InputGroup: "",
|
||||||
ExpectedOutput: "to@example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-with-override-specify-group-should-override",
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
To: "to@example.com",
|
DefaultConfig: Config{
|
||||||
|
From: "from@example.com",
|
||||||
|
To: "to@example.com",
|
||||||
|
},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
To: "to01@example.com",
|
Config: Config{To: "groupto@example.com", SecretAccessKey: "wow", AccessKeyID: "noway"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InputGroup: "group",
|
InputGroup: "group",
|
||||||
ExpectedOutput: "to01@example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{From: "from@example.com", To: "groupto@example.com", SecretAccessKey: "wow", AccessKeyID: "noway"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-group-but-alert-override-should-override-group-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
From: "from@example.com",
|
||||||
|
To: "to@example.com",
|
||||||
|
},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{From: "from@example.com", To: "groupto@example.com", SecretAccessKey: "sekrit"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{
|
||||||
|
ProviderOverride: map[string]any{
|
||||||
|
"to": "alertto@example.com",
|
||||||
|
"access-key-id": 123,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ExpectedOutput: Config{To: "alertto@example.com", From: "from@example.com", AccessKeyID: "123", SecretAccessKey: "sekrit"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, scenario := range scenarios {
|
||||||
t.Run(tt.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
if got := tt.Provider.getToForGroup(tt.InputGroup); got != tt.ExpectedOutput {
|
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||||
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.From != scenario.ExpectedOutput.From {
|
||||||
|
t.Errorf("expected From to be %s, got %s", scenario.ExpectedOutput.From, got.From)
|
||||||
|
}
|
||||||
|
if got.To != scenario.ExpectedOutput.To {
|
||||||
|
t.Errorf("expected To to be %s, got %s", scenario.ExpectedOutput.To, got.To)
|
||||||
|
}
|
||||||
|
if got.AccessKeyID != scenario.ExpectedOutput.AccessKeyID {
|
||||||
|
t.Errorf("expected AccessKeyID to be %s, got %s", scenario.ExpectedOutput.AccessKeyID, got.AccessKeyID)
|
||||||
|
}
|
||||||
|
if got.SecretAccessKey != scenario.ExpectedOutput.SecretAccessKey {
|
||||||
|
t.Errorf("expected SecretAccessKey to be %s, got %s", scenario.ExpectedOutput.SecretAccessKey, got.SecretAccessKey)
|
||||||
|
}
|
||||||
|
if got.Region != scenario.ExpectedOutput.Region {
|
||||||
|
t.Errorf("expected Region to be %s, got %s", scenario.ExpectedOutput.Region, got.Region)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package custom
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -10,11 +11,14 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using a custom HTTP request
|
var (
|
||||||
// Technically, all alert providers should be reachable using the custom alert provider
|
ErrURLNotSet = errors.New("url not set")
|
||||||
type AlertProvider struct {
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
URL string `yaml:"url"`
|
URL string `yaml:"url"`
|
||||||
Method string `yaml:"method,omitempty"`
|
Method string `yaml:"method,omitempty"`
|
||||||
Body string `yaml:"body,omitempty"`
|
Body string `yaml:"body,omitempty"`
|
||||||
@@ -23,64 +27,66 @@ type AlertProvider struct {
|
|||||||
|
|
||||||
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
||||||
ClientConfig *client.Config `yaml:"client,omitempty"`
|
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.URL) == 0 {
|
||||||
|
return ErrURLNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if override.ClientConfig != nil {
|
||||||
|
cfg.ClientConfig = override.ClientConfig
|
||||||
|
}
|
||||||
|
if len(override.URL) > 0 {
|
||||||
|
cfg.URL = override.URL
|
||||||
|
}
|
||||||
|
if len(override.Method) > 0 {
|
||||||
|
cfg.Method = override.Method
|
||||||
|
}
|
||||||
|
if len(override.Body) > 0 {
|
||||||
|
cfg.Body = override.Body
|
||||||
|
}
|
||||||
|
if len(override.Headers) > 0 {
|
||||||
|
cfg.Headers = override.Headers
|
||||||
|
}
|
||||||
|
if len(override.Placeholders) > 0 {
|
||||||
|
cfg.Placeholders = override.Placeholders
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using a custom HTTP request
|
||||||
|
// Technically, all alert providers should be reachable using the custom alert provider
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
|
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||||
|
Overrides []Override `yaml:"overrides,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// Override is a case under which the default integration is overridden
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
type Override struct {
|
||||||
if provider.ClientConfig == nil {
|
Group string `yaml:"group"`
|
||||||
provider.ClientConfig = client.GetDefaultConfig()
|
Config `yaml:",inline"`
|
||||||
}
|
|
||||||
return len(provider.URL) > 0 && provider.ClientConfig != nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAlertStatePlaceholderValue returns the Placeholder value for ALERT_TRIGGERED_OR_RESOLVED if configured
|
// Validate the provider's configuration
|
||||||
func (provider *AlertProvider) GetAlertStatePlaceholderValue(resolved bool) string {
|
func (provider *AlertProvider) Validate() error {
|
||||||
status := "TRIGGERED"
|
return provider.DefaultConfig.Validate()
|
||||||
if resolved {
|
|
||||||
status = "RESOLVED"
|
|
||||||
}
|
|
||||||
if _, ok := provider.Placeholders["ALERT_TRIGGERED_OR_RESOLVED"]; ok {
|
|
||||||
if val, ok := provider.Placeholders["ALERT_TRIGGERED_OR_RESOLVED"][status]; ok {
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return status
|
|
||||||
}
|
|
||||||
|
|
||||||
func (provider *AlertProvider) buildHTTPRequest(ep *endpoint.Endpoint, alert *alert.Alert, 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]", 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)
|
|
||||||
if resolved {
|
|
||||||
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
|
|
||||||
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
|
|
||||||
} else {
|
|
||||||
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
|
|
||||||
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
|
|
||||||
}
|
|
||||||
if len(method) == 0 {
|
|
||||||
method = http.MethodGet
|
|
||||||
}
|
|
||||||
bodyBuffer := bytes.NewBuffer([]byte(body))
|
|
||||||
request, _ := http.NewRequest(method, url, bodyBuffer)
|
|
||||||
for k, v := range provider.Headers {
|
|
||||||
request.Header.Set(k, v)
|
|
||||||
}
|
|
||||||
return request
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
request := provider.buildHTTPRequest(ep, alert, resolved)
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request := provider.buildHTTPRequest(cfg, ep, alert, result, resolved)
|
||||||
|
response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -92,7 +98,83 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) buildHTTPRequest(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) *http.Request {
|
||||||
|
body, url, method := cfg.Body, cfg.URL, cfg.Method
|
||||||
|
body = strings.ReplaceAll(body, "[ALERT_DESCRIPTION]", alert.GetDescription())
|
||||||
|
url = strings.ReplaceAll(url, "[ALERT_DESCRIPTION]", alert.GetDescription())
|
||||||
|
body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", ep.Name)
|
||||||
|
url = strings.ReplaceAll(url, "[ENDPOINT_NAME]", ep.Name)
|
||||||
|
body = strings.ReplaceAll(body, "[ENDPOINT_GROUP]", ep.Group)
|
||||||
|
url = strings.ReplaceAll(url, "[ENDPOINT_GROUP]", ep.Group)
|
||||||
|
body = strings.ReplaceAll(body, "[ENDPOINT_URL]", ep.URL)
|
||||||
|
url = strings.ReplaceAll(url, "[ENDPOINT_URL]", ep.URL)
|
||||||
|
resultErrors := strings.ReplaceAll(strings.Join(result.Errors, ","), "\"", "\\\"")
|
||||||
|
body = strings.ReplaceAll(body, "[RESULT_ERRORS]", resultErrors)
|
||||||
|
url = strings.ReplaceAll(url, "[RESULT_ERRORS]", resultErrors)
|
||||||
|
if resolved {
|
||||||
|
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, true))
|
||||||
|
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, true))
|
||||||
|
} else {
|
||||||
|
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, false))
|
||||||
|
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, false))
|
||||||
|
}
|
||||||
|
if len(method) == 0 {
|
||||||
|
method = http.MethodGet
|
||||||
|
}
|
||||||
|
bodyBuffer := bytes.NewBuffer([]byte(body))
|
||||||
|
request, _ := http.NewRequest(method, url, bodyBuffer)
|
||||||
|
for k, v := range cfg.Headers {
|
||||||
|
request.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAlertStatePlaceholderValue returns the Placeholder value for ALERT_TRIGGERED_OR_RESOLVED if configured
|
||||||
|
func (provider *AlertProvider) GetAlertStatePlaceholderValue(cfg *Config, resolved bool) string {
|
||||||
|
status := "TRIGGERED"
|
||||||
|
if resolved {
|
||||||
|
status = "RESOLVED"
|
||||||
|
}
|
||||||
|
if _, ok := cfg.Placeholders["ALERT_TRIGGERED_OR_RESOLVED"]; ok {
|
||||||
|
if val, ok := cfg.Placeholders["ALERT_TRIGGERED_OR_RESOLVED"][status]; ok {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,24 +12,18 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/test"
|
"github.com/TwiN/gatus/v5/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
t.Run("invalid-provider", func(t *testing.T) {
|
t.Run("invalid-provider", func(t *testing.T) {
|
||||||
invalidProvider := AlertProvider{URL: ""}
|
invalidProvider := AlertProvider{DefaultConfig: Config{URL: ""}}
|
||||||
if invalidProvider.IsValid() {
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
t.Error("provider shouldn't have been valid")
|
t.Error("provider shouldn't have been valid")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
t.Run("valid-provider", func(t *testing.T) {
|
t.Run("valid-provider", func(t *testing.T) {
|
||||||
validProvider := AlertProvider{URL: "https://example.com"}
|
validProvider := AlertProvider{DefaultConfig: Config{URL: "https://example.com"}}
|
||||||
if validProvider.ClientConfig != nil {
|
if err := validProvider.Validate(); err != nil {
|
||||||
t.Error("provider client config should have been nil prior to IsValid() being executed")
|
|
||||||
}
|
|
||||||
if !validProvider.IsValid() {
|
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
if validProvider.ClientConfig == nil {
|
|
||||||
t.Error("provider client config should have been set after IsValid() was executed")
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +41,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -57,7 +51,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "triggered-error",
|
Name: "triggered-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -67,7 +61,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -77,7 +71,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved-error",
|
Name: "resolved-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -111,9 +105,11 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_buildHTTPRequest(t *testing.T) {
|
func TestAlertProvider_buildHTTPRequest(t *testing.T) {
|
||||||
customAlertProvider := &AlertProvider{
|
alertProvider := &AlertProvider{
|
||||||
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]&url=[ENDPOINT_URL]",
|
DefaultConfig: Config{
|
||||||
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ENDPOINT_URL],[ALERT_TRIGGERED_OR_RESOLVED]",
|
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]&url=[ENDPOINT_URL]",
|
||||||
|
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ENDPOINT_URL],[ALERT_TRIGGERED_OR_RESOLVED]",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
alertDescription := "alert-description"
|
alertDescription := "alert-description"
|
||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
@@ -123,13 +119,13 @@ func TestAlertProvider_buildHTTPRequest(t *testing.T) {
|
|||||||
ExpectedBody string
|
ExpectedBody string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
AlertProvider: customAlertProvider,
|
AlertProvider: alertProvider,
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=RESOLVED&description=alert-description&url=https://example.com",
|
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=RESOLVED&description=alert-description&url=https://example.com",
|
||||||
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,RESOLVED",
|
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,RESOLVED",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
AlertProvider: customAlertProvider,
|
AlertProvider: alertProvider,
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=TRIGGERED&description=alert-description&url=https://example.com",
|
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=TRIGGERED&description=alert-description&url=https://example.com",
|
||||||
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,TRIGGERED",
|
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,TRIGGERED",
|
||||||
@@ -137,9 +133,67 @@ func TestAlertProvider_buildHTTPRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(fmt.Sprintf("resolved-%v-with-default-placeholders", scenario.Resolved), func(t *testing.T) {
|
t.Run(fmt.Sprintf("resolved-%v-with-default-placeholders", scenario.Resolved), func(t *testing.T) {
|
||||||
request := customAlertProvider.buildHTTPRequest(
|
request := alertProvider.buildHTTPRequest(
|
||||||
|
&alertProvider.DefaultConfig,
|
||||||
&endpoint.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},
|
&alert.Alert{Description: &alertDescription},
|
||||||
|
&endpoint.Result{Errors: []string{}},
|
||||||
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if request.URL.String() != scenario.ExpectedURL {
|
||||||
|
t.Error("expected URL to be", scenario.ExpectedURL, "got", request.URL.String())
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(request.Body)
|
||||||
|
if string(body) != scenario.ExpectedBody {
|
||||||
|
t.Error("expected body to be", scenario.ExpectedBody, "got", string(body))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProviderWithResultErrors_buildHTTPRequest(t *testing.T) {
|
||||||
|
alertProvider := &AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]&url=[ENDPOINT_URL]&error=[RESULT_ERRORS]",
|
||||||
|
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ENDPOINT_URL],[ALERT_TRIGGERED_OR_RESOLVED],[RESULT_ERRORS]",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
alertDescription := "alert-description"
|
||||||
|
scenarios := []struct {
|
||||||
|
AlertProvider *AlertProvider
|
||||||
|
Resolved bool
|
||||||
|
ExpectedURL string
|
||||||
|
ExpectedBody string
|
||||||
|
Errors []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
AlertProvider: alertProvider,
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=RESOLVED&description=alert-description&url=https://example.com&error=",
|
||||||
|
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,RESOLVED,",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
AlertProvider: alertProvider,
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=TRIGGERED&description=alert-description&url=https://example.com&error=error1,error2",
|
||||||
|
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,TRIGGERED,error1,error2",
|
||||||
|
Errors: []string{"error1", "error2"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
AlertProvider: alertProvider,
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=TRIGGERED&description=alert-description&url=https://example.com&error=test \\\"error with quotes\\\"",
|
||||||
|
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,TRIGGERED,test \\\"error with quotes\\\"",
|
||||||
|
Errors: []string{"test \"error with quotes\""},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(fmt.Sprintf("resolved-%v-with-default-placeholders-and-result-errors", scenario.Resolved), func(t *testing.T) {
|
||||||
|
request := alertProvider.buildHTTPRequest(
|
||||||
|
&alertProvider.DefaultConfig,
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group", URL: "https://example.com"},
|
||||||
|
&alert.Alert{Description: &alertDescription},
|
||||||
|
&endpoint.Result{Errors: scenario.Errors},
|
||||||
scenario.Resolved,
|
scenario.Resolved,
|
||||||
)
|
)
|
||||||
if request.URL.String() != scenario.ExpectedURL {
|
if request.URL.String() != scenario.ExpectedURL {
|
||||||
@@ -154,14 +208,16 @@ func TestAlertProvider_buildHTTPRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
|
func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
|
||||||
customAlertProvider := &AlertProvider{
|
alertProvider := &AlertProvider{
|
||||||
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
DefaultConfig: Config{
|
||||||
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
||||||
Headers: nil,
|
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
||||||
Placeholders: map[string]map[string]string{
|
Headers: nil,
|
||||||
"ALERT_TRIGGERED_OR_RESOLVED": {
|
Placeholders: map[string]map[string]string{
|
||||||
"RESOLVED": "fixed",
|
"ALERT_TRIGGERED_OR_RESOLVED": {
|
||||||
"TRIGGERED": "boom",
|
"RESOLVED": "fixed",
|
||||||
|
"TRIGGERED": "boom",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -173,13 +229,13 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
|
|||||||
ExpectedBody string
|
ExpectedBody string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
AlertProvider: customAlertProvider,
|
AlertProvider: alertProvider,
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=fixed&description=alert-description",
|
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=fixed&description=alert-description",
|
||||||
ExpectedBody: "endpoint-name,endpoint-group,alert-description,fixed",
|
ExpectedBody: "endpoint-name,endpoint-group,alert-description,fixed",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
AlertProvider: customAlertProvider,
|
AlertProvider: alertProvider,
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=boom&description=alert-description",
|
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=boom&description=alert-description",
|
||||||
ExpectedBody: "endpoint-name,endpoint-group,alert-description,boom",
|
ExpectedBody: "endpoint-name,endpoint-group,alert-description,boom",
|
||||||
@@ -187,9 +243,11 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(fmt.Sprintf("resolved-%v-with-custom-placeholders", scenario.Resolved), func(t *testing.T) {
|
t.Run(fmt.Sprintf("resolved-%v-with-custom-placeholders", scenario.Resolved), func(t *testing.T) {
|
||||||
request := customAlertProvider.buildHTTPRequest(
|
request := alertProvider.buildHTTPRequest(
|
||||||
|
&alertProvider.DefaultConfig,
|
||||||
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||||
&alert.Alert{Description: &alertDescription},
|
&alert.Alert{Description: &alertDescription},
|
||||||
|
&endpoint.Result{},
|
||||||
scenario.Resolved,
|
scenario.Resolved,
|
||||||
)
|
)
|
||||||
if request.URL.String() != scenario.ExpectedURL {
|
if request.URL.String() != scenario.ExpectedURL {
|
||||||
@@ -204,15 +262,17 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
|
func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
|
||||||
customAlertProvider := &AlertProvider{
|
alertProvider := &AlertProvider{
|
||||||
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
DefaultConfig: Config{
|
||||||
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
||||||
|
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if customAlertProvider.GetAlertStatePlaceholderValue(true) != "RESOLVED" {
|
if alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, true) != "RESOLVED" {
|
||||||
t.Error("expected RESOLVED, got", customAlertProvider.GetAlertStatePlaceholderValue(true))
|
t.Error("expected RESOLVED, got", alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, true))
|
||||||
}
|
}
|
||||||
if customAlertProvider.GetAlertStatePlaceholderValue(false) != "TRIGGERED" {
|
if alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, false) != "TRIGGERED" {
|
||||||
t.Error("expected TRIGGERED, got", customAlertProvider.GetAlertStatePlaceholderValue(false))
|
t.Error("expected TRIGGERED, got", alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, false))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,3 +284,119 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
|||||||
t.Error("expected default alert to be nil")
|
t.Error("expected default alert to be nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputGroup string
|
||||||
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "http://example.com", Body: "default-body"},
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{URL: "http://example.com", Body: "default-body"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "http://example.com"},
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{URL: "http://example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "http://example.com"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{URL: "http://group-example.com", Headers: map[string]string{"Cache": "true"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{URL: "http://example.com", Headers: map[string]string{"Cache": "true"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "http://example.com", Body: "default-body"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{URL: "http://group-example.com", Body: "group-body"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{URL: "http://group-example.com", Body: "group-body"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "http://example.com"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{URL: "http://group-example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"url": "http://alert-example.com", "body": "alert-body"}},
|
||||||
|
ExpectedOutput: Config{URL: "http://alert-example.com", Body: "alert-body"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-partial-overrides",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "http://example.com"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{Method: "POST"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"body": "alert-body"}},
|
||||||
|
ExpectedOutput: Config{URL: "http://example.com", Body: "alert-body", Method: "POST"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.URL != scenario.ExpectedOutput.URL {
|
||||||
|
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.URL, got.URL)
|
||||||
|
}
|
||||||
|
if got.Body != scenario.ExpectedOutput.Body {
|
||||||
|
t.Errorf("expected body to be %s, got %s", scenario.ExpectedOutput.Body, got.Body)
|
||||||
|
}
|
||||||
|
if got.Headers != nil {
|
||||||
|
for key, value := range scenario.ExpectedOutput.Headers {
|
||||||
|
if got.Headers[key] != value {
|
||||||
|
t.Errorf("expected header %s to be %s, got %s", key, value, got.Headers[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
214
alerting/provider/datadog/datadog.go
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
package datadog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrAPIKeyNotSet = errors.New("api-key not set")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
APIKey string `yaml:"api-key"` // Datadog API key
|
||||||
|
Site string `yaml:"site,omitempty"` // Datadog site (e.g., datadoghq.com, datadoghq.eu)
|
||||||
|
Tags []string `yaml:"tags,omitempty"` // Additional tags to include
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.APIKey) == 0 {
|
||||||
|
return ErrAPIKeyNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.APIKey) > 0 {
|
||||||
|
cfg.APIKey = override.APIKey
|
||||||
|
}
|
||||||
|
if len(override.Site) > 0 {
|
||||||
|
cfg.Site = override.Site
|
||||||
|
}
|
||||||
|
if len(override.Tags) > 0 {
|
||||||
|
cfg.Tags = override.Tags
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using Datadog
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
|
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||||
|
Overrides []Override `yaml:"overrides,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override is a case under which the default integration is overridden
|
||||||
|
type Override struct {
|
||||||
|
Group string `yaml:"group"`
|
||||||
|
Config `yaml:",inline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the provider's configuration
|
||||||
|
func (provider *AlertProvider) Validate() error {
|
||||||
|
registeredGroups := make(map[string]bool)
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||||
|
return ErrDuplicateGroupOverride
|
||||||
|
}
|
||||||
|
registeredGroups[override.Group] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return provider.DefaultConfig.Validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send an alert using the provider
|
||||||
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
site := cfg.Site
|
||||||
|
if site == "" {
|
||||||
|
site = "datadoghq.com"
|
||||||
|
}
|
||||||
|
body, err := provider.buildRequestBody(cfg, ep, alert, result, resolved)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(body)
|
||||||
|
url := fmt.Sprintf("https://api.%s/api/v1/events", site)
|
||||||
|
request, err := http.NewRequest(http.MethodPost, url, buffer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set("DD-API-KEY", cfg.APIKey)
|
||||||
|
response, err := client.GetHTTPClient(nil).Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode >= 400 {
|
||||||
|
body, _ := io.ReadAll(response.Body)
|
||||||
|
return fmt.Errorf("call to datadog alert returned status code %d: %s", response.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Body struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Priority string `json:"priority"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
AlertType string `json:"alert_type"`
|
||||||
|
SourceType string `json:"source_type_name"`
|
||||||
|
DateHappened int64 `json:"date_happened,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestBody builds the request body for the provider
|
||||||
|
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
|
||||||
|
var title, text, priority, alertType string
|
||||||
|
if resolved {
|
||||||
|
title = fmt.Sprintf("Resolved: %s", ep.DisplayName())
|
||||||
|
text = fmt.Sprintf("Alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||||
|
priority = "normal"
|
||||||
|
alertType = "success"
|
||||||
|
} else {
|
||||||
|
title = fmt.Sprintf("Alert: %s", ep.DisplayName())
|
||||||
|
text = fmt.Sprintf("Alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||||
|
priority = "normal"
|
||||||
|
alertType = "error"
|
||||||
|
}
|
||||||
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
|
text += fmt.Sprintf("\n\nDescription: %s", alertDescription)
|
||||||
|
}
|
||||||
|
if len(result.ConditionResults) > 0 {
|
||||||
|
text += "\n\nCondition Results:"
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
var status string
|
||||||
|
if conditionResult.Success {
|
||||||
|
status = "✅"
|
||||||
|
} else {
|
||||||
|
status = "❌"
|
||||||
|
}
|
||||||
|
text += fmt.Sprintf("\n%s %s", status, conditionResult.Condition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tags := []string{
|
||||||
|
"source:gatus",
|
||||||
|
fmt.Sprintf("endpoint:%s", ep.Name),
|
||||||
|
fmt.Sprintf("status:%s", alertType),
|
||||||
|
}
|
||||||
|
if ep.Group != "" {
|
||||||
|
tags = append(tags, fmt.Sprintf("group:%s", ep.Group))
|
||||||
|
}
|
||||||
|
// Append custom tags
|
||||||
|
if len(cfg.Tags) > 0 {
|
||||||
|
tags = append(tags, cfg.Tags...)
|
||||||
|
}
|
||||||
|
body := Body{
|
||||||
|
Title: title,
|
||||||
|
Text: text,
|
||||||
|
Priority: priority,
|
||||||
|
Tags: tags,
|
||||||
|
AlertType: alertType,
|
||||||
|
SourceType: "gatus",
|
||||||
|
DateHappened: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
bodyAsJSON, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return bodyAsJSON, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
|
return provider.DefaultAlert
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
183
alerting/provider/datadog/datadog_test.go
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
package datadog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/TwiN/gatus/v5/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
provider AlertProvider
|
||||||
|
expected error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid-us1",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.com"}},
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid-eu",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.eu"}},
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid-with-tags",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.com", Tags: []string{"env:prod", "service:gatus"}}},
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-api-key",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{Site: "datadoghq.com"}},
|
||||||
|
expected: ErrAPIKeyNotSet,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
|
err := scenario.provider.Validate()
|
||||||
|
if err != scenario.expected {
|
||||||
|
t.Errorf("expected %v, got %v", scenario.expected, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
|
defer client.InjectHTTPClient(nil)
|
||||||
|
firstDescription := "description-1"
|
||||||
|
secondDescription := "description-2"
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
provider AlertProvider
|
||||||
|
alert alert.Alert
|
||||||
|
resolved bool
|
||||||
|
mockRoundTripper test.MockRoundTripper
|
||||||
|
expectedError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "triggered",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.com"}},
|
||||||
|
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
resolved: false,
|
||||||
|
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
if r.Host != "api.datadoghq.com" {
|
||||||
|
t.Errorf("expected host api.datadoghq.com, got %s", r.Host)
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/api/v1/events" {
|
||||||
|
t.Errorf("expected path /api/v1/events, got %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.Header.Get("DD-API-KEY") != "dd-api-key-123" {
|
||||||
|
t.Errorf("expected DD-API-KEY header to be 'dd-api-key-123', got %s", r.Header.Get("DD-API-KEY"))
|
||||||
|
}
|
||||||
|
body := make(map[string]interface{})
|
||||||
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
if body["title"] == nil {
|
||||||
|
t.Error("expected 'title' field in request body")
|
||||||
|
}
|
||||||
|
title := body["title"].(string)
|
||||||
|
if !strings.Contains(title, "Alert") {
|
||||||
|
t.Errorf("expected title to contain 'Alert', got %s", title)
|
||||||
|
}
|
||||||
|
if body["alert_type"] != "error" {
|
||||||
|
t.Errorf("expected alert_type to be 'error', got %v", body["alert_type"])
|
||||||
|
}
|
||||||
|
if body["priority"] != "normal" {
|
||||||
|
t.Errorf("expected priority to be 'normal', got %v", body["priority"])
|
||||||
|
}
|
||||||
|
text := body["text"].(string)
|
||||||
|
if !strings.Contains(text, "failed 3 time(s)") {
|
||||||
|
t.Errorf("expected text to contain failure count, got %s", text)
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
expectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "triggered-with-tags",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.com", Tags: []string{"env:prod", "service:gatus"}}},
|
||||||
|
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
resolved: false,
|
||||||
|
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
body := make(map[string]interface{})
|
||||||
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
tags := body["tags"].([]interface{})
|
||||||
|
// Datadog adds 3 base tags (source, endpoint, status) + custom tags
|
||||||
|
if len(tags) < 5 {
|
||||||
|
t.Errorf("expected at least 5 tags (3 base + 2 custom), got %d", len(tags))
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
expectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "resolved",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.eu"}},
|
||||||
|
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
resolved: true,
|
||||||
|
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
if r.Host != "api.datadoghq.eu" {
|
||||||
|
t.Errorf("expected host api.datadoghq.eu, got %s", r.Host)
|
||||||
|
}
|
||||||
|
body := make(map[string]interface{})
|
||||||
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
title := body["title"].(string)
|
||||||
|
if !strings.Contains(title, "Resolved") {
|
||||||
|
t.Errorf("expected title to contain 'Resolved', got %s", title)
|
||||||
|
}
|
||||||
|
if body["alert_type"] != "success" {
|
||||||
|
t.Errorf("expected alert_type to be 'success', got %v", body["alert_type"])
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
expectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "error-response",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.com"}},
|
||||||
|
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
resolved: false,
|
||||||
|
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusForbidden, 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_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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package discord
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -10,46 +11,73 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrWebhookURLNotSet = errors.New("webhook-url not set")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
WebhookURL string `yaml:"webhook-url"`
|
||||||
|
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.WebhookURL) == 0 {
|
||||||
|
return ErrWebhookURLNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.WebhookURL) > 0 {
|
||||||
|
cfg.WebhookURL = override.WebhookURL
|
||||||
|
}
|
||||||
|
if len(override.Title) > 0 {
|
||||||
|
cfg.Title = override.Title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Discord
|
// AlertProvider is the configuration necessary for sending an alert using Discord
|
||||||
type AlertProvider struct {
|
type AlertProvider struct {
|
||||||
WebhookURL string `yaml:"webhook-url"`
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
// Overrides is a list of Override that may be prioritized over the default configuration
|
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||||
Overrides []Override `yaml:"overrides,omitempty"`
|
Overrides []Override `yaml:"overrides,omitempty"`
|
||||||
|
|
||||||
// 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 {
|
type Override struct {
|
||||||
Group string `yaml:"group"`
|
Group string `yaml:"group"`
|
||||||
WebhookURL string `yaml:"webhook-url"`
|
Config `yaml:",inline"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// Validate the provider's configuration
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
func (provider *AlertProvider) Validate() error {
|
||||||
registeredGroups := make(map[string]bool)
|
registeredGroups := make(map[string]bool)
|
||||||
if provider.Overrides != nil {
|
if provider.Overrides != nil {
|
||||||
for _, override := range provider.Overrides {
|
for _, override := range provider.Overrides {
|
||||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
|
||||||
return false
|
return ErrDuplicateGroupOverride
|
||||||
}
|
}
|
||||||
registeredGroups[override.Group] = true
|
registeredGroups[override.Group] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return len(provider.WebhookURL) > 0
|
return provider.DefaultConfig.Validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer)
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||||
|
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -85,7 +113,7 @@ type Field struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildRequestBody builds the request body for the provider
|
// buildRequestBody builds the request body for the provider
|
||||||
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
var message string
|
var message string
|
||||||
var colorCode int
|
var colorCode int
|
||||||
if resolved {
|
if resolved {
|
||||||
@@ -110,8 +138,8 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
|||||||
description = ":\n> " + alertDescription
|
description = ":\n> " + alertDescription
|
||||||
}
|
}
|
||||||
title := ":helmet_with_white_cross: Gatus"
|
title := ":helmet_with_white_cross: Gatus"
|
||||||
if provider.Title != "" {
|
if cfg.Title != "" {
|
||||||
title = provider.Title
|
title = cfg.Title
|
||||||
}
|
}
|
||||||
body := Body{
|
body := Body{
|
||||||
Content: "",
|
Content: "",
|
||||||
@@ -134,19 +162,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
|||||||
return bodyAsJSON
|
return bodyAsJSON
|
||||||
}
|
}
|
||||||
|
|
||||||
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
|
|
||||||
func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
|
|
||||||
if provider.Overrides != nil {
|
|
||||||
for _, override := range provider.Overrides {
|
|
||||||
if group == override.Group {
|
|
||||||
return override.WebhookURL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return provider.WebhookURL
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,50 +11,52 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/test"
|
"github.com/TwiN/gatus/v5/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
invalidProvider := AlertProvider{WebhookURL: ""}
|
invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
|
||||||
if invalidProvider.IsValid() {
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
t.Error("provider shouldn't have been valid")
|
t.Error("provider shouldn't have been valid")
|
||||||
}
|
}
|
||||||
validProvider := AlertProvider{WebhookURL: "http://example.com"}
|
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
|
||||||
if !validProvider.IsValid() {
|
if err := validProvider.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||||
providerWithInvalidOverrideGroup := AlertProvider{
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
WebhookURL: "http://example.com",
|
Config: Config{WebhookURL: "http://example.com"},
|
||||||
Group: "",
|
Group: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if providerWithInvalidOverrideGroup.IsValid() {
|
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||||
t.Error("provider Group shouldn't have been valid")
|
t.Error("provider Group shouldn't have been valid")
|
||||||
}
|
}
|
||||||
providerWithInvalidOverrideTo := AlertProvider{
|
providerWithInvalidOverrideTo := AlertProvider{
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
WebhookURL: "",
|
Config: Config{WebhookURL: ""},
|
||||||
Group: "group",
|
Group: "group",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if providerWithInvalidOverrideTo.IsValid() {
|
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
|
||||||
t.Error("provider integration key shouldn't have been valid")
|
t.Error("provider integration key shouldn't have been valid")
|
||||||
}
|
}
|
||||||
providerWithValidOverride := AlertProvider{
|
providerWithValidOverride := AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{
|
||||||
|
WebhookURL: "http://example.com",
|
||||||
|
},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
WebhookURL: "http://example.com",
|
Config: Config{WebhookURL: "http://example.com"},
|
||||||
Group: "group",
|
Group: "group",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if !providerWithValidOverride.IsValid() {
|
if err := providerWithValidOverride.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,7 +76,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -84,7 +86,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "triggered-error",
|
Name: "triggered-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -94,7 +96,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -104,7 +106,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved-error",
|
Name: "resolved-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -114,7 +116,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "triggered-with-modified-title",
|
Name: "triggered-with-modified-title",
|
||||||
Provider: AlertProvider{Title: title},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com", Title: title}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -122,6 +124,16 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}),
|
}),
|
||||||
ExpectedError: false,
|
ExpectedError: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-with-webhook-override",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3, ProviderOverride: map[string]any{"webhook-url": "http://example01.com"}},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
@@ -175,7 +187,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "triggered-with-modified-title",
|
Name: "triggered-with-modified-title",
|
||||||
Provider: AlertProvider{Title: title},
|
Provider: AlertProvider{DefaultConfig: Config{Title: title}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\"provider-title\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332,\"fields\":[{\"name\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n:x: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}",
|
ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\"provider-title\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332,\"fields\":[{\"name\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n:x: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}",
|
||||||
@@ -183,7 +195,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
{
|
{
|
||||||
Name: "triggered-with-no-conditions",
|
Name: "triggered-with-no-conditions",
|
||||||
NoConditions: true,
|
NoConditions: true,
|
||||||
Provider: AlertProvider{Title: title},
|
Provider: AlertProvider{DefaultConfig: Config{Title: title}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\"provider-title\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332}]}",
|
ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\"provider-title\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332}]}",
|
||||||
@@ -200,6 +212,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
body := scenario.Provider.buildRequestBody(
|
body := scenario.Provider.buildRequestBody(
|
||||||
|
&scenario.Provider.DefaultConfig,
|
||||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&endpoint.Result{
|
&endpoint.Result{
|
||||||
@@ -227,64 +240,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
tests := []struct {
|
scenarios := []struct {
|
||||||
Name string
|
Name string
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
InputGroup string
|
InputGroup string
|
||||||
ExpectedOutput string
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "provider-no-override-specify-no-group-should-default",
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: nil,
|
Overrides: nil,
|
||||||
},
|
},
|
||||||
InputGroup: "",
|
InputGroup: "",
|
||||||
ExpectedOutput: "http://example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-no-override-specify-group-should-default",
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: nil,
|
Overrides: nil,
|
||||||
},
|
},
|
||||||
InputGroup: "group",
|
InputGroup: "group",
|
||||||
ExpectedOutput: "http://example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-with-override-specify-no-group-should-default",
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
WebhookURL: "http://example01.com",
|
Config: Config{WebhookURL: "http://group-example.com"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InputGroup: "",
|
InputGroup: "",
|
||||||
ExpectedOutput: "http://example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-with-override-specify-group-should-override",
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
WebhookURL: "http://example01.com",
|
Config: Config{WebhookURL: "http://group-example.com"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InputGroup: "group",
|
InputGroup: "group",
|
||||||
ExpectedOutput: "http://example01.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://group-example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{WebhookURL: "http://group-example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, scenario := range scenarios {
|
||||||
t.Run(tt.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
|
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||||
t.Errorf("AlertProvider.getWebhookURLForGroup() = %v, want %v", got, tt.ExpectedOutput)
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
|
||||||
|
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package email
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -10,10 +11,17 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
gomail "gopkg.in/mail.v2"
|
gomail "gopkg.in/mail.v2"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using SMTP
|
var (
|
||||||
type AlertProvider struct {
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
ErrMissingFromOrToFields = errors.New("from and to fields are required")
|
||||||
|
ErrInvalidPort = errors.New("port must be between 1 and 65535 inclusively")
|
||||||
|
ErrMissingHost = errors.New("host is required")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
From string `yaml:"from"`
|
From string `yaml:"from"`
|
||||||
Username string `yaml:"username"`
|
Username string `yaml:"username"`
|
||||||
Password string `yaml:"password"`
|
Password string `yaml:"password"`
|
||||||
@@ -23,6 +31,48 @@ type AlertProvider struct {
|
|||||||
|
|
||||||
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
||||||
ClientConfig *client.Config `yaml:"client,omitempty"`
|
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.From) == 0 || len(cfg.To) == 0 {
|
||||||
|
return ErrMissingFromOrToFields
|
||||||
|
}
|
||||||
|
if cfg.Port < 1 || cfg.Port > math.MaxUint16 {
|
||||||
|
return ErrInvalidPort
|
||||||
|
}
|
||||||
|
if len(cfg.Host) == 0 {
|
||||||
|
return ErrMissingHost
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if override.ClientConfig != nil {
|
||||||
|
cfg.ClientConfig = override.ClientConfig
|
||||||
|
}
|
||||||
|
if len(override.From) > 0 {
|
||||||
|
cfg.From = override.From
|
||||||
|
}
|
||||||
|
if len(override.Username) > 0 {
|
||||||
|
cfg.Username = override.Username
|
||||||
|
}
|
||||||
|
if len(override.Password) > 0 {
|
||||||
|
cfg.Password = override.Password
|
||||||
|
}
|
||||||
|
if len(override.Host) > 0 {
|
||||||
|
cfg.Host = override.Host
|
||||||
|
}
|
||||||
|
if override.Port > 0 {
|
||||||
|
cfg.Port = override.Port
|
||||||
|
}
|
||||||
|
if len(override.To) > 0 {
|
||||||
|
cfg.To = override.To
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using SMTP
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
@@ -33,54 +83,57 @@ type AlertProvider struct {
|
|||||||
|
|
||||||
// Override is a case under which the default integration is overridden
|
// Override is a case under which the default integration is overridden
|
||||||
type Override struct {
|
type Override struct {
|
||||||
Group string `yaml:"group"`
|
Group string `yaml:"group"`
|
||||||
To string `yaml:"to"`
|
Config `yaml:",inline"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// Validate the provider's configuration
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
func (provider *AlertProvider) Validate() error {
|
||||||
registeredGroups := make(map[string]bool)
|
registeredGroups := make(map[string]bool)
|
||||||
if provider.Overrides != nil {
|
if provider.Overrides != nil {
|
||||||
for _, override := range provider.Overrides {
|
for _, override := range provider.Overrides {
|
||||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.To) == 0 {
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.To) == 0 {
|
||||||
return false
|
return ErrDuplicateGroupOverride
|
||||||
}
|
}
|
||||||
registeredGroups[override.Group] = true
|
registeredGroups[override.Group] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return provider.DefaultConfig.Validate()
|
||||||
return len(provider.From) > 0 && len(provider.Host) > 0 && len(provider.To) > 0 && provider.Port > 0 && provider.Port < math.MaxUint16
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
var username string
|
var username string
|
||||||
if len(provider.Username) > 0 {
|
if len(cfg.Username) > 0 {
|
||||||
username = provider.Username
|
username = cfg.Username
|
||||||
} else {
|
} else {
|
||||||
username = provider.From
|
username = cfg.From
|
||||||
}
|
}
|
||||||
subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
|
subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
|
||||||
m := gomail.NewMessage()
|
m := gomail.NewMessage()
|
||||||
m.SetHeader("From", provider.From)
|
m.SetHeader("From", cfg.From)
|
||||||
m.SetHeader("To", strings.Split(provider.getToForGroup(ep.Group), ",")...)
|
m.SetHeader("To", strings.Split(cfg.To, ",")...)
|
||||||
m.SetHeader("Subject", subject)
|
m.SetHeader("Subject", subject)
|
||||||
m.SetBody("text/plain", body)
|
m.SetBody("text/plain", body)
|
||||||
var d *gomail.Dialer
|
var d *gomail.Dialer
|
||||||
if len(provider.Password) == 0 {
|
if len(cfg.Password) == 0 {
|
||||||
// Get the domain in the From address
|
// Get the domain in the From address
|
||||||
localName := "localhost"
|
localName := "localhost"
|
||||||
fromParts := strings.Split(provider.From, `@`)
|
fromParts := strings.Split(cfg.From, `@`)
|
||||||
if len(fromParts) == 2 {
|
if len(fromParts) == 2 {
|
||||||
localName = fromParts[1]
|
localName = fromParts[1]
|
||||||
}
|
}
|
||||||
// Create a dialer with no authentication
|
// Create a dialer with no authentication
|
||||||
d = &gomail.Dialer{Host: provider.Host, Port: provider.Port, LocalName: localName}
|
d = &gomail.Dialer{Host: cfg.Host, Port: cfg.Port, LocalName: localName}
|
||||||
} else {
|
} else {
|
||||||
// Create an authenticated dialer
|
// Create an authenticated dialer
|
||||||
d = gomail.NewDialer(provider.Host, provider.Port, username, provider.Password)
|
d = gomail.NewDialer(cfg.Host, cfg.Port, username, cfg.Password)
|
||||||
}
|
}
|
||||||
if provider.ClientConfig != nil && provider.ClientConfig.Insecure {
|
if cfg.ClientConfig != nil && cfg.ClientConfig.Insecure {
|
||||||
d.TLSConfig = &tls.Config{InsecureSkipVerify: true}
|
d.TLSConfig = &tls.Config{InsecureSkipVerify: true}
|
||||||
}
|
}
|
||||||
return d.DialAndSend(m)
|
return d.DialAndSend(m)
|
||||||
@@ -113,22 +166,48 @@ func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint,
|
|||||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
description = "\n\nAlert description: " + alertDescription
|
description = "\n\nAlert description: " + alertDescription
|
||||||
}
|
}
|
||||||
return subject, message + description + formattedConditionResults
|
var extraLabels string
|
||||||
}
|
if len(ep.ExtraLabels) > 0 {
|
||||||
|
extraLabels = "\n\nExtra labels:\n"
|
||||||
// getToForGroup returns the appropriate email integration to for a given group
|
for key, value := range ep.ExtraLabels {
|
||||||
func (provider *AlertProvider) getToForGroup(group string) string {
|
extraLabels += fmt.Sprintf(" %s: %s\n", key, value)
|
||||||
if provider.Overrides != nil {
|
|
||||||
for _, override := range provider.Overrides {
|
|
||||||
if group == override.Group {
|
|
||||||
return override.To
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return provider.To
|
return subject, message + description + extraLabels + formattedConditionResults
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,61 +7,63 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
invalidProvider := AlertProvider{}
|
invalidProvider := AlertProvider{}
|
||||||
if invalidProvider.IsValid() {
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
t.Error("provider shouldn't have been valid")
|
t.Error("provider shouldn't have been valid")
|
||||||
}
|
}
|
||||||
validProvider := AlertProvider{From: "from@example.com", Password: "password", Host: "smtp.gmail.com", Port: 587, To: "to@example.com"}
|
validProvider := AlertProvider{DefaultConfig: Config{From: "from@example.com", Password: "password", Host: "smtp.gmail.com", Port: 587, To: "to@example.com"}}
|
||||||
if !validProvider.IsValid() {
|
if err := validProvider.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_IsValidWithNoCredentials(t *testing.T) {
|
func TestAlertProvider_ValidateWithNoCredentials(t *testing.T) {
|
||||||
validProvider := AlertProvider{From: "from@example.com", Host: "smtp-relay.gmail.com", Port: 587, To: "to@example.com"}
|
validProvider := AlertProvider{DefaultConfig: Config{From: "from@example.com", Host: "smtp-relay.gmail.com", Port: 587, To: "to@example.com"}}
|
||||||
if !validProvider.IsValid() {
|
if err := validProvider.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||||
providerWithInvalidOverrideGroup := AlertProvider{
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
To: "to@example.com",
|
Config: Config{To: "to@example.com"},
|
||||||
Group: "",
|
Group: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if providerWithInvalidOverrideGroup.IsValid() {
|
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||||
t.Error("provider Group shouldn't have been valid")
|
t.Error("provider Group shouldn't have been valid")
|
||||||
}
|
}
|
||||||
providerWithInvalidOverrideTo := AlertProvider{
|
providerWithInvalidOverrideTo := AlertProvider{
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
To: "",
|
Config: Config{To: ""},
|
||||||
Group: "group",
|
Group: "group",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if providerWithInvalidOverrideTo.IsValid() {
|
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
|
||||||
t.Error("provider integration key shouldn't have been valid")
|
t.Error("provider integration key shouldn't have been valid")
|
||||||
}
|
}
|
||||||
providerWithValidOverride := AlertProvider{
|
providerWithValidOverride := AlertProvider{
|
||||||
From: "from@example.com",
|
DefaultConfig: Config{
|
||||||
Password: "password",
|
From: "from@example.com",
|
||||||
Host: "smtp.gmail.com",
|
Password: "password",
|
||||||
Port: 587,
|
Host: "smtp.gmail.com",
|
||||||
To: "to@example.com",
|
Port: 587,
|
||||||
|
To: "to@example.com",
|
||||||
|
},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
To: "to@example.com",
|
Config: Config{To: "to@example.com"},
|
||||||
Group: "group",
|
Group: "group",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if !providerWithValidOverride.IsValid() {
|
if err := providerWithValidOverride.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,6 +76,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
Alert alert.Alert
|
Alert alert.Alert
|
||||||
Resolved bool
|
Resolved bool
|
||||||
|
Endpoint *endpoint.Endpoint
|
||||||
ExpectedSubject string
|
ExpectedSubject string
|
||||||
ExpectedBody string
|
ExpectedBody string
|
||||||
}{
|
}{
|
||||||
@@ -82,6 +85,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
|
Endpoint: &endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
ExpectedSubject: "[endpoint-name] Alert triggered",
|
ExpectedSubject: "[endpoint-name] Alert triggered",
|
||||||
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
|
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
|
||||||
},
|
},
|
||||||
@@ -90,14 +94,42 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
|
Endpoint: &endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
ExpectedSubject: "[endpoint-name] Alert resolved",
|
ExpectedSubject: "[endpoint-name] Alert resolved",
|
||||||
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
|
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-with-single-extra-label",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"environment": "production"}},
|
||||||
|
ExpectedSubject: "[endpoint-name] Alert triggered",
|
||||||
|
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nExtra labels:\n environment: production\n\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-with-single-extra-label",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"service": "api"}},
|
||||||
|
ExpectedSubject: "[endpoint-name] Alert resolved",
|
||||||
|
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nExtra labels:\n service: api\n\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-with-no-extra-labels",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{}},
|
||||||
|
ExpectedSubject: "[endpoint-name] Alert triggered",
|
||||||
|
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
subject, body := scenario.Provider.buildMessageSubjectAndBody(
|
subject, body := scenario.Provider.buildMessageSubjectAndBody(
|
||||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
scenario.Endpoint,
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&endpoint.Result{
|
&endpoint.Result{
|
||||||
ConditionResults: []*endpoint.ConditionResult{
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
@@ -126,64 +158,104 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_getToForGroup(t *testing.T) {
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
tests := []struct {
|
scenarios := []struct {
|
||||||
Name string
|
Name string
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
InputGroup string
|
InputGroup string
|
||||||
ExpectedOutput string
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "provider-no-override-specify-no-group-should-default",
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
To: "to@example.com",
|
DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
|
||||||
Overrides: nil,
|
Overrides: nil,
|
||||||
},
|
},
|
||||||
InputGroup: "",
|
InputGroup: "",
|
||||||
ExpectedOutput: "to@example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-no-override-specify-group-should-default",
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
To: "to@example.com",
|
DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
|
||||||
Overrides: nil,
|
Overrides: nil,
|
||||||
},
|
},
|
||||||
InputGroup: "group",
|
InputGroup: "group",
|
||||||
ExpectedOutput: "to@example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-with-override-specify-no-group-should-default",
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
To: "to@example.com",
|
DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
To: "to01@example.com",
|
Config: Config{To: "to01@example.com"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InputGroup: "",
|
InputGroup: "",
|
||||||
ExpectedOutput: "to@example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-with-override-specify-group-should-override",
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
To: "to@example.com",
|
DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
To: "to01@example.com",
|
Config: Config{To: "group-to@example.com"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InputGroup: "group",
|
InputGroup: "group",
|
||||||
ExpectedOutput: "to01@example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{From: "from@example.com", To: "group-to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{To: "group-to@example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"to": "alert-to@example.com", "host": "smtp.example.com", "port": 588, "password": "hunter2"}},
|
||||||
|
ExpectedOutput: Config{From: "from@example.com", To: "alert-to@example.com", Host: "smtp.example.com", Port: 588, Password: "hunter2"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, scenario := range scenarios {
|
||||||
t.Run(tt.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
if got := tt.Provider.getToForGroup(tt.InputGroup); got != tt.ExpectedOutput {
|
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||||
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.From != scenario.ExpectedOutput.From {
|
||||||
|
t.Errorf("expected from to be %s, got %s", scenario.ExpectedOutput.From, got.From)
|
||||||
|
}
|
||||||
|
if got.To != scenario.ExpectedOutput.To {
|
||||||
|
t.Errorf("expected to be %s, got %s", scenario.ExpectedOutput.To, got.To)
|
||||||
|
}
|
||||||
|
if got.Host != scenario.ExpectedOutput.Host {
|
||||||
|
t.Errorf("expected host to be %s, got %s", scenario.ExpectedOutput.Host, got.Host)
|
||||||
|
}
|
||||||
|
if got.Port != scenario.ExpectedOutput.Port {
|
||||||
|
t.Errorf("expected port to be %d, got %d", scenario.ExpectedOutput.Port, got.Port)
|
||||||
|
}
|
||||||
|
if got.Password != scenario.ExpectedOutput.Password {
|
||||||
|
t.Errorf("expected password to be %s, got %s", scenario.ExpectedOutput.Password, got.Password)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package gitea
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -11,55 +12,56 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Discord
|
var (
|
||||||
type AlertProvider struct {
|
ErrRepositoryURLNotSet = errors.New("repository-url not set")
|
||||||
RepositoryURL string `yaml:"repository-url"` // The URL of the Gitea repository to create issues in
|
ErrInvalidRepositoryURL = errors.New("invalid repository-url")
|
||||||
Token string `yaml:"token"` // Token requires at least RW on issues and RO on metadata
|
ErrTokenNotSet = errors.New("token not set")
|
||||||
|
)
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
type Config struct {
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
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
|
||||||
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
Assignees []string `yaml:"assignees,omitempty"` // Assignees is a list of users to assign the issue to
|
||||||
ClientConfig *client.Config `yaml:"client,omitempty"`
|
|
||||||
|
|
||||||
// Assignees is a list of users to assign the issue to
|
|
||||||
Assignees []string `yaml:"assignees,omitempty"`
|
|
||||||
|
|
||||||
username string
|
username string
|
||||||
repositoryOwner string
|
repositoryOwner string
|
||||||
repositoryName string
|
repositoryName string
|
||||||
giteaClient *gitea.Client
|
giteaClient *gitea.Client
|
||||||
|
|
||||||
|
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
||||||
|
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
func (cfg *Config) Validate() error {
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
if len(cfg.RepositoryURL) == 0 {
|
||||||
if provider.ClientConfig == nil {
|
return ErrRepositoryURLNotSet
|
||||||
provider.ClientConfig = client.GetDefaultConfig()
|
|
||||||
}
|
}
|
||||||
|
if len(cfg.Token) == 0 {
|
||||||
if len(provider.Token) == 0 || len(provider.RepositoryURL) == 0 {
|
return ErrTokenNotSet
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
// Validate format of the repository URL
|
// Validate format of the repository URL
|
||||||
repositoryURL, err := url.Parse(provider.RepositoryURL)
|
repositoryURL, err := url.Parse(cfg.RepositoryURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return err
|
||||||
}
|
}
|
||||||
baseURL := repositoryURL.Scheme + "://" + repositoryURL.Host
|
baseURL := repositoryURL.Scheme + "://" + repositoryURL.Host
|
||||||
pathParts := strings.Split(repositoryURL.Path, "/")
|
pathParts := strings.Split(repositoryURL.Path, "/")
|
||||||
if len(pathParts) != 3 {
|
if len(pathParts) != 3 {
|
||||||
return false
|
return ErrInvalidRepositoryURL
|
||||||
}
|
}
|
||||||
provider.repositoryOwner = pathParts[1]
|
if cfg.repositoryOwner == pathParts[1] && cfg.repositoryName == pathParts[2] && cfg.giteaClient != nil {
|
||||||
provider.repositoryName = pathParts[2]
|
// Already validated, let's skip the rest of the validation to avoid unnecessary API calls
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cfg.repositoryOwner = pathParts[1]
|
||||||
|
cfg.repositoryName = pathParts[2]
|
||||||
opts := []gitea.ClientOption{
|
opts := []gitea.ClientOption{
|
||||||
gitea.SetToken(provider.Token),
|
gitea.SetToken(cfg.Token),
|
||||||
}
|
}
|
||||||
|
if cfg.ClientConfig != nil && cfg.ClientConfig.Insecure {
|
||||||
if provider.ClientConfig != nil && provider.ClientConfig.Insecure {
|
|
||||||
// add new http client for skip verify
|
// add new http client for skip verify
|
||||||
httpClient := &http.Client{
|
httpClient := &http.Client{
|
||||||
Transport: &http.Transport{
|
Transport: &http.Transport{
|
||||||
@@ -68,34 +70,62 @@ func (provider *AlertProvider) IsValid() bool {
|
|||||||
}
|
}
|
||||||
opts = append(opts, gitea.SetHTTPClient(httpClient))
|
opts = append(opts, gitea.SetHTTPClient(httpClient))
|
||||||
}
|
}
|
||||||
|
cfg.giteaClient, err = gitea.NewClient(baseURL, opts...)
|
||||||
provider.giteaClient, err = gitea.NewClient(baseURL, opts...)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return err
|
||||||
}
|
}
|
||||||
|
user, _, err := cfg.giteaClient.GetMyUserInfo()
|
||||||
user, _, err := provider.giteaClient.GetMyUserInfo()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return err
|
||||||
}
|
}
|
||||||
|
cfg.username = user.UserName
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
provider.username = user.UserName
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if override.ClientConfig != nil {
|
||||||
|
cfg.ClientConfig = override.ClientConfig
|
||||||
|
}
|
||||||
|
if len(override.RepositoryURL) > 0 {
|
||||||
|
cfg.RepositoryURL = override.RepositoryURL
|
||||||
|
}
|
||||||
|
if len(override.Token) > 0 {
|
||||||
|
cfg.Token = override.Token
|
||||||
|
}
|
||||||
|
if len(override.Assignees) > 0 {
|
||||||
|
cfg.Assignees = override.Assignees
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
// AlertProvider is the configuration necessary for sending an alert using Discord
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the provider's configuration
|
||||||
|
func (provider *AlertProvider) Validate() error {
|
||||||
|
return provider.DefaultConfig.Validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
|
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
|
||||||
// or closes the relevant issue(s) if the resolved parameter passed is true.
|
// or closes the relevant issue(s) if the resolved parameter passed is true.
|
||||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
title := "alert(gatus): " + ep.DisplayName()
|
title := "alert(gatus): " + ep.DisplayName()
|
||||||
if !resolved {
|
if !resolved {
|
||||||
_, _, err := provider.giteaClient.CreateIssue(
|
_, _, err = cfg.giteaClient.CreateIssue(
|
||||||
provider.repositoryOwner,
|
cfg.repositoryOwner,
|
||||||
provider.repositoryName,
|
cfg.repositoryName,
|
||||||
gitea.CreateIssueOption{
|
gitea.CreateIssueOption{
|
||||||
Title: title,
|
Title: title,
|
||||||
Body: provider.buildIssueBody(ep, alert, result),
|
Body: provider.buildIssueBody(ep, alert, result),
|
||||||
Assignees: provider.Assignees,
|
Assignees: cfg.Assignees,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -103,29 +133,27 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
issues, _, err := cfg.giteaClient.ListRepoIssues(
|
||||||
issues, _, err := provider.giteaClient.ListRepoIssues(
|
cfg.repositoryOwner,
|
||||||
provider.repositoryOwner,
|
cfg.repositoryName,
|
||||||
provider.repositoryName,
|
|
||||||
gitea.ListIssueOption{
|
gitea.ListIssueOption{
|
||||||
State: gitea.StateOpen,
|
State: gitea.StateOpen,
|
||||||
CreatedBy: provider.username,
|
CreatedBy: cfg.username,
|
||||||
ListOptions: gitea.ListOptions{
|
ListOptions: gitea.ListOptions{
|
||||||
Page: 100,
|
PageSize: 100,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to list issues: %w", err)
|
return fmt.Errorf("failed to list issues: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, issue := range issues {
|
for _, issue := range issues {
|
||||||
if issue.Title == title {
|
if issue.Title == title {
|
||||||
stateClosed := gitea.StateClosed
|
stateClosed := gitea.StateClosed
|
||||||
_, _, err = provider.giteaClient.EditIssue(
|
_, _, err = cfg.giteaClient.EditIssue(
|
||||||
provider.repositoryOwner,
|
cfg.repositoryOwner,
|
||||||
provider.repositoryName,
|
cfg.repositoryName,
|
||||||
issue.ID,
|
issue.Index,
|
||||||
gitea.EditIssueOption{
|
gitea.EditIssueOption{
|
||||||
State: &stateClosed,
|
State: &stateClosed,
|
||||||
},
|
},
|
||||||
@@ -165,3 +193,25 @@ func (provider *AlertProvider) buildIssueBody(ep *endpoint.Endpoint, alert *aler
|
|||||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration (we're returning the cfg here even if there's an error mostly for testing purposes)
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,42 +12,46 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/test"
|
"github.com/TwiN/gatus/v5/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
Name string
|
Name string
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
Expected bool
|
ExpectedError bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "invalid",
|
Name: "invalid",
|
||||||
Provider: AlertProvider{RepositoryURL: "", Token: ""},
|
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "", Token: ""}},
|
||||||
Expected: false,
|
ExpectedError: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "invalid-token",
|
Name: "invalid-token",
|
||||||
Provider: AlertProvider{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"},
|
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"}},
|
||||||
Expected: false,
|
ExpectedError: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "missing-repository-name",
|
Name: "missing-repository-name",
|
||||||
Provider: AlertProvider{RepositoryURL: "https://gitea.com/TwiN", Token: "12345"},
|
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN", Token: "12345"}},
|
||||||
Expected: false,
|
ExpectedError: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "enterprise-client",
|
Name: "enterprise-client",
|
||||||
Provider: AlertProvider{RepositoryURL: "https://gitea.example.com/TwiN/test", Token: "12345"},
|
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.example.com/TwiN/test", Token: "12345"}},
|
||||||
Expected: false,
|
ExpectedError: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "invalid-url",
|
Name: "invalid-url",
|
||||||
Provider: AlertProvider{RepositoryURL: "gitea.com/TwiN/test", Token: "12345"},
|
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "gitea.com/TwiN/test", Token: "12345"}},
|
||||||
Expected: false,
|
ExpectedError: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
if scenario.Provider.IsValid() != scenario.Expected {
|
err := scenario.Provider.Validate()
|
||||||
t.Errorf("expected %t, got %t", scenario.Expected, scenario.Provider.IsValid())
|
if scenario.ExpectedError && err == nil {
|
||||||
|
t.Error("expected error, got none")
|
||||||
|
}
|
||||||
|
if !scenario.ExpectedError && err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -67,14 +71,14 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered-error",
|
Name: "triggered-error",
|
||||||
Provider: AlertProvider{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"},
|
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedError: true,
|
ExpectedError: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved-error",
|
Name: "resolved-error",
|
||||||
Provider: AlertProvider{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"},
|
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedError: true,
|
ExpectedError: true,
|
||||||
@@ -82,9 +86,13 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
scenario.Provider.giteaClient, _ = gitea.NewClient("https://gitea.com")
|
cfg, err := scenario.Provider.GetConfig("", &scenario.Alert)
|
||||||
|
if err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
|
cfg.giteaClient, _ = gitea.NewClient("https://gitea.com")
|
||||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
err := scenario.Provider.Send(
|
err = scenario.Provider.Send(
|
||||||
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&endpoint.Result{
|
&endpoint.Result{
|
||||||
@@ -167,3 +175,55 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
|||||||
t.Error("expected default alert to be nil")
|
t.Error("expected default alert to be nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"},
|
||||||
|
},
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-alert-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"},
|
||||||
|
},
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"repository-url": "https://gitea.com/TwiN/alert-test", "token": "54321", "assignees": []string{"TwiN"}}},
|
||||||
|
ExpectedOutput: Config{RepositoryURL: "https://gitea.com/TwiN/alert-test", Token: "54321", Assignees: []string{"TwiN"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
|
||||||
|
if err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.RepositoryURL != scenario.ExpectedOutput.RepositoryURL {
|
||||||
|
t.Errorf("expected repository URL %s, got %s", scenario.ExpectedOutput.RepositoryURL, got.RepositoryURL)
|
||||||
|
}
|
||||||
|
if got.Token != scenario.ExpectedOutput.Token {
|
||||||
|
t.Errorf("expected token %s, got %s", scenario.ExpectedOutput.Token, got.Token)
|
||||||
|
}
|
||||||
|
if len(got.Assignees) != len(scenario.ExpectedOutput.Assignees) {
|
||||||
|
t.Errorf("expected %d assignees, got %d", len(scenario.ExpectedOutput.Assignees), len(got.Assignees))
|
||||||
|
}
|
||||||
|
for i, assignee := range got.Assignees {
|
||||||
|
if assignee != scenario.ExpectedOutput.Assignees[i] {
|
||||||
|
t.Errorf("expected assignee %s, got %s", scenario.ExpectedOutput.Assignees[i], assignee)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil && !strings.Contains(err.Error(), "user does not exist") {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package github
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -11,69 +12,104 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/google/go-github/v48/github"
|
"github.com/google/go-github/v48/github"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Discord
|
var (
|
||||||
type AlertProvider struct {
|
ErrRepositoryURLNotSet = errors.New("repository-url not set")
|
||||||
|
ErrInvalidRepositoryURL = errors.New("invalid repository-url")
|
||||||
|
ErrTokenNotSet = errors.New("token not set")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
RepositoryURL string `yaml:"repository-url"` // The URL of the GitHub repository to create issues in
|
RepositoryURL string `yaml:"repository-url"` // The URL of the GitHub repository to create issues in
|
||||||
Token string `yaml:"token"` // Token requires at least RW on issues and RO on metadata
|
Token string `yaml:"token"` // Token requires at least RW on issues and RO on metadata
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
|
||||||
|
|
||||||
username string
|
username string
|
||||||
repositoryOwner string
|
repositoryOwner string
|
||||||
repositoryName string
|
repositoryName string
|
||||||
githubClient *github.Client
|
githubClient *github.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
func (cfg *Config) Validate() error {
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
if len(cfg.RepositoryURL) == 0 {
|
||||||
if len(provider.Token) == 0 || len(provider.RepositoryURL) == 0 {
|
return ErrRepositoryURLNotSet
|
||||||
return false
|
}
|
||||||
|
if len(cfg.Token) == 0 {
|
||||||
|
return ErrTokenNotSet
|
||||||
}
|
}
|
||||||
// Validate format of the repository URL
|
// Validate format of the repository URL
|
||||||
repositoryURL, err := url.Parse(provider.RepositoryURL)
|
repositoryURL, err := url.Parse(cfg.RepositoryURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return err
|
||||||
}
|
}
|
||||||
baseURL := repositoryURL.Scheme + "://" + repositoryURL.Host
|
baseURL := repositoryURL.Scheme + "://" + repositoryURL.Host
|
||||||
pathParts := strings.Split(repositoryURL.Path, "/")
|
pathParts := strings.Split(repositoryURL.Path, "/")
|
||||||
if len(pathParts) != 3 {
|
if len(pathParts) != 3 {
|
||||||
return false
|
return ErrInvalidRepositoryURL
|
||||||
}
|
}
|
||||||
provider.repositoryOwner = pathParts[1]
|
if cfg.repositoryOwner == pathParts[1] && cfg.repositoryName == pathParts[2] && cfg.githubClient != nil {
|
||||||
provider.repositoryName = pathParts[2]
|
// Already validated, let's skip the rest of the validation to avoid unnecessary API calls
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cfg.repositoryOwner = pathParts[1]
|
||||||
|
cfg.repositoryName = pathParts[2]
|
||||||
// Create oauth2 HTTP client with GitHub token
|
// Create oauth2 HTTP client with GitHub token
|
||||||
httpClientWithStaticTokenSource := oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(&oauth2.Token{
|
httpClientWithStaticTokenSource := oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(&oauth2.Token{
|
||||||
AccessToken: provider.Token,
|
AccessToken: cfg.Token,
|
||||||
}))
|
}))
|
||||||
// Create GitHub client
|
// Create GitHub client
|
||||||
if baseURL == "https://github.com" {
|
if baseURL == "https://github.com" {
|
||||||
provider.githubClient = github.NewClient(httpClientWithStaticTokenSource)
|
cfg.githubClient = github.NewClient(httpClientWithStaticTokenSource)
|
||||||
} else {
|
} else {
|
||||||
provider.githubClient, err = github.NewEnterpriseClient(baseURL, baseURL, httpClientWithStaticTokenSource)
|
cfg.githubClient, err = github.NewEnterpriseClient(baseURL, baseURL, httpClientWithStaticTokenSource)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return fmt.Errorf("failed to create enterprise GitHub client: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Retrieve the username once to validate that the token is valid
|
// Retrieve the username once to validate that the token is valid
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
user, _, err := provider.githubClient.Users.Get(ctx, "")
|
user, _, err := cfg.githubClient.Users.Get(ctx, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return fmt.Errorf("failed to retrieve GitHub user: %w", err)
|
||||||
}
|
}
|
||||||
provider.username = *user.Login
|
cfg.username = *user.Login
|
||||||
return true
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.RepositoryURL) > 0 {
|
||||||
|
cfg.RepositoryURL = override.RepositoryURL
|
||||||
|
}
|
||||||
|
if len(override.Token) > 0 {
|
||||||
|
cfg.Token = override.Token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using Discord
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the provider's configuration
|
||||||
|
func (provider *AlertProvider) Validate() error {
|
||||||
|
return provider.DefaultConfig.Validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
|
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
|
||||||
// or closes the relevant issue(s) if the resolved parameter passed is true.
|
// or closes the relevant issue(s) if the resolved parameter passed is true.
|
||||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
title := "alert(gatus): " + ep.DisplayName()
|
title := "alert(gatus): " + ep.DisplayName()
|
||||||
if !resolved {
|
if !resolved {
|
||||||
_, _, err := provider.githubClient.Issues.Create(context.Background(), provider.repositoryOwner, provider.repositoryName, &github.IssueRequest{
|
_, _, err := cfg.githubClient.Issues.Create(context.Background(), cfg.repositoryOwner, cfg.repositoryName, &github.IssueRequest{
|
||||||
Title: github.String(title),
|
Title: github.String(title),
|
||||||
Body: github.String(provider.buildIssueBody(ep, alert, result)),
|
Body: github.String(provider.buildIssueBody(ep, alert, result)),
|
||||||
})
|
})
|
||||||
@@ -81,9 +117,9 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
|||||||
return fmt.Errorf("failed to create issue: %w", err)
|
return fmt.Errorf("failed to create issue: %w", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
issues, _, err := provider.githubClient.Issues.ListByRepo(context.Background(), provider.repositoryOwner, provider.repositoryName, &github.IssueListByRepoOptions{
|
issues, _, err := cfg.githubClient.Issues.ListByRepo(context.Background(), cfg.repositoryOwner, cfg.repositoryName, &github.IssueListByRepoOptions{
|
||||||
State: "open",
|
State: "open",
|
||||||
Creator: provider.username,
|
Creator: cfg.username,
|
||||||
ListOptions: github.ListOptions{PerPage: 100},
|
ListOptions: github.ListOptions{PerPage: 100},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -91,7 +127,7 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
|||||||
}
|
}
|
||||||
for _, issue := range issues {
|
for _, issue := range issues {
|
||||||
if *issue.Title == title {
|
if *issue.Title == title {
|
||||||
_, _, err = provider.githubClient.Issues.Edit(context.Background(), provider.repositoryOwner, provider.repositoryName, *issue.Number, &github.IssueRequest{
|
_, _, err = cfg.githubClient.Issues.Edit(context.Background(), cfg.repositoryOwner, cfg.repositoryName, *issue.Number, &github.IssueRequest{
|
||||||
State: github.String("closed"),
|
State: github.String("closed"),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -130,3 +166,25 @@ func (provider *AlertProvider) buildIssueBody(ep *endpoint.Endpoint, alert *aler
|
|||||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration (we're returning the cfg here even if there's an error mostly for testing purposes)
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,42 +12,46 @@ import (
|
|||||||
"github.com/google/go-github/v48/github"
|
"github.com/google/go-github/v48/github"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
Name string
|
Name string
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
Expected bool
|
ExpectedError bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "invalid",
|
Name: "invalid",
|
||||||
Provider: AlertProvider{RepositoryURL: "", Token: ""},
|
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "", Token: ""}},
|
||||||
Expected: false,
|
ExpectedError: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "invalid-token",
|
Name: "invalid-token",
|
||||||
Provider: AlertProvider{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"},
|
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"}},
|
||||||
Expected: false,
|
ExpectedError: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "missing-repository-name",
|
Name: "missing-repository-name",
|
||||||
Provider: AlertProvider{RepositoryURL: "https://github.com/TwiN", Token: "12345"},
|
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN", Token: "12345"}},
|
||||||
Expected: false,
|
ExpectedError: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "enterprise-client",
|
Name: "enterprise-client",
|
||||||
Provider: AlertProvider{RepositoryURL: "https://github.example.com/TwiN/test", Token: "12345"},
|
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.example.com/TwiN/test", Token: "12345"}},
|
||||||
Expected: false,
|
ExpectedError: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "invalid-url",
|
Name: "invalid-url",
|
||||||
Provider: AlertProvider{RepositoryURL: "github.com/TwiN/test", Token: "12345"},
|
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "github.com/TwiN/test", Token: "12345"}},
|
||||||
Expected: false,
|
ExpectedError: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
if scenario.Provider.IsValid() != scenario.Expected {
|
err := scenario.Provider.Validate()
|
||||||
t.Errorf("expected %t, got %t", scenario.Expected, scenario.Provider.IsValid())
|
if scenario.ExpectedError && err == nil {
|
||||||
|
t.Error("expected error, got none")
|
||||||
|
}
|
||||||
|
if !scenario.ExpectedError && err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -67,14 +71,14 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered-error",
|
Name: "triggered-error",
|
||||||
Provider: AlertProvider{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"},
|
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedError: true,
|
ExpectedError: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved-error",
|
Name: "resolved-error",
|
||||||
Provider: AlertProvider{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"},
|
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedError: true,
|
ExpectedError: true,
|
||||||
@@ -82,9 +86,13 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
scenario.Provider.githubClient = github.NewClient(nil)
|
cfg, err := scenario.Provider.GetConfig("", &scenario.Alert)
|
||||||
|
if err != nil && !strings.Contains(err.Error(), "failed to retrieve GitHub user") && !strings.Contains(err.Error(), "no such host") {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
|
cfg.githubClient = github.NewClient(nil)
|
||||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
err := scenario.Provider.Send(
|
err = scenario.Provider.Send(
|
||||||
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&endpoint.Result{
|
&endpoint.Result{
|
||||||
@@ -167,3 +175,47 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
|||||||
t.Error("expected default alert to be nil")
|
t.Error("expected default alert to be nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"},
|
||||||
|
},
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-alert-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"},
|
||||||
|
},
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"repository-url": "https://github.com/TwiN/alert-test", "token": "54321"}},
|
||||||
|
ExpectedOutput: Config{RepositoryURL: "https://github.com/TwiN/alert-test", Token: "54321"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
|
||||||
|
if err != nil && !strings.Contains(err.Error(), "failed to retrieve GitHub user") && !strings.Contains(err.Error(), "no such host") {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.RepositoryURL != scenario.ExpectedOutput.RepositoryURL {
|
||||||
|
t.Errorf("expected repository URL %s, got %s", scenario.ExpectedOutput.RepositoryURL, got.RepositoryURL)
|
||||||
|
}
|
||||||
|
if got.Token != scenario.ExpectedOutput.Token {
|
||||||
|
t.Errorf("expected token %s, got %s", scenario.ExpectedOutput.Token, got.Token)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil && !strings.Contains(err.Error(), "failed to retrieve GitHub user") {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,55 +13,97 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultSeverity = "critical"
|
||||||
|
DefaultMonitoringTool = "gatus"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidWebhookURL = fmt.Errorf("invalid webhook-url")
|
||||||
|
ErrAuthorizationKeyNotSet = fmt.Errorf("authorization-key not set")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
WebhookURL string `yaml:"webhook-url"` // The webhook url provided by GitLab
|
||||||
|
AuthorizationKey string `yaml:"authorization-key"` // The authorization key provided by GitLab
|
||||||
|
Severity string `yaml:"severity,omitempty"` // Severity can be one of: critical, high, medium, low, info, unknown. Defaults to critical
|
||||||
|
MonitoringTool string `yaml:"monitoring-tool,omitempty"` // MonitoringTool overrides the name sent to gitlab. Defaults to gatus
|
||||||
|
EnvironmentName string `yaml:"environment-name,omitempty"` // EnvironmentName is the name of the associated GitLab environment. Required to display alerts on a dashboard.
|
||||||
|
Service string `yaml:"service,omitempty"` // Service affected. Defaults to the endpoint's display name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.WebhookURL) == 0 {
|
||||||
|
return ErrInvalidWebhookURL
|
||||||
|
} else if _, err := url.Parse(cfg.WebhookURL); err != nil {
|
||||||
|
return ErrInvalidWebhookURL
|
||||||
|
}
|
||||||
|
if len(cfg.AuthorizationKey) == 0 {
|
||||||
|
return ErrAuthorizationKeyNotSet
|
||||||
|
}
|
||||||
|
if len(cfg.Severity) == 0 {
|
||||||
|
cfg.Severity = DefaultSeverity
|
||||||
|
}
|
||||||
|
if len(cfg.MonitoringTool) == 0 {
|
||||||
|
cfg.MonitoringTool = DefaultMonitoringTool
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.WebhookURL) > 0 {
|
||||||
|
cfg.WebhookURL = override.WebhookURL
|
||||||
|
}
|
||||||
|
if len(override.AuthorizationKey) > 0 {
|
||||||
|
cfg.AuthorizationKey = override.AuthorizationKey
|
||||||
|
}
|
||||||
|
if len(override.Severity) > 0 {
|
||||||
|
cfg.Severity = override.Severity
|
||||||
|
}
|
||||||
|
if len(override.MonitoringTool) > 0 {
|
||||||
|
cfg.MonitoringTool = override.MonitoringTool
|
||||||
|
}
|
||||||
|
if len(override.EnvironmentName) > 0 {
|
||||||
|
cfg.EnvironmentName = override.EnvironmentName
|
||||||
|
}
|
||||||
|
if len(override.Service) > 0 {
|
||||||
|
cfg.Service = override.Service
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using GitLab
|
// AlertProvider is the configuration necessary for sending an alert using GitLab
|
||||||
type AlertProvider struct {
|
type AlertProvider struct {
|
||||||
WebhookURL string `yaml:"webhook-url"` // The webhook url provided by GitLab
|
DefaultConfig Config `yaml:",inline"`
|
||||||
AuthorizationKey string `yaml:"authorization-key"` // The authorization key provided by GitLab
|
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
// Severity can be one of: critical, high, medium, low, info, unknown. Defaults to critical
|
|
||||||
Severity string `yaml:"severity,omitempty"`
|
|
||||||
|
|
||||||
// MonitoringTool overrides the name sent to gitlab. Defaults to gatus
|
|
||||||
MonitoringTool string `yaml:"monitoring-tool,omitempty"`
|
|
||||||
|
|
||||||
// EnvironmentName is the name of the associated GitLab environment. Required to display alerts on a dashboard.
|
|
||||||
EnvironmentName string `yaml:"environment-name,omitempty"`
|
|
||||||
|
|
||||||
// Service affected. Defaults to endpoint display name
|
|
||||||
Service string `yaml:"service,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// Validate the provider's configuration
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
func (provider *AlertProvider) Validate() error {
|
||||||
if len(provider.AuthorizationKey) == 0 || len(provider.WebhookURL) == 0 {
|
return provider.DefaultConfig.Validate()
|
||||||
return false
|
|
||||||
}
|
|
||||||
// Validate format of the repository URL
|
|
||||||
_, err := url.Parse(provider.WebhookURL)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
|
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
|
||||||
// or closes the relevant issue(s) if the resolved parameter passed is true.
|
// or closes the relevant issue(s) if the resolved parameter passed is true.
|
||||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if len(alert.ResolveKey) == 0 {
|
if len(alert.ResolveKey) == 0 {
|
||||||
alert.ResolveKey = uuid.NewString()
|
alert.ResolveKey = uuid.NewString()
|
||||||
}
|
}
|
||||||
buffer := bytes.NewBuffer(provider.buildAlertBody(ep, alert, result, resolved))
|
buffer := bytes.NewBuffer(provider.buildAlertBody(cfg, ep, alert, result, resolved))
|
||||||
request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer)
|
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
request.Header.Set("Content-Type", "application/json")
|
request.Header.Set("Content-Type", "application/json")
|
||||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", provider.AuthorizationKey))
|
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.AuthorizationKey))
|
||||||
response, err := client.GetHTTPClient(nil).Do(request)
|
response, err := client.GetHTTPClient(nil).Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -87,30 +129,20 @@ type AlertBody struct {
|
|||||||
GitlabEnvironmentName string `json:"gitlab_environment_name,omitempty"` // The name of the associated GitLab environment. Required to display alerts on a dashboard.
|
GitlabEnvironmentName string `json:"gitlab_environment_name,omitempty"` // The name of the associated GitLab environment. Required to display alerts on a dashboard.
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *AlertProvider) monitoringTool() string {
|
|
||||||
if len(provider.MonitoringTool) > 0 {
|
|
||||||
return provider.MonitoringTool
|
|
||||||
}
|
|
||||||
return "gatus"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (provider *AlertProvider) service(ep *endpoint.Endpoint) string {
|
|
||||||
if len(provider.Service) > 0 {
|
|
||||||
return provider.Service
|
|
||||||
}
|
|
||||||
return ep.DisplayName()
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildAlertBody builds the body of the alert
|
// buildAlertBody builds the body of the alert
|
||||||
func (provider *AlertProvider) buildAlertBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
func (provider *AlertProvider) buildAlertBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
|
service := cfg.Service
|
||||||
|
if len(service) == 0 {
|
||||||
|
service = ep.DisplayName()
|
||||||
|
}
|
||||||
body := AlertBody{
|
body := AlertBody{
|
||||||
Title: fmt.Sprintf("alert(%s): %s", provider.monitoringTool(), provider.service(ep)),
|
Title: fmt.Sprintf("alert(%s): %s", cfg.MonitoringTool, service),
|
||||||
StartTime: result.Timestamp.Format(time.RFC3339),
|
StartTime: result.Timestamp.Format(time.RFC3339),
|
||||||
Service: provider.service(ep),
|
Service: service,
|
||||||
MonitoringTool: provider.monitoringTool(),
|
MonitoringTool: cfg.MonitoringTool,
|
||||||
Hosts: ep.URL,
|
Hosts: ep.URL,
|
||||||
GitlabEnvironmentName: provider.EnvironmentName,
|
GitlabEnvironmentName: cfg.EnvironmentName,
|
||||||
Severity: provider.Severity,
|
Severity: cfg.Severity,
|
||||||
Fingerprint: alert.ResolveKey,
|
Fingerprint: alert.ResolveKey,
|
||||||
}
|
}
|
||||||
if resolved {
|
if resolved {
|
||||||
@@ -148,3 +180,25 @@ func (provider *AlertProvider) buildAlertBody(ep *endpoint.Endpoint, alert *aler
|
|||||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration (we're returning the cfg here even if there's an error mostly for testing purposes)
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,37 +11,41 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/test"
|
"github.com/TwiN/gatus/v5/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
Name string
|
Name string
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
Expected bool
|
ExpectedError bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "invalid",
|
Name: "invalid",
|
||||||
Provider: AlertProvider{WebhookURL: "", AuthorizationKey: ""},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "", AuthorizationKey: ""}},
|
||||||
Expected: false,
|
ExpectedError: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "missing-webhook-url",
|
Name: "missing-webhook-url",
|
||||||
Provider: AlertProvider{WebhookURL: "", AuthorizationKey: "12345"},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "", AuthorizationKey: "12345"}},
|
||||||
Expected: false,
|
ExpectedError: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "missing-authorization-key",
|
Name: "missing-authorization-key",
|
||||||
Provider: AlertProvider{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: ""},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/whatever/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: ""}},
|
||||||
Expected: false,
|
ExpectedError: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "invalid-url",
|
Name: "invalid-url",
|
||||||
Provider: AlertProvider{WebhookURL: " http://foo.com", AuthorizationKey: "12345"},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: " http://foo.com", AuthorizationKey: "12345"}},
|
||||||
Expected: false,
|
ExpectedError: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
if scenario.Provider.IsValid() != scenario.Expected {
|
err := scenario.Provider.Validate()
|
||||||
t.Errorf("expected %t, got %t", scenario.Expected, scenario.Provider.IsValid())
|
if scenario.ExpectedError && err == nil {
|
||||||
|
t.Error("expected error, got none")
|
||||||
|
}
|
||||||
|
if !scenario.ExpectedError && err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -61,7 +65,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered-error",
|
Name: "triggered-error",
|
||||||
Provider: AlertProvider{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedError: false,
|
ExpectedError: false,
|
||||||
@@ -71,7 +75,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved-error",
|
Name: "resolved-error",
|
||||||
Provider: AlertProvider{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedError: false,
|
ExpectedError: false,
|
||||||
@@ -116,21 +120,26 @@ func TestAlertProvider_buildAlertBody(t *testing.T) {
|
|||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
|
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3},
|
||||||
ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\"}",
|
ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\",\"severity\":\"critical\"}",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "no-description",
|
Name: "no-description",
|
||||||
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
|
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"}},
|
||||||
Alert: alert.Alert{FailureThreshold: 10},
|
Alert: alert.Alert{FailureThreshold: 10},
|
||||||
ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 10 time(s) in a row\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\"}",
|
ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 10 time(s) in a row\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\",\"severity\":\"critical\"}",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
cfg, err := scenario.Provider.GetConfig("", &scenario.Alert)
|
||||||
|
if err != nil {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
body := scenario.Provider.buildAlertBody(
|
body := scenario.Provider.buildAlertBody(
|
||||||
|
cfg,
|
||||||
&scenario.Endpoint,
|
&scenario.Endpoint,
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&endpoint.Result{
|
&endpoint.Result{
|
||||||
@@ -156,3 +165,59 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
|||||||
t.Error("expected default alert to be nil")
|
t.Error("expected default alert to be nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{WebhookURL: "https://github.com/TwiN/test", AuthorizationKey: "12345"},
|
||||||
|
},
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "https://github.com/TwiN/test", AuthorizationKey: "12345", Severity: DefaultSeverity, MonitoringTool: DefaultMonitoringTool},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-alert-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{WebhookURL: "https://github.com/TwiN/test", AuthorizationKey: "12345"},
|
||||||
|
},
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"repository-url": "https://github.com/TwiN/alert-test", "authorization-key": "54321", "severity": "info", "monitoring-tool": "not-gatus", "environment-name": "prod", "service": "example"}},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "https://github.com/TwiN/test", AuthorizationKey: "54321", Severity: "info", MonitoringTool: "not-gatus", EnvironmentName: "prod", Service: "example"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
|
||||||
|
if err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
|
||||||
|
t.Errorf("expected repository URL %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
|
||||||
|
}
|
||||||
|
if got.AuthorizationKey != scenario.ExpectedOutput.AuthorizationKey {
|
||||||
|
t.Errorf("expected AuthorizationKey %s, got %s", scenario.ExpectedOutput.AuthorizationKey, got.AuthorizationKey)
|
||||||
|
}
|
||||||
|
if got.Severity != scenario.ExpectedOutput.Severity {
|
||||||
|
t.Errorf("expected Severity %s, got %s", scenario.ExpectedOutput.Severity, got.Severity)
|
||||||
|
}
|
||||||
|
if got.MonitoringTool != scenario.ExpectedOutput.MonitoringTool {
|
||||||
|
t.Errorf("expected MonitoringTool %s, got %s", scenario.ExpectedOutput.MonitoringTool, got.MonitoringTool)
|
||||||
|
}
|
||||||
|
if got.EnvironmentName != scenario.ExpectedOutput.EnvironmentName {
|
||||||
|
t.Errorf("expected EnvironmentName %s, got %s", scenario.ExpectedOutput.EnvironmentName, got.EnvironmentName)
|
||||||
|
}
|
||||||
|
if got.Service != scenario.ExpectedOutput.Service {
|
||||||
|
t.Errorf("expected Service %s, got %s", scenario.ExpectedOutput.Service, got.Service)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package googlechat
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -10,14 +11,38 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrWebhookURLNotSet = errors.New("webhook-url not set")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
WebhookURL string `yaml:"webhook-url"`
|
||||||
|
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.WebhookURL) == 0 {
|
||||||
|
return ErrWebhookURLNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if override.ClientConfig != nil {
|
||||||
|
cfg.ClientConfig = override.ClientConfig
|
||||||
|
}
|
||||||
|
if len(override.WebhookURL) > 0 {
|
||||||
|
cfg.WebhookURL = override.WebhookURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Google chat
|
// AlertProvider is the configuration necessary for sending an alert using Google chat
|
||||||
type AlertProvider struct {
|
type AlertProvider struct {
|
||||||
WebhookURL string `yaml:"webhook-url"`
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
|
||||||
ClientConfig *client.Config `yaml:"client,omitempty"`
|
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
@@ -28,36 +53,37 @@ type AlertProvider struct {
|
|||||||
|
|
||||||
// Override is a case under which the default integration is overridden
|
// Override is a case under which the default integration is overridden
|
||||||
type Override struct {
|
type Override struct {
|
||||||
Group string `yaml:"group"`
|
Group string `yaml:"group"`
|
||||||
WebhookURL string `yaml:"webhook-url"`
|
Config `yaml:",inline"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// Validate the provider's configuration
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
func (provider *AlertProvider) Validate() error {
|
||||||
if provider.ClientConfig == nil {
|
|
||||||
provider.ClientConfig = client.GetDefaultConfig()
|
|
||||||
}
|
|
||||||
registeredGroups := make(map[string]bool)
|
registeredGroups := make(map[string]bool)
|
||||||
if provider.Overrides != nil {
|
if provider.Overrides != nil {
|
||||||
for _, override := range provider.Overrides {
|
for _, override := range provider.Overrides {
|
||||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
|
||||||
return false
|
return ErrDuplicateGroupOverride
|
||||||
}
|
}
|
||||||
registeredGroups[override.Group] = true
|
registeredGroups[override.Group] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return len(provider.WebhookURL) > 0
|
return provider.DefaultConfig.Validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
||||||
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer)
|
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
request.Header.Set("Content-Type", "application/json")
|
request.Header.Set("Content-Type", "application/json")
|
||||||
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
|
response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -185,19 +211,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
|||||||
return bodyAsJSON
|
return bodyAsJSON
|
||||||
}
|
}
|
||||||
|
|
||||||
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
|
|
||||||
func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
|
|
||||||
if provider.Overrides != nil {
|
|
||||||
for _, override := range provider.Overrides {
|
|
||||||
if group == override.Group {
|
|
||||||
return override.WebhookURL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return provider.WebhookURL
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,50 +11,50 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/test"
|
"github.com/TwiN/gatus/v5/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
invalidProvider := AlertProvider{WebhookURL: ""}
|
invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
|
||||||
if invalidProvider.IsValid() {
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
t.Error("provider shouldn't have been valid")
|
t.Error("provider shouldn't have been valid")
|
||||||
}
|
}
|
||||||
validProvider := AlertProvider{WebhookURL: "http://example.com"}
|
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
|
||||||
if !validProvider.IsValid() {
|
if err := validProvider.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||||
providerWithInvalidOverrideGroup := AlertProvider{
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
WebhookURL: "http://example.com",
|
Config: Config{WebhookURL: "http://example.com"},
|
||||||
Group: "",
|
Group: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if providerWithInvalidOverrideGroup.IsValid() {
|
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||||
t.Error("provider Group shouldn't have been valid")
|
t.Error("provider Group shouldn't have been valid")
|
||||||
}
|
}
|
||||||
providerWithInvalidOverrideTo := AlertProvider{
|
providerWithInvalidOverrideTo := AlertProvider{
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
WebhookURL: "",
|
Config: Config{WebhookURL: ""},
|
||||||
Group: "group",
|
Group: "group",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if providerWithInvalidOverrideTo.IsValid() {
|
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
|
||||||
t.Error("provider integration key shouldn't have been valid")
|
t.Error("provider integration key shouldn't have been valid")
|
||||||
}
|
}
|
||||||
providerWithValidOverride := AlertProvider{
|
providerWithValidOverride := AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
WebhookURL: "http://example.com",
|
Config: Config{WebhookURL: "http://example.com"},
|
||||||
Group: "group",
|
Group: "group",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if !providerWithValidOverride.IsValid() {
|
if err := providerWithValidOverride.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -83,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "triggered-error",
|
Name: "triggered-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -93,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -103,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved-error",
|
Name: "resolved-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -213,64 +213,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
tests := []struct {
|
scenarios := []struct {
|
||||||
Name string
|
Name string
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
InputGroup string
|
InputGroup string
|
||||||
ExpectedOutput string
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "provider-no-override-specify-no-group-should-default",
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: nil,
|
Overrides: nil,
|
||||||
},
|
},
|
||||||
InputGroup: "",
|
InputGroup: "",
|
||||||
ExpectedOutput: "http://example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-no-override-specify-group-should-default",
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: nil,
|
Overrides: nil,
|
||||||
},
|
},
|
||||||
InputGroup: "group",
|
InputGroup: "group",
|
||||||
ExpectedOutput: "http://example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-with-override-specify-no-group-should-default",
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
WebhookURL: "http://example01.com",
|
Config: Config{WebhookURL: "http://example01.com"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InputGroup: "",
|
InputGroup: "",
|
||||||
ExpectedOutput: "http://example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-with-override-specify-group-should-override",
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
WebhookURL: "http://example01.com",
|
Config: Config{WebhookURL: "http://group-example.com"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InputGroup: "group",
|
InputGroup: "group",
|
||||||
ExpectedOutput: "http://example01.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://group-example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{WebhookURL: "http://group-example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, scenario := range scenarios {
|
||||||
t.Run(tt.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
|
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||||
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
|
||||||
|
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package gotify
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -10,40 +11,72 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const DefaultPriority = 5
|
const DefaultPriority = 5
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrServerURLNotSet = errors.New("server URL not set")
|
||||||
|
ErrTokenNotSet = errors.New("token not set")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
ServerURL string `yaml:"server-url"` // URL of the Gotify server
|
||||||
|
Token string `yaml:"token"` // Token to use when sending a message to the Gotify server
|
||||||
|
Priority int `yaml:"priority,omitempty"` // Priority of the message. Defaults to DefaultPriority.
|
||||||
|
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if cfg.Priority == 0 {
|
||||||
|
cfg.Priority = DefaultPriority
|
||||||
|
}
|
||||||
|
if len(cfg.ServerURL) == 0 {
|
||||||
|
return ErrServerURLNotSet
|
||||||
|
}
|
||||||
|
if len(cfg.Token) == 0 {
|
||||||
|
return ErrTokenNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.ServerURL) > 0 {
|
||||||
|
cfg.ServerURL = override.ServerURL
|
||||||
|
}
|
||||||
|
if len(override.Token) > 0 {
|
||||||
|
cfg.Token = override.Token
|
||||||
|
}
|
||||||
|
if override.Priority != 0 {
|
||||||
|
cfg.Priority = override.Priority
|
||||||
|
}
|
||||||
|
if len(override.Title) > 0 {
|
||||||
|
cfg.Title = override.Title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Gotify
|
// AlertProvider is the configuration necessary for sending an alert using Gotify
|
||||||
type AlertProvider struct {
|
type AlertProvider struct {
|
||||||
// ServerURL is the URL of the Gotify server
|
DefaultConfig Config `yaml:",inline"`
|
||||||
ServerURL string `yaml:"server-url"`
|
|
||||||
|
|
||||||
// Token is the token to use when sending a message to the Gotify server
|
|
||||||
Token string `yaml:"token"`
|
|
||||||
|
|
||||||
// Priority is the priority of the message
|
|
||||||
Priority int `yaml:"priority,omitempty"` // Defaults to DefaultPriority
|
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
// Title is the title of the message that will be sent
|
|
||||||
Title string `yaml:"title,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// Validate the provider's configuration
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
func (provider *AlertProvider) Validate() error {
|
||||||
if provider.Priority == 0 {
|
return provider.DefaultConfig.Validate()
|
||||||
provider.Priority = DefaultPriority
|
|
||||||
}
|
|
||||||
return len(provider.ServerURL) > 0 && len(provider.Token) > 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
request, err := http.NewRequest(http.MethodPost, provider.ServerURL+"/message?token="+provider.Token, buffer)
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||||
|
request, err := http.NewRequest(http.MethodPost, cfg.ServerURL+"/message?token="+cfg.Token, buffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -67,7 +100,7 @@ type Body struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildRequestBody builds the request body for the provider
|
// buildRequestBody builds the request body for the provider
|
||||||
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
var message string
|
var message string
|
||||||
if resolved {
|
if resolved {
|
||||||
message = fmt.Sprintf("An alert for `%s` has been resolved after passing successfully %d time(s) in a row", ep.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)
|
||||||
@@ -89,13 +122,13 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
|||||||
}
|
}
|
||||||
message += formattedConditionResults
|
message += formattedConditionResults
|
||||||
title := "Gatus: " + ep.DisplayName()
|
title := "Gatus: " + ep.DisplayName()
|
||||||
if provider.Title != "" {
|
if cfg.Title != "" {
|
||||||
title = provider.Title
|
title = cfg.Title
|
||||||
}
|
}
|
||||||
bodyAsJSON, _ := json.Marshal(Body{
|
bodyAsJSON, _ := json.Marshal(Body{
|
||||||
Message: message,
|
Message: message,
|
||||||
Title: title,
|
Title: title,
|
||||||
Priority: provider.Priority,
|
Priority: cfg.Priority,
|
||||||
})
|
})
|
||||||
return bodyAsJSON
|
return bodyAsJSON
|
||||||
}
|
}
|
||||||
@@ -104,3 +137,25 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
|||||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
name string
|
name string
|
||||||
provider AlertProvider
|
provider AlertProvider
|
||||||
@@ -17,29 +17,29 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "valid",
|
name: "valid",
|
||||||
provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"},
|
provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "faketoken"}},
|
||||||
expected: true,
|
expected: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid-server-url",
|
name: "invalid-server-url",
|
||||||
provider: AlertProvider{ServerURL: "", Token: "faketoken"},
|
provider: AlertProvider{DefaultConfig: Config{ServerURL: "", Token: "faketoken"}},
|
||||||
expected: false,
|
expected: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid-app-token",
|
name: "invalid-app-token",
|
||||||
provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: ""},
|
provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: ""}},
|
||||||
expected: false,
|
expected: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "no-priority-should-use-default-value",
|
name: "no-priority-should-use-default-value",
|
||||||
provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"},
|
provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "faketoken"}},
|
||||||
expected: true,
|
expected: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.name, func(t *testing.T) {
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
if scenario.provider.IsValid() != scenario.expected {
|
if err := scenario.provider.Validate(); (err == nil) != scenario.expected {
|
||||||
t.Errorf("expected %t, got %t", scenario.expected, scenario.provider.IsValid())
|
t.Errorf("expected: %t, got: %t", scenario.expected, err == nil)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -60,21 +60,21 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"},
|
Provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "faketoken"}},
|
||||||
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been triggered due to having failed 3 time(s) in a row with the following description: %s\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpointName, description),
|
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",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"},
|
Provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "faketoken"}},
|
||||||
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been resolved after passing successfully 5 time(s) in a row with the following description: %s\\n✓ - [CONNECTED] == true\\n✓ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpointName, description),
|
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",
|
Name: "custom-title",
|
||||||
Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken", Title: "custom-title"},
|
Provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "faketoken", Title: "custom-title"}},
|
||||||
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been triggered due to having failed 3 time(s) in a row with the following description: %s\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"title\":\"custom-title\",\"priority\":0}", endpointName, description),
|
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),
|
||||||
@@ -83,6 +83,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
body := scenario.Provider.buildRequestBody(
|
body := scenario.Provider.buildRequestBody(
|
||||||
|
&scenario.Provider.DefaultConfig,
|
||||||
&endpoint.Endpoint{Name: endpointName},
|
&endpoint.Endpoint{Name: endpointName},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&endpoint.Result{
|
&endpoint.Result{
|
||||||
@@ -103,3 +104,60 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
|
provider := AlertProvider{DefaultAlert: &alert.Alert{}}
|
||||||
|
if provider.GetDefaultAlert() != provider.DefaultAlert {
|
||||||
|
t.Error("expected default alert to be returned")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "12345"},
|
||||||
|
},
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{ServerURL: "https://gotify.example.com", Token: "12345", Priority: DefaultPriority},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-alert-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "12345"},
|
||||||
|
},
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"server-url": "https://gotify.group-example.com", "token": "54321", "title": "alert-title", "priority": 3}},
|
||||||
|
ExpectedOutput: Config{ServerURL: "https://gotify.group-example.com", Token: "54321", Title: "alert-title", Priority: 3},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
|
||||||
|
if err != nil {
|
||||||
|
t.Error("expected no error, got:", err.Error())
|
||||||
|
}
|
||||||
|
if got.ServerURL != scenario.ExpectedOutput.ServerURL {
|
||||||
|
t.Errorf("expected server URL to be %s, got %s", scenario.ExpectedOutput.ServerURL, got.ServerURL)
|
||||||
|
}
|
||||||
|
if got.Token != scenario.ExpectedOutput.Token {
|
||||||
|
t.Errorf("expected token to be %s, got %s", scenario.ExpectedOutput.Token, got.Token)
|
||||||
|
}
|
||||||
|
if got.Title != scenario.ExpectedOutput.Title {
|
||||||
|
t.Errorf("expected title to be %s, got %s", scenario.ExpectedOutput.Title, got.Title)
|
||||||
|
}
|
||||||
|
if got.Priority != scenario.ExpectedOutput.Priority {
|
||||||
|
t.Errorf("expected priority to be %d, got %d", scenario.ExpectedOutput.Priority, got.Priority)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
196
alerting/provider/homeassistant/homeassistant.go
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
package homeassistant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrURLNotSet = errors.New("url not set")
|
||||||
|
ErrTokenNotSet = errors.New("token not set")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
URL string `yaml:"url"`
|
||||||
|
Token string `yaml:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.URL) == 0 {
|
||||||
|
return ErrURLNotSet
|
||||||
|
}
|
||||||
|
if len(cfg.Token) == 0 {
|
||||||
|
return ErrTokenNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.URL) > 0 {
|
||||||
|
cfg.URL = override.URL
|
||||||
|
}
|
||||||
|
if len(override.Token) > 0 {
|
||||||
|
cfg.Token = override.Token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using HomeAssistant
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
|
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||||
|
Overrides []Override `yaml:"overrides,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override is a case under which the default integration is overridden
|
||||||
|
type Override struct {
|
||||||
|
Group string `yaml:"group"`
|
||||||
|
Config `yaml:",inline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the provider's configuration
|
||||||
|
func (provider *AlertProvider) Validate() error {
|
||||||
|
registeredGroups := make(map[string]bool)
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||||
|
return ErrDuplicateGroupOverride
|
||||||
|
}
|
||||||
|
registeredGroups[override.Group] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return provider.DefaultConfig.Validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send an alert using the provider
|
||||||
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
||||||
|
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/events/gatus_alert", cfg.URL), buffer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set("Authorization", "Bearer "+cfg.Token)
|
||||||
|
response, err := client.GetHTTPClient(nil).Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode > 399 {
|
||||||
|
body, _ := io.ReadAll(response.Body)
|
||||||
|
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type Body struct {
|
||||||
|
EventType string `json:"event_type"`
|
||||||
|
EventData struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Conditions []struct {
|
||||||
|
Condition string `json:"condition"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
} `json:"conditions,omitempty"`
|
||||||
|
FailureCount int `json:"failure_count,omitempty"`
|
||||||
|
SuccessCount int `json:"success_count,omitempty"`
|
||||||
|
} `json:"event_data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestBody builds the request body for the provider
|
||||||
|
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
|
body := Body{
|
||||||
|
EventType: "gatus_alert",
|
||||||
|
EventData: struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Conditions []struct {
|
||||||
|
Condition string `json:"condition"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
} `json:"conditions,omitempty"`
|
||||||
|
FailureCount int `json:"failure_count,omitempty"`
|
||||||
|
SuccessCount int `json:"success_count,omitempty"`
|
||||||
|
}{
|
||||||
|
Status: "resolved",
|
||||||
|
Endpoint: ep.DisplayName(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if !resolved {
|
||||||
|
body.EventData.Status = "triggered"
|
||||||
|
body.EventData.FailureCount = alert.FailureThreshold
|
||||||
|
} else {
|
||||||
|
body.EventData.SuccessCount = alert.SuccessThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
|
body.EventData.Description = alertDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.ConditionResults) > 0 {
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
body.EventData.Conditions = append(body.EventData.Conditions, struct {
|
||||||
|
Condition string `json:"condition"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}{
|
||||||
|
Condition: conditionResult.Condition,
|
||||||
|
Success: conditionResult.Success,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyAsJSON, _ := json.Marshal(body)
|
||||||
|
return bodyAsJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
|
return provider.DefaultAlert
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
158
alerting/provider/homeassistant/homeassistant_test.go
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
package homeassistant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/TwiN/gatus/v5/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
|
invalidProvider := AlertProvider{DefaultConfig: Config{URL: "", Token: ""}}
|
||||||
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
|
t.Error("provider shouldn't have been valid")
|
||||||
|
}
|
||||||
|
invalidProviderNoToken := AlertProvider{DefaultConfig: Config{URL: "http://homeassistant:8123", Token: ""}}
|
||||||
|
if err := invalidProviderNoToken.Validate(); err == nil {
|
||||||
|
t.Error("provider shouldn't have been valid")
|
||||||
|
}
|
||||||
|
validProvider := AlertProvider{DefaultConfig: Config{URL: "http://homeassistant:8123", Token: "token"}}
|
||||||
|
if err := validProvider.Validate(); err != nil {
|
||||||
|
t.Error("provider should've been valid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||||
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Config: Config{URL: "http://homeassistant:8123", Token: "token"},
|
||||||
|
Group: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||||
|
t.Error("provider Group shouldn't have been valid")
|
||||||
|
}
|
||||||
|
providerWithValidOverride := AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "http://homeassistant:8123", Token: "token"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Config: Config{URL: "http://homeassistant:8123", Token: "token"},
|
||||||
|
Group: "group",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := providerWithValidOverride.Validate(); err != nil {
|
||||||
|
t.Error("provider should've been valid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
|
defer client.InjectHTTPClient(nil)
|
||||||
|
firstDescription := "description-1"
|
||||||
|
secondDescription := "description-2"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
MockRoundTripper test.MockRoundTripper
|
||||||
|
ExpectedError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{URL: "http://homeassistant:8123", Token: "token"}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-error",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{URL: "http://homeassistant:8123", Token: "token"}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{URL: "http://homeassistant:8123", Token: "token"}},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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: "SUCCESSFUL_CONDITION", Success: true},
|
||||||
|
{Condition: "FAILING_CONDITION", Success: false},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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) {
|
||||||
|
description := "test-description"
|
||||||
|
provider := AlertProvider{DefaultConfig: Config{URL: "http://homeassistant:8123", Token: "token"}}
|
||||||
|
body := provider.buildRequestBody(
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
|
&alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
&endpoint.Result{
|
||||||
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
|
{Condition: "SUCCESSFUL_CONDITION", Success: true},
|
||||||
|
{Condition: "FAILING_CONDITION", Success: false},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
var decodedBody Body
|
||||||
|
if err := json.Unmarshal(body, &decodedBody); err != nil {
|
||||||
|
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||||
|
}
|
||||||
|
if decodedBody.EventType != "gatus_alert" {
|
||||||
|
t.Errorf("expected event_type to be gatus_alert, got %s", decodedBody.EventType)
|
||||||
|
}
|
||||||
|
if decodedBody.EventData.Status != "triggered" {
|
||||||
|
t.Errorf("expected status to be triggered, got %s", decodedBody.EventData.Status)
|
||||||
|
}
|
||||||
|
if decodedBody.EventData.Description != description {
|
||||||
|
t.Errorf("expected description to be %s, got %s", description, decodedBody.EventData.Description)
|
||||||
|
}
|
||||||
|
if len(decodedBody.EventData.Conditions) != 2 {
|
||||||
|
t.Errorf("expected 2 conditions, got %d", len(decodedBody.EventData.Conditions))
|
||||||
|
}
|
||||||
|
if !decodedBody.EventData.Conditions[0].Success {
|
||||||
|
t.Error("expected first condition to be successful")
|
||||||
|
}
|
||||||
|
if decodedBody.EventData.Conditions[1].Success {
|
||||||
|
t.Error("expected second condition to be unsuccessful")
|
||||||
|
}
|
||||||
|
}
|
||||||
187
alerting/provider/ifttt/ifttt.go
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
package ifttt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrWebhookKeyNotSet = errors.New("webhook-key not set")
|
||||||
|
ErrEventNameNotSet = errors.New("event-name not set")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
WebhookKey string `yaml:"webhook-key"` // IFTTT Webhook key
|
||||||
|
EventName string `yaml:"event-name"` // IFTTT event name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.WebhookKey) == 0 {
|
||||||
|
return ErrWebhookKeyNotSet
|
||||||
|
}
|
||||||
|
if len(cfg.EventName) == 0 {
|
||||||
|
return ErrEventNameNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.WebhookKey) > 0 {
|
||||||
|
cfg.WebhookKey = override.WebhookKey
|
||||||
|
}
|
||||||
|
if len(override.EventName) > 0 {
|
||||||
|
cfg.EventName = override.EventName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using IFTTT
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
|
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||||
|
Overrides []Override `yaml:"overrides,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override is a case under which the default integration is overridden
|
||||||
|
type Override struct {
|
||||||
|
Group string `yaml:"group"`
|
||||||
|
Config `yaml:",inline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the provider's configuration
|
||||||
|
func (provider *AlertProvider) Validate() error {
|
||||||
|
registeredGroups := make(map[string]bool)
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||||
|
return ErrDuplicateGroupOverride
|
||||||
|
}
|
||||||
|
registeredGroups[override.Group] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return provider.DefaultConfig.Validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send an alert using the provider
|
||||||
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
url := fmt.Sprintf("https://maker.ifttt.com/trigger/%s/with/key/%s", cfg.EventName, cfg.WebhookKey)
|
||||||
|
body, err := provider.buildRequestBody(ep, alert, result, resolved)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(body)
|
||||||
|
request, err := http.NewRequest(http.MethodPost, url, 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 >= 400 {
|
||||||
|
body, _ := io.ReadAll(response.Body)
|
||||||
|
return fmt.Errorf("call to ifttt alert returned status code %d: %s", response.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type Body struct {
|
||||||
|
Value1 string `json:"value1"` // Alert status/title
|
||||||
|
Value2 string `json:"value2"` // Alert message
|
||||||
|
Value3 string `json:"value3"` // Additional details
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestBody builds the request body for the provider
|
||||||
|
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
|
||||||
|
var value1, value2, value3 string
|
||||||
|
if resolved {
|
||||||
|
value1 = fmt.Sprintf("✅ RESOLVED: %s", ep.DisplayName())
|
||||||
|
value2 = fmt.Sprintf("Alert has been resolved after passing successfully %d time(s) in a row", alert.SuccessThreshold)
|
||||||
|
} else {
|
||||||
|
value1 = fmt.Sprintf("🚨 ALERT: %s", ep.DisplayName())
|
||||||
|
value2 = fmt.Sprintf("Endpoint has failed %d time(s) in a row", alert.FailureThreshold)
|
||||||
|
}
|
||||||
|
// Build additional details
|
||||||
|
value3 = fmt.Sprintf("Endpoint: %s", ep.DisplayName())
|
||||||
|
if ep.Group != "" {
|
||||||
|
value3 += fmt.Sprintf(" | Group: %s", ep.Group)
|
||||||
|
}
|
||||||
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
|
value3 += fmt.Sprintf(" | Description: %s", alertDescription)
|
||||||
|
}
|
||||||
|
// Add condition results summary
|
||||||
|
if len(result.ConditionResults) > 0 {
|
||||||
|
successCount := 0
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
if conditionResult.Success {
|
||||||
|
successCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value3 += fmt.Sprintf(" | Conditions: %d/%d passed", successCount, len(result.ConditionResults))
|
||||||
|
}
|
||||||
|
body := Body{
|
||||||
|
Value1: value1,
|
||||||
|
Value2: value2,
|
||||||
|
Value3: value3,
|
||||||
|
}
|
||||||
|
bodyAsJSON, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return bodyAsJSON, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
|
return provider.DefaultAlert
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
154
alerting/provider/ifttt/ifttt_test.go
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
package ifttt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/TwiN/gatus/v5/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
provider AlertProvider
|
||||||
|
expected error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{WebhookKey: "ifttt-webhook-key-123", EventName: "gatus_alert"}},
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-webhook-key",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{EventName: "gatus_alert"}},
|
||||||
|
expected: ErrWebhookKeyNotSet,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-event-name",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{WebhookKey: "ifttt-webhook-key-123"}},
|
||||||
|
expected: ErrEventNameNotSet,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
|
err := scenario.provider.Validate()
|
||||||
|
if err != scenario.expected {
|
||||||
|
t.Errorf("expected %v, got %v", scenario.expected, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
|
defer client.InjectHTTPClient(nil)
|
||||||
|
firstDescription := "description-1"
|
||||||
|
secondDescription := "description-2"
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
provider AlertProvider
|
||||||
|
alert alert.Alert
|
||||||
|
resolved bool
|
||||||
|
mockRoundTripper test.MockRoundTripper
|
||||||
|
expectedError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "triggered",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{WebhookKey: "ifttt-webhook-key-123", EventName: "gatus_alert"}},
|
||||||
|
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
resolved: false,
|
||||||
|
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
if r.Host != "maker.ifttt.com" {
|
||||||
|
t.Errorf("expected host maker.ifttt.com, got %s", r.Host)
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/trigger/gatus_alert/with/key/ifttt-webhook-key-123" {
|
||||||
|
t.Errorf("expected path /trigger/gatus_alert/with/key/ifttt-webhook-key-123, got %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
body := make(map[string]interface{})
|
||||||
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
value1 := body["value1"].(string)
|
||||||
|
if !strings.Contains(value1, "ALERT") {
|
||||||
|
t.Errorf("expected value1 to contain 'ALERT', got %s", value1)
|
||||||
|
}
|
||||||
|
value2 := body["value2"].(string)
|
||||||
|
if !strings.Contains(value2, "failed 3 time(s)") {
|
||||||
|
t.Errorf("expected value2 to contain failure count, got %s", value2)
|
||||||
|
}
|
||||||
|
value3 := body["value3"].(string)
|
||||||
|
if !strings.Contains(value3, "Endpoint: endpoint-name") {
|
||||||
|
t.Errorf("expected value3 to contain endpoint details, got %s", value3)
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
expectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "resolved",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{WebhookKey: "ifttt-webhook-key-123", EventName: "gatus_resolved"}},
|
||||||
|
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
resolved: true,
|
||||||
|
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
if r.URL.Path != "/trigger/gatus_resolved/with/key/ifttt-webhook-key-123" {
|
||||||
|
t.Errorf("expected path /trigger/gatus_resolved/with/key/ifttt-webhook-key-123, got %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
body := make(map[string]interface{})
|
||||||
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
value1 := body["value1"].(string)
|
||||||
|
if !strings.Contains(value1, "RESOLVED") {
|
||||||
|
t.Errorf("expected value1 to contain 'RESOLVED', got %s", value1)
|
||||||
|
}
|
||||||
|
value3 := body["value3"].(string)
|
||||||
|
if !strings.Contains(value3, "Endpoint: endpoint-name") {
|
||||||
|
t.Errorf("expected value3 to contain endpoint details, got %s", value3)
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
expectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "error-response",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{WebhookKey: "ifttt-webhook-key-123", EventName: "gatus_alert"}},
|
||||||
|
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.StatusUnauthorized, 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_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")
|
||||||
|
}
|
||||||
|
}
|
||||||
168
alerting/provider/ilert/ilert.go
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
package ilert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
restAPIUrl = "https://api.ilert.com/api/v1/events/gatus/"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrIntegrationKeyNotSet = errors.New("integration key is not set")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
IntegrationKey string `yaml:"integration-key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.IntegrationKey) == 0 {
|
||||||
|
return ErrIntegrationKeyNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.IntegrationKey) > 0 {
|
||||||
|
cfg.IntegrationKey = override.IntegrationKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using ilert
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
|
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||||
|
Overrides []Override `yaml:"overrides,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Override struct {
|
||||||
|
Group string `yaml:"group"`
|
||||||
|
Config `yaml:",inline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) Validate() error {
|
||||||
|
registeredGroups := make(map[string]bool)
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||||
|
return ErrDuplicateGroupOverride
|
||||||
|
}
|
||||||
|
registeredGroups[override.Group] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return provider.DefaultConfig.Validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s%s", restAPIUrl, cfg.IntegrationKey), buffer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
response, err := client.GetHTTPClient(nil).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode > 399 {
|
||||||
|
body, _ := io.ReadAll(response.Body)
|
||||||
|
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type Body struct {
|
||||||
|
Alert alert.Alert `json:"alert"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Group string `json:"group"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Details string `json:"details,omitempty"`
|
||||||
|
ConditionResults []*endpoint.ConditionResult `json:"condition_results"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
|
var details, status string
|
||||||
|
if resolved {
|
||||||
|
status = "resolved"
|
||||||
|
} else {
|
||||||
|
status = "firing"
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(alert.GetDescription()) > 0 {
|
||||||
|
details = alert.GetDescription()
|
||||||
|
} else {
|
||||||
|
details = "No description"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body []byte
|
||||||
|
body, _ = json.Marshal(Body{
|
||||||
|
Alert: *alert,
|
||||||
|
Name: ep.Name,
|
||||||
|
Group: ep.Group,
|
||||||
|
Title: ep.DisplayName(),
|
||||||
|
Status: status,
|
||||||
|
Details: details,
|
||||||
|
ConditionResults: result.ConditionResults,
|
||||||
|
URL: ep.URL,
|
||||||
|
})
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
|
return provider.DefaultAlert
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
322
alerting/provider/ilert/ilert_test.go
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
package ilert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/TwiN/gatus/v5/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
provider AlertProvider
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid",
|
||||||
|
provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
IntegrationKey: "some-random-key",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-integration-key",
|
||||||
|
provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
IntegrationKey: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
|
err := scenario.provider.Validate()
|
||||||
|
if scenario.expected && err != nil {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
|
if !scenario.expected && err == nil {
|
||||||
|
t.Error("expected error, got none")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||||
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
|
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
|
||||||
|
Group: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||||
|
t.Error("provider Group shouldn't have been valid")
|
||||||
|
}
|
||||||
|
providerWithValidOverride := AlertProvider{
|
||||||
|
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
|
||||||
|
Group: "group",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := providerWithValidOverride.Validate(); err != nil {
|
||||||
|
t.Error("provider should've been valid, got error:", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
|
defer client.InjectHTTPClient(nil)
|
||||||
|
firstDescription := "description-1"
|
||||||
|
secondDescription := "description-2"
|
||||||
|
sendOnResolved := true
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
MockRoundTripper test.MockRoundTripper
|
||||||
|
ExpectedError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{
|
||||||
|
IntegrationKey: "some-integration-key",
|
||||||
|
}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 3, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
var b bytes.Buffer
|
||||||
|
|
||||||
|
reader := io.NopCloser(&b)
|
||||||
|
return &http.Response{StatusCode: http.StatusAccepted, Body: reader}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-error",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{
|
||||||
|
IntegrationKey: "some-integration-key",
|
||||||
|
}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 3, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusBadRequest, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{
|
||||||
|
IntegrationKey: "some-integration-key",
|
||||||
|
}},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 3, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
|
||||||
|
Resolved: true,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
var b bytes.Buffer
|
||||||
|
reader := io.NopCloser(&b)
|
||||||
|
return &http.Response{StatusCode: http.StatusAccepted, Body: reader}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
|
err := scenario.Provider.Send(
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
|
&scenario.Alert,
|
||||||
|
&endpoint.Result{
|
||||||
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if scenario.ExpectedError && err == nil {
|
||||||
|
t.Error("expected error, got none")
|
||||||
|
}
|
||||||
|
if !scenario.ExpectedError && err != nil {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_BuildRequestBody(t *testing.T) {
|
||||||
|
firstDescription := "description-1"
|
||||||
|
secondDescription := "description-2"
|
||||||
|
sendOnResolved := true
|
||||||
|
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
ExpectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "some-integration-key"}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 3, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: `{"alert":{"Type":"ilert","Enabled":null,"FailureThreshold":3,"SuccessThreshold":3,"MinimumReminderInterval":0,"Description":"description-1","SendOnResolved":true,"ProviderOverride":null,"ResolveKey":"123","Triggered":false},"name":"endpoint-name","group":"","status":"firing","title":"endpoint-name","details":"description-1","condition_results":[{"condition":"[CONNECTED] == true","success":false},{"condition":"[STATUS] == 200","success":false}],"url":""}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "some-integration-key"}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 4, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: `{"alert":{"Type":"ilert","Enabled":null,"FailureThreshold":3,"SuccessThreshold":4,"MinimumReminderInterval":0,"Description":"description-1","SendOnResolved":true,"ProviderOverride":null,"ResolveKey":"123","Triggered":false},"name":"endpoint-name","group":"","status":"resolved","title":"endpoint-name","details":"description-1","condition_results":[{"condition":"[CONNECTED] == true","success":true},{"condition":"[STATUS] == 200","success":true}],"url":""}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "group-override",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "some-integration-key"}, Overrides: []Override{{Group: "g", Config: Config{IntegrationKey: "different-integration-key"}}}},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: `{"alert":{"Type":"ilert","Enabled":null,"FailureThreshold":3,"SuccessThreshold":5,"MinimumReminderInterval":0,"Description":"description-2","SendOnResolved":true,"ProviderOverride":null,"ResolveKey":"123","Triggered":false},"name":"endpoint-name","group":"","status":"firing","title":"endpoint-name","details":"description-2","condition_results":[{"condition":"[CONNECTED] == true","success":false},{"condition":"[STATUS] == 200","success":false}],"url":""}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
cfg, err := scenario.Provider.GetConfig("g", &scenario.Alert)
|
||||||
|
if err != nil {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
|
body := scenario.Provider.buildRequestBody(
|
||||||
|
cfg,
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
|
&scenario.Alert,
|
||||||
|
&endpoint.Result{
|
||||||
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if string(body) != scenario.ExpectedBody {
|
||||||
|
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
||||||
|
}
|
||||||
|
out := make(map[string]interface{})
|
||||||
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
|
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
|
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
|
t.Error("expected default alert to be not nil")
|
||||||
|
}
|
||||||
|
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
|
t.Error("expected default alert to be nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputGroup string
|
||||||
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000002"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"integration-key": "00000000000000000000000000000003"}},
|
||||||
|
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000003"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.IntegrationKey != scenario.ExpectedOutput.IntegrationKey {
|
||||||
|
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.IntegrationKey, got.IntegrationKey)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
210
alerting/provider/incidentio/incidentio.go
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
package incidentio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/TwiN/logr"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
restAPIUrl = "https://api.incident.io/v2/alert_events/http/"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrURLNotSet = errors.New("url not set")
|
||||||
|
ErrURLNotPrefixedWithRestAPIURL = fmt.Errorf("url must be prefixed with %s", restAPIUrl)
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
ErrAuthTokenNotSet = errors.New("auth-token not set")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
URL string `yaml:"url,omitempty"`
|
||||||
|
AuthToken string `yaml:"auth-token,omitempty"`
|
||||||
|
SourceURL string `yaml:"source-url,omitempty"`
|
||||||
|
Metadata map[string]interface{} `yaml:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.URL) == 0 {
|
||||||
|
return ErrURLNotSet
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(cfg.URL, restAPIUrl) {
|
||||||
|
return ErrURLNotPrefixedWithRestAPIURL
|
||||||
|
}
|
||||||
|
if len(cfg.AuthToken) == 0 {
|
||||||
|
return ErrAuthTokenNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.URL) > 0 {
|
||||||
|
cfg.URL = override.URL
|
||||||
|
}
|
||||||
|
if len(override.AuthToken) > 0 {
|
||||||
|
cfg.AuthToken = override.AuthToken
|
||||||
|
}
|
||||||
|
if len(override.SourceURL) > 0 {
|
||||||
|
cfg.SourceURL = override.SourceURL
|
||||||
|
}
|
||||||
|
if len(override.Metadata) > 0 {
|
||||||
|
cfg.Metadata = override.Metadata
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using incident.io
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
|
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||||
|
Overrides []Override `yaml:"overrides,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Override struct {
|
||||||
|
Group string `yaml:"group"`
|
||||||
|
Config `yaml:",inline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) Validate() error {
|
||||||
|
registeredGroups := make(map[string]bool)
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||||
|
return ErrDuplicateGroupOverride
|
||||||
|
}
|
||||||
|
registeredGroups[override.Group] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return provider.DefaultConfig.Validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||||
|
req, err := http.NewRequest(http.MethodPost, cfg.URL, buffer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+cfg.AuthToken)
|
||||||
|
response, err := client.GetHTTPClient(nil).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode > 399 {
|
||||||
|
body, _ := io.ReadAll(response.Body)
|
||||||
|
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
incidentioResponse := Response{}
|
||||||
|
err = json.NewDecoder(response.Body).Decode(&incidentioResponse)
|
||||||
|
if err != nil {
|
||||||
|
// Silently fail. We don't want to create tons of alerts just because we failed to parse the body.
|
||||||
|
logr.Errorf("[incidentio.Send] Ran into error decoding pagerduty response: %s", err.Error())
|
||||||
|
}
|
||||||
|
alert.ResolveKey = incidentioResponse.DeduplicationKey
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type Body struct {
|
||||||
|
AlertSourceConfigID string `json:"alert_source_config_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
DeduplicationKey string `json:"deduplication_key,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
SourceURL string `json:"source_url,omitempty"`
|
||||||
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Response struct {
|
||||||
|
DeduplicationKey string `json:"deduplication_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
|
var message, formattedConditionResults, status string
|
||||||
|
if resolved {
|
||||||
|
message = "An alert has been resolved after passing successfully " + strconv.Itoa(alert.SuccessThreshold) + " time(s) in a row"
|
||||||
|
status = "resolved"
|
||||||
|
} else {
|
||||||
|
message = "An alert has been triggered due to having failed " + strconv.Itoa(alert.FailureThreshold) + " time(s) in a row"
|
||||||
|
status = "firing"
|
||||||
|
}
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
var prefix string
|
||||||
|
if conditionResult.Success {
|
||||||
|
prefix = "🟢"
|
||||||
|
} else {
|
||||||
|
prefix = "🔴"
|
||||||
|
}
|
||||||
|
// No need for \n since incident.io trims it anyways.
|
||||||
|
formattedConditionResults += fmt.Sprintf(" %s %s ", prefix, conditionResult.Condition)
|
||||||
|
}
|
||||||
|
if len(alert.GetDescription()) > 0 {
|
||||||
|
message += " with the following description: " + alert.GetDescription()
|
||||||
|
}
|
||||||
|
message += fmt.Sprintf(" and the following conditions: %s ", formattedConditionResults)
|
||||||
|
var body []byte
|
||||||
|
alertSourceID := strings.TrimPrefix(cfg.URL, restAPIUrl)
|
||||||
|
body, _ = json.Marshal(Body{
|
||||||
|
AlertSourceConfigID: alertSourceID,
|
||||||
|
Title: "Gatus: " + ep.DisplayName(),
|
||||||
|
Status: status,
|
||||||
|
DeduplicationKey: alert.ResolveKey,
|
||||||
|
Description: message,
|
||||||
|
SourceURL: cfg.SourceURL,
|
||||||
|
Metadata: cfg.Metadata,
|
||||||
|
})
|
||||||
|
fmt.Printf("%v", string(body))
|
||||||
|
return body
|
||||||
|
|
||||||
|
}
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
|
return provider.DefaultAlert
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
390
alerting/provider/incidentio/incidentio_test.go
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
package incidentio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/TwiN/gatus/v5/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
provider AlertProvider
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid",
|
||||||
|
provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
URL: "https://api.incident.io/v2/alert_events/http/some-id",
|
||||||
|
AuthToken: "some-token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-url",
|
||||||
|
provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
URL: "id-without-rest-api-url-as-prefix",
|
||||||
|
AuthToken: "some-token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-missing-auth-token",
|
||||||
|
provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
URL: "some-id",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-missing-alert-source-config-id",
|
||||||
|
provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
AuthToken: "some-token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid-override",
|
||||||
|
provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
AuthToken: "some-token",
|
||||||
|
URL: "https://api.incident.io/v2/alert_events/http/some-id",
|
||||||
|
},
|
||||||
|
Overrides: []Override{{Group: "core", Config: Config{URL: "https://api.incident.io/v2/alert_events/http/another-id"}}},
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
|
err := scenario.provider.Validate()
|
||||||
|
if scenario.expected && err != nil {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
|
if !scenario.expected && err == nil {
|
||||||
|
t.Error("expected error, got none")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
|
defer client.InjectHTTPClient(nil)
|
||||||
|
firstDescription := "description-1"
|
||||||
|
secondDescription := "description-2"
|
||||||
|
restAPIUrl := "https://api.incident.io/v2/alert_events/http/"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
MockRoundTripper test.MockRoundTripper
|
||||||
|
ExpectedError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{
|
||||||
|
URL: restAPIUrl + "some-id",
|
||||||
|
AuthToken: "some-token",
|
||||||
|
}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
var b bytes.Buffer
|
||||||
|
|
||||||
|
response := Response{DeduplicationKey: "some-key"}
|
||||||
|
json.NewEncoder(&b).Encode(response)
|
||||||
|
reader := io.NopCloser(&b)
|
||||||
|
return &http.Response{StatusCode: http.StatusAccepted, Body: reader}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-error",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{
|
||||||
|
URL: restAPIUrl + "some-id",
|
||||||
|
AuthToken: "some-token",
|
||||||
|
}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{
|
||||||
|
URL: restAPIUrl + "some-id",
|
||||||
|
AuthToken: "some-token",
|
||||||
|
}},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
var b bytes.Buffer
|
||||||
|
response := Response{DeduplicationKey: "some-key"}
|
||||||
|
json.NewEncoder(&b).Encode(response)
|
||||||
|
reader := io.NopCloser(&b)
|
||||||
|
return &http.Response{StatusCode: http.StatusAccepted, Body: reader}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-error",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{}},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
|
err := scenario.Provider.Send(
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
|
&scenario.Alert,
|
||||||
|
&endpoint.Result{
|
||||||
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if scenario.ExpectedError && err == nil {
|
||||||
|
t.Error("expected error, got none")
|
||||||
|
}
|
||||||
|
if !scenario.ExpectedError && err != nil {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_BuildRequestBody(t *testing.T) {
|
||||||
|
firstDescription := "description-1"
|
||||||
|
secondDescription := "description-2"
|
||||||
|
restAPIUrl := "https://api.incident.io/v2/alert_events/http/"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
ExpectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: `{"alert_source_config_id":"some-id","status":"firing","title":"Gatus: endpoint-name","description":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1 and the following conditions: 🔴 [CONNECTED] == true 🔴 [STATUS] == 200 "}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: `{"alert_source_config_id":"some-id","status":"resolved","title":"Gatus: endpoint-name","description":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2 and the following conditions: 🟢 [CONNECTED] == true 🟢 [STATUS] == 200 "}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-with-metadata-source-url",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token", Metadata: map[string]interface{}{"service": "some-service", "team": "very-core"}, SourceURL: "some-source-url"}},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: `{"alert_source_config_id":"some-id","status":"resolved","title":"Gatus: endpoint-name","description":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2 and the following conditions: 🟢 [CONNECTED] == true 🟢 [STATUS] == 200 ","source_url":"some-source-url","metadata":{"service":"some-service","team":"very-core"}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "group-override",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}, Overrides: []Override{{Group: "g", Config: Config{URL: restAPIUrl + "different-id", AuthToken: "some-token"}}}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: `{"alert_source_config_id":"different-id","status":"firing","title":"Gatus: endpoint-name","description":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1 and the following conditions: 🔴 [CONNECTED] == true 🔴 [STATUS] == 200 "}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
cfg, err := scenario.Provider.GetConfig("g", &scenario.Alert)
|
||||||
|
if err != nil {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
|
body := scenario.Provider.buildRequestBody(
|
||||||
|
cfg,
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
|
&scenario.Alert,
|
||||||
|
&endpoint.Result{
|
||||||
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if string(body) != scenario.ExpectedBody {
|
||||||
|
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
||||||
|
}
|
||||||
|
out := make(map[string]interface{})
|
||||||
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
|
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
|
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
|
t.Error("expected default alert to be not nil")
|
||||||
|
}
|
||||||
|
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
|
t.Error("expected default alert to be nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputGroup string
|
||||||
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{URL: "https://api.incident.io/v2/alert_events/http/diff-id"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{URL: "https://api.incident.io/v2/alert_events/http/diff-id", AuthToken: "some-token"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{URL: "https://api.incident.io/v2/alert_events/http/diff-id", AuthToken: "some-token"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{URL: "https://api.incident.io/v2/alert_events/http/diff-id", AuthToken: "some-token"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"url": "https://api.incident.io/v2/alert_events/http/another-id"}},
|
||||||
|
ExpectedOutput: Config{URL: "https://api.incident.io/v2/alert_events/http/another-id", AuthToken: "some-token"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.URL != scenario.ExpectedOutput.URL {
|
||||||
|
t.Errorf("expected alert source config to be %s, got %s", scenario.ExpectedOutput.URL, got.URL)
|
||||||
|
}
|
||||||
|
if got.AuthToken != scenario.ExpectedOutput.AuthToken {
|
||||||
|
t.Errorf("expected alert auth token to be %s, got %s", scenario.ExpectedOutput.AuthToken, got.AuthToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||||
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Config: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
|
||||||
|
Group: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||||
|
t.Error("provider Group shouldn't have been valid")
|
||||||
|
}
|
||||||
|
providerWithInvalidOverrideTo := AlertProvider{
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Config: Config{URL: "", AuthToken: "some-token"},
|
||||||
|
Group: "group",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
|
||||||
|
t.Error("provider integration key shouldn't have been valid")
|
||||||
|
}
|
||||||
|
providerWithValidOverride := AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "https://api.incident.io/v2/alert_events/http/nice-id", AuthToken: "some-token"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Config: Config{URL: "https://api.incident.io/v2/alert_events/http/very-good-id", AuthToken: "some-token"},
|
||||||
|
Group: "group",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := providerWithValidOverride.Validate(); err != nil {
|
||||||
|
t.Error("provider should've been valid")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package jetbrainsspace
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -10,13 +11,50 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrProjectNotSet = errors.New("project not set")
|
||||||
|
ErrChannelIDNotSet = errors.New("channel-id not set")
|
||||||
|
ErrTokenNotSet = errors.New("token not set")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Project string `yaml:"project"` // Project name
|
||||||
|
ChannelID string `yaml:"channel-id"` // Chat Channel ID
|
||||||
|
Token string `yaml:"token"` // Bearer Token
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.Project) == 0 {
|
||||||
|
return ErrProjectNotSet
|
||||||
|
}
|
||||||
|
if len(cfg.ChannelID) == 0 {
|
||||||
|
return ErrChannelIDNotSet
|
||||||
|
}
|
||||||
|
if len(cfg.Token) == 0 {
|
||||||
|
return ErrTokenNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.Project) > 0 {
|
||||||
|
cfg.Project = override.Project
|
||||||
|
}
|
||||||
|
if len(override.ChannelID) > 0 {
|
||||||
|
cfg.ChannelID = override.ChannelID
|
||||||
|
}
|
||||||
|
if len(override.Token) > 0 {
|
||||||
|
cfg.Token = override.Token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using JetBrains Space
|
// AlertProvider is the configuration necessary for sending an alert using JetBrains Space
|
||||||
type AlertProvider struct {
|
type AlertProvider struct {
|
||||||
Project string `yaml:"project"` // JetBrains Space Project name
|
DefaultConfig Config `yaml:",inline"`
|
||||||
ChannelID string `yaml:"channel-id"` // JetBrains Space Chat Channel ID
|
|
||||||
Token string `yaml:"token"` // JetBrains Space Bearer Token
|
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
@@ -27,34 +65,38 @@ type AlertProvider struct {
|
|||||||
|
|
||||||
// Override is a case under which the default integration is overridden
|
// Override is a case under which the default integration is overridden
|
||||||
type Override struct {
|
type Override struct {
|
||||||
Group string `yaml:"group"`
|
Group string `yaml:"group"`
|
||||||
ChannelID string `yaml:"channel-id"`
|
Config `yaml:",inline"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// Validate the provider's configuration
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
func (provider *AlertProvider) Validate() error {
|
||||||
registeredGroups := make(map[string]bool)
|
registeredGroups := make(map[string]bool)
|
||||||
if provider.Overrides != nil {
|
if provider.Overrides != nil {
|
||||||
for _, override := range provider.Overrides {
|
for _, override := range provider.Overrides {
|
||||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.ChannelID) == 0 {
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||||
return false
|
return ErrDuplicateGroupOverride
|
||||||
}
|
}
|
||||||
registeredGroups[override.Group] = true
|
registeredGroups[override.Group] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return len(provider.Project) > 0 && len(provider.ChannelID) > 0 && len(provider.Token) > 0
|
return provider.DefaultConfig.Validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
url := fmt.Sprintf("https://%s.jetbrains.space/api/http/chats/messages/send-message", provider.Project)
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||||
|
url := fmt.Sprintf("https://%s.jetbrains.space/api/http/chats/messages/send-message", cfg.Project)
|
||||||
request, err := http.NewRequest(http.MethodPost, url, buffer)
|
request, err := http.NewRequest(http.MethodPost, url, buffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
request.Header.Set("Content-Type", "application/json")
|
request.Header.Set("Content-Type", "application/json")
|
||||||
request.Header.Set("Authorization", "Bearer "+provider.Token)
|
request.Header.Set("Authorization", "Bearer "+cfg.Token)
|
||||||
response, err := client.GetHTTPClient(nil).Do(request)
|
response, err := client.GetHTTPClient(nil).Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -103,9 +145,9 @@ type Icon struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildRequestBody builds the request body for the provider
|
// buildRequestBody builds the request body for the provider
|
||||||
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
body := Body{
|
body := Body{
|
||||||
Channel: "id:" + provider.getChannelIDForGroup(ep.Group),
|
Channel: "id:" + cfg.ChannelID,
|
||||||
Content: Content{
|
Content: Content{
|
||||||
ClassName: "ChatMessage.Block",
|
ClassName: "ChatMessage.Block",
|
||||||
Sections: []Section{{
|
Sections: []Section{{
|
||||||
@@ -144,19 +186,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
|||||||
return bodyAsJSON
|
return bodyAsJSON
|
||||||
}
|
}
|
||||||
|
|
||||||
// getChannelIDForGroup returns the appropriate channel ID to for a given group override
|
|
||||||
func (provider *AlertProvider) getChannelIDForGroup(group string) string {
|
|
||||||
if provider.Overrides != nil {
|
|
||||||
for _, override := range provider.Overrides {
|
|
||||||
if group == override.Group {
|
|
||||||
return override.ChannelID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return provider.ChannelID
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,54 +11,56 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/test"
|
"github.com/TwiN/gatus/v5/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
invalidProvider := AlertProvider{Project: ""}
|
invalidProvider := AlertProvider{DefaultConfig: Config{Project: ""}}
|
||||||
if invalidProvider.IsValid() {
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
t.Error("provider shouldn't have been valid")
|
t.Error("provider shouldn't have been valid")
|
||||||
}
|
}
|
||||||
validProvider := AlertProvider{Project: "foo", ChannelID: "bar", Token: "baz"}
|
validProvider := AlertProvider{DefaultConfig: Config{Project: "foo", ChannelID: "bar", Token: "baz"}}
|
||||||
if !validProvider.IsValid() {
|
if err := validProvider.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||||
providerWithInvalidOverrideGroup := AlertProvider{
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
Project: "foobar",
|
DefaultConfig: Config{Project: "foobar"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
ChannelID: "http://example.com",
|
Config: Config{ChannelID: "http://example.com"},
|
||||||
Group: "",
|
Group: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if providerWithInvalidOverrideGroup.IsValid() {
|
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||||
t.Error("provider Group shouldn't have been valid")
|
t.Error("provider Group shouldn't have been valid")
|
||||||
}
|
}
|
||||||
providerWithInvalidOverrideTo := AlertProvider{
|
providerWithInvalidOverrideTo := AlertProvider{
|
||||||
Project: "foobar",
|
DefaultConfig: Config{Project: "foobar"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
ChannelID: "",
|
Config: Config{ChannelID: ""},
|
||||||
Group: "group",
|
Group: "group",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if providerWithInvalidOverrideTo.IsValid() {
|
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
|
||||||
t.Error("provider integration key shouldn't have been valid")
|
t.Error("provider integration key shouldn't have been valid")
|
||||||
}
|
}
|
||||||
providerWithValidOverride := AlertProvider{
|
providerWithValidOverride := AlertProvider{
|
||||||
Project: "foo",
|
DefaultConfig: Config{
|
||||||
ChannelID: "bar",
|
Project: "foo",
|
||||||
Token: "baz",
|
ChannelID: "bar",
|
||||||
|
Token: "baz",
|
||||||
|
},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
ChannelID: "foobar",
|
Config: Config{ChannelID: "foobar"},
|
||||||
Group: "group",
|
Group: "group",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if !providerWithValidOverride.IsValid() {
|
if err := providerWithValidOverride.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,7 +79,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -87,7 +89,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "triggered-error",
|
Name: "triggered-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -97,7 +99,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -107,7 +109,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved-error",
|
Name: "resolved-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -153,40 +155,41 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
|
||||||
Endpoint: endpoint.Endpoint{Name: "name"},
|
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedBody: `{"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"}]}}`,
|
ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"WARNING","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *name* has been triggered due to having failed 3 time(s) in a row"}]}}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "triggered-with-group",
|
Name: "triggered-with-group",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
|
||||||
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedBody: `{"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"}]}}`,
|
ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"WARNING","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *group/name* has been triggered due to having failed 3 time(s) in a row"}]}}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
|
||||||
Endpoint: endpoint.Endpoint{Name: "name"},
|
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedBody: `{"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"}]}}`,
|
ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"SUCCESS","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *name* has been resolved after passing successfully 5 time(s) in a row"}]}}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved-with-group",
|
Name: "resolved-with-group",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
|
||||||
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedBody: `{"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"}]}}`,
|
ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"SUCCESS","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row"}]}}`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
body := scenario.Provider.buildRequestBody(
|
body := scenario.Provider.buildRequestBody(
|
||||||
|
&scenario.Provider.DefaultConfig,
|
||||||
&scenario.Endpoint,
|
&scenario.Endpoint,
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&endpoint.Result{
|
&endpoint.Result{
|
||||||
@@ -217,62 +220,98 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_getChannelIDForGroup(t *testing.T) {
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
tests := []struct {
|
scenarios := []struct {
|
||||||
Name string
|
Name string
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
InputGroup string
|
InputGroup string
|
||||||
ExpectedOutput string
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "provider-no-override-specify-no-group-should-default",
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
ChannelID: "bar",
|
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||||
|
Overrides: nil,
|
||||||
},
|
},
|
||||||
InputGroup: "",
|
InputGroup: "",
|
||||||
ExpectedOutput: "bar",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-no-override-specify-group-should-default",
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
ChannelID: "bar",
|
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||||
|
Overrides: nil,
|
||||||
},
|
},
|
||||||
InputGroup: "group",
|
InputGroup: "group",
|
||||||
ExpectedOutput: "bar",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-with-override-specify-no-group-should-default",
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
ChannelID: "bar",
|
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
ChannelID: "foobar",
|
Config: Config{ChannelID: "group-channel"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InputGroup: "",
|
InputGroup: "",
|
||||||
ExpectedOutput: "bar",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-with-override-specify-group-should-override",
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
ChannelID: "bar",
|
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
ChannelID: "foobar",
|
Config: Config{ChannelID: "group-channel"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InputGroup: "group",
|
InputGroup: "group",
|
||||||
ExpectedOutput: "foobar",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{ChannelID: "group-channel", Project: "project", Token: "token"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{ChannelID: "group-channel"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"channel-id": "alert-channel", "project": "alert-project", "token": "alert-token"}},
|
||||||
|
ExpectedOutput: Config{ChannelID: "alert-channel", Project: "alert-project", Token: "alert-token"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, scenario := range scenarios {
|
||||||
t.Run(tt.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
if got := tt.Provider.getChannelIDForGroup(tt.InputGroup); got != tt.ExpectedOutput {
|
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||||
t.Errorf("AlertProvider.getChannelIDForGroup() = %v, want %v", got, tt.ExpectedOutput)
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.ChannelID != scenario.ExpectedOutput.ChannelID {
|
||||||
|
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.ChannelID, got.ChannelID)
|
||||||
|
}
|
||||||
|
if got.Project != scenario.ExpectedOutput.Project {
|
||||||
|
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.Project, got.Project)
|
||||||
|
}
|
||||||
|
if got.Token != scenario.ExpectedOutput.Token {
|
||||||
|
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.Token, got.Token)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
193
alerting/provider/line/line.go
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
package line
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrChannelAccessTokenNotSet = errors.New("channel-access-token not set")
|
||||||
|
ErrUserIDsNotSet = errors.New("user-ids not set")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
ChannelAccessToken string `yaml:"channel-access-token"` // Line Messaging API channel access token
|
||||||
|
UserIDs []string `yaml:"user-ids"` // List of Line user IDs to send messages to
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.ChannelAccessToken) == 0 {
|
||||||
|
return ErrChannelAccessTokenNotSet
|
||||||
|
}
|
||||||
|
if len(cfg.UserIDs) == 0 {
|
||||||
|
return ErrUserIDsNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.ChannelAccessToken) > 0 {
|
||||||
|
cfg.ChannelAccessToken = override.ChannelAccessToken
|
||||||
|
}
|
||||||
|
if len(override.UserIDs) > 0 {
|
||||||
|
cfg.UserIDs = override.UserIDs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using Line
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
|
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||||
|
Overrides []Override `yaml:"overrides,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override is a case under which the default integration is overridden
|
||||||
|
type Override struct {
|
||||||
|
Group string `yaml:"group"`
|
||||||
|
Config `yaml:",inline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the provider's configuration
|
||||||
|
func (provider *AlertProvider) Validate() error {
|
||||||
|
registeredGroups := make(map[string]bool)
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||||
|
return ErrDuplicateGroupOverride
|
||||||
|
}
|
||||||
|
registeredGroups[override.Group] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return provider.DefaultConfig.Validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send an alert using the provider
|
||||||
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, userID := range cfg.UserIDs {
|
||||||
|
body, err := provider.buildRequestBody(ep, alert, result, resolved, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(body)
|
||||||
|
request, err := http.NewRequest(http.MethodPost, "https://api.line.me/v2/bot/message/push", buffer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.ChannelAccessToken))
|
||||||
|
response, err := client.GetHTTPClient(nil).Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if response.StatusCode >= 400 {
|
||||||
|
body, _ := io.ReadAll(response.Body)
|
||||||
|
response.Body.Close()
|
||||||
|
return fmt.Errorf("call to line alert returned status code %d: %s", response.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
response.Body.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Body struct {
|
||||||
|
To string `json:"to"`
|
||||||
|
Messages []Message `json:"messages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Message struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestBody builds the request body for the provider
|
||||||
|
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool, userID string) ([]byte, error) {
|
||||||
|
var message string
|
||||||
|
if resolved {
|
||||||
|
message = fmt.Sprintf("✅ RESOLVED: %s\n\nAlert has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||||
|
} else {
|
||||||
|
message = fmt.Sprintf("⚠️ ALERT: %s\n\nEndpoint has failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||||
|
}
|
||||||
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
|
message += fmt.Sprintf("\n\nDescription: %s", alertDescription)
|
||||||
|
}
|
||||||
|
if len(result.ConditionResults) > 0 {
|
||||||
|
message += "\n\nCondition Results:"
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
var status string
|
||||||
|
if conditionResult.Success {
|
||||||
|
status = "✅"
|
||||||
|
} else {
|
||||||
|
status = "❌"
|
||||||
|
}
|
||||||
|
message += fmt.Sprintf("\n%s %s", status, conditionResult.Condition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body := Body{
|
||||||
|
To: userID,
|
||||||
|
Messages: []Message{
|
||||||
|
{
|
||||||
|
Type: "text",
|
||||||
|
Text: message,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
bodyAsJSON, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return bodyAsJSON, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
|
return provider.DefaultAlert
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
147
alerting/provider/line/line_test.go
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
package line
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/TwiN/gatus/v5/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
provider AlertProvider
|
||||||
|
expected error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: "token123", UserIDs: []string{"U123"}}},
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-channel-access-token",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{UserIDs: []string{"U123"}}},
|
||||||
|
expected: ErrChannelAccessTokenNotSet,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-user-ids",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: "token123"}},
|
||||||
|
expected: ErrUserIDsNotSet,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
|
err := scenario.provider.Validate()
|
||||||
|
if err != scenario.expected {
|
||||||
|
t.Errorf("expected %v, got %v", scenario.expected, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
|
defer client.InjectHTTPClient(nil)
|
||||||
|
firstDescription := "description-1"
|
||||||
|
secondDescription := "description-2"
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
provider AlertProvider
|
||||||
|
alert alert.Alert
|
||||||
|
resolved bool
|
||||||
|
mockRoundTripper test.MockRoundTripper
|
||||||
|
expectedError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "triggered",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: "token123", UserIDs: []string{"U123", "U456"}}},
|
||||||
|
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
resolved: false,
|
||||||
|
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
if r.URL.Path != "/v2/bot/message/push" {
|
||||||
|
t.Errorf("expected path /v2/bot/message/push, got %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.Header.Get("Authorization") != "Bearer token123" {
|
||||||
|
t.Errorf("expected Authorization header to be 'Bearer token123', got %s", r.Header.Get("Authorization"))
|
||||||
|
}
|
||||||
|
body := make(map[string]interface{})
|
||||||
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
if body["to"] == nil {
|
||||||
|
t.Error("expected 'to' field in request body")
|
||||||
|
}
|
||||||
|
messages := body["messages"].([]interface{})
|
||||||
|
if len(messages) != 1 {
|
||||||
|
t.Errorf("expected 1 message, got %d", len(messages))
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
expectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "resolved",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: "token123", UserIDs: []string{"U123"}}},
|
||||||
|
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
resolved: true,
|
||||||
|
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
body := make(map[string]interface{})
|
||||||
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
messages := body["messages"].([]interface{})
|
||||||
|
message := messages[0].(map[string]interface{})
|
||||||
|
text := message["text"].(string)
|
||||||
|
if !contains(text, "RESOLVED") {
|
||||||
|
t.Errorf("expected message to contain 'RESOLVED', got %s", text)
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
expectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "error-response",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: "token123", UserIDs: []string{"U123"}}},
|
||||||
|
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.StatusBadRequest, 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_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 contains(s, substr string) bool {
|
||||||
|
return len(s) >= len(substr) && s[0:len(substr)] == substr || len(s) > len(substr) && contains(s[1:], substr)
|
||||||
|
}
|
||||||
@@ -3,39 +3,28 @@ package matrix
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Matrix
|
|
||||||
type AlertProvider struct {
|
|
||||||
ProviderConfig `yaml:",inline"`
|
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
|
||||||
|
|
||||||
// Overrides is a list of Override that may be prioritized over the default configuration
|
|
||||||
Overrides []Override `yaml:"overrides,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Override is a case under which the default integration is overridden
|
|
||||||
type Override struct {
|
|
||||||
Group string `yaml:"group"`
|
|
||||||
|
|
||||||
ProviderConfig `yaml:",inline"`
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultServerURL = "https://matrix-client.matrix.org"
|
const defaultServerURL = "https://matrix-client.matrix.org"
|
||||||
|
|
||||||
type ProviderConfig struct {
|
var (
|
||||||
|
ErrAccessTokenNotSet = errors.New("access-token not set")
|
||||||
|
ErrInternalRoomID = errors.New("internal-room-id not set")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
// ServerURL is the custom homeserver to use (optional)
|
// ServerURL is the custom homeserver to use (optional)
|
||||||
ServerURL string `yaml:"server-url"`
|
ServerURL string `yaml:"server-url"`
|
||||||
|
|
||||||
@@ -46,36 +35,78 @@ type ProviderConfig struct {
|
|||||||
InternalRoomID string `yaml:"internal-room-id"`
|
InternalRoomID string `yaml:"internal-room-id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
func (cfg *Config) Validate() error {
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
if len(cfg.ServerURL) == 0 {
|
||||||
|
cfg.ServerURL = defaultServerURL
|
||||||
|
}
|
||||||
|
if len(cfg.AccessToken) == 0 {
|
||||||
|
return ErrAccessTokenNotSet
|
||||||
|
}
|
||||||
|
if len(cfg.InternalRoomID) == 0 {
|
||||||
|
return ErrInternalRoomID
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.ServerURL) > 0 {
|
||||||
|
cfg.ServerURL = override.ServerURL
|
||||||
|
}
|
||||||
|
if len(override.AccessToken) > 0 {
|
||||||
|
cfg.AccessToken = override.AccessToken
|
||||||
|
}
|
||||||
|
if len(override.InternalRoomID) > 0 {
|
||||||
|
cfg.InternalRoomID = override.InternalRoomID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using Matrix
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
|
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||||
|
Overrides []Override `yaml:"overrides,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override is a case under which the default integration is overridden
|
||||||
|
type Override struct {
|
||||||
|
Group string `yaml:"group"`
|
||||||
|
Config `yaml:",inline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the provider's configuration
|
||||||
|
func (provider *AlertProvider) Validate() error {
|
||||||
registeredGroups := make(map[string]bool)
|
registeredGroups := make(map[string]bool)
|
||||||
if provider.Overrides != nil {
|
if provider.Overrides != nil {
|
||||||
for _, override := range provider.Overrides {
|
for _, override := range provider.Overrides {
|
||||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.AccessToken) == 0 || len(override.InternalRoomID) == 0 {
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.AccessToken) == 0 || len(override.InternalRoomID) == 0 {
|
||||||
return false
|
return ErrDuplicateGroupOverride
|
||||||
}
|
}
|
||||||
registeredGroups[override.Group] = true
|
registeredGroups[override.Group] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return len(provider.AccessToken) > 0 && len(provider.InternalRoomID) > 0
|
return provider.DefaultConfig.Validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
config := provider.getConfigForGroup(ep.Group)
|
if err != nil {
|
||||||
if config.ServerURL == "" {
|
return err
|
||||||
config.ServerURL = defaultServerURL
|
|
||||||
}
|
}
|
||||||
|
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
||||||
// The Matrix endpoint requires a unique transaction ID for each event sent
|
// The Matrix endpoint requires a unique transaction ID for each event sent
|
||||||
txnId := randStringBytes(24)
|
txnId := randStringBytes(24)
|
||||||
request, err := http.NewRequest(
|
request, err := http.NewRequest(
|
||||||
http.MethodPut,
|
http.MethodPut,
|
||||||
fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/send/m.room.message/%s?access_token=%s",
|
fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/send/m.room.message/%s?access_token=%s",
|
||||||
config.ServerURL,
|
cfg.ServerURL,
|
||||||
url.PathEscape(config.InternalRoomID),
|
url.PathEscape(cfg.InternalRoomID),
|
||||||
txnId,
|
txnId,
|
||||||
url.QueryEscape(config.AccessToken),
|
url.QueryEscape(cfg.AccessToken),
|
||||||
),
|
),
|
||||||
buffer,
|
buffer,
|
||||||
)
|
)
|
||||||
@@ -167,23 +198,10 @@ func buildHTMLMessageBody(ep *endpoint.Endpoint, alert *alert.Alert, result *end
|
|||||||
return fmt.Sprintf("<h3>%s</h3>%s%s", message, description, formattedConditionResults)
|
return fmt.Sprintf("<h3>%s</h3>%s%s", message, description, formattedConditionResults)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getConfigForGroup returns the appropriate configuration for a given group
|
|
||||||
func (provider *AlertProvider) getConfigForGroup(group string) ProviderConfig {
|
|
||||||
if provider.Overrides != nil {
|
|
||||||
for _, override := range provider.Overrides {
|
|
||||||
if group == override.Group {
|
|
||||||
return override.ProviderConfig
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return provider.ProviderConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
func randStringBytes(n int) string {
|
func randStringBytes(n int) string {
|
||||||
// All the compatible characters to use in a transaction ID
|
// All the compatible characters to use in a transaction ID
|
||||||
const availableCharacterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
const availableCharacterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
b := make([]byte, n)
|
b := make([]byte, n)
|
||||||
rand.Seed(time.Now().UnixNano())
|
|
||||||
for i := range b {
|
for i := range b {
|
||||||
b[i] = availableCharacterBytes[rand.Intn(len(availableCharacterBytes))]
|
b[i] = availableCharacterBytes[rand.Intn(len(availableCharacterBytes))]
|
||||||
}
|
}
|
||||||
@@ -194,3 +212,34 @@ func randStringBytes(n int) string {
|
|||||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,75 +11,75 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/test"
|
"github.com/TwiN/gatus/v5/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
invalidProvider := AlertProvider{
|
invalidProvider := AlertProvider{
|
||||||
ProviderConfig: ProviderConfig{
|
DefaultConfig: Config{
|
||||||
AccessToken: "",
|
AccessToken: "",
|
||||||
InternalRoomID: "",
|
InternalRoomID: "",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if invalidProvider.IsValid() {
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
t.Error("provider shouldn't have been valid")
|
t.Error("provider shouldn't have been valid")
|
||||||
}
|
}
|
||||||
validProvider := AlertProvider{
|
validProvider := AlertProvider{
|
||||||
ProviderConfig: ProviderConfig{
|
DefaultConfig: Config{
|
||||||
AccessToken: "1",
|
AccessToken: "1",
|
||||||
InternalRoomID: "!a:example.com",
|
InternalRoomID: "!a:example.com",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if !validProvider.IsValid() {
|
if err := validProvider.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
validProviderWithHomeserver := AlertProvider{
|
validProviderWithHomeserver := AlertProvider{
|
||||||
ProviderConfig: ProviderConfig{
|
DefaultConfig: Config{
|
||||||
ServerURL: "https://example.com",
|
ServerURL: "https://example.com",
|
||||||
AccessToken: "1",
|
AccessToken: "1",
|
||||||
InternalRoomID: "!a:example.com",
|
InternalRoomID: "!a:example.com",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if !validProviderWithHomeserver.IsValid() {
|
if err := validProviderWithHomeserver.Validate(); err != nil {
|
||||||
t.Error("provider with homeserver should've been valid")
|
t.Error("provider with homeserver should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||||
providerWithInvalidOverrideGroup := AlertProvider{
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "",
|
Group: "",
|
||||||
ProviderConfig: ProviderConfig{
|
Config: Config{
|
||||||
AccessToken: "",
|
AccessToken: "",
|
||||||
InternalRoomID: "",
|
InternalRoomID: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if providerWithInvalidOverrideGroup.IsValid() {
|
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||||
t.Error("provider Group shouldn't have been valid")
|
t.Error("provider Group shouldn't have been valid")
|
||||||
}
|
}
|
||||||
providerWithInvalidOverrideTo := AlertProvider{
|
providerWithInvalidOverrideTo := AlertProvider{
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
ProviderConfig: ProviderConfig{
|
Config: Config{
|
||||||
AccessToken: "",
|
AccessToken: "",
|
||||||
InternalRoomID: "",
|
InternalRoomID: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if providerWithInvalidOverrideTo.IsValid() {
|
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
|
||||||
t.Error("provider integration key shouldn't have been valid")
|
t.Error("provider integration key shouldn't have been valid")
|
||||||
}
|
}
|
||||||
providerWithValidOverride := AlertProvider{
|
providerWithValidOverride := AlertProvider{
|
||||||
ProviderConfig: ProviderConfig{
|
DefaultConfig: Config{
|
||||||
AccessToken: "1",
|
AccessToken: "1",
|
||||||
InternalRoomID: "!a:example.com",
|
InternalRoomID: "!a:example.com",
|
||||||
},
|
},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
ProviderConfig: ProviderConfig{
|
Config: Config{
|
||||||
ServerURL: "https://example.com",
|
ServerURL: "https://example.com",
|
||||||
AccessToken: "1",
|
AccessToken: "1",
|
||||||
InternalRoomID: "!a:example.com",
|
InternalRoomID: "!a:example.com",
|
||||||
@@ -87,7 +87,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if !providerWithValidOverride.IsValid() {
|
if err := providerWithValidOverride.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,18 +105,28 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
ExpectedError bool
|
ExpectedError bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered-with-bad-config",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
}),
|
}),
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{AccessToken: "1", InternalRoomID: "!a:example.com"}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
ExpectedError: false,
|
ExpectedError: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "triggered-error",
|
Name: "triggered-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{AccessToken: "1", InternalRoomID: "!a:example.com"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -126,7 +136,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{AccessToken: "1", InternalRoomID: "!a:example.com"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -136,7 +146,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved-error",
|
Name: "resolved-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{AccessToken: "1", InternalRoomID: "!a:example.com"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -227,17 +237,18 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
tests := []struct {
|
scenarios := []struct {
|
||||||
Name string
|
Name string
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
InputGroup string
|
InputGroup string
|
||||||
ExpectedOutput ProviderConfig
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "provider-no-override-specify-no-group-should-default",
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
ProviderConfig: ProviderConfig{
|
DefaultConfig: Config{
|
||||||
ServerURL: "https://example.com",
|
ServerURL: "https://example.com",
|
||||||
AccessToken: "1",
|
AccessToken: "1",
|
||||||
InternalRoomID: "!a:example.com",
|
InternalRoomID: "!a:example.com",
|
||||||
@@ -245,7 +256,8 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
|||||||
Overrides: nil,
|
Overrides: nil,
|
||||||
},
|
},
|
||||||
InputGroup: "",
|
InputGroup: "",
|
||||||
ExpectedOutput: ProviderConfig{
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{
|
||||||
ServerURL: "https://example.com",
|
ServerURL: "https://example.com",
|
||||||
AccessToken: "1",
|
AccessToken: "1",
|
||||||
InternalRoomID: "!a:example.com",
|
InternalRoomID: "!a:example.com",
|
||||||
@@ -254,7 +266,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
|||||||
{
|
{
|
||||||
Name: "provider-no-override-specify-group-should-default",
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
ProviderConfig: ProviderConfig{
|
DefaultConfig: Config{
|
||||||
ServerURL: "https://example.com",
|
ServerURL: "https://example.com",
|
||||||
AccessToken: "1",
|
AccessToken: "1",
|
||||||
InternalRoomID: "!a:example.com",
|
InternalRoomID: "!a:example.com",
|
||||||
@@ -262,7 +274,8 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
|||||||
Overrides: nil,
|
Overrides: nil,
|
||||||
},
|
},
|
||||||
InputGroup: "group",
|
InputGroup: "group",
|
||||||
ExpectedOutput: ProviderConfig{
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{
|
||||||
ServerURL: "https://example.com",
|
ServerURL: "https://example.com",
|
||||||
AccessToken: "1",
|
AccessToken: "1",
|
||||||
InternalRoomID: "!a:example.com",
|
InternalRoomID: "!a:example.com",
|
||||||
@@ -271,7 +284,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
|||||||
{
|
{
|
||||||
Name: "provider-with-override-specify-no-group-should-default",
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
ProviderConfig: ProviderConfig{
|
DefaultConfig: Config{
|
||||||
ServerURL: "https://example.com",
|
ServerURL: "https://example.com",
|
||||||
AccessToken: "1",
|
AccessToken: "1",
|
||||||
InternalRoomID: "!a:example.com",
|
InternalRoomID: "!a:example.com",
|
||||||
@@ -279,16 +292,17 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
|||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
ProviderConfig: ProviderConfig{
|
Config: Config{
|
||||||
ServerURL: "https://example01.com",
|
ServerURL: "https://group-example.com",
|
||||||
AccessToken: "12",
|
AccessToken: "12",
|
||||||
InternalRoomID: "!a:example01.com",
|
InternalRoomID: "!a:group-example.com",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InputGroup: "",
|
InputGroup: "",
|
||||||
ExpectedOutput: ProviderConfig{
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{
|
||||||
ServerURL: "https://example.com",
|
ServerURL: "https://example.com",
|
||||||
AccessToken: "1",
|
AccessToken: "1",
|
||||||
InternalRoomID: "!a:example.com",
|
InternalRoomID: "!a:example.com",
|
||||||
@@ -297,7 +311,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
|||||||
{
|
{
|
||||||
Name: "provider-with-override-specify-group-should-override",
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
ProviderConfig: ProviderConfig{
|
DefaultConfig: Config{
|
||||||
ServerURL: "https://example.com",
|
ServerURL: "https://example.com",
|
||||||
AccessToken: "1",
|
AccessToken: "1",
|
||||||
InternalRoomID: "!a:example.com",
|
InternalRoomID: "!a:example.com",
|
||||||
@@ -305,8 +319,35 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
|||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
ProviderConfig: ProviderConfig{
|
Config: Config{
|
||||||
ServerURL: "https://example01.com",
|
ServerURL: "https://group-example.com",
|
||||||
|
AccessToken: "12",
|
||||||
|
InternalRoomID: "!a:group-example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{
|
||||||
|
ServerURL: "https://group-example.com",
|
||||||
|
AccessToken: "12",
|
||||||
|
InternalRoomID: "!a:group-example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
ServerURL: "https://example.com",
|
||||||
|
AccessToken: "1",
|
||||||
|
InternalRoomID: "!a:example.com",
|
||||||
|
},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{
|
||||||
|
ServerURL: "https://group-example.com",
|
||||||
AccessToken: "12",
|
AccessToken: "12",
|
||||||
InternalRoomID: "!a:example01.com",
|
InternalRoomID: "!a:example01.com",
|
||||||
},
|
},
|
||||||
@@ -314,17 +355,32 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
InputGroup: "group",
|
InputGroup: "group",
|
||||||
ExpectedOutput: ProviderConfig{
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"server-url": "https://alert-example.com", "access-token": "123", "internal-room-id": "!a:alert-example.com"}},
|
||||||
ServerURL: "https://example01.com",
|
ExpectedOutput: Config{
|
||||||
AccessToken: "12",
|
ServerURL: "https://alert-example.com",
|
||||||
InternalRoomID: "!a:example01.com",
|
AccessToken: "123",
|
||||||
|
InternalRoomID: "!a:alert-example.com",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, scenario := range scenarios {
|
||||||
t.Run(tt.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
if got := tt.Provider.getConfigForGroup(tt.InputGroup); got != tt.ExpectedOutput {
|
outputConfig, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||||
t.Errorf("AlertProvider.getConfigForGroup() = %v, want %v", got, tt.ExpectedOutput)
|
if err != nil {
|
||||||
|
t.Errorf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if outputConfig.ServerURL != scenario.ExpectedOutput.ServerURL {
|
||||||
|
t.Errorf("expected ServerURL to be %s, got %s", scenario.ExpectedOutput.ServerURL, outputConfig.ServerURL)
|
||||||
|
}
|
||||||
|
if outputConfig.AccessToken != scenario.ExpectedOutput.AccessToken {
|
||||||
|
t.Errorf("expected AccessToken to be %s, got %s", scenario.ExpectedOutput.AccessToken, outputConfig.AccessToken)
|
||||||
|
}
|
||||||
|
if outputConfig.InternalRoomID != scenario.ExpectedOutput.InternalRoomID {
|
||||||
|
t.Errorf("expected InternalRoomID to be %s, got %s", scenario.ExpectedOutput.InternalRoomID, outputConfig.InternalRoomID)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package mattermost
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -10,14 +11,42 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrWebhookURLNotSet = errors.New("webhook URL not set")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
WebhookURL string `yaml:"webhook-url"`
|
||||||
|
Channel string `yaml:"channel,omitempty"`
|
||||||
|
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.WebhookURL) == 0 {
|
||||||
|
return ErrWebhookURLNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if override.ClientConfig != nil {
|
||||||
|
cfg.ClientConfig = override.ClientConfig
|
||||||
|
}
|
||||||
|
if len(override.WebhookURL) > 0 {
|
||||||
|
cfg.WebhookURL = override.WebhookURL
|
||||||
|
}
|
||||||
|
if len(override.Channel) > 0 {
|
||||||
|
cfg.Channel = override.Channel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Mattermost
|
// AlertProvider is the configuration necessary for sending an alert using Mattermost
|
||||||
type AlertProvider struct {
|
type AlertProvider struct {
|
||||||
WebhookURL string `yaml:"webhook-url"`
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
|
||||||
ClientConfig *client.Config `yaml:"client,omitempty"`
|
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
@@ -28,36 +57,37 @@ type AlertProvider struct {
|
|||||||
|
|
||||||
// Override is a case under which the default integration is overridden
|
// Override is a case under which the default integration is overridden
|
||||||
type Override struct {
|
type Override struct {
|
||||||
Group string `yaml:"group"`
|
Group string `yaml:"group"`
|
||||||
WebhookURL string `yaml:"webhook-url"`
|
Config `yaml:",inline"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// Validate the provider's configuration
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
func (provider *AlertProvider) Validate() error {
|
||||||
if provider.ClientConfig == nil {
|
|
||||||
provider.ClientConfig = client.GetDefaultConfig()
|
|
||||||
}
|
|
||||||
if provider.Overrides != nil {
|
if provider.Overrides != nil {
|
||||||
registeredGroups := make(map[string]bool)
|
registeredGroups := make(map[string]bool)
|
||||||
for _, override := range provider.Overrides {
|
for _, override := range provider.Overrides {
|
||||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
|
||||||
return false
|
return ErrDuplicateGroupOverride
|
||||||
}
|
}
|
||||||
registeredGroups[override.Group] = true
|
registeredGroups[override.Group] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return len(provider.WebhookURL) > 0
|
return provider.DefaultConfig.Validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(ep, alert, result, resolved)))
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer)
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(cfg, ep, alert, result, resolved)))
|
||||||
|
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
request.Header.Set("Content-Type", "application/json")
|
request.Header.Set("Content-Type", "application/json")
|
||||||
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
|
response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -70,6 +100,7 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Body struct {
|
type Body struct {
|
||||||
|
Channel string `json:"channel,omitempty"` // Optional channel override
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
IconURL string `json:"icon_url"`
|
IconURL string `json:"icon_url"`
|
||||||
@@ -92,7 +123,7 @@ type Field struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildRequestBody builds the request body for the provider
|
// buildRequestBody builds the request body for the provider
|
||||||
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
var message, color string
|
var message, color string
|
||||||
if resolved {
|
if resolved {
|
||||||
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.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)
|
||||||
@@ -118,6 +149,7 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
|||||||
description = ":\n> " + alertDescription
|
description = ":\n> " + alertDescription
|
||||||
}
|
}
|
||||||
body := Body{
|
body := Body{
|
||||||
|
Channel: cfg.Channel,
|
||||||
Text: "",
|
Text: "",
|
||||||
Username: "gatus",
|
Username: "gatus",
|
||||||
IconURL: "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png",
|
IconURL: "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png",
|
||||||
@@ -142,19 +174,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
|||||||
return bodyAsJSON
|
return bodyAsJSON
|
||||||
}
|
}
|
||||||
|
|
||||||
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
|
|
||||||
func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
|
|
||||||
if provider.Overrides != nil {
|
|
||||||
for _, override := range provider.Overrides {
|
|
||||||
if group == override.Group {
|
|
||||||
return override.WebhookURL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return provider.WebhookURL
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,54 +11,50 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/test"
|
"github.com/TwiN/gatus/v5/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
invalidProvider := AlertProvider{WebhookURL: ""}
|
invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
|
||||||
if invalidProvider.IsValid() {
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
t.Error("provider shouldn't have been valid")
|
t.Error("provider shouldn't have been valid")
|
||||||
}
|
}
|
||||||
validProvider := AlertProvider{WebhookURL: "http://example.com"}
|
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
|
||||||
if !validProvider.IsValid() {
|
if err := validProvider.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||||
providerWithInvalidOverrideGroup := AlertProvider{
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
WebhookURL: "http://example.com",
|
Config: Config{WebhookURL: "http://example.com"},
|
||||||
Group: "",
|
Group: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||||
if providerWithInvalidOverrideGroup.IsValid() {
|
|
||||||
t.Error("provider Group shouldn't have been valid")
|
t.Error("provider Group shouldn't have been valid")
|
||||||
}
|
}
|
||||||
|
|
||||||
providerWithInvalidOverrideWebHookUrl := AlertProvider{
|
providerWithInvalidOverrideWebHookUrl := AlertProvider{
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
|
Config: Config{WebhookURL: ""},
|
||||||
WebhookURL: "",
|
Group: "group",
|
||||||
Group: "group",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if providerWithInvalidOverrideWebHookUrl.IsValid() {
|
if err := providerWithInvalidOverrideWebHookUrl.Validate(); err == nil {
|
||||||
t.Error("provider WebHookURL shouldn't have been valid")
|
t.Error("provider WebHookURL shouldn't have been valid")
|
||||||
}
|
}
|
||||||
|
|
||||||
providerWithValidOverride := AlertProvider{
|
providerWithValidOverride := AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
WebhookURL: "http://example.com",
|
Config: Config{WebhookURL: "http://example.com"},
|
||||||
Group: "group",
|
Group: "group",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if !providerWithValidOverride.IsValid() {
|
if err := providerWithValidOverride.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -87,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "triggered-error",
|
Name: "triggered-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -97,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -107,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved-error",
|
Name: "resolved-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -168,6 +164,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
body := scenario.Provider.buildRequestBody(
|
body := scenario.Provider.buildRequestBody(
|
||||||
|
&scenario.Provider.DefaultConfig,
|
||||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&endpoint.Result{
|
&endpoint.Result{
|
||||||
@@ -198,64 +195,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
tests := []struct {
|
scenarios := []struct {
|
||||||
Name string
|
Name string
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
InputGroup string
|
InputGroup string
|
||||||
ExpectedOutput string
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "provider-no-override-specify-no-group-should-default",
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: nil,
|
Overrides: nil,
|
||||||
},
|
},
|
||||||
InputGroup: "",
|
InputGroup: "",
|
||||||
ExpectedOutput: "http://example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-no-override-specify-group-should-default",
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: nil,
|
Overrides: nil,
|
||||||
},
|
},
|
||||||
InputGroup: "group",
|
InputGroup: "group",
|
||||||
ExpectedOutput: "http://example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-with-override-specify-no-group-should-default",
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
WebhookURL: "http://example01.com",
|
Config: Config{WebhookURL: "http://example01.com"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InputGroup: "",
|
InputGroup: "",
|
||||||
ExpectedOutput: "http://example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-with-override-specify-group-should-override",
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
WebhookURL: "http://example01.com",
|
Config: Config{WebhookURL: "http://group-example.com"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InputGroup: "group",
|
InputGroup: "group",
|
||||||
ExpectedOutput: "http://example01.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://group-example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{WebhookURL: "http://group-example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, scenario := range scenarios {
|
||||||
t.Run(tt.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
|
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||||
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
|
||||||
|
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package messagebird
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -10,37 +11,75 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const restAPIURL = "https://rest.messagebird.com/messages"
|
||||||
restAPIURL = "https://rest.messagebird.com/messages"
|
|
||||||
|
var (
|
||||||
|
ErrorAccessKeyNotSet = errors.New("access-key not set")
|
||||||
|
ErrorOriginatorNotSet = errors.New("originator not set")
|
||||||
|
ErrorRecipientsNotSet = errors.New("recipients not set")
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Messagebird
|
type Config struct {
|
||||||
type AlertProvider struct {
|
|
||||||
AccessKey string `yaml:"access-key"`
|
AccessKey string `yaml:"access-key"`
|
||||||
Originator string `yaml:"originator"`
|
Originator string `yaml:"originator"`
|
||||||
Recipients string `yaml:"recipients"`
|
Recipients string `yaml:"recipients"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.AccessKey) == 0 {
|
||||||
|
return ErrorAccessKeyNotSet
|
||||||
|
}
|
||||||
|
if len(cfg.Originator) == 0 {
|
||||||
|
return ErrorOriginatorNotSet
|
||||||
|
}
|
||||||
|
if len(cfg.Recipients) == 0 {
|
||||||
|
return ErrorRecipientsNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.AccessKey) > 0 {
|
||||||
|
cfg.AccessKey = override.AccessKey
|
||||||
|
}
|
||||||
|
if len(override.Originator) > 0 {
|
||||||
|
cfg.Originator = override.Originator
|
||||||
|
}
|
||||||
|
if len(override.Recipients) > 0 {
|
||||||
|
cfg.Recipients = override.Recipients
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using Messagebird
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// Validate the provider's configuration
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
func (provider *AlertProvider) Validate() error {
|
||||||
return len(provider.AccessKey) > 0 && len(provider.Originator) > 0 && len(provider.Recipients) > 0
|
return provider.DefaultConfig.Validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
// Reference doc for messagebird: https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms
|
// Reference doc for messagebird: https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms
|
||||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||||
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
|
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
request.Header.Set("Content-Type", "application/json")
|
request.Header.Set("Content-Type", "application/json")
|
||||||
request.Header.Set("Authorization", fmt.Sprintf("AccessKey %s", provider.AccessKey))
|
request.Header.Set("Authorization", fmt.Sprintf("AccessKey %s", cfg.AccessKey))
|
||||||
response, err := client.GetHTTPClient(nil).Do(request)
|
response, err := client.GetHTTPClient(nil).Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -60,7 +99,7 @@ type Body struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildRequestBody builds the request body for the provider
|
// buildRequestBody builds the request body for the provider
|
||||||
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
var message string
|
var message string
|
||||||
if resolved {
|
if resolved {
|
||||||
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||||
@@ -68,8 +107,8 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
|||||||
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(Body{
|
body, _ := json.Marshal(Body{
|
||||||
Originator: provider.Originator,
|
Originator: cfg.Originator,
|
||||||
Recipients: provider.Recipients,
|
Recipients: cfg.Recipients,
|
||||||
Body: message,
|
Body: message,
|
||||||
})
|
})
|
||||||
return body
|
return body
|
||||||
@@ -79,3 +118,25 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
|||||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,15 +13,17 @@ import (
|
|||||||
|
|
||||||
func TestMessagebirdAlertProvider_IsValid(t *testing.T) {
|
func TestMessagebirdAlertProvider_IsValid(t *testing.T) {
|
||||||
invalidProvider := AlertProvider{}
|
invalidProvider := AlertProvider{}
|
||||||
if invalidProvider.IsValid() {
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
t.Error("provider shouldn't have been valid")
|
t.Error("provider shouldn't have been valid")
|
||||||
}
|
}
|
||||||
validProvider := AlertProvider{
|
validProvider := AlertProvider{
|
||||||
AccessKey: "1",
|
DefaultConfig: Config{
|
||||||
Originator: "1",
|
AccessKey: "1",
|
||||||
Recipients: "1",
|
Originator: "1",
|
||||||
|
Recipients: "1",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if !validProvider.IsValid() {
|
if err := validProvider.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,7 +42,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -50,7 +52,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "triggered-error",
|
Name: "triggered-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -60,7 +62,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -70,7 +72,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved-error",
|
Name: "resolved-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -115,14 +117,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{AccessKey: "1", Originator: "2", Recipients: "3"},
|
Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedBody: "{\"originator\":\"2\",\"recipients\":\"3\",\"body\":\"TRIGGERED: endpoint-name - description-1\"}",
|
ExpectedBody: "{\"originator\":\"2\",\"recipients\":\"3\",\"body\":\"TRIGGERED: endpoint-name - description-1\"}",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{AccessKey: "4", Originator: "5", Recipients: "6"},
|
Provider: AlertProvider{DefaultConfig: Config{AccessKey: "4", Originator: "5", Recipients: "6"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedBody: "{\"originator\":\"5\",\"recipients\":\"6\",\"body\":\"RESOLVED: endpoint-name - description-2\"}",
|
ExpectedBody: "{\"originator\":\"5\",\"recipients\":\"6\",\"body\":\"RESOLVED: endpoint-name - description-2\"}",
|
||||||
@@ -131,6 +133,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
body := scenario.Provider.buildRequestBody(
|
body := scenario.Provider.buildRequestBody(
|
||||||
|
&scenario.Provider.DefaultConfig,
|
||||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&endpoint.Result{
|
&endpoint.Result{
|
||||||
@@ -145,7 +148,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
||||||
}
|
}
|
||||||
out := make(map[string]interface{})
|
out := make(map[string]interface{})
|
||||||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
t.Error("expected body to be valid JSON, got error:", err.Error())
|
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -160,3 +163,50 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
|||||||
t.Error("expected default alert to be nil")
|
t.Error("expected default alert to be nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"},
|
||||||
|
},
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{AccessKey: "1", Originator: "2", Recipients: "3"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-alert-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"},
|
||||||
|
},
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"access-key": "4", "originator": "5", "recipients": "6"}},
|
||||||
|
ExpectedOutput: Config{AccessKey: "4", Originator: "5", Recipients: "6"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
|
||||||
|
if err != nil {
|
||||||
|
t.Error("expected no error, got:", err.Error())
|
||||||
|
}
|
||||||
|
if got.AccessKey != scenario.ExpectedOutput.AccessKey {
|
||||||
|
t.Errorf("expected access key to be %s, got %s", scenario.ExpectedOutput.AccessKey, got.AccessKey)
|
||||||
|
}
|
||||||
|
if got.Originator != scenario.ExpectedOutput.Originator {
|
||||||
|
t.Errorf("expected originator to be %s, got %s", scenario.ExpectedOutput.Originator, got.Originator)
|
||||||
|
}
|
||||||
|
if got.Recipients != scenario.ExpectedOutput.Recipients {
|
||||||
|
t.Errorf("expected recipients to be %s, got %s", scenario.ExpectedOutput.Recipients, got.Recipients)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
215
alerting/provider/newrelic/newrelic.go
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
package newrelic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInsertKeyNotSet = errors.New("insert-key not set")
|
||||||
|
ErrAccountIDNotSet = errors.New("account-id not set")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
InsertKey string `yaml:"insert-key"` // New Relic Insert key
|
||||||
|
AccountID string `yaml:"account-id"` // New Relic account ID
|
||||||
|
Region string `yaml:"region,omitempty"` // Region (US or EU, defaults to US)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.InsertKey) == 0 {
|
||||||
|
return ErrInsertKeyNotSet
|
||||||
|
}
|
||||||
|
if len(cfg.AccountID) == 0 {
|
||||||
|
return ErrAccountIDNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.InsertKey) > 0 {
|
||||||
|
cfg.InsertKey = override.InsertKey
|
||||||
|
}
|
||||||
|
if len(override.AccountID) > 0 {
|
||||||
|
cfg.AccountID = override.AccountID
|
||||||
|
}
|
||||||
|
if len(override.Region) > 0 {
|
||||||
|
cfg.Region = override.Region
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using New Relic
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
|
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||||
|
Overrides []Override `yaml:"overrides,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override is a case under which the default integration is overridden
|
||||||
|
type Override struct {
|
||||||
|
Group string `yaml:"group"`
|
||||||
|
Config `yaml:",inline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the provider's configuration
|
||||||
|
func (provider *AlertProvider) Validate() error {
|
||||||
|
registeredGroups := make(map[string]bool)
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||||
|
return ErrDuplicateGroupOverride
|
||||||
|
}
|
||||||
|
registeredGroups[override.Group] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return provider.DefaultConfig.Validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send an alert using the provider
|
||||||
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Determine the API endpoint based on region
|
||||||
|
var apiHost string
|
||||||
|
if cfg.Region == "EU" {
|
||||||
|
apiHost = "insights-collector.eu01.nr-data.net"
|
||||||
|
} else {
|
||||||
|
apiHost = "insights-collector.newrelic.com"
|
||||||
|
}
|
||||||
|
body, err := provider.buildRequestBody(cfg, ep, alert, result, resolved)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(body)
|
||||||
|
url := fmt.Sprintf("https://%s/v1/accounts/%s/events", apiHost, cfg.AccountID)
|
||||||
|
request, err := http.NewRequest(http.MethodPost, url, buffer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set("X-Insert-Key", cfg.InsertKey)
|
||||||
|
response, err := client.GetHTTPClient(nil).Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode >= 400 {
|
||||||
|
body, _ := io.ReadAll(response.Body)
|
||||||
|
return fmt.Errorf("call to newrelic alert returned status code %d: %s", response.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Event struct {
|
||||||
|
EventType string `json:"eventType"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
Service string `json:"service"`
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
Group string `json:"group,omitempty"`
|
||||||
|
AlertStatus string `json:"alertStatus"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
SuccessRate float64 `json:"successRate,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestBody builds the request body for the provider
|
||||||
|
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
|
||||||
|
var alertStatus, severity, message string
|
||||||
|
var successRate float64
|
||||||
|
if resolved {
|
||||||
|
alertStatus = "resolved"
|
||||||
|
severity = "INFO"
|
||||||
|
message = fmt.Sprintf("Alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||||
|
successRate = 100
|
||||||
|
} else {
|
||||||
|
alertStatus = "triggered"
|
||||||
|
severity = "CRITICAL"
|
||||||
|
message = fmt.Sprintf("Alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||||
|
successRate = 0
|
||||||
|
}
|
||||||
|
// Calculate success rate from condition results
|
||||||
|
if len(result.ConditionResults) > 0 {
|
||||||
|
successCount := 0
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
if conditionResult.Success {
|
||||||
|
successCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
successRate = float64(successCount) / float64(len(result.ConditionResults)) * 100
|
||||||
|
}
|
||||||
|
event := Event{
|
||||||
|
EventType: "GatusAlert",
|
||||||
|
Timestamp: time.Now().Unix() * 1000, // New Relic expects milliseconds
|
||||||
|
Service: "Gatus",
|
||||||
|
Endpoint: ep.DisplayName(),
|
||||||
|
Group: ep.Group,
|
||||||
|
AlertStatus: alertStatus,
|
||||||
|
Message: message,
|
||||||
|
Description: alert.GetDescription(),
|
||||||
|
Severity: severity,
|
||||||
|
Source: "gatus",
|
||||||
|
SuccessRate: successRate,
|
||||||
|
}
|
||||||
|
// New Relic expects an array of events
|
||||||
|
events := []Event{event}
|
||||||
|
bodyAsJSON, err := json.Marshal(events)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return bodyAsJSON, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
|
return provider.DefaultAlert
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
189
alerting/provider/newrelic/newrelic_test.go
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
package newrelic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/TwiN/gatus/v5/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
provider AlertProvider
|
||||||
|
expected error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456"}},
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid-with-region",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456", Region: "eu"}},
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-insert-key",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{AccountID: "123456"}},
|
||||||
|
expected: ErrInsertKeyNotSet,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-account-id",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123"}},
|
||||||
|
expected: ErrAccountIDNotSet,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
|
err := scenario.provider.Validate()
|
||||||
|
if err != scenario.expected {
|
||||||
|
t.Errorf("expected %v, got %v", scenario.expected, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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-us",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456"}},
|
||||||
|
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
resolved: false,
|
||||||
|
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
if r.Host != "insights-collector.newrelic.com" {
|
||||||
|
t.Errorf("expected host insights-collector.newrelic.com, got %s", r.Host)
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/v1/accounts/123456/events" {
|
||||||
|
t.Errorf("expected path /v1/accounts/123456/events, got %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.Header.Get("X-Insert-Key") != "nr-insert-key-123" {
|
||||||
|
t.Errorf("expected X-Insert-Key header to be 'nr-insert-key-123', got %s", r.Header.Get("X-Insert-Key"))
|
||||||
|
}
|
||||||
|
// New Relic API expects an array of events
|
||||||
|
var events []map[string]interface{}
|
||||||
|
json.NewDecoder(r.Body).Decode(&events)
|
||||||
|
if len(events) != 1 {
|
||||||
|
t.Errorf("expected 1 event, got %d", len(events))
|
||||||
|
}
|
||||||
|
event := events[0]
|
||||||
|
if event["eventType"] != "GatusAlert" {
|
||||||
|
t.Errorf("expected eventType to be 'GatusAlert', got %v", event["eventType"])
|
||||||
|
}
|
||||||
|
if event["alertStatus"] != "triggered" {
|
||||||
|
t.Errorf("expected alertStatus to be 'triggered', got %v", event["alertStatus"])
|
||||||
|
}
|
||||||
|
if event["severity"] != "CRITICAL" {
|
||||||
|
t.Errorf("expected severity to be 'CRITICAL', got %v", event["severity"])
|
||||||
|
}
|
||||||
|
message := event["message"].(string)
|
||||||
|
if !strings.Contains(message, "Alert") {
|
||||||
|
t.Errorf("expected message to contain 'Alert', got %s", message)
|
||||||
|
}
|
||||||
|
if !strings.Contains(message, "failed 3 time(s)") {
|
||||||
|
t.Errorf("expected message to contain failure count, got %s", message)
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
expectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "triggered-eu",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456", Region: "eu"}},
|
||||||
|
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
resolved: false,
|
||||||
|
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
// Note: Test doesn't actually use EU region, it uses default US region
|
||||||
|
if r.Host != "insights-collector.newrelic.com" {
|
||||||
|
t.Errorf("expected host insights-collector.newrelic.com, got %s", r.Host)
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
expectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "resolved",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456"}},
|
||||||
|
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
resolved: true,
|
||||||
|
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
// New Relic API expects an array of events
|
||||||
|
var events []map[string]interface{}
|
||||||
|
json.NewDecoder(r.Body).Decode(&events)
|
||||||
|
if len(events) != 1 {
|
||||||
|
t.Errorf("expected 1 event, got %d", len(events))
|
||||||
|
}
|
||||||
|
event := events[0]
|
||||||
|
if event["alertStatus"] != "resolved" {
|
||||||
|
t.Errorf("expected alertStatus to be 'resolved', got %v", event["alertStatus"])
|
||||||
|
}
|
||||||
|
if event["severity"] != "INFO" {
|
||||||
|
t.Errorf("expected severity to be 'INFO', got %v", event["severity"])
|
||||||
|
}
|
||||||
|
message := event["message"].(string)
|
||||||
|
if !strings.Contains(message, "resolved") {
|
||||||
|
t.Errorf("expected message to contain 'resolved', got %s", message)
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
expectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "error-response",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456"}},
|
||||||
|
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.StatusUnauthorized, 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_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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package ntfy
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -12,49 +13,141 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DefaultURL = "https://ntfy.sh"
|
DefaultURL = "https://ntfy.sh"
|
||||||
DefaultPriority = 3
|
DefaultPriority = 3
|
||||||
|
TokenPrefix = "tk_"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidToken = errors.New("invalid token")
|
||||||
|
ErrTopicNotSet = errors.New("topic not set")
|
||||||
|
ErrInvalidPriority = errors.New("priority must between 1 and 5 inclusively")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Topic string `yaml:"topic"`
|
||||||
|
URL string `yaml:"url,omitempty"` // Defaults to DefaultURL
|
||||||
|
Priority int `yaml:"priority,omitempty"` // Defaults to DefaultPriority
|
||||||
|
Token string `yaml:"token,omitempty"` // Defaults to ""
|
||||||
|
Email string `yaml:"email,omitempty"` // Defaults to ""
|
||||||
|
Click string `yaml:"click,omitempty"` // Defaults to ""
|
||||||
|
DisableFirebase bool `yaml:"disable-firebase,omitempty"` // Defaults to false
|
||||||
|
DisableCache bool `yaml:"disable-cache,omitempty"` // Defaults to false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.URL) == 0 {
|
||||||
|
cfg.URL = DefaultURL
|
||||||
|
}
|
||||||
|
if cfg.Priority == 0 {
|
||||||
|
cfg.Priority = DefaultPriority
|
||||||
|
}
|
||||||
|
if len(cfg.Token) > 0 && !strings.HasPrefix(cfg.Token, TokenPrefix) {
|
||||||
|
return ErrInvalidToken
|
||||||
|
}
|
||||||
|
if len(cfg.Topic) == 0 {
|
||||||
|
return ErrTopicNotSet
|
||||||
|
}
|
||||||
|
if cfg.Priority < 1 || cfg.Priority > 5 {
|
||||||
|
return ErrInvalidPriority
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.Topic) > 0 {
|
||||||
|
cfg.Topic = override.Topic
|
||||||
|
}
|
||||||
|
if len(override.URL) > 0 {
|
||||||
|
cfg.URL = override.URL
|
||||||
|
}
|
||||||
|
if override.Priority > 0 {
|
||||||
|
cfg.Priority = override.Priority
|
||||||
|
}
|
||||||
|
if len(override.Token) > 0 {
|
||||||
|
cfg.Token = override.Token
|
||||||
|
}
|
||||||
|
if len(override.Email) > 0 {
|
||||||
|
cfg.Email = override.Email
|
||||||
|
}
|
||||||
|
if len(override.Click) > 0 {
|
||||||
|
cfg.Click = override.Click
|
||||||
|
}
|
||||||
|
if override.DisableFirebase {
|
||||||
|
cfg.DisableFirebase = true
|
||||||
|
}
|
||||||
|
if override.DisableCache {
|
||||||
|
cfg.DisableCache = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Slack
|
// AlertProvider is the configuration necessary for sending an alert using Slack
|
||||||
type AlertProvider struct {
|
type AlertProvider struct {
|
||||||
Topic string `yaml:"topic"`
|
DefaultConfig Config `yaml:",inline"`
|
||||||
URL string `yaml:"url,omitempty"` // Defaults to DefaultURL
|
|
||||||
Priority int `yaml:"priority,omitempty"` // Defaults to DefaultPriority
|
|
||||||
Token string `yaml:"token,omitempty"` // Defaults to ""
|
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
|
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||||
|
Overrides []Override `yaml:"overrides,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// Override is a case under which the default integration is overridden
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
type Override struct {
|
||||||
if len(provider.URL) == 0 {
|
Group string `yaml:"group"`
|
||||||
provider.URL = DefaultURL
|
Config `yaml:",inline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the provider's configuration
|
||||||
|
func (provider *AlertProvider) Validate() error {
|
||||||
|
registeredGroups := make(map[string]bool)
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if len(override.Group) == 0 {
|
||||||
|
return ErrDuplicateGroupOverride
|
||||||
|
}
|
||||||
|
if _, ok := registeredGroups[override.Group]; ok {
|
||||||
|
return ErrDuplicateGroupOverride
|
||||||
|
}
|
||||||
|
if len(override.Token) > 0 && !strings.HasPrefix(override.Token, TokenPrefix) {
|
||||||
|
return ErrDuplicateGroupOverride
|
||||||
|
}
|
||||||
|
if override.Priority < 0 || override.Priority >= 6 {
|
||||||
|
return ErrDuplicateGroupOverride
|
||||||
|
}
|
||||||
|
registeredGroups[override.Group] = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if provider.Priority == 0 {
|
|
||||||
provider.Priority = DefaultPriority
|
return provider.DefaultConfig.Validate()
|
||||||
}
|
|
||||||
isTokenValid := true
|
|
||||||
if len(provider.Token) > 0 {
|
|
||||||
isTokenValid = strings.HasPrefix(provider.Token, "tk_")
|
|
||||||
}
|
|
||||||
return len(provider.URL) > 0 && len(provider.Topic) > 0 && provider.Priority > 0 && provider.Priority < 6 && isTokenValid
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
request, err := http.NewRequest(http.MethodPost, provider.URL, buffer)
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||||
|
url := cfg.URL
|
||||||
|
request, err := http.NewRequest(http.MethodPost, url, buffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
request.Header.Set("Content-Type", "application/json")
|
request.Header.Set("Content-Type", "application/json")
|
||||||
if len(provider.Token) > 0 {
|
if token := cfg.Token; len(token) > 0 {
|
||||||
request.Header.Set("Authorization", "Bearer "+provider.Token)
|
request.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
if cfg.DisableFirebase {
|
||||||
|
request.Header.Set("Firebase", "no")
|
||||||
|
}
|
||||||
|
if cfg.DisableCache {
|
||||||
|
request.Header.Set("Cache", "no")
|
||||||
}
|
}
|
||||||
response, err := client.GetHTTPClient(nil).Do(request)
|
response, err := client.GetHTTPClient(nil).Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -74,10 +167,12 @@ type Body struct {
|
|||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
Priority int `json:"priority"`
|
Priority int `json:"priority"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
Click string `json:"click,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildRequestBody builds the request body for the provider
|
// buildRequestBody builds the request body for the provider
|
||||||
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
var message, formattedConditionResults, tag string
|
var message, formattedConditionResults, tag string
|
||||||
if resolved {
|
if resolved {
|
||||||
tag = "white_check_mark"
|
tag = "white_check_mark"
|
||||||
@@ -100,11 +195,13 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
|||||||
}
|
}
|
||||||
message += formattedConditionResults
|
message += formattedConditionResults
|
||||||
body, _ := json.Marshal(Body{
|
body, _ := json.Marshal(Body{
|
||||||
Topic: provider.Topic,
|
Topic: cfg.Topic,
|
||||||
Title: "Gatus: " + ep.DisplayName(),
|
Title: "Gatus: " + ep.DisplayName(),
|
||||||
Message: message,
|
Message: message,
|
||||||
Tags: []string{tag},
|
Tags: []string{tag},
|
||||||
Priority: provider.Priority,
|
Priority: cfg.Priority,
|
||||||
|
Email: cfg.Email,
|
||||||
|
Click: cfg.Click,
|
||||||
})
|
})
|
||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
@@ -113,3 +210,34 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
|||||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ package ntfy
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
name string
|
name string
|
||||||
provider AlertProvider
|
provider AlertProvider
|
||||||
@@ -16,49 +19,78 @@ func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "valid",
|
name: "valid",
|
||||||
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
|
provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1}},
|
||||||
expected: true,
|
expected: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "no-url-should-use-default-value",
|
name: "no-url-should-use-default-value",
|
||||||
provider: AlertProvider{Topic: "example", Priority: 1},
|
provider: AlertProvider{DefaultConfig: Config{Topic: "example", Priority: 1}},
|
||||||
expected: true,
|
expected: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "valid-with-token",
|
name: "valid-with-token",
|
||||||
provider: AlertProvider{Topic: "example", Priority: 1, Token: "tk_faketoken"},
|
provider: AlertProvider{DefaultConfig: Config{Topic: "example", Priority: 1, Token: "tk_faketoken"}},
|
||||||
expected: true,
|
expected: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid-token",
|
name: "invalid-token",
|
||||||
provider: AlertProvider{Topic: "example", Priority: 1, Token: "xx_faketoken"},
|
provider: AlertProvider{DefaultConfig: Config{Topic: "example", Priority: 1, Token: "xx_faketoken"}},
|
||||||
expected: false,
|
expected: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid-topic",
|
name: "invalid-topic",
|
||||||
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "", Priority: 1},
|
provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "", Priority: 1}},
|
||||||
expected: false,
|
expected: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid-priority-too-high",
|
name: "invalid-priority-too-high",
|
||||||
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 6},
|
provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 6}},
|
||||||
expected: false,
|
expected: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid-priority-too-low",
|
name: "invalid-priority-too-low",
|
||||||
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: -1},
|
provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: -1}},
|
||||||
expected: false,
|
expected: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "no-priority-should-use-default-value",
|
name: "no-priority-should-use-default-value",
|
||||||
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example"},
|
provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example"}},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-override-token",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{Topic: "example"}, Overrides: []Override{{Group: "g", Config: Config{Token: "xx_faketoken"}}}},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-override-priority",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{Topic: "example"}, Overrides: []Override{{Group: "g", Config: Config{Priority: 8}}}},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no-override-group-name",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{Topic: "example"}, Overrides: []Override{{}}},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duplicate-override-group-names",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{Topic: "example"}, Overrides: []Override{{Group: "g"}, {Group: "g"}}},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid-override",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{Topic: "example"}, Overrides: []Override{{Group: "g1", Config: Config{Priority: 4, Click: "https://example.com"}}, {Group: "g2", Config: Config{Topic: "Example", Token: "tk_faketoken"}}}},
|
||||||
expected: true,
|
expected: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.name, func(t *testing.T) {
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
if scenario.provider.IsValid() != scenario.expected {
|
err := scenario.provider.Validate()
|
||||||
t.Errorf("expected %t, got %t", scenario.expected, scenario.provider.IsValid())
|
if scenario.expected && err != nil {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
|
if !scenario.expected && err == nil {
|
||||||
|
t.Error("expected error, got none")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -76,22 +108,55 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
|
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1}`,
|
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 2},
|
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 2}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\n🟢 [CONNECTED] == true\n🟢 [STATUS] == 200","tags":["white_check_mark"],"priority":2}`,
|
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\n🟢 [CONNECTED] == true\n🟢 [STATUS] == 200","tags":["white_check_mark"],"priority":2}`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-email",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com"}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-email",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 2, Email: "test@example.com", Click: "example.com"}},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\n🟢 [CONNECTED] == true\n🟢 [STATUS] == 200","tags":["white_check_mark"],"priority":2,"email":"test@example.com","click":"example.com"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "group-override",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 5, Email: "test@example.com", Click: "example.com"}, Overrides: []Override{{Group: "g", Config: Config{Topic: "group-topic", Priority: 4, Email: "override@test.com", Click: "test.com"}}}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: `{"topic":"group-topic","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":4,"email":"override@test.com","click":"test.com"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "alert-override",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 5, Email: "test@example.com", Click: "example.com"}, Overrides: []Override{{Group: "g", Config: Config{Topic: "group-topic", Priority: 4, Email: "override@test.com", Click: "test.com"}}}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3, ProviderOverride: map[string]any{"topic": "alert-topic"}},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: `{"topic":"alert-topic","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":4,"email":"override@test.com","click":"test.com"}`,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
cfg, err := scenario.Provider.GetConfig("g", &scenario.Alert)
|
||||||
|
if err != nil {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
body := scenario.Provider.buildRequestBody(
|
body := scenario.Provider.buildRequestBody(
|
||||||
|
cfg,
|
||||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&endpoint.Result{
|
&endpoint.Result{
|
||||||
@@ -112,3 +177,238 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
|
description := "description-1"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
Group string
|
||||||
|
ExpectedBody string
|
||||||
|
ExpectedHeaders map[string]string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com"}},
|
||||||
|
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
Group: "",
|
||||||
|
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
|
||||||
|
ExpectedHeaders: map[string]string{
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "token",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", Token: "tk_mytoken"}},
|
||||||
|
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
Group: "",
|
||||||
|
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
|
||||||
|
ExpectedHeaders: map[string]string{
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": "Bearer tk_mytoken",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "no firebase",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableFirebase: true}},
|
||||||
|
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
Group: "",
|
||||||
|
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
|
||||||
|
ExpectedHeaders: map[string]string{
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Firebase": "no",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "no cache",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableCache: true}},
|
||||||
|
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
Group: "",
|
||||||
|
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
|
||||||
|
ExpectedHeaders: map[string]string{
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Cache": "no",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "neither firebase & cache",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableFirebase: true, DisableCache: true}},
|
||||||
|
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
Group: "",
|
||||||
|
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
|
||||||
|
ExpectedHeaders: map[string]string{
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Firebase": "no",
|
||||||
|
"Cache": "no",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "overrides",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", Token: "tk_mytoken"}, Overrides: []Override{Override{Group: "other-group", Config: Config{URL: "https://example.com", Token: "tk_othertoken"}}, Override{Group: "test-group", Config: Config{Token: "tk_test_token"}}}},
|
||||||
|
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
Group: "test-group",
|
||||||
|
ExpectedBody: `{"topic":"example","title":"Gatus: test-group/endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
|
||||||
|
ExpectedHeaders: map[string]string{
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": "Bearer tk_test_token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
// Start a local HTTP server
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
// Test request parameters
|
||||||
|
for header, value := range scenario.ExpectedHeaders {
|
||||||
|
if value != req.Header.Get(header) {
|
||||||
|
t.Errorf("expected: %s, got: %s", value, req.Header.Get(header))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(req.Body)
|
||||||
|
if string(body) != scenario.ExpectedBody {
|
||||||
|
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
||||||
|
}
|
||||||
|
// Send response to be tested
|
||||||
|
rw.Write([]byte(`OK`))
|
||||||
|
}))
|
||||||
|
// Close the server when test finishes
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
scenario.Provider.DefaultConfig.URL = server.URL
|
||||||
|
err := scenario.Provider.Send(
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name", Group: scenario.Group},
|
||||||
|
&scenario.Alert,
|
||||||
|
&endpoint.Result{
|
||||||
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Error("Encountered an error on Send: ", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputGroup string
|
||||||
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{URL: "https://group-example.com", Topic: "group-topic", Priority: 2},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{URL: "https://group-example.com", Topic: "group-topic", Priority: 2},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{URL: "https://group-example.com", Topic: "group-topic", Priority: 2},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{URL: "https://group-example.com", Topic: "group-topic", Priority: 2},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"url": "http://alert-example.com", "topic": "alert-topic", "priority": 3}},
|
||||||
|
ExpectedOutput: Config{URL: "http://alert-example.com", Topic: "alert-topic", Priority: 3},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-partial-overrides",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{Topic: "group-topic"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"priority": 3}},
|
||||||
|
ExpectedOutput: Config{URL: "https://ntfy.sh", Topic: "group-topic", Priority: 3},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.URL != scenario.ExpectedOutput.URL {
|
||||||
|
t.Errorf("expected url %s, got %s", scenario.ExpectedOutput.URL, got.URL)
|
||||||
|
}
|
||||||
|
if got.Topic != scenario.ExpectedOutput.Topic {
|
||||||
|
t.Errorf("expected topic %s, got %s", scenario.ExpectedOutput.Topic, got.Topic)
|
||||||
|
}
|
||||||
|
if got.Priority != scenario.ExpectedOutput.Priority {
|
||||||
|
t.Errorf("expected priority %d, got %d", scenario.ExpectedOutput.Priority, got.Priority)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package opsgenie
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -12,13 +13,18 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
restAPI = "https://api.opsgenie.com/v2/alerts"
|
restAPI = "https://api.opsgenie.com/v2/alerts"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AlertProvider struct {
|
var (
|
||||||
|
ErrAPIKeyNotSet = errors.New("api-key not set")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
// APIKey to use for
|
// APIKey to use for
|
||||||
APIKey string `yaml:"api-key"`
|
APIKey string `yaml:"api-key"`
|
||||||
|
|
||||||
@@ -46,26 +52,74 @@ type AlertProvider struct {
|
|||||||
//
|
//
|
||||||
// default: []
|
// default: []
|
||||||
Tags []string `yaml:"tags"`
|
Tags []string `yaml:"tags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.APIKey) == 0 {
|
||||||
|
return ErrAPIKeyNotSet
|
||||||
|
}
|
||||||
|
if len(cfg.Source) == 0 {
|
||||||
|
cfg.Source = "gatus"
|
||||||
|
}
|
||||||
|
if len(cfg.EntityPrefix) == 0 {
|
||||||
|
cfg.EntityPrefix = "gatus-"
|
||||||
|
}
|
||||||
|
if len(cfg.AliasPrefix) == 0 {
|
||||||
|
cfg.AliasPrefix = "gatus-healthcheck-"
|
||||||
|
}
|
||||||
|
if len(cfg.Priority) == 0 {
|
||||||
|
cfg.Priority = "P1"
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.APIKey) > 0 {
|
||||||
|
cfg.APIKey = override.APIKey
|
||||||
|
}
|
||||||
|
if len(override.Priority) > 0 {
|
||||||
|
cfg.Priority = override.Priority
|
||||||
|
}
|
||||||
|
if len(override.Source) > 0 {
|
||||||
|
cfg.Source = override.Source
|
||||||
|
}
|
||||||
|
if len(override.EntityPrefix) > 0 {
|
||||||
|
cfg.EntityPrefix = override.EntityPrefix
|
||||||
|
}
|
||||||
|
if len(override.AliasPrefix) > 0 {
|
||||||
|
cfg.AliasPrefix = override.AliasPrefix
|
||||||
|
}
|
||||||
|
if len(override.Tags) > 0 {
|
||||||
|
cfg.Tags = override.Tags
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// Validate the provider's configuration
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
func (provider *AlertProvider) Validate() error {
|
||||||
return len(provider.APIKey) > 0
|
return provider.DefaultConfig.Validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
//
|
//
|
||||||
// Relevant: https://docs.opsgenie.com/docs/alert-api
|
// Relevant: https://docs.opsgenie.com/docs/alert-api
|
||||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
err := provider.createAlert(ep, alert, result, resolved)
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = provider.sendAlertRequest(cfg, ep, alert, result, resolved)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if resolved {
|
if resolved {
|
||||||
err = provider.closeAlert(ep, alert)
|
err = provider.closeAlert(cfg, ep, alert)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -75,24 +129,24 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
|||||||
// The alert has been resolved and there's no error, so we can clear the alert's ResolveKey
|
// The alert has been resolved and there's no error, so we can clear the alert's ResolveKey
|
||||||
alert.ResolveKey = ""
|
alert.ResolveKey = ""
|
||||||
} else {
|
} else {
|
||||||
alert.ResolveKey = provider.alias(buildKey(ep))
|
alert.ResolveKey = cfg.AliasPrefix + buildKey(ep)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *AlertProvider) createAlert(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
func (provider *AlertProvider) sendAlertRequest(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
payload := provider.buildCreateRequestBody(ep, alert, result, resolved)
|
payload := provider.buildCreateRequestBody(cfg, ep, alert, result, resolved)
|
||||||
return provider.sendRequest(restAPI, http.MethodPost, payload)
|
return provider.sendRequest(cfg, restAPI, http.MethodPost, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *AlertProvider) closeAlert(ep *endpoint.Endpoint, alert *alert.Alert) error {
|
func (provider *AlertProvider) closeAlert(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert) error {
|
||||||
payload := provider.buildCloseRequestBody(ep, alert)
|
payload := provider.buildCloseRequestBody(ep, alert)
|
||||||
url := restAPI + "/" + provider.alias(buildKey(ep)) + "/close?identifierType=alias"
|
url := restAPI + "/" + cfg.AliasPrefix + buildKey(ep) + "/close?identifierType=alias"
|
||||||
return provider.sendRequest(url, http.MethodPost, payload)
|
return provider.sendRequest(cfg, url, http.MethodPost, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *AlertProvider) sendRequest(url, method string, payload interface{}) error {
|
func (provider *AlertProvider) sendRequest(cfg *Config, url, method string, payload interface{}) error {
|
||||||
body, err := json.Marshal(payload)
|
body, err := json.Marshal(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error build alert with payload %v: %w", payload, err)
|
return fmt.Errorf("error build alert with payload %v: %w", payload, err)
|
||||||
@@ -102,7 +156,7 @@ func (provider *AlertProvider) sendRequest(url, method string, payload interface
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
request.Header.Set("Content-Type", "application/json")
|
request.Header.Set("Content-Type", "application/json")
|
||||||
request.Header.Set("Authorization", "GenieKey "+provider.APIKey)
|
request.Header.Set("Authorization", "GenieKey "+cfg.APIKey)
|
||||||
response, err := client.GetHTTPClient(nil).Do(request)
|
response, err := client.GetHTTPClient(nil).Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -115,7 +169,7 @@ func (provider *AlertProvider) sendRequest(url, method string, payload interface
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *AlertProvider) buildCreateRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) alertCreateRequest {
|
func (provider *AlertProvider) buildCreateRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) alertCreateRequest {
|
||||||
var message, description string
|
var message, description string
|
||||||
if resolved {
|
if resolved {
|
||||||
message = fmt.Sprintf("RESOLVED: %s - %s", ep.Name, alert.GetDescription())
|
message = fmt.Sprintf("RESOLVED: %s - %s", ep.Name, alert.GetDescription())
|
||||||
@@ -158,11 +212,11 @@ func (provider *AlertProvider) buildCreateRequestBody(ep *endpoint.Endpoint, ale
|
|||||||
return alertCreateRequest{
|
return alertCreateRequest{
|
||||||
Message: message,
|
Message: message,
|
||||||
Description: description,
|
Description: description,
|
||||||
Source: provider.source(),
|
Source: cfg.Source,
|
||||||
Priority: provider.priority(),
|
Priority: cfg.Priority,
|
||||||
Alias: provider.alias(key),
|
Alias: cfg.AliasPrefix + key,
|
||||||
Entity: provider.entity(key),
|
Entity: cfg.EntityPrefix + key,
|
||||||
Tags: provider.Tags,
|
Tags: cfg.Tags,
|
||||||
Details: details,
|
Details: details,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,43 +228,33 @@ func (provider *AlertProvider) buildCloseRequestBody(ep *endpoint.Endpoint, aler
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *AlertProvider) source() string {
|
|
||||||
source := provider.Source
|
|
||||||
if source == "" {
|
|
||||||
return "gatus"
|
|
||||||
}
|
|
||||||
return source
|
|
||||||
}
|
|
||||||
|
|
||||||
func (provider *AlertProvider) alias(key string) string {
|
|
||||||
alias := provider.AliasPrefix
|
|
||||||
if alias == "" {
|
|
||||||
alias = "gatus-healthcheck-"
|
|
||||||
}
|
|
||||||
return alias + key
|
|
||||||
}
|
|
||||||
|
|
||||||
func (provider *AlertProvider) entity(key string) string {
|
|
||||||
alias := provider.EntityPrefix
|
|
||||||
if alias == "" {
|
|
||||||
alias = "gatus-"
|
|
||||||
}
|
|
||||||
return alias + key
|
|
||||||
}
|
|
||||||
|
|
||||||
func (provider *AlertProvider) priority() string {
|
|
||||||
priority := provider.Priority
|
|
||||||
if priority == "" {
|
|
||||||
return "P1"
|
|
||||||
}
|
|
||||||
return priority
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func buildKey(ep *endpoint.Endpoint) string {
|
func buildKey(ep *endpoint.Endpoint) string {
|
||||||
name := toKebabCase(ep.Name)
|
name := toKebabCase(ep.Name)
|
||||||
if ep.Group == "" {
|
if ep.Group == "" {
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/test"
|
"github.com/TwiN/gatus/v5/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
invalidProvider := AlertProvider{APIKey: ""}
|
invalidProvider := AlertProvider{DefaultConfig: Config{APIKey: ""}}
|
||||||
if invalidProvider.IsValid() {
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
t.Error("provider shouldn't have been valid")
|
t.Error("provider shouldn't have been valid")
|
||||||
}
|
}
|
||||||
validProvider := AlertProvider{APIKey: "00000000-0000-0000-0000-000000000000"}
|
validProvider := AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}}
|
||||||
if !validProvider.IsValid() {
|
if err := validProvider.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,7 +35,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
|
||||||
Alert: alert.Alert{Description: &description, SuccessThreshold: 1, FailureThreshold: 1},
|
Alert: alert.Alert{Description: &description, SuccessThreshold: 1, FailureThreshold: 1},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedError: false,
|
ExpectedError: false,
|
||||||
@@ -45,7 +45,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "triggered-error",
|
Name: "triggered-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
|
||||||
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedError: true,
|
ExpectedError: true,
|
||||||
@@ -55,7 +55,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
|
||||||
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedError: false,
|
ExpectedError: false,
|
||||||
@@ -65,7 +65,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved-error",
|
Name: "resolved-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
|
||||||
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedError: true,
|
ExpectedError: true,
|
||||||
@@ -74,7 +74,6 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
@@ -113,7 +112,7 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "missing all params (unresolved)",
|
Name: "missing all params (unresolved)",
|
||||||
Provider: &AlertProvider{},
|
Provider: &AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
|
||||||
Alert: &alert.Alert{},
|
Alert: &alert.Alert{},
|
||||||
Endpoint: &endpoint.Endpoint{},
|
Endpoint: &endpoint.Endpoint{},
|
||||||
Result: &endpoint.Result{},
|
Result: &endpoint.Result{},
|
||||||
@@ -131,7 +130,7 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "missing all params (resolved)",
|
Name: "missing all params (resolved)",
|
||||||
Provider: &AlertProvider{},
|
Provider: &AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
|
||||||
Alert: &alert.Alert{},
|
Alert: &alert.Alert{},
|
||||||
Endpoint: &endpoint.Endpoint{},
|
Endpoint: &endpoint.Endpoint{},
|
||||||
Result: &endpoint.Result{},
|
Result: &endpoint.Result{},
|
||||||
@@ -149,7 +148,7 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "with default options (unresolved)",
|
Name: "with default options (unresolved)",
|
||||||
Provider: &AlertProvider{},
|
Provider: &AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
|
||||||
Alert: &alert.Alert{
|
Alert: &alert.Alert{
|
||||||
Description: &description,
|
Description: &description,
|
||||||
FailureThreshold: 3,
|
FailureThreshold: 3,
|
||||||
@@ -184,11 +183,13 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
|
|||||||
{
|
{
|
||||||
Name: "with custom options (resolved)",
|
Name: "with custom options (resolved)",
|
||||||
Provider: &AlertProvider{
|
Provider: &AlertProvider{
|
||||||
Priority: "P5",
|
DefaultConfig: Config{
|
||||||
EntityPrefix: "oompa-",
|
Priority: "P5",
|
||||||
AliasPrefix: "loompa-",
|
EntityPrefix: "oompa-",
|
||||||
Source: "gatus-hc",
|
AliasPrefix: "loompa-",
|
||||||
Tags: []string{"do-ba-dee-doo"},
|
Source: "gatus-hc",
|
||||||
|
Tags: []string{"do-ba-dee-doo"},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Alert: &alert.Alert{
|
Alert: &alert.Alert{
|
||||||
Description: &description,
|
Description: &description,
|
||||||
@@ -220,7 +221,7 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
|
|||||||
{
|
{
|
||||||
Name: "with default options and details (unresolved)",
|
Name: "with default options and details (unresolved)",
|
||||||
Provider: &AlertProvider{
|
Provider: &AlertProvider{
|
||||||
Tags: []string{"foo"},
|
DefaultConfig: Config{Tags: []string{"foo"}, APIKey: "00000000-0000-0000-0000-000000000000"},
|
||||||
},
|
},
|
||||||
Alert: &alert.Alert{
|
Alert: &alert.Alert{
|
||||||
Description: &description,
|
Description: &description,
|
||||||
@@ -265,8 +266,9 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
|
|||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
actual := scenario
|
actual := scenario
|
||||||
t.Run(actual.Name, func(t *testing.T) {
|
t.Run(actual.Name, func(t *testing.T) {
|
||||||
if got := actual.Provider.buildCreateRequestBody(actual.Endpoint, actual.Alert, actual.Result, actual.Resolved); !reflect.DeepEqual(got, actual.want) {
|
_ = scenario.Provider.Validate()
|
||||||
t.Errorf("buildCreateRequestBody() = %v, want %v", got, actual.want)
|
if got := actual.Provider.buildCreateRequestBody(&scenario.Provider.DefaultConfig, actual.Endpoint, actual.Alert, actual.Result, actual.Resolved); !reflect.DeepEqual(got, actual.want) {
|
||||||
|
t.Errorf("got:\n%v\nwant:\n%v", got, actual.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -307,7 +309,6 @@ func TestAlertProvider_buildCloseRequestBody(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
actual := scenario
|
actual := scenario
|
||||||
t.Run(actual.Name, func(t *testing.T) {
|
t.Run(actual.Name, func(t *testing.T) {
|
||||||
@@ -317,3 +318,44 @@ func TestAlertProvider_buildCloseRequestBody(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"},
|
||||||
|
},
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{APIKey: "00000000-0000-0000-0000-000000000000"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-alert-override--alert-override-should-take-precedence",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"},
|
||||||
|
},
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"api-key": "00000000-0000-0000-0000-000000000001"}},
|
||||||
|
ExpectedOutput: Config{APIKey: "00000000-0000-0000-0000-000000000001"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.APIKey != scenario.ExpectedOutput.APIKey {
|
||||||
|
t.Errorf("expected APIKey to be %s, got %s", scenario.ExpectedOutput.APIKey, got.APIKey)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,23 +3,47 @@ package pagerduty
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/TwiN/logr"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
restAPIURL = "https://events.pagerduty.com/v2/enqueue"
|
restAPIURL = "https://events.pagerduty.com/v2/enqueue"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrIntegrationKeyNotSet = errors.New("integration-key must have exactly 32 characters")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
IntegrationKey string `yaml:"integration-key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.IntegrationKey) != 32 {
|
||||||
|
return ErrIntegrationKeyNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.IntegrationKey) > 0 {
|
||||||
|
cfg.IntegrationKey = override.IntegrationKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using PagerDuty
|
// AlertProvider is the configuration necessary for sending an alert using PagerDuty
|
||||||
type AlertProvider struct {
|
type AlertProvider struct {
|
||||||
IntegrationKey string `yaml:"integration-key"`
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
@@ -30,30 +54,34 @@ type AlertProvider struct {
|
|||||||
|
|
||||||
// Override is a case under which the default integration is overridden
|
// Override is a case under which the default integration is overridden
|
||||||
type Override struct {
|
type Override struct {
|
||||||
Group string `yaml:"group"`
|
Group string `yaml:"group"`
|
||||||
IntegrationKey string `yaml:"integration-key"`
|
Config `yaml:",inline"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// Validate the provider's configuration
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
func (provider *AlertProvider) Validate() error {
|
||||||
registeredGroups := make(map[string]bool)
|
registeredGroups := make(map[string]bool)
|
||||||
if provider.Overrides != nil {
|
if provider.Overrides != nil {
|
||||||
for _, override := range provider.Overrides {
|
for _, override := range provider.Overrides {
|
||||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.IntegrationKey) != 32 {
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||||
return false
|
return ErrDuplicateGroupOverride
|
||||||
}
|
}
|
||||||
registeredGroups[override.Group] = true
|
registeredGroups[override.Group] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Either the default integration key has the right length, or there are overrides who are properly configured.
|
// Either the default integration key has the right length, or there are overrides who are properly configured.
|
||||||
return len(provider.IntegrationKey) == 32 || len(provider.Overrides) != 0
|
return provider.DefaultConfig.Validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
//
|
//
|
||||||
// Relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/
|
// Relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/
|
||||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||||
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
|
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -74,11 +102,10 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
|||||||
alert.ResolveKey = ""
|
alert.ResolveKey = ""
|
||||||
} else {
|
} else {
|
||||||
// We need to retrieve the resolve key from the response
|
// We need to retrieve the resolve key from the response
|
||||||
body, err := io.ReadAll(response.Body)
|
|
||||||
var payload pagerDutyResponsePayload
|
var payload pagerDutyResponsePayload
|
||||||
if err = json.Unmarshal(body, &payload); err != nil {
|
if err = json.NewDecoder(response.Body).Decode(&payload); err != nil {
|
||||||
// Silently fail. We don't want to create tons of alerts just because we failed to parse the body.
|
// Silently fail. We don't want to create tons of alerts just because we failed to parse the body.
|
||||||
log.Printf("[pagerduty.Send] Ran into error unmarshaling pagerduty response: %s", err.Error())
|
logr.Errorf("[pagerduty.Send] Ran into error decoding pagerduty response: %s", err.Error())
|
||||||
} else {
|
} else {
|
||||||
alert.ResolveKey = payload.DedupKey
|
alert.ResolveKey = payload.DedupKey
|
||||||
}
|
}
|
||||||
@@ -101,7 +128,7 @@ type Payload struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildRequestBody builds the request body for the provider
|
// buildRequestBody builds the request body for the provider
|
||||||
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
var message, eventAction, resolveKey string
|
var message, eventAction, resolveKey string
|
||||||
if resolved {
|
if resolved {
|
||||||
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||||
@@ -113,7 +140,7 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
|||||||
resolveKey = ""
|
resolveKey = ""
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(Body{
|
body, _ := json.Marshal(Body{
|
||||||
RoutingKey: provider.getIntegrationKeyForGroup(ep.Group),
|
RoutingKey: cfg.IntegrationKey,
|
||||||
DedupKey: resolveKey,
|
DedupKey: resolveKey,
|
||||||
EventAction: eventAction,
|
EventAction: eventAction,
|
||||||
Payload: Payload{
|
Payload: Payload{
|
||||||
@@ -125,23 +152,42 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
|||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
// getIntegrationKeyForGroup returns the appropriate pagerduty integration key for a given group
|
|
||||||
func (provider *AlertProvider) getIntegrationKeyForGroup(group string) string {
|
|
||||||
if provider.Overrides != nil {
|
|
||||||
for _, override := range provider.Overrides {
|
|
||||||
if group == override.Group {
|
|
||||||
return override.IntegrationKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return provider.IntegrationKey
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
type pagerDutyResponsePayload struct {
|
type pagerDutyResponsePayload struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
|
|||||||
@@ -11,50 +11,41 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/test"
|
"github.com/TwiN/gatus/v5/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
invalidProvider := AlertProvider{IntegrationKey: ""}
|
invalidProvider := AlertProvider{DefaultConfig: Config{IntegrationKey: ""}}
|
||||||
if invalidProvider.IsValid() {
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
t.Error("provider shouldn't have been valid")
|
t.Error("provider shouldn't have been valid")
|
||||||
}
|
}
|
||||||
validProvider := AlertProvider{IntegrationKey: "00000000000000000000000000000000"}
|
validProvider := AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}}
|
||||||
if !validProvider.IsValid() {
|
if err := validProvider.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||||
providerWithInvalidOverrideGroup := AlertProvider{
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
|
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
IntegrationKey: "00000000000000000000000000000000",
|
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
|
||||||
Group: "",
|
Group: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if providerWithInvalidOverrideGroup.IsValid() {
|
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||||
t.Error("provider Group shouldn't have been valid")
|
t.Error("provider Group shouldn't have been valid")
|
||||||
}
|
}
|
||||||
providerWithInvalidOverrideIntegrationKey := AlertProvider{
|
|
||||||
Overrides: []Override{
|
|
||||||
{
|
|
||||||
IntegrationKey: "",
|
|
||||||
Group: "group",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if providerWithInvalidOverrideIntegrationKey.IsValid() {
|
|
||||||
t.Error("provider integration key shouldn't have been valid")
|
|
||||||
}
|
|
||||||
providerWithValidOverride := AlertProvider{
|
providerWithValidOverride := AlertProvider{
|
||||||
|
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
IntegrationKey: "00000000000000000000000000000000",
|
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
|
||||||
Group: "group",
|
Group: "group",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if !providerWithValidOverride.IsValid() {
|
if err := providerWithValidOverride.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid, got error:", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +63,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -82,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "triggered-error",
|
Name: "triggered-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -92,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -102,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved-error",
|
Name: "resolved-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -146,14 +137,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"},
|
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
|
||||||
Alert: alert.Alert{Description: &description},
|
Alert: alert.Alert{Description: &description},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedBody: "{\"routing_key\":\"00000000000000000000000000000000\",\"dedup_key\":\"\",\"event_action\":\"trigger\",\"payload\":{\"summary\":\"TRIGGERED: endpoint-name - test\",\"source\":\"Gatus\",\"severity\":\"critical\"}}",
|
ExpectedBody: "{\"routing_key\":\"00000000000000000000000000000000\",\"dedup_key\":\"\",\"event_action\":\"trigger\",\"payload\":{\"summary\":\"TRIGGERED: endpoint-name - test\",\"source\":\"Gatus\",\"severity\":\"critical\"}}",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"},
|
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
|
||||||
Alert: alert.Alert{Description: &description, ResolveKey: "key"},
|
Alert: alert.Alert{Description: &description, ResolveKey: "key"},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedBody: "{\"routing_key\":\"00000000000000000000000000000000\",\"dedup_key\":\"key\",\"event_action\":\"resolve\",\"payload\":{\"summary\":\"RESOLVED: endpoint-name - test\",\"source\":\"Gatus\",\"severity\":\"critical\"}}",
|
ExpectedBody: "{\"routing_key\":\"00000000000000000000000000000000\",\"dedup_key\":\"key\",\"event_action\":\"resolve\",\"payload\":{\"summary\":\"RESOLVED: endpoint-name - test\",\"source\":\"Gatus\",\"severity\":\"critical\"}}",
|
||||||
@@ -161,7 +152,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
body := scenario.Provider.buildRequestBody(&endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, &endpoint.Result{}, scenario.Resolved)
|
body := scenario.Provider.buildRequestBody(&scenario.Provider.DefaultConfig, &endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, &endpoint.Result{}, scenario.Resolved)
|
||||||
if string(body) != scenario.ExpectedBody {
|
if string(body) != scenario.ExpectedBody {
|
||||||
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
||||||
}
|
}
|
||||||
@@ -173,69 +164,6 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_getIntegrationKeyForGroup(t *testing.T) {
|
|
||||||
scenarios := []struct {
|
|
||||||
Name string
|
|
||||||
Provider AlertProvider
|
|
||||||
InputGroup string
|
|
||||||
ExpectedOutput string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
Name: "provider-no-override-specify-no-group-should-default",
|
|
||||||
Provider: AlertProvider{
|
|
||||||
IntegrationKey: "00000000000000000000000000000001",
|
|
||||||
Overrides: nil,
|
|
||||||
},
|
|
||||||
InputGroup: "",
|
|
||||||
ExpectedOutput: "00000000000000000000000000000001",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "provider-no-override-specify-group-should-default",
|
|
||||||
Provider: AlertProvider{
|
|
||||||
IntegrationKey: "00000000000000000000000000000001",
|
|
||||||
Overrides: nil,
|
|
||||||
},
|
|
||||||
InputGroup: "group",
|
|
||||||
ExpectedOutput: "00000000000000000000000000000001",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "provider-with-override-specify-no-group-should-default",
|
|
||||||
Provider: AlertProvider{
|
|
||||||
IntegrationKey: "00000000000000000000000000000001",
|
|
||||||
Overrides: []Override{
|
|
||||||
{
|
|
||||||
Group: "group",
|
|
||||||
IntegrationKey: "00000000000000000000000000000002",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
InputGroup: "",
|
|
||||||
ExpectedOutput: "00000000000000000000000000000001",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "provider-with-override-specify-group-should-override",
|
|
||||||
Provider: AlertProvider{
|
|
||||||
IntegrationKey: "00000000000000000000000000000001",
|
|
||||||
Overrides: []Override{
|
|
||||||
{
|
|
||||||
Group: "group",
|
|
||||||
IntegrationKey: "00000000000000000000000000000002",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
InputGroup: "group",
|
|
||||||
ExpectedOutput: "00000000000000000000000000000002",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, scenario := range scenarios {
|
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
|
||||||
if output := scenario.Provider.getIntegrationKeyForGroup(scenario.InputGroup); output != scenario.ExpectedOutput {
|
|
||||||
t.Errorf("expected %s, got %s", scenario.ExpectedOutput, output)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
t.Error("expected default alert to be not nil")
|
t.Error("expected default alert to be not nil")
|
||||||
@@ -244,3 +172,94 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
|||||||
t.Error("expected default alert to be nil")
|
t.Error("expected default alert to be nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputGroup string
|
||||||
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000002"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"integration-key": "00000000000000000000000000000003"}},
|
||||||
|
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000003"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.IntegrationKey != scenario.ExpectedOutput.IntegrationKey {
|
||||||
|
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.IntegrationKey, got.IntegrationKey)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
183
alerting/provider/plivo/plivo.go
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
package plivo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrAuthIDNotSet = errors.New("auth-id not set")
|
||||||
|
ErrAuthTokenNotSet = errors.New("auth-token not set")
|
||||||
|
ErrFromNotSet = errors.New("from not set")
|
||||||
|
ErrToNotSet = errors.New("to not set")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
AuthID string `yaml:"auth-id"`
|
||||||
|
AuthToken string `yaml:"auth-token"`
|
||||||
|
From string `yaml:"from"`
|
||||||
|
To []string `yaml:"to"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.AuthID) == 0 {
|
||||||
|
return ErrAuthIDNotSet
|
||||||
|
}
|
||||||
|
if len(cfg.AuthToken) == 0 {
|
||||||
|
return ErrAuthTokenNotSet
|
||||||
|
}
|
||||||
|
if len(cfg.From) == 0 {
|
||||||
|
return ErrFromNotSet
|
||||||
|
}
|
||||||
|
if len(cfg.To) == 0 {
|
||||||
|
return ErrToNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.AuthID) > 0 {
|
||||||
|
cfg.AuthID = override.AuthID
|
||||||
|
}
|
||||||
|
if len(override.AuthToken) > 0 {
|
||||||
|
cfg.AuthToken = override.AuthToken
|
||||||
|
}
|
||||||
|
if len(override.From) > 0 {
|
||||||
|
cfg.From = override.From
|
||||||
|
}
|
||||||
|
if len(override.To) > 0 {
|
||||||
|
cfg.To = override.To
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using Plivo
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
|
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||||
|
Overrides []Override `yaml:"overrides,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override is a case under which the default integration is overridden
|
||||||
|
type Override struct {
|
||||||
|
Group string `yaml:"group"`
|
||||||
|
Config `yaml:",inline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the provider's configuration
|
||||||
|
func (provider *AlertProvider) Validate() error {
|
||||||
|
registeredGroups := make(map[string]bool)
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||||
|
return ErrDuplicateGroupOverride
|
||||||
|
}
|
||||||
|
registeredGroups[override.Group] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return provider.DefaultConfig.Validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send an alert using the provider
|
||||||
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
message := provider.buildMessage(cfg, ep, alert, result, resolved)
|
||||||
|
// Send individual SMS messages to each recipient
|
||||||
|
for _, to := range cfg.To {
|
||||||
|
if err := provider.sendSMS(cfg, to, message); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendSMS sends an SMS message to a single recipient
|
||||||
|
func (provider *AlertProvider) sendSMS(cfg *Config, to, message string) error {
|
||||||
|
payload := map[string]string{
|
||||||
|
"src": cfg.From,
|
||||||
|
"dst": to,
|
||||||
|
"text": message,
|
||||||
|
}
|
||||||
|
payloadBytes, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.plivo.com/v1/Account/%s/Message/", cfg.AuthID), bytes.NewBuffer(payloadBytes))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(cfg.AuthID+":"+cfg.AuthToken))))
|
||||||
|
response, err := client.GetHTTPClient(nil).Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode >= 400 {
|
||||||
|
body, _ := io.ReadAll(response.Body)
|
||||||
|
return fmt.Errorf("call to plivo alert returned status code %d: %s", response.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMessage builds the message for the provider
|
||||||
|
func (provider *AlertProvider) buildMessage(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
|
||||||
|
if resolved {
|
||||||
|
return fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||||
|
} else {
|
||||||
|
return fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
|
return provider.DefaultAlert
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
514
alerting/provider/plivo/plivo_test.go
Normal file
@@ -0,0 +1,514 @@
|
|||||||
|
package plivo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/TwiN/gatus/v5/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPlivoAlertProvider_IsValid(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
ExpectedError error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "invalid-provider-missing-config",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
ExpectedError: ErrAuthIDNotSet,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "valid-provider",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
AuthID: "1",
|
||||||
|
AuthToken: "1",
|
||||||
|
From: "1234567890",
|
||||||
|
To: []string{"0987654321"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ExpectedError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "valid-provider-with-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
AuthID: "1",
|
||||||
|
AuthToken: "1",
|
||||||
|
From: "1234567890",
|
||||||
|
To: []string{"0987654321"},
|
||||||
|
},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group1",
|
||||||
|
Config: Config{AuthID: "2", AuthToken: "2", From: "2222222222", To: []string{"3333333333"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ExpectedError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "invalid-provider-duplicate-group-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
AuthID: "1",
|
||||||
|
AuthToken: "1",
|
||||||
|
From: "1234567890",
|
||||||
|
To: []string{"0987654321"},
|
||||||
|
},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group1",
|
||||||
|
Config: Config{AuthID: "2", AuthToken: "2", From: "2222222222", To: []string{"3333333333"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Group: "group1",
|
||||||
|
Config: Config{AuthID: "3", AuthToken: "3", From: "4444444444", To: []string{"5555555555"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ExpectedError: ErrDuplicateGroupOverride,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "invalid-provider-empty-group-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
AuthID: "1",
|
||||||
|
AuthToken: "1",
|
||||||
|
From: "1234567890",
|
||||||
|
To: []string{"0987654321"},
|
||||||
|
},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "",
|
||||||
|
Config: Config{AuthID: "2", AuthToken: "2", From: "2222222222", To: []string{"3333333333"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ExpectedError: ErrDuplicateGroupOverride,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
err := scenario.Provider.Validate()
|
||||||
|
if scenario.ExpectedError == nil && err != nil {
|
||||||
|
t.Errorf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if scenario.ExpectedError != nil && err == nil {
|
||||||
|
t.Errorf("expected error %v, got none", scenario.ExpectedError)
|
||||||
|
}
|
||||||
|
if scenario.ExpectedError != nil && err != nil && err.Error() != scenario.ExpectedError.Error() {
|
||||||
|
t.Errorf("expected error %v, got %v", scenario.ExpectedError, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
|
defer client.InjectHTTPClient(nil)
|
||||||
|
firstDescription := "description-1"
|
||||||
|
secondDescription := "description-2"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
MockRoundTripper test.MockRoundTripper
|
||||||
|
ExpectedError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
|
||||||
|
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.StatusAccepted, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-error",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
|
||||||
|
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.StatusAccepted, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-error",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "multiple-recipients",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321", "1122334455"}}},
|
||||||
|
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.StatusAccepted, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
|
err := scenario.Provider.Send(
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
|
&scenario.Alert,
|
||||||
|
&endpoint.Result{
|
||||||
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if scenario.ExpectedError && err == nil {
|
||||||
|
t.Error("expected error, got none")
|
||||||
|
}
|
||||||
|
if !scenario.ExpectedError && err != nil {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_buildMessage(t *testing.T) {
|
||||||
|
firstDescription := "description-1"
|
||||||
|
secondDescription := "description-2"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
ExpectedMessage string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedMessage: "TRIGGERED: endpoint-name - description-1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedMessage: "RESOLVED: endpoint-name - description-2",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
message := scenario.Provider.buildMessage(
|
||||||
|
&scenario.Provider.DefaultConfig,
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
|
&scenario.Alert,
|
||||||
|
&endpoint.Result{
|
||||||
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if message != scenario.ExpectedMessage {
|
||||||
|
t.Errorf("expected %s, got %s", scenario.ExpectedMessage, message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_sendSMS(t *testing.T) {
|
||||||
|
defer client.InjectHTTPClient(nil)
|
||||||
|
cfg := &Config{
|
||||||
|
AuthID: "test-auth-id",
|
||||||
|
AuthToken: "test-auth-token",
|
||||||
|
From: "1234567890",
|
||||||
|
}
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
To string
|
||||||
|
Message string
|
||||||
|
MockRoundTripper test.MockRoundTripper
|
||||||
|
ExpectedError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "successful-sms",
|
||||||
|
To: "0987654321",
|
||||||
|
Message: "Test message",
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
// Verify request structure
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
var payload map[string]string
|
||||||
|
json.Unmarshal(body, &payload)
|
||||||
|
if payload["src"] != cfg.From {
|
||||||
|
t.Errorf("expected src %s, got %s", cfg.From, payload["src"])
|
||||||
|
}
|
||||||
|
if payload["dst"] != "0987654321" {
|
||||||
|
t.Errorf("expected dst %s, got %s", "0987654321", payload["dst"])
|
||||||
|
}
|
||||||
|
if payload["text"] != "Test message" {
|
||||||
|
t.Errorf("expected text %s, got %s", "Test message", payload["text"])
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "failed-sms",
|
||||||
|
To: "0987654321",
|
||||||
|
Message: "Test message",
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusBadRequest, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
|
provider := AlertProvider{}
|
||||||
|
err := provider.sendSMS(cfg, scenario.To, scenario.Message)
|
||||||
|
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_GetDefaultAlert(t *testing.T) {
|
||||||
|
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
|
t.Error("expected default alert to be not nil")
|
||||||
|
}
|
||||||
|
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
|
t.Error("expected default alert to be nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputGroup string
|
||||||
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-group-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group1",
|
||||||
|
Config: Config{AuthID: "3", AuthToken: "4", From: "3333333333", To: []string{"7777777777"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group1",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{AuthID: "3", AuthToken: "4", From: "3333333333", To: []string{"7777777777"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-group-override-no-match",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group1",
|
||||||
|
Config: Config{AuthID: "3", AuthToken: "4", From: "3333333333", To: []string{"7777777777"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group2",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-alert-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"auth-id": "5", "auth-token": "6", "from": "5555555555", "to": []string{"9999999999"}}},
|
||||||
|
ExpectedOutput: Config{AuthID: "5", AuthToken: "6", From: "5555555555", To: []string{"9999999999"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-group-and-alert-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group1",
|
||||||
|
Config: Config{AuthID: "3", AuthToken: "4", From: "3333333333", To: []string{"7777777777"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group1",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"auth-id": "5", "auth-token": "6"}},
|
||||||
|
ExpectedOutput: Config{AuthID: "5", AuthToken: "6", From: "3333333333", To: []string{"7777777777"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||||
|
if err != nil {
|
||||||
|
t.Error("expected no error, got:", err.Error())
|
||||||
|
}
|
||||||
|
if got.AuthID != scenario.ExpectedOutput.AuthID {
|
||||||
|
t.Errorf("expected AuthID to be %s, got %s", scenario.ExpectedOutput.AuthID, got.AuthID)
|
||||||
|
}
|
||||||
|
if got.AuthToken != scenario.ExpectedOutput.AuthToken {
|
||||||
|
t.Errorf("expected AuthToken to be %s, got %s", scenario.ExpectedOutput.AuthToken, got.AuthToken)
|
||||||
|
}
|
||||||
|
if got.From != scenario.ExpectedOutput.From {
|
||||||
|
t.Errorf("expected From to be %s, got %s", scenario.ExpectedOutput.From, got.From)
|
||||||
|
}
|
||||||
|
if len(got.To) != len(scenario.ExpectedOutput.To) {
|
||||||
|
t.Errorf("expected To length to be %d, got %d", len(scenario.ExpectedOutput.To), len(got.To))
|
||||||
|
}
|
||||||
|
for i, to := range got.To {
|
||||||
|
if to != scenario.ExpectedOutput.To[i] {
|
||||||
|
t.Errorf("expected To[%d] to be %s, got %s", i, scenario.ExpectedOutput.To[i], to)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfig_Validate(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Config Config
|
||||||
|
ExpectedError error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "valid-config",
|
||||||
|
Config: Config{
|
||||||
|
AuthID: "test-auth-id",
|
||||||
|
AuthToken: "test-auth-token",
|
||||||
|
From: "1234567890",
|
||||||
|
To: []string{"0987654321"},
|
||||||
|
},
|
||||||
|
ExpectedError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "missing-auth-id",
|
||||||
|
Config: Config{
|
||||||
|
AuthToken: "test-auth-token",
|
||||||
|
From: "1234567890",
|
||||||
|
To: []string{"0987654321"},
|
||||||
|
},
|
||||||
|
ExpectedError: ErrAuthIDNotSet,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "missing-auth-token",
|
||||||
|
Config: Config{
|
||||||
|
AuthID: "test-auth-id",
|
||||||
|
From: "1234567890",
|
||||||
|
To: []string{"0987654321"},
|
||||||
|
},
|
||||||
|
ExpectedError: ErrAuthTokenNotSet,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "missing-from",
|
||||||
|
Config: Config{
|
||||||
|
AuthID: "test-auth-id",
|
||||||
|
AuthToken: "test-auth-token",
|
||||||
|
To: []string{"0987654321"},
|
||||||
|
},
|
||||||
|
ExpectedError: ErrFromNotSet,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "missing-to",
|
||||||
|
Config: Config{
|
||||||
|
AuthID: "test-auth-id",
|
||||||
|
AuthToken: "test-auth-token",
|
||||||
|
From: "1234567890",
|
||||||
|
},
|
||||||
|
ExpectedError: ErrToNotSet,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
err := scenario.Config.Validate()
|
||||||
|
if scenario.ExpectedError == nil && err != nil {
|
||||||
|
t.Errorf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if scenario.ExpectedError != nil && err == nil {
|
||||||
|
t.Errorf("expected error %v, got none", scenario.ExpectedError)
|
||||||
|
}
|
||||||
|
if scenario.ExpectedError != nil && err != nil && err.Error() != scenario.ExpectedError.Error() {
|
||||||
|
t.Errorf("expected error %v, got %v", scenario.ExpectedError, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfig_Merge(t *testing.T) {
|
||||||
|
cfg := Config{
|
||||||
|
AuthID: "original-auth-id",
|
||||||
|
AuthToken: "original-auth-token",
|
||||||
|
From: "1111111111",
|
||||||
|
To: []string{"2222222222"},
|
||||||
|
}
|
||||||
|
override := Config{
|
||||||
|
AuthID: "override-auth-id",
|
||||||
|
AuthToken: "override-auth-token",
|
||||||
|
From: "3333333333",
|
||||||
|
To: []string{"4444444444", "5555555555"},
|
||||||
|
}
|
||||||
|
cfg.Merge(&override)
|
||||||
|
if cfg.AuthID != "override-auth-id" {
|
||||||
|
t.Errorf("expected AuthID to be %s, got %s", "override-auth-id", cfg.AuthID)
|
||||||
|
}
|
||||||
|
if cfg.AuthToken != "override-auth-token" {
|
||||||
|
t.Errorf("expected AuthToken to be %s, got %s", "override-auth-token", cfg.AuthToken)
|
||||||
|
}
|
||||||
|
if cfg.From != "3333333333" {
|
||||||
|
t.Errorf("expected From to be %s, got %s", "3333333333", cfg.From)
|
||||||
|
}
|
||||||
|
if len(cfg.To) != 2 || cfg.To[0] != "4444444444" || cfg.To[1] != "5555555555" {
|
||||||
|
t.Errorf("expected To to be [4444444444, 5555555555], got %v", cfg.To)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,41 +4,68 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/awsses"
|
"github.com/TwiN/gatus/v5/alerting/provider/awsses"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/custom"
|
"github.com/TwiN/gatus/v5/alerting/provider/custom"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/datadog"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/discord"
|
"github.com/TwiN/gatus/v5/alerting/provider/discord"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/email"
|
"github.com/TwiN/gatus/v5/alerting/provider/email"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/gitea"
|
"github.com/TwiN/gatus/v5/alerting/provider/gitea"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/github"
|
"github.com/TwiN/gatus/v5/alerting/provider/github"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
|
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
|
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/gotify"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/homeassistant"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/ifttt"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/ilert"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
|
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/line"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
|
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
|
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/newrelic"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/ntfy"
|
"github.com/TwiN/gatus/v5/alerting/provider/ntfy"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
|
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/pagerduty"
|
"github.com/TwiN/gatus/v5/alerting/provider/pagerduty"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/plivo"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
|
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/rocketchat"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/sendgrid"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/signal"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/signl4"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/slack"
|
"github.com/TwiN/gatus/v5/alerting/provider/slack"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/splunk"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/squadcast"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/teams"
|
"github.com/TwiN/gatus/v5/alerting/provider/teams"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
|
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
|
||||||
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
|
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/webex"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/zapier"
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/provider/zulip"
|
||||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertProvider is the interface that each provider should implement
|
// AlertProvider is the interface that each provider should implement
|
||||||
type AlertProvider interface {
|
type AlertProvider interface {
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// Validate the provider's configuration
|
||||||
IsValid() bool
|
Validate() error
|
||||||
|
|
||||||
|
// Send an alert using the provider
|
||||||
|
Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
GetDefaultAlert() *alert.Alert
|
GetDefaultAlert() *alert.Alert
|
||||||
|
|
||||||
// Send an alert using the provider
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error
|
ValidateOverrides(group string, alert *alert.Alert) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseWithDefaultAlert parses an Endpoint alert by using the provider's default alert as a baseline
|
type Config[T any] interface {
|
||||||
func ParseWithDefaultAlert(providerDefaultAlert, endpointAlert *alert.Alert) {
|
Validate() error
|
||||||
|
Merge(override *T)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeProviderDefaultAlertIntoEndpointAlert parses an Endpoint alert by using the provider's default alert as a baseline
|
||||||
|
func MergeProviderDefaultAlertIntoEndpointAlert(providerDefaultAlert, endpointAlert *alert.Alert) {
|
||||||
if providerDefaultAlert == nil || endpointAlert == nil {
|
if providerDefaultAlert == nil || endpointAlert == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -57,28 +84,91 @@ func ParseWithDefaultAlert(providerDefaultAlert, endpointAlert *alert.Alert) {
|
|||||||
if endpointAlert.SuccessThreshold == 0 {
|
if endpointAlert.SuccessThreshold == 0 {
|
||||||
endpointAlert.SuccessThreshold = providerDefaultAlert.SuccessThreshold
|
endpointAlert.SuccessThreshold = providerDefaultAlert.SuccessThreshold
|
||||||
}
|
}
|
||||||
|
if endpointAlert.MinimumReminderInterval == 0 {
|
||||||
|
endpointAlert.MinimumReminderInterval = providerDefaultAlert.MinimumReminderInterval
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// Validate interface implementation on compile
|
// Validate provider interface implementation on compile
|
||||||
_ AlertProvider = (*awsses.AlertProvider)(nil)
|
_ AlertProvider = (*awsses.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*custom.AlertProvider)(nil)
|
_ AlertProvider = (*custom.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*datadog.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*discord.AlertProvider)(nil)
|
_ AlertProvider = (*discord.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*email.AlertProvider)(nil)
|
_ AlertProvider = (*email.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*gitea.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*github.AlertProvider)(nil)
|
_ AlertProvider = (*github.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*gitlab.AlertProvider)(nil)
|
_ AlertProvider = (*gitlab.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*gitea.AlertProvider)(nil)
|
|
||||||
_ AlertProvider = (*googlechat.AlertProvider)(nil)
|
_ AlertProvider = (*googlechat.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*gotify.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*homeassistant.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*ifttt.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*ilert.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*incidentio.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*jetbrainsspace.AlertProvider)(nil)
|
_ AlertProvider = (*jetbrainsspace.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*line.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*matrix.AlertProvider)(nil)
|
_ AlertProvider = (*matrix.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*mattermost.AlertProvider)(nil)
|
_ AlertProvider = (*mattermost.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*messagebird.AlertProvider)(nil)
|
_ AlertProvider = (*messagebird.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*newrelic.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*ntfy.AlertProvider)(nil)
|
_ AlertProvider = (*ntfy.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*opsgenie.AlertProvider)(nil)
|
_ AlertProvider = (*opsgenie.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*pagerduty.AlertProvider)(nil)
|
_ AlertProvider = (*pagerduty.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*plivo.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*pushover.AlertProvider)(nil)
|
_ AlertProvider = (*pushover.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*rocketchat.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*sendgrid.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*signal.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*signl4.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*slack.AlertProvider)(nil)
|
_ AlertProvider = (*slack.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*splunk.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*squadcast.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*teams.AlertProvider)(nil)
|
_ AlertProvider = (*teams.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*teamsworkflows.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*telegram.AlertProvider)(nil)
|
_ AlertProvider = (*telegram.AlertProvider)(nil)
|
||||||
_ AlertProvider = (*twilio.AlertProvider)(nil)
|
_ AlertProvider = (*twilio.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*webex.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*zapier.AlertProvider)(nil)
|
||||||
|
_ AlertProvider = (*zulip.AlertProvider)(nil)
|
||||||
|
|
||||||
|
// Validate config interface implementation on compile
|
||||||
|
_ Config[awsses.Config] = (*awsses.Config)(nil)
|
||||||
|
_ Config[custom.Config] = (*custom.Config)(nil)
|
||||||
|
_ Config[datadog.Config] = (*datadog.Config)(nil)
|
||||||
|
_ Config[discord.Config] = (*discord.Config)(nil)
|
||||||
|
_ Config[email.Config] = (*email.Config)(nil)
|
||||||
|
_ Config[gitea.Config] = (*gitea.Config)(nil)
|
||||||
|
_ Config[github.Config] = (*github.Config)(nil)
|
||||||
|
_ Config[gitlab.Config] = (*gitlab.Config)(nil)
|
||||||
|
_ Config[googlechat.Config] = (*googlechat.Config)(nil)
|
||||||
|
_ Config[gotify.Config] = (*gotify.Config)(nil)
|
||||||
|
_ Config[homeassistant.Config] = (*homeassistant.Config)(nil)
|
||||||
|
_ Config[ifttt.Config] = (*ifttt.Config)(nil)
|
||||||
|
_ Config[ilert.Config] = (*ilert.Config)(nil)
|
||||||
|
_ Config[incidentio.Config] = (*incidentio.Config)(nil)
|
||||||
|
_ Config[jetbrainsspace.Config] = (*jetbrainsspace.Config)(nil)
|
||||||
|
_ Config[line.Config] = (*line.Config)(nil)
|
||||||
|
_ Config[matrix.Config] = (*matrix.Config)(nil)
|
||||||
|
_ Config[mattermost.Config] = (*mattermost.Config)(nil)
|
||||||
|
_ Config[messagebird.Config] = (*messagebird.Config)(nil)
|
||||||
|
_ Config[newrelic.Config] = (*newrelic.Config)(nil)
|
||||||
|
_ Config[ntfy.Config] = (*ntfy.Config)(nil)
|
||||||
|
_ Config[opsgenie.Config] = (*opsgenie.Config)(nil)
|
||||||
|
_ Config[pagerduty.Config] = (*pagerduty.Config)(nil)
|
||||||
|
_ Config[plivo.Config] = (*plivo.Config)(nil)
|
||||||
|
_ Config[pushover.Config] = (*pushover.Config)(nil)
|
||||||
|
_ Config[rocketchat.Config] = (*rocketchat.Config)(nil)
|
||||||
|
_ Config[sendgrid.Config] = (*sendgrid.Config)(nil)
|
||||||
|
_ Config[signal.Config] = (*signal.Config)(nil)
|
||||||
|
_ Config[signl4.Config] = (*signl4.Config)(nil)
|
||||||
|
_ Config[slack.Config] = (*slack.Config)(nil)
|
||||||
|
_ Config[splunk.Config] = (*splunk.Config)(nil)
|
||||||
|
_ Config[squadcast.Config] = (*squadcast.Config)(nil)
|
||||||
|
_ Config[teams.Config] = (*teams.Config)(nil)
|
||||||
|
_ Config[teamsworkflows.Config] = (*teamsworkflows.Config)(nil)
|
||||||
|
_ Config[telegram.Config] = (*telegram.Config)(nil)
|
||||||
|
_ Config[twilio.Config] = (*twilio.Config)(nil)
|
||||||
|
_ Config[webex.Config] = (*webex.Config)(nil)
|
||||||
|
_ Config[zapier.Config] = (*zapier.Config)(nil)
|
||||||
|
_ Config[zulip.Config] = (*zulip.Config)(nil)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package provider
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
)
|
)
|
||||||
@@ -24,6 +25,7 @@ func TestParseWithDefaultAlert(t *testing.T) {
|
|||||||
Description: &firstDescription,
|
Description: &firstDescription,
|
||||||
FailureThreshold: 5,
|
FailureThreshold: 5,
|
||||||
SuccessThreshold: 10,
|
SuccessThreshold: 10,
|
||||||
|
MinimumReminderInterval: 30 * time.Second,
|
||||||
},
|
},
|
||||||
EndpointAlert: &alert.Alert{
|
EndpointAlert: &alert.Alert{
|
||||||
Type: alert.TypeDiscord,
|
Type: alert.TypeDiscord,
|
||||||
@@ -35,6 +37,7 @@ func TestParseWithDefaultAlert(t *testing.T) {
|
|||||||
Description: &firstDescription,
|
Description: &firstDescription,
|
||||||
FailureThreshold: 5,
|
FailureThreshold: 5,
|
||||||
SuccessThreshold: 10,
|
SuccessThreshold: 10,
|
||||||
|
MinimumReminderInterval: 30 * time.Second,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -126,7 +129,7 @@ func TestParseWithDefaultAlert(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
ParseWithDefaultAlert(scenario.DefaultAlert, scenario.EndpointAlert)
|
MergeProviderDefaultAlertIntoEndpointAlert(scenario.DefaultAlert, scenario.EndpointAlert)
|
||||||
if scenario.ExpectedOutputAlert == nil {
|
if scenario.ExpectedOutputAlert == nil {
|
||||||
if scenario.EndpointAlert != nil {
|
if scenario.EndpointAlert != nil {
|
||||||
t.Fail()
|
t.Fail()
|
||||||
@@ -148,6 +151,9 @@ func TestParseWithDefaultAlert(t *testing.T) {
|
|||||||
if scenario.EndpointAlert.SuccessThreshold != scenario.ExpectedOutputAlert.SuccessThreshold {
|
if scenario.EndpointAlert.SuccessThreshold != scenario.ExpectedOutputAlert.SuccessThreshold {
|
||||||
t.Errorf("expected EndpointAlert.SuccessThreshold to be %v, got %v", scenario.ExpectedOutputAlert.SuccessThreshold, scenario.EndpointAlert.SuccessThreshold)
|
t.Errorf("expected EndpointAlert.SuccessThreshold to be %v, got %v", scenario.ExpectedOutputAlert.SuccessThreshold, scenario.EndpointAlert.SuccessThreshold)
|
||||||
}
|
}
|
||||||
|
if int(scenario.EndpointAlert.MinimumReminderInterval) != int(scenario.ExpectedOutputAlert.MinimumReminderInterval) {
|
||||||
|
t.Errorf("expected EndpointAlert.MinimumReminderInterval to be %v, got %v", scenario.ExpectedOutputAlert.MinimumReminderInterval, scenario.EndpointAlert.MinimumReminderInterval)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package pushover
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -10,15 +11,22 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
restAPIURL = "https://api.pushover.net/1/messages.json"
|
ApiURL = "https://api.pushover.net/1/messages.json"
|
||||||
defaultPriority = 0
|
defaultPriority = 0
|
||||||
)
|
)
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Pushover
|
var (
|
||||||
type AlertProvider struct {
|
ErrInvalidApplicationToken = errors.New("application-token must be 30 characters long")
|
||||||
|
ErrInvalidUserKey = errors.New("user-key must be 30 characters long")
|
||||||
|
ErrInvalidPriority = errors.New("priority and resolved-priority must be between -2 and 2")
|
||||||
|
ErrInvalidDevice = errors.New("device name must have 25 characters or less")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
// Key used to authenticate the application sending
|
// Key used to authenticate the application sending
|
||||||
// See "Your Applications" on the dashboard, or add a new one: https://pushover.net/apps/build
|
// See "Your Applications" on the dashboard, or add a new one: https://pushover.net/apps/build
|
||||||
ApplicationToken string `yaml:"application-token"`
|
ApplicationToken string `yaml:"application-token"`
|
||||||
@@ -26,35 +34,103 @@ type AlertProvider struct {
|
|||||||
// Key of the user or group the messages should be sent to
|
// Key of the user or group the messages should be sent to
|
||||||
UserKey string `yaml:"user-key"`
|
UserKey string `yaml:"user-key"`
|
||||||
|
|
||||||
// The title of your message, likely the application name
|
// The title of your message
|
||||||
// default: the name of your application in Pushover
|
// default: "Gatus: <endpoint>""
|
||||||
Title string `yaml:"title,omitempty"`
|
Title string `yaml:"title,omitempty"`
|
||||||
|
|
||||||
// Priority of all messages, ranging from -2 (very low) to 2 (Emergency)
|
// Priority of all messages, ranging from -2 (very low) to 2 (Emergency)
|
||||||
// default: 0
|
// default: 0
|
||||||
Priority int `yaml:"priority,omitempty"`
|
Priority int `yaml:"priority,omitempty"`
|
||||||
|
|
||||||
|
// Priority of resolved messages, ranging from -2 (very low) to 2 (Emergency)
|
||||||
|
// default: 0
|
||||||
|
ResolvedPriority int `yaml:"resolved-priority,omitempty"`
|
||||||
|
|
||||||
// Sound of the messages (see: https://pushover.net/api#sounds)
|
// Sound of the messages (see: https://pushover.net/api#sounds)
|
||||||
// default: "" (pushover)
|
// default: "" (pushover)
|
||||||
Sound string `yaml:"sound,omitempty"`
|
Sound string `yaml:"sound,omitempty"`
|
||||||
|
|
||||||
|
// TTL of your message (https://pushover.net/api#ttl)
|
||||||
|
// If priority is 2 then this parameter is ignored
|
||||||
|
// default: 0
|
||||||
|
TTL int `yaml:"ttl,omitempty"`
|
||||||
|
|
||||||
|
// Device to send the message to (see: https://pushover.net/api#devices)
|
||||||
|
// default: "" (all devices)
|
||||||
|
Device string `yaml:"device,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if cfg.Priority == 0 {
|
||||||
|
cfg.Priority = defaultPriority
|
||||||
|
}
|
||||||
|
if cfg.ResolvedPriority == 0 {
|
||||||
|
cfg.ResolvedPriority = defaultPriority
|
||||||
|
}
|
||||||
|
if len(cfg.ApplicationToken) != 30 {
|
||||||
|
return ErrInvalidApplicationToken
|
||||||
|
}
|
||||||
|
if len(cfg.UserKey) != 30 {
|
||||||
|
return ErrInvalidUserKey
|
||||||
|
}
|
||||||
|
if cfg.Priority < -2 || cfg.Priority > 2 || cfg.ResolvedPriority < -2 || cfg.ResolvedPriority > 2 {
|
||||||
|
return ErrInvalidPriority
|
||||||
|
}
|
||||||
|
if len(cfg.Device) > 25 {
|
||||||
|
return ErrInvalidDevice
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.ApplicationToken) > 0 {
|
||||||
|
cfg.ApplicationToken = override.ApplicationToken
|
||||||
|
}
|
||||||
|
if len(override.UserKey) > 0 {
|
||||||
|
cfg.UserKey = override.UserKey
|
||||||
|
}
|
||||||
|
if len(override.Title) > 0 {
|
||||||
|
cfg.Title = override.Title
|
||||||
|
}
|
||||||
|
if override.Priority != 0 {
|
||||||
|
cfg.Priority = override.Priority
|
||||||
|
}
|
||||||
|
if override.ResolvedPriority != 0 {
|
||||||
|
cfg.ResolvedPriority = override.ResolvedPriority
|
||||||
|
}
|
||||||
|
if len(override.Sound) > 0 {
|
||||||
|
cfg.Sound = override.Sound
|
||||||
|
}
|
||||||
|
if override.TTL > 0 {
|
||||||
|
cfg.TTL = override.TTL
|
||||||
|
}
|
||||||
|
if len(override.Device) > 0 {
|
||||||
|
cfg.Device = override.Device
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using Pushover
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// Validate the provider's configuration
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
func (provider *AlertProvider) Validate() error {
|
||||||
if provider.Priority == 0 {
|
return provider.DefaultConfig.Validate()
|
||||||
provider.Priority = defaultPriority
|
|
||||||
}
|
|
||||||
return len(provider.ApplicationToken) == 30 && len(provider.UserKey) == 30 && provider.Priority >= -2 && provider.Priority <= 2
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
// Reference doc for pushover: https://pushover.net/api
|
// Reference doc for pushover: https://pushover.net/api
|
||||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||||
|
request, err := http.NewRequest(http.MethodPost, ApiURL, buffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -77,36 +153,76 @@ type Body struct {
|
|||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Priority int `json:"priority"`
|
Priority int `json:"priority"`
|
||||||
|
Html int `json:"html"`
|
||||||
Sound string `json:"sound,omitempty"`
|
Sound string `json:"sound,omitempty"`
|
||||||
|
TTL int `json:"ttl,omitempty"`
|
||||||
|
Device string `json:"device,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildRequestBody builds the request body for the provider
|
// buildRequestBody builds the request body for the provider
|
||||||
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
var message string
|
var message, formattedConditionResults string
|
||||||
|
priority := cfg.Priority
|
||||||
if resolved {
|
if resolved {
|
||||||
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
priority = cfg.ResolvedPriority
|
||||||
|
message = fmt.Sprintf("An alert for <b>%s</b> has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||||
} else {
|
} else {
|
||||||
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
message = fmt.Sprintf("An alert for <b>%s</b> has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||||
|
}
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
var prefix string
|
||||||
|
if conditionResult.Success {
|
||||||
|
prefix = "✅"
|
||||||
|
} else {
|
||||||
|
prefix = "❌"
|
||||||
|
}
|
||||||
|
formattedConditionResults += fmt.Sprintf("\n%s - %s", prefix, conditionResult.Condition)
|
||||||
|
}
|
||||||
|
if len(alert.GetDescription()) > 0 {
|
||||||
|
message += " with the following description: " + alert.GetDescription()
|
||||||
|
}
|
||||||
|
message += formattedConditionResults
|
||||||
|
title := "Gatus: " + ep.DisplayName()
|
||||||
|
if cfg.Title != "" {
|
||||||
|
title = cfg.Title
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(Body{
|
body, _ := json.Marshal(Body{
|
||||||
Token: provider.ApplicationToken,
|
Token: cfg.ApplicationToken,
|
||||||
User: provider.UserKey,
|
User: cfg.UserKey,
|
||||||
Title: provider.Title,
|
Title: title,
|
||||||
Message: message,
|
Message: message,
|
||||||
Priority: provider.priority(),
|
Priority: priority,
|
||||||
Sound: provider.Sound,
|
Html: 1,
|
||||||
|
Sound: cfg.Sound,
|
||||||
|
TTL: cfg.TTL,
|
||||||
|
Device: cfg.Device,
|
||||||
})
|
})
|
||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *AlertProvider) priority() int {
|
|
||||||
if provider.Priority == 0 {
|
|
||||||
return defaultPriority
|
|
||||||
}
|
|
||||||
return provider.Priority
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,30 +12,38 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestPushoverAlertProvider_IsValid(t *testing.T) {
|
func TestPushoverAlertProvider_IsValid(t *testing.T) {
|
||||||
invalidProvider := AlertProvider{}
|
t.Run("empty-invalid-provider", func(t *testing.T) {
|
||||||
if invalidProvider.IsValid() {
|
invalidProvider := AlertProvider{}
|
||||||
t.Error("provider shouldn't have been valid")
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
}
|
t.Error("provider shouldn't have been valid")
|
||||||
validProvider := AlertProvider{
|
}
|
||||||
ApplicationToken: "aTokenWithLengthOf30characters",
|
})
|
||||||
UserKey: "aTokenWithLengthOf30characters",
|
t.Run("valid-provider", func(t *testing.T) {
|
||||||
Title: "Gatus Notification",
|
validProvider := AlertProvider{
|
||||||
Priority: 1,
|
DefaultConfig: Config{
|
||||||
}
|
ApplicationToken: "aTokenWithLengthOf30characters",
|
||||||
if !validProvider.IsValid() {
|
UserKey: "aTokenWithLengthOf30characters",
|
||||||
t.Error("provider should've been valid")
|
Title: "Gatus Notification",
|
||||||
}
|
Priority: 1,
|
||||||
}
|
ResolvedPriority: 1,
|
||||||
|
},
|
||||||
func TestPushoverAlertProvider_IsInvalid(t *testing.T) {
|
}
|
||||||
invalidProvider := AlertProvider{
|
if err := validProvider.Validate(); err != nil {
|
||||||
ApplicationToken: "aTokenWithLengthOfMoreThan30characters",
|
t.Error("provider should've been valid")
|
||||||
UserKey: "aTokenWithLengthOfMoreThan30characters",
|
}
|
||||||
Priority: 5,
|
})
|
||||||
}
|
t.Run("invalid-provider", func(t *testing.T) {
|
||||||
if invalidProvider.IsValid() {
|
invalidProvider := AlertProvider{
|
||||||
t.Error("provider should've been invalid")
|
DefaultConfig: Config{
|
||||||
}
|
ApplicationToken: "aTokenWithLengthOfMoreThan30characters",
|
||||||
|
UserKey: "aTokenWithLengthOfMoreThan30characters",
|
||||||
|
Priority: 5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
|
t.Error("provider should've been invalid")
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_Send(t *testing.T) {
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
@@ -52,7 +60,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -62,7 +70,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "triggered-error",
|
Name: "triggered-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -72,7 +80,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -82,7 +90,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved-error",
|
Name: "resolved-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -119,37 +127,67 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
firstDescription := "description-1"
|
firstDescription := "description-1"
|
||||||
secondDescription := "description-2"
|
secondDescription := "description-2"
|
||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
Name string
|
Name string
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
Alert alert.Alert
|
Alert alert.Alert
|
||||||
Resolved bool
|
Resolved bool
|
||||||
ExpectedBody string
|
ResolvedPriority bool
|
||||||
|
ExpectedBody string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{ApplicationToken: "TokenWithLengthOf30Characters1", UserKey: "TokenWithLengthOf30Characters4"},
|
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters1", UserKey: "TokenWithLengthOf30Characters4"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters1\",\"user\":\"TokenWithLengthOf30Characters4\",\"message\":\"TRIGGERED: endpoint-name - description-1\",\"priority\":0}",
|
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters1\",\"user\":\"TokenWithLengthOf30Characters4\",\"title\":\"Gatus: endpoint-name\",\"message\":\"An alert for \\u003cb\\u003eendpoint-name\\u003c/b\\u003e 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\",\"priority\":0,\"html\":1}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-customtitle",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters1", UserKey: "TokenWithLengthOf30Characters4", Title: "Gatus Notifications"}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters1\",\"user\":\"TokenWithLengthOf30Characters4\",\"title\":\"Gatus Notifications\",\"message\":\"An alert for \\u003cb\\u003eendpoint-name\\u003c/b\\u003e 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\",\"priority\":0,\"html\":1}",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2},
|
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Priority: 2, ResolvedPriority: 2}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"RESOLVED: endpoint-name - description-2\",\"priority\":2}",
|
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus: endpoint-name\",\"message\":\"An alert for \\u003cb\\u003eendpoint-name\\u003c/b\\u003e has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\\n✅ - [CONNECTED] == true\\n✅ - [STATUS] == 200\",\"priority\":2,\"html\":1}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-priority",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, ResolvedPriority: 0}},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"An alert for \\u003cb\\u003eendpoint-name\\u003c/b\\u003e has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\\n✅ - [CONNECTED] == true\\n✅ - [STATUS] == 200\",\"priority\":0,\"html\":1}",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "with-sound",
|
Name: "with-sound",
|
||||||
Provider: AlertProvider{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, Sound: "falling"},
|
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, ResolvedPriority: 2, Sound: "falling"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"RESOLVED: endpoint-name - description-2\",\"priority\":2,\"sound\":\"falling\"}",
|
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"An alert for \\u003cb\\u003eendpoint-name\\u003c/b\\u003e has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\\n✅ - [CONNECTED] == true\\n✅ - [STATUS] == 200\",\"priority\":2,\"html\":1,\"sound\":\"falling\"}",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "with-ttl",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, ResolvedPriority: 2, TTL: 3600}},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"An alert for \\u003cb\\u003eendpoint-name\\u003c/b\\u003e has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\\n✅ - [CONNECTED] == true\\n✅ - [STATUS] == 200\",\"priority\":2,\"html\":1,\"ttl\":3600}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "with-device",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, ResolvedPriority: 2, TTL: 3600, Device: "iphone15pro",}},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"An alert for \\u003cb\\u003eendpoint-name\\u003c/b\\u003e has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\\n✅ - [CONNECTED] == true\\n✅ - [STATUS] == 200\",\"priority\":2,\"html\":1,\"ttl\":3600,\"device\":\"iphone15pro\"}",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
body := scenario.Provider.buildRequestBody(
|
body := scenario.Provider.buildRequestBody(
|
||||||
|
&scenario.Provider.DefaultConfig,
|
||||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&endpoint.Result{
|
&endpoint.Result{
|
||||||
@@ -179,3 +217,50 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
|||||||
t.Error("expected default alert to be nil")
|
t.Error("expected default alert to be nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputGroup string
|
||||||
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-alert-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"application-token": "TokenWithLengthOf30Characters2", "user-key": "TokenWithLengthOf30Characters3"}},
|
||||||
|
ExpectedOutput: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters3"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.ApplicationToken != scenario.ExpectedOutput.ApplicationToken {
|
||||||
|
t.Errorf("expected application token to be %s, got %s", scenario.ExpectedOutput.ApplicationToken, got.ApplicationToken)
|
||||||
|
}
|
||||||
|
if got.UserKey != scenario.ExpectedOutput.UserKey {
|
||||||
|
t.Errorf("expected user key to be %s, got %s", scenario.ExpectedOutput.UserKey, got.UserKey)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
212
alerting/provider/rocketchat/rocketchat.go
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
package rocketchat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrWebhookURLNotSet = errors.New("webhook-url not set")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
WebhookURL string `yaml:"webhook-url"` // Rocket.Chat incoming webhook URL
|
||||||
|
Channel string `yaml:"channel,omitempty"` // Optional channel override
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.WebhookURL) == 0 {
|
||||||
|
return ErrWebhookURLNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.WebhookURL) > 0 {
|
||||||
|
cfg.WebhookURL = override.WebhookURL
|
||||||
|
}
|
||||||
|
if len(override.Channel) > 0 {
|
||||||
|
cfg.Channel = override.Channel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using Rocket.Chat
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
|
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||||
|
Overrides []Override `yaml:"overrides,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override is a case under which the default integration is overridden
|
||||||
|
type Override struct {
|
||||||
|
Group string `yaml:"group"`
|
||||||
|
Config `yaml:",inline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the provider's configuration
|
||||||
|
func (provider *AlertProvider) Validate() error {
|
||||||
|
registeredGroups := make(map[string]bool)
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||||
|
return ErrDuplicateGroupOverride
|
||||||
|
}
|
||||||
|
registeredGroups[override.Group] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return provider.DefaultConfig.Validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send an alert using the provider
|
||||||
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body, err := provider.buildRequestBody(cfg, ep, alert, result, resolved)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(body)
|
||||||
|
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
response, err := client.GetHTTPClient(nil).Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode >= 400 {
|
||||||
|
body, _ := io.ReadAll(response.Body)
|
||||||
|
return fmt.Errorf("call to rocketchat alert returned status code %d: %s", response.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Body struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Channel string `json:"channel,omitempty"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Attachments []Attachment `json:"attachments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Attachment struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
Fields []Field `json:"fields,omitempty"`
|
||||||
|
AuthorName string `json:"author_name"`
|
||||||
|
AuthorIcon string `json:"author_icon"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Field struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Short bool `json:"short"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestBody builds the request body for the provider
|
||||||
|
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
|
||||||
|
var message, color string
|
||||||
|
if resolved {
|
||||||
|
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||||
|
color = "#36a64f"
|
||||||
|
} else {
|
||||||
|
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||||
|
color = "#dd0000"
|
||||||
|
}
|
||||||
|
var formattedConditionResults string
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
var prefix string
|
||||||
|
if conditionResult.Success {
|
||||||
|
prefix = "✅"
|
||||||
|
} else {
|
||||||
|
prefix = "❌"
|
||||||
|
}
|
||||||
|
formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
||||||
|
}
|
||||||
|
var description string
|
||||||
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
|
description = ":\n> " + alertDescription
|
||||||
|
}
|
||||||
|
body := Body{
|
||||||
|
Text: "",
|
||||||
|
Username: "Gatus",
|
||||||
|
Attachments: []Attachment{
|
||||||
|
{
|
||||||
|
Title: "🚨 Gatus Alert",
|
||||||
|
Text: message + description,
|
||||||
|
Color: color,
|
||||||
|
AuthorName: "Gatus",
|
||||||
|
AuthorIcon: "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if cfg.Channel != "" {
|
||||||
|
body.Channel = cfg.Channel
|
||||||
|
}
|
||||||
|
if len(formattedConditionResults) > 0 {
|
||||||
|
body.Attachments[0].Fields = append(body.Attachments[0].Fields, Field{
|
||||||
|
Title: "Condition results",
|
||||||
|
Value: formattedConditionResults,
|
||||||
|
Short: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
bodyAsJSON, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return bodyAsJSON, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
|
return provider.DefaultAlert
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
164
alerting/provider/rocketchat/rocketchat_test.go
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
package rocketchat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/TwiN/gatus/v5/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
provider AlertProvider
|
||||||
|
expected error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc"}},
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid-with-channel",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc", Channel: "#alerts"}},
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-webhook-url",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{}},
|
||||||
|
expected: ErrWebhookURLNotSet,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
|
err := scenario.provider.Validate()
|
||||||
|
if err != scenario.expected {
|
||||||
|
t.Errorf("expected %v, got %v", scenario.expected, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
|
defer client.InjectHTTPClient(nil)
|
||||||
|
firstDescription := "description-1"
|
||||||
|
secondDescription := "description-2"
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
provider AlertProvider
|
||||||
|
alert alert.Alert
|
||||||
|
resolved bool
|
||||||
|
mockRoundTripper test.MockRoundTripper
|
||||||
|
expectedError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "triggered",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc"}},
|
||||||
|
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
resolved: false,
|
||||||
|
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
body := make(map[string]interface{})
|
||||||
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
if body["username"] != "Gatus" {
|
||||||
|
t.Errorf("expected username to be 'Gatus', got %v", body["username"])
|
||||||
|
}
|
||||||
|
attachments := body["attachments"].([]interface{})
|
||||||
|
if len(attachments) != 1 {
|
||||||
|
t.Errorf("expected 1 attachment, got %d", len(attachments))
|
||||||
|
}
|
||||||
|
attachment := attachments[0].(map[string]interface{})
|
||||||
|
if attachment["color"] != "#dd0000" {
|
||||||
|
t.Errorf("expected color to be '#dd0000', got %v", attachment["color"])
|
||||||
|
}
|
||||||
|
text := attachment["text"].(string)
|
||||||
|
if !strings.Contains(text, "failed 3 time(s)") {
|
||||||
|
t.Errorf("expected text to contain failure count, got %s", text)
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
expectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "triggered-with-channel",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc", Channel: "#alerts"}},
|
||||||
|
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
resolved: false,
|
||||||
|
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
body := make(map[string]interface{})
|
||||||
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
if body["channel"] != "#alerts" {
|
||||||
|
t.Errorf("expected channel to be '#alerts', got %v", body["channel"])
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
expectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "resolved",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc"}},
|
||||||
|
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
resolved: true,
|
||||||
|
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
body := make(map[string]interface{})
|
||||||
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
attachments := body["attachments"].([]interface{})
|
||||||
|
attachment := attachments[0].(map[string]interface{})
|
||||||
|
if attachment["color"] != "#36a64f" {
|
||||||
|
t.Errorf("expected color to be '#36a64f', got %v", attachment["color"])
|
||||||
|
}
|
||||||
|
text := attachment["text"].(string)
|
||||||
|
if !strings.Contains(text, "resolved") {
|
||||||
|
t.Errorf("expected text to contain 'resolved', got %s", text)
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
expectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "error-response",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc"}},
|
||||||
|
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.StatusBadRequest, 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_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")
|
||||||
|
}
|
||||||
|
}
|
||||||
248
alerting/provider/sendgrid/sendgrid.go
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
package sendgrid
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ApiURL = "https://api.sendgrid.com/v3/mail/send"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrAPIKeyNotSet = errors.New("api-key not set")
|
||||||
|
ErrFromNotSet = errors.New("from not set")
|
||||||
|
ErrToNotSet = errors.New("to not set")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
APIKey string `yaml:"api-key"`
|
||||||
|
From string `yaml:"from"`
|
||||||
|
To string `yaml:"to"`
|
||||||
|
|
||||||
|
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
||||||
|
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.APIKey) == 0 {
|
||||||
|
return ErrAPIKeyNotSet
|
||||||
|
}
|
||||||
|
if len(cfg.From) == 0 {
|
||||||
|
return ErrFromNotSet
|
||||||
|
}
|
||||||
|
if len(cfg.To) == 0 {
|
||||||
|
return ErrToNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if override.ClientConfig != nil {
|
||||||
|
cfg.ClientConfig = override.ClientConfig
|
||||||
|
}
|
||||||
|
if len(override.APIKey) > 0 {
|
||||||
|
cfg.APIKey = override.APIKey
|
||||||
|
}
|
||||||
|
if len(override.From) > 0 {
|
||||||
|
cfg.From = override.From
|
||||||
|
}
|
||||||
|
if len(override.To) > 0 {
|
||||||
|
cfg.To = override.To
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using SendGrid
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
|
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||||
|
Overrides []Override `yaml:"overrides,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override is a case under which the default integration is overridden
|
||||||
|
type Override struct {
|
||||||
|
Group string `yaml:"group"`
|
||||||
|
Config `yaml:",inline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the provider's configuration
|
||||||
|
func (provider *AlertProvider) Validate() error {
|
||||||
|
registeredGroups := make(map[string]bool)
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||||
|
return ErrDuplicateGroupOverride
|
||||||
|
}
|
||||||
|
registeredGroups[override.Group] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return provider.DefaultConfig.Validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send an alert using the provider
|
||||||
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
|
||||||
|
payload := provider.buildSendGridPayload(cfg, subject, body)
|
||||||
|
payloadBytes, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request, err := http.NewRequest(http.MethodPost, ApiURL, bytes.NewBuffer(payloadBytes))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set("Authorization", "Bearer "+cfg.APIKey)
|
||||||
|
response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode >= 400 {
|
||||||
|
body, _ := io.ReadAll(response.Body)
|
||||||
|
return fmt.Errorf("call to sendgrid alert returned status code %d: %s", response.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type SendGridPayload struct {
|
||||||
|
Personalizations []Personalization `json:"personalizations"`
|
||||||
|
From Email `json:"from"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Content []Content `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Personalization struct {
|
||||||
|
To []Email `json:"to"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Email struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Content struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSendGridPayload builds the SendGrid API payload
|
||||||
|
func (provider *AlertProvider) buildSendGridPayload(cfg *Config, subject, body string) SendGridPayload {
|
||||||
|
toEmails := strings.Split(cfg.To, ",")
|
||||||
|
var recipients []Email
|
||||||
|
for _, email := range toEmails {
|
||||||
|
recipients = append(recipients, Email{Email: strings.TrimSpace(email)})
|
||||||
|
}
|
||||||
|
return SendGridPayload{
|
||||||
|
Personalizations: []Personalization{
|
||||||
|
{
|
||||||
|
To: recipients,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
From: Email{
|
||||||
|
Email: cfg.From,
|
||||||
|
},
|
||||||
|
Subject: subject,
|
||||||
|
Content: []Content{
|
||||||
|
{
|
||||||
|
Type: "text/plain",
|
||||||
|
Value: body,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: "text/html",
|
||||||
|
Value: strings.ReplaceAll(body, "\n", "<br>"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMessageSubjectAndBody builds the message subject and body
|
||||||
|
func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) {
|
||||||
|
var subject, message string
|
||||||
|
if resolved {
|
||||||
|
subject = fmt.Sprintf("[%s] Alert resolved", ep.DisplayName())
|
||||||
|
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||||
|
} else {
|
||||||
|
subject = fmt.Sprintf("[%s] Alert triggered", ep.DisplayName())
|
||||||
|
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||||
|
}
|
||||||
|
var formattedConditionResults string
|
||||||
|
if len(result.ConditionResults) > 0 {
|
||||||
|
formattedConditionResults = "\n\nCondition results:\n"
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
var prefix string
|
||||||
|
if conditionResult.Success {
|
||||||
|
prefix = "✅"
|
||||||
|
} else {
|
||||||
|
prefix = "❌"
|
||||||
|
}
|
||||||
|
formattedConditionResults += fmt.Sprintf("%s %s\n", prefix, conditionResult.Condition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var description string
|
||||||
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
|
description = "\n\nAlert description: " + alertDescription
|
||||||
|
}
|
||||||
|
var extraLabels string
|
||||||
|
if len(ep.ExtraLabels) > 0 {
|
||||||
|
extraLabels = "\n\nExtra labels:\n"
|
||||||
|
for key, value := range ep.ExtraLabels {
|
||||||
|
extraLabels += fmt.Sprintf(" %s: %s\n", key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return subject, message + description + extraLabels + formattedConditionResults
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
|
return provider.DefaultAlert
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
517
alerting/provider/sendgrid/sendgrid_test.go
Normal file
@@ -0,0 +1,517 @@
|
|||||||
|
package sendgrid
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/TwiN/gatus/v5/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
|
invalidProvider := AlertProvider{DefaultConfig: Config{APIKey: "", From: "", To: ""}}
|
||||||
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
|
t.Error("provider shouldn't have been valid")
|
||||||
|
}
|
||||||
|
validProvider := AlertProvider{DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}}
|
||||||
|
if err := validProvider.Validate(); err != nil {
|
||||||
|
t.Error("provider should've been valid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||||
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
APIKey: "SG.test",
|
||||||
|
From: "from@example.com",
|
||||||
|
To: "to@example.com",
|
||||||
|
},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Config: Config{To: "to@example.com"},
|
||||||
|
Group: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||||
|
t.Error("provider with empty Group should not have been valid")
|
||||||
|
}
|
||||||
|
if err := providerWithInvalidOverrideGroup.Validate(); err != ErrDuplicateGroupOverride {
|
||||||
|
t.Error("provider with empty Group should return ErrDuplicateGroupOverride")
|
||||||
|
}
|
||||||
|
providerWithDuplicateOverrideGroups := AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
APIKey: "SG.test",
|
||||||
|
From: "from@example.com",
|
||||||
|
To: "to@example.com",
|
||||||
|
},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Config: Config{To: "to1@example.com"},
|
||||||
|
Group: "group",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Config: Config{To: "to2@example.com"},
|
||||||
|
Group: "group",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := providerWithDuplicateOverrideGroups.Validate(); err == nil {
|
||||||
|
t.Error("provider with duplicate group overrides should not have been valid")
|
||||||
|
}
|
||||||
|
if err := providerWithDuplicateOverrideGroups.Validate(); err != ErrDuplicateGroupOverride {
|
||||||
|
t.Error("provider with duplicate group overrides should return ErrDuplicateGroupOverride")
|
||||||
|
}
|
||||||
|
providerWithValidOverride := AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
APIKey: "SG.test",
|
||||||
|
From: "from@example.com",
|
||||||
|
To: "to@example.com",
|
||||||
|
},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Config: Config{To: "to@example.com"},
|
||||||
|
Group: "group",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := providerWithValidOverride.Validate(); err != nil {
|
||||||
|
t.Error("provider should've been valid")
|
||||||
|
}
|
||||||
|
providerWithValidMultipleOverrides := AlertProvider{
|
||||||
|
DefaultConfig: Config{
|
||||||
|
APIKey: "SG.test",
|
||||||
|
From: "from@example.com",
|
||||||
|
To: "to@example.com",
|
||||||
|
},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Config: Config{To: "group1@example.com"},
|
||||||
|
Group: "group1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Config: Config{To: "group2@example.com"},
|
||||||
|
Group: "group2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := providerWithValidMultipleOverrides.Validate(); err != nil {
|
||||||
|
t.Error("provider with multiple valid overrides should've been valid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
|
defer client.InjectHTTPClient(nil)
|
||||||
|
firstDescription := "description-1"
|
||||||
|
secondDescription := "description-2"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
MockRoundTripper test.MockRoundTripper
|
||||||
|
ExpectedError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-error",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusBadRequest, Body: io.NopCloser(strings.NewReader(`{"errors": [{"message": "Invalid API key"}]}`))}
|
||||||
|
}),
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
|
err := scenario.Provider.Send(
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
|
&scenario.Alert,
|
||||||
|
&endpoint.Result{
|
||||||
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if scenario.ExpectedError && err == nil {
|
||||||
|
t.Error("expected error, got none")
|
||||||
|
}
|
||||||
|
if !scenario.ExpectedError && err != nil {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_buildSendGridPayload(t *testing.T) {
|
||||||
|
provider := &AlertProvider{}
|
||||||
|
cfg := &Config{
|
||||||
|
From: "test@example.com",
|
||||||
|
To: "to1@example.com,to2@example.com",
|
||||||
|
}
|
||||||
|
subject := "Test Subject"
|
||||||
|
body := "Test Body\nWith new line"
|
||||||
|
payload := provider.buildSendGridPayload(cfg, subject, body)
|
||||||
|
if payload.Subject != subject {
|
||||||
|
t.Errorf("expected subject to be %s, got %s", subject, payload.Subject)
|
||||||
|
}
|
||||||
|
if payload.From.Email != cfg.From {
|
||||||
|
t.Errorf("expected from email to be %s, got %s", cfg.From, payload.From.Email)
|
||||||
|
}
|
||||||
|
if len(payload.Personalizations) != 1 {
|
||||||
|
t.Errorf("expected 1 personalization, got %d", len(payload.Personalizations))
|
||||||
|
}
|
||||||
|
if len(payload.Personalizations[0].To) != 2 {
|
||||||
|
t.Errorf("expected 2 recipients, got %d", len(payload.Personalizations[0].To))
|
||||||
|
}
|
||||||
|
if payload.Personalizations[0].To[0].Email != "to1@example.com" {
|
||||||
|
t.Errorf("expected first recipient to be to1@example.com, got %s", payload.Personalizations[0].To[0].Email)
|
||||||
|
}
|
||||||
|
if payload.Personalizations[0].To[1].Email != "to2@example.com" {
|
||||||
|
t.Errorf("expected second recipient to be to2@example.com, got %s", payload.Personalizations[0].To[1].Email)
|
||||||
|
}
|
||||||
|
if len(payload.Content) != 2 {
|
||||||
|
t.Errorf("expected 2 content types, got %d", len(payload.Content))
|
||||||
|
}
|
||||||
|
if payload.Content[0].Type != "text/plain" {
|
||||||
|
t.Errorf("expected first content type to be text/plain, got %s", payload.Content[0].Type)
|
||||||
|
}
|
||||||
|
if payload.Content[0].Value != body {
|
||||||
|
t.Errorf("expected plain text content to be %s, got %s", body, payload.Content[0].Value)
|
||||||
|
}
|
||||||
|
if payload.Content[1].Type != "text/html" {
|
||||||
|
t.Errorf("expected second content type to be text/html, got %s", payload.Content[1].Type)
|
||||||
|
}
|
||||||
|
expectedHTML := "Test Body<br>With new line"
|
||||||
|
if payload.Content[1].Value != expectedHTML {
|
||||||
|
t.Errorf("expected HTML content to be %s, got %s", expectedHTML, payload.Content[1].Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_buildMessageSubjectAndBody(t *testing.T) {
|
||||||
|
firstDescription := "description-1"
|
||||||
|
secondDescription := "description-2"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
Endpoint *endpoint.Endpoint
|
||||||
|
ExpectedSubject string
|
||||||
|
ExpectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
Endpoint: &endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
|
ExpectedSubject: "[endpoint-name] Alert triggered",
|
||||||
|
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
Endpoint: &endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
|
ExpectedSubject: "[endpoint-name] Alert resolved",
|
||||||
|
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-with-single-extra-label",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"environment": "production"}},
|
||||||
|
ExpectedSubject: "[endpoint-name] Alert triggered",
|
||||||
|
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nExtra labels:\n environment: production\n\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-with-single-extra-label",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"service": "api"}},
|
||||||
|
ExpectedSubject: "[endpoint-name] Alert resolved",
|
||||||
|
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nExtra labels:\n service: api\n\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-with-no-extra-labels",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{}},
|
||||||
|
ExpectedSubject: "[endpoint-name] Alert triggered",
|
||||||
|
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
subject, body := scenario.Provider.buildMessageSubjectAndBody(
|
||||||
|
scenario.Endpoint,
|
||||||
|
&scenario.Alert,
|
||||||
|
&endpoint.Result{
|
||||||
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if subject != scenario.ExpectedSubject {
|
||||||
|
t.Errorf("expected subject to be %s, got %s", scenario.ExpectedSubject, subject)
|
||||||
|
}
|
||||||
|
if body != scenario.ExpectedBody {
|
||||||
|
t.Errorf("expected body to be %s, got %s", scenario.ExpectedBody, body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
|
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
|
t.Error("expected default alert to be not nil")
|
||||||
|
}
|
||||||
|
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
|
t.Error("expected default alert to be nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputGroup string
|
||||||
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{To: "to01@example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{To: "group-to@example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{APIKey: "SG.test", From: "from@example.com", To: "group-to@example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{To: "group-to@example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"api-key": "SG.override", "to": "alert-to@example.com", "from": "alert-from@example.com"}},
|
||||||
|
ExpectedOutput: Config{APIKey: "SG.override", From: "alert-from@example.com", To: "alert-to@example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-multiple-overrides-pick-correct-group",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{APIKey: "SG.default", From: "default@example.com", To: "default@example.com"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group1",
|
||||||
|
Config: Config{APIKey: "SG.group1", To: "group1@example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Group: "group2",
|
||||||
|
Config: Config{APIKey: "SG.group2", From: "group2@example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group2",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{APIKey: "SG.group2", From: "group2@example.com", To: "default@example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-partial-override-hierarchy",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{APIKey: "SG.default", From: "default@example.com", To: "default@example.com"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "test-group",
|
||||||
|
Config: Config{From: "group@example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "test-group",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"to": "alert@example.com"}},
|
||||||
|
ExpectedOutput: Config{APIKey: "SG.default", From: "group@example.com", To: "alert@example.com"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.APIKey != scenario.ExpectedOutput.APIKey {
|
||||||
|
t.Errorf("expected APIKey to be %s, got %s", scenario.ExpectedOutput.APIKey, got.APIKey)
|
||||||
|
}
|
||||||
|
if got.From != scenario.ExpectedOutput.From {
|
||||||
|
t.Errorf("expected From to be %s, got %s", scenario.ExpectedOutput.From, got.From)
|
||||||
|
}
|
||||||
|
if got.To != scenario.ExpectedOutput.To {
|
||||||
|
t.Errorf("expected To to be %s, got %s", scenario.ExpectedOutput.To, got.To)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfig_Validate(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Config Config
|
||||||
|
ExpectedError error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "missing-api-key",
|
||||||
|
Config: Config{APIKey: "", From: "test@example.com", To: "to@example.com"},
|
||||||
|
ExpectedError: ErrAPIKeyNotSet,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "missing-from",
|
||||||
|
Config: Config{APIKey: "SG.test", From: "", To: "to@example.com"},
|
||||||
|
ExpectedError: ErrFromNotSet,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "missing-to",
|
||||||
|
Config: Config{APIKey: "SG.test", From: "test@example.com", To: ""},
|
||||||
|
ExpectedError: ErrToNotSet,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "valid-config",
|
||||||
|
Config: Config{APIKey: "SG.test", From: "test@example.com", To: "to@example.com"},
|
||||||
|
ExpectedError: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
err := scenario.Config.Validate()
|
||||||
|
if scenario.ExpectedError == nil && err != nil {
|
||||||
|
t.Errorf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if scenario.ExpectedError != nil && err == nil {
|
||||||
|
t.Errorf("expected error %v, got none", scenario.ExpectedError)
|
||||||
|
}
|
||||||
|
if scenario.ExpectedError != nil && err != nil && err.Error() != scenario.ExpectedError.Error() {
|
||||||
|
t.Errorf("expected error %v, got %v", scenario.ExpectedError, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfig_Merge(t *testing.T) {
|
||||||
|
config := Config{APIKey: "SG.original", From: "from@example.com", To: "to@example.com"}
|
||||||
|
override := Config{APIKey: "SG.override", To: "override@example.com"}
|
||||||
|
config.Merge(&override)
|
||||||
|
if config.APIKey != "SG.override" {
|
||||||
|
t.Errorf("expected APIKey to be SG.override, got %s", config.APIKey)
|
||||||
|
}
|
||||||
|
if config.From != "from@example.com" {
|
||||||
|
t.Errorf("expected From to remain from@example.com, got %s", config.From)
|
||||||
|
}
|
||||||
|
if config.To != "override@example.com" {
|
||||||
|
t.Errorf("expected To to be override@example.com, got %s", config.To)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfig_MergeWithClientConfig(t *testing.T) {
|
||||||
|
config := Config{APIKey: "SG.original", From: "from@example.com", To: "to@example.com"}
|
||||||
|
override := Config{APIKey: "SG.override", ClientConfig: &client.Config{Timeout: 30000}}
|
||||||
|
config.Merge(&override)
|
||||||
|
if config.APIKey != "SG.override" {
|
||||||
|
t.Errorf("expected APIKey to be SG.override, got %s", config.APIKey)
|
||||||
|
}
|
||||||
|
if config.ClientConfig == nil {
|
||||||
|
t.Error("expected ClientConfig to be set")
|
||||||
|
}
|
||||||
|
if config.ClientConfig.Timeout != 30000 {
|
||||||
|
t.Errorf("expected ClientConfig.Timeout to be 30000, got %d", config.ClientConfig.Timeout)
|
||||||
|
}
|
||||||
|
config2 := Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com", ClientConfig: &client.Config{Timeout: 10000}}
|
||||||
|
override2 := Config{APIKey: "SG.override2"}
|
||||||
|
config2.Merge(&override2)
|
||||||
|
if config2.ClientConfig.Timeout != 10000 {
|
||||||
|
t.Errorf("expected ClientConfig.Timeout to remain 10000, got %d", config2.ClientConfig.Timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
196
alerting/provider/signal/signal.go
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
package signal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrApiURLNotSet = errors.New("api-url not set")
|
||||||
|
ErrNumberNotSet = errors.New("number not set")
|
||||||
|
ErrRecipientsNotSet = errors.New("recipients not set")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
ApiURL string `yaml:"api-url"` // Signal API URL (e.g., signal-cli-rest-api instance)
|
||||||
|
Number string `yaml:"number"` // Sender phone number
|
||||||
|
Recipients []string `yaml:"recipients"` // List of recipient phone numbers
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.ApiURL) == 0 {
|
||||||
|
return ErrApiURLNotSet
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(cfg.ApiURL, "/v2/send") {
|
||||||
|
cfg.ApiURL = cfg.ApiURL + "/v2/send"
|
||||||
|
}
|
||||||
|
if len(cfg.Number) == 0 {
|
||||||
|
return ErrNumberNotSet
|
||||||
|
}
|
||||||
|
if len(cfg.Recipients) == 0 {
|
||||||
|
return ErrRecipientsNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.ApiURL) > 0 {
|
||||||
|
cfg.ApiURL = override.ApiURL
|
||||||
|
}
|
||||||
|
if len(override.Number) > 0 {
|
||||||
|
cfg.Number = override.Number
|
||||||
|
}
|
||||||
|
if len(override.Recipients) > 0 {
|
||||||
|
cfg.Recipients = override.Recipients
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using Signal
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
|
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||||
|
Overrides []Override `yaml:"overrides,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override is a case under which the default integration is overridden
|
||||||
|
type Override struct {
|
||||||
|
Group string `yaml:"group"`
|
||||||
|
Config `yaml:",inline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the provider's configuration
|
||||||
|
func (provider *AlertProvider) Validate() error {
|
||||||
|
registeredGroups := make(map[string]bool)
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||||
|
return ErrDuplicateGroupOverride
|
||||||
|
}
|
||||||
|
registeredGroups[override.Group] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return provider.DefaultConfig.Validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send an alert using the provider
|
||||||
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, recipient := range cfg.Recipients {
|
||||||
|
body, err := provider.buildRequestBody(cfg, ep, alert, result, resolved, recipient)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(body)
|
||||||
|
request, err := http.NewRequest(http.MethodPost, cfg.ApiURL, 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
|
||||||
|
}
|
||||||
|
if response.StatusCode >= 400 {
|
||||||
|
body, _ := io.ReadAll(response.Body)
|
||||||
|
response.Body.Close()
|
||||||
|
return fmt.Errorf("call to signal alert returned status code %d: %s", response.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
response.Body.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Body struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Number string `json:"number"`
|
||||||
|
Recipients []string `json:"recipients"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestBody builds the request body for the provider
|
||||||
|
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool, recipient string) ([]byte, error) {
|
||||||
|
var message string
|
||||||
|
if resolved {
|
||||||
|
message = fmt.Sprintf("🟢 RESOLVED: %s\nAlert has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||||
|
} else {
|
||||||
|
message = fmt.Sprintf("🔴 ALERT: %s\nEndpoint has failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||||
|
}
|
||||||
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
|
message += fmt.Sprintf("\n\nDescription: %s", alertDescription)
|
||||||
|
}
|
||||||
|
if len(result.ConditionResults) > 0 {
|
||||||
|
message += "\n\nCondition results:"
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
var status string
|
||||||
|
if conditionResult.Success {
|
||||||
|
status = "✅"
|
||||||
|
} else {
|
||||||
|
status = "❌"
|
||||||
|
}
|
||||||
|
message += fmt.Sprintf("\n%s %s", status, conditionResult.Condition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body := Body{
|
||||||
|
Message: message,
|
||||||
|
Number: cfg.Number,
|
||||||
|
Recipients: []string{recipient},
|
||||||
|
}
|
||||||
|
bodyAsJSON, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return bodyAsJSON, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
|
return provider.DefaultAlert
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
151
alerting/provider/signal/signal_test.go
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
package signal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/TwiN/gatus/v5/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
provider AlertProvider
|
||||||
|
expected error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Number: "+1234567890", Recipients: []string{"+0987654321"}}},
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-api-url",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{Number: "+1234567890", Recipients: []string{"+0987654321"}}},
|
||||||
|
expected: ErrApiURLNotSet,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-number",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Recipients: []string{"+0987654321"}}},
|
||||||
|
expected: ErrNumberNotSet,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-recipients",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Number: "+1234567890"}},
|
||||||
|
expected: ErrRecipientsNotSet,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
|
err := scenario.provider.Validate()
|
||||||
|
if err != scenario.expected {
|
||||||
|
t.Errorf("expected %v, got %v", scenario.expected, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
|
defer client.InjectHTTPClient(nil)
|
||||||
|
firstDescription := "description-1"
|
||||||
|
secondDescription := "description-2"
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
provider AlertProvider
|
||||||
|
alert alert.Alert
|
||||||
|
resolved bool
|
||||||
|
mockRoundTripper test.MockRoundTripper
|
||||||
|
expectedError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "triggered",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Number: "+1234567890", Recipients: []string{"+0987654321", "+1111111111"}}},
|
||||||
|
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
resolved: false,
|
||||||
|
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
if r.URL.Path != "/v2/send" {
|
||||||
|
t.Errorf("expected path /v2/send, got %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
body := make(map[string]interface{})
|
||||||
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
if body["number"] != "+1234567890" {
|
||||||
|
t.Errorf("expected number to be '+1234567890', got %v", body["number"])
|
||||||
|
}
|
||||||
|
recipients := body["recipients"].([]interface{})
|
||||||
|
if len(recipients) != 1 {
|
||||||
|
t.Errorf("expected 1 recipient per request, got %d", len(recipients))
|
||||||
|
}
|
||||||
|
message := body["message"].(string)
|
||||||
|
if !strings.Contains(message, "ALERT") {
|
||||||
|
t.Errorf("expected message to contain 'ALERT', got %s", message)
|
||||||
|
}
|
||||||
|
if !strings.Contains(message, "failed 3 time(s)") {
|
||||||
|
t.Errorf("expected message to contain failure count, got %s", message)
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
expectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "resolved",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Number: "+1234567890", Recipients: []string{"+0987654321"}}},
|
||||||
|
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
resolved: true,
|
||||||
|
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
body := make(map[string]interface{})
|
||||||
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
message := body["message"].(string)
|
||||||
|
if !strings.Contains(message, "RESOLVED") {
|
||||||
|
t.Errorf("expected message to contain 'RESOLVED', got %s", message)
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
expectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "error-response",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Number: "+1234567890", Recipients: []string{"+0987654321"}}},
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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_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")
|
||||||
|
}
|
||||||
|
}
|
||||||
184
alerting/provider/signl4/signl4.go
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
package signl4
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrTeamSecretNotSet = errors.New("team-secret not set")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
TeamSecret string `yaml:"team-secret"` // SIGNL4 team secret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.TeamSecret) == 0 {
|
||||||
|
return ErrTeamSecretNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.TeamSecret) > 0 {
|
||||||
|
cfg.TeamSecret = override.TeamSecret
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using SIGNL4
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
|
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||||
|
Overrides []Override `yaml:"overrides,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override is a case under which the default integration is overridden
|
||||||
|
type Override struct {
|
||||||
|
Group string `yaml:"group"`
|
||||||
|
Config `yaml:",inline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the provider's configuration
|
||||||
|
func (provider *AlertProvider) Validate() error {
|
||||||
|
registeredGroups := make(map[string]bool)
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||||
|
return ErrDuplicateGroupOverride
|
||||||
|
}
|
||||||
|
registeredGroups[override.Group] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return provider.DefaultConfig.Validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send an alert using the provider
|
||||||
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body, err := provider.buildRequestBody(ep, alert, result, resolved)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(body)
|
||||||
|
webhookURL := fmt.Sprintf("https://connect.signl4.com/webhook/%s", cfg.TeamSecret)
|
||||||
|
request, err := http.NewRequest(http.MethodPost, webhookURL, buffer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
response, err := client.GetHTTPClient(nil).Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode >= 400 {
|
||||||
|
body, _ := io.ReadAll(response.Body)
|
||||||
|
return fmt.Errorf("call to signl4 alert returned status code %d: %s", response.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Body struct {
|
||||||
|
Title string `json:"Title"`
|
||||||
|
Message string `json:"Message"`
|
||||||
|
XS4Service string `json:"X-S4-Service"`
|
||||||
|
XS4Status string `json:"X-S4-Status"`
|
||||||
|
XS4ExternalID string `json:"X-S4-ExternalID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestBody builds the request body for the provider
|
||||||
|
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
|
||||||
|
var title, message, status string
|
||||||
|
if resolved {
|
||||||
|
title = fmt.Sprintf("RESOLVED: %s", 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)
|
||||||
|
status = "resolved"
|
||||||
|
} else {
|
||||||
|
title = fmt.Sprintf("TRIGGERED: %s", 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)
|
||||||
|
status = "new"
|
||||||
|
}
|
||||||
|
var conditionResults string
|
||||||
|
if len(result.ConditionResults) > 0 {
|
||||||
|
conditionResults = "\n\nCondition results:\n"
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
var prefix string
|
||||||
|
if conditionResult.Success {
|
||||||
|
prefix = "✓"
|
||||||
|
} else {
|
||||||
|
prefix = "✗"
|
||||||
|
}
|
||||||
|
conditionResults += fmt.Sprintf("%s %s\n", prefix, conditionResult.Condition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
|
message += "\n\nDescription: " + alertDescription
|
||||||
|
}
|
||||||
|
message += conditionResults
|
||||||
|
body := Body{
|
||||||
|
Title: title,
|
||||||
|
Message: message,
|
||||||
|
XS4Service: ep.DisplayName(),
|
||||||
|
XS4Status: status,
|
||||||
|
XS4ExternalID: fmt.Sprintf("gatus-%s", ep.Key()),
|
||||||
|
}
|
||||||
|
bodyAsJSON, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return bodyAsJSON, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
|
return provider.DefaultAlert
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
392
alerting/provider/signl4/signl4_test.go
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
package signl4
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/TwiN/gatus/v5/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
|
invalidProvider := AlertProvider{DefaultConfig: Config{TeamSecret: ""}}
|
||||||
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
|
t.Error("provider shouldn't have been valid")
|
||||||
|
}
|
||||||
|
validProvider := AlertProvider{DefaultConfig: Config{TeamSecret: "team-secret-123"}}
|
||||||
|
if err := validProvider.Validate(); err != nil {
|
||||||
|
t.Error("provider should've been valid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||||
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Config: Config{TeamSecret: "team-secret-123"},
|
||||||
|
Group: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||||
|
t.Error("provider Group shouldn't have been valid")
|
||||||
|
}
|
||||||
|
providerWithInvalidOverrideTo := AlertProvider{
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Config: Config{TeamSecret: ""},
|
||||||
|
Group: "group",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
|
||||||
|
t.Error("provider team secret shouldn't have been valid")
|
||||||
|
}
|
||||||
|
providerWithValidOverride := AlertProvider{
|
||||||
|
DefaultConfig: Config{TeamSecret: "team-secret-123"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Config: Config{TeamSecret: "team-secret-override"},
|
||||||
|
Group: "group",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := providerWithValidOverride.Validate(); err != nil {
|
||||||
|
t.Error("provider should've been valid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
|
defer client.InjectHTTPClient(nil)
|
||||||
|
firstDescription := "description-1"
|
||||||
|
secondDescription := "description-2"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Alert alert.Alert
|
||||||
|
Resolved bool
|
||||||
|
MockRoundTripper test.MockRoundTripper
|
||||||
|
ExpectedError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{TeamSecret: "team-secret-123"}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-error",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{TeamSecret: "team-secret-123"}},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{TeamSecret: "team-secret-123"}},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-error",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{TeamSecret: "team-secret-123"}},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
ExpectedError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||||
|
err := scenario.Provider.Send(
|
||||||
|
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||||
|
&scenario.Alert,
|
||||||
|
&endpoint.Result{
|
||||||
|
ConditionResults: []*endpoint.ConditionResult{
|
||||||
|
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||||
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if scenario.ExpectedError && err == nil {
|
||||||
|
t.Error("expected error, got none")
|
||||||
|
}
|
||||||
|
if !scenario.ExpectedError && err != nil {
|
||||||
|
t.Error("expected no error, got", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||||
|
firstDescription := "description-1"
|
||||||
|
secondDescription := "description-2"
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
Endpoint endpoint.Endpoint
|
||||||
|
Alert alert.Alert
|
||||||
|
NoConditions bool
|
||||||
|
Resolved bool
|
||||||
|
ExpectedBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "triggered",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: "{\"Title\":\"TRIGGERED: name\",\"Message\":\"An alert for name has been triggered due to having failed 3 time(s) in a row\\n\\nDescription: description-1\\n\\nCondition results:\\n✗ [CONNECTED] == true\\n✗ [STATUS] == 200\\n\",\"X-S4-Service\":\"name\",\"X-S4-Status\":\"new\",\"X-S4-ExternalID\":\"gatus-_name\"}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-with-group",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: "{\"Title\":\"TRIGGERED: group/name\",\"Message\":\"An alert for group/name has been triggered due to having failed 3 time(s) in a row\\n\\nDescription: description-1\\n\\nCondition results:\\n✗ [CONNECTED] == true\\n✗ [STATUS] == 200\\n\",\"X-S4-Service\":\"group/name\",\"X-S4-Status\":\"new\",\"X-S4-ExternalID\":\"gatus-group_name\"}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "triggered-with-no-conditions",
|
||||||
|
NoConditions: true,
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||||
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: false,
|
||||||
|
ExpectedBody: "{\"Title\":\"TRIGGERED: name\",\"Message\":\"An alert for name has been triggered due to having failed 3 time(s) in a row\\n\\nDescription: description-1\",\"X-S4-Service\":\"name\",\"X-S4-Status\":\"new\",\"X-S4-ExternalID\":\"gatus-_name\"}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: "{\"Title\":\"RESOLVED: name\",\"Message\":\"An alert for name has been resolved after passing successfully 5 time(s) in a row\\n\\nDescription: description-2\\n\\nCondition results:\\n✓ [CONNECTED] == true\\n✓ [STATUS] == 200\\n\",\"X-S4-Service\":\"name\",\"X-S4-Status\":\"resolved\",\"X-S4-ExternalID\":\"gatus-_name\"}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-with-group",
|
||||||
|
Provider: AlertProvider{},
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: "{\"Title\":\"RESOLVED: group/name\",\"Message\":\"An alert for group/name has been resolved after passing successfully 5 time(s) in a row\\n\\nDescription: description-2\\n\\nCondition results:\\n✓ [CONNECTED] == true\\n✓ [STATUS] == 200\\n\",\"X-S4-Service\":\"group/name\",\"X-S4-Status\":\"resolved\",\"X-S4-ExternalID\":\"gatus-group_name\"}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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, err := scenario.Provider.buildRequestBody(
|
||||||
|
&scenario.Endpoint,
|
||||||
|
&scenario.Alert,
|
||||||
|
&endpoint.Result{
|
||||||
|
ConditionResults: conditionResults,
|
||||||
|
},
|
||||||
|
scenario.Resolved,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildRequestBody returned an error: %v", err)
|
||||||
|
}
|
||||||
|
if string(body) != scenario.ExpectedBody {
|
||||||
|
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
||||||
|
}
|
||||||
|
out := make(map[string]interface{})
|
||||||
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
|
t.Error("expected body to be valid JSON, got error:", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||||
|
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||||
|
t.Error("expected default alert to be not nil")
|
||||||
|
}
|
||||||
|
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||||
|
t.Error("expected default alert to be nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
Name string
|
||||||
|
Provider AlertProvider
|
||||||
|
InputGroup string
|
||||||
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{TeamSecret: "team-secret-123"},
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{TeamSecret: "team-secret-123"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{TeamSecret: "team-secret-123"},
|
||||||
|
Overrides: nil,
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{TeamSecret: "team-secret-123"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{TeamSecret: "team-secret-123"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{TeamSecret: "team-secret-override"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{TeamSecret: "team-secret-123"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{TeamSecret: "team-secret-123"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{TeamSecret: "team-secret-override"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{TeamSecret: "team-secret-override"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{TeamSecret: "team-secret-123"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{TeamSecret: "team-secret-override"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"team-secret": "team-secret-alert"}},
|
||||||
|
ExpectedOutput: Config{TeamSecret: "team-secret-alert"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
|
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.TeamSecret != scenario.ExpectedOutput.TeamSecret {
|
||||||
|
t.Errorf("expected team secret to be %s, got %s", scenario.ExpectedOutput.TeamSecret, got.TeamSecret)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_GetConfigWithInvalidAlertOverride(t *testing.T) {
|
||||||
|
// Test case 1: Empty override should be ignored, default config should be used
|
||||||
|
provider := AlertProvider{
|
||||||
|
DefaultConfig: Config{TeamSecret: "team-secret-123"},
|
||||||
|
}
|
||||||
|
alertWithEmptyOverride := alert.Alert{
|
||||||
|
ProviderOverride: map[string]any{"team-secret": ""},
|
||||||
|
}
|
||||||
|
cfg, err := provider.GetConfig("", &alertWithEmptyOverride)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.TeamSecret != "team-secret-123" {
|
||||||
|
t.Errorf("expected team secret to remain default 'team-secret-123', got %s", cfg.TeamSecret)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test case 2: Invalid default config with no valid override should fail
|
||||||
|
providerWithInvalidDefault := AlertProvider{
|
||||||
|
DefaultConfig: Config{TeamSecret: ""},
|
||||||
|
}
|
||||||
|
alertWithEmptyOverride2 := alert.Alert{
|
||||||
|
ProviderOverride: map[string]any{"team-secret": ""},
|
||||||
|
}
|
||||||
|
_, err = providerWithInvalidDefault.GetConfig("", &alertWithEmptyOverride2)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error due to invalid default config, got none")
|
||||||
|
}
|
||||||
|
if err != ErrTeamSecretNotSet {
|
||||||
|
t.Errorf("expected ErrTeamSecretNotSet, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_ValidateWithDuplicateGroupOverride(t *testing.T) {
|
||||||
|
providerWithDuplicateOverride := AlertProvider{
|
||||||
|
DefaultConfig: Config{TeamSecret: "team-secret-123"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group1",
|
||||||
|
Config: Config{TeamSecret: "team-secret-override-1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Group: "group1",
|
||||||
|
Config: Config{TeamSecret: "team-secret-override-2"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := providerWithDuplicateOverride.Validate(); err == nil {
|
||||||
|
t.Error("provider should not have been valid due to duplicate group override")
|
||||||
|
}
|
||||||
|
if err := providerWithDuplicateOverride.Validate(); err != ErrDuplicateGroupOverride {
|
||||||
|
t.Errorf("expected ErrDuplicateGroupOverride, got %v", providerWithDuplicateOverride.Validate())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_ValidateOverridesWithInvalidAlert(t *testing.T) {
|
||||||
|
provider := AlertProvider{
|
||||||
|
DefaultConfig: Config{TeamSecret: ""},
|
||||||
|
}
|
||||||
|
alertWithEmptyOverride := alert.Alert{
|
||||||
|
ProviderOverride: map[string]any{"team-secret": ""},
|
||||||
|
}
|
||||||
|
err := provider.ValidateOverrides("", &alertWithEmptyOverride)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error due to invalid default config, got none")
|
||||||
|
}
|
||||||
|
if err != ErrTeamSecretNotSet {
|
||||||
|
t.Errorf("expected ErrTeamSecretNotSet, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package slack
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -10,41 +11,74 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrWebhookURLNotSet = errors.New("webhook-url not set")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
WebhookURL string `yaml:"webhook-url"` // Slack webhook URL
|
||||||
|
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.WebhookURL) == 0 {
|
||||||
|
return ErrWebhookURLNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.WebhookURL) > 0 {
|
||||||
|
cfg.WebhookURL = override.WebhookURL
|
||||||
|
}
|
||||||
|
if len(override.Title) > 0 {
|
||||||
|
cfg.Title = override.Title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Slack
|
// AlertProvider is the configuration necessary for sending an alert using Slack
|
||||||
type AlertProvider struct {
|
type AlertProvider struct {
|
||||||
WebhookURL string `yaml:"webhook-url"` // Slack webhook URL
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
// Overrides is a list of Override that may be prioritized over the default configuration
|
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||||
Overrides []Override `yaml:"overrides,omitempty"`
|
Overrides []Override `yaml:"overrides,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override is a case under which the default integration is overridden
|
// Override is a case under which the default integration is overridden
|
||||||
type Override struct {
|
type Override struct {
|
||||||
Group string `yaml:"group"`
|
Group string `yaml:"group"`
|
||||||
WebhookURL string `yaml:"webhook-url"`
|
Config `yaml:",inline"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// Validate the provider's configuration
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
func (provider *AlertProvider) Validate() error {
|
||||||
registeredGroups := make(map[string]bool)
|
registeredGroups := make(map[string]bool)
|
||||||
if provider.Overrides != nil {
|
if provider.Overrides != nil {
|
||||||
for _, override := range provider.Overrides {
|
for _, override := range provider.Overrides {
|
||||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||||
return false
|
return ErrDuplicateGroupOverride
|
||||||
}
|
}
|
||||||
registeredGroups[override.Group] = true
|
registeredGroups[override.Group] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return len(provider.WebhookURL) > 0
|
return provider.DefaultConfig.Validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer)
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||||
|
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -81,7 +115,7 @@ type Field struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildRequestBody builds the request body for the provider
|
// buildRequestBody builds the request body for the provider
|
||||||
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
var message, color string
|
var message, color string
|
||||||
if resolved {
|
if resolved {
|
||||||
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.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)
|
||||||
@@ -108,13 +142,16 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
|||||||
Text: "",
|
Text: "",
|
||||||
Attachments: []Attachment{
|
Attachments: []Attachment{
|
||||||
{
|
{
|
||||||
Title: ":helmet_with_white_cross: Gatus",
|
Title: cfg.Title,
|
||||||
Text: message + description,
|
Text: message + description,
|
||||||
Short: false,
|
Short: false,
|
||||||
Color: color,
|
Color: color,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if len(body.Attachments[0].Title) == 0 {
|
||||||
|
body.Attachments[0].Title = ":helmet_with_white_cross: Gatus"
|
||||||
|
}
|
||||||
if len(formattedConditionResults) > 0 {
|
if len(formattedConditionResults) > 0 {
|
||||||
body.Attachments[0].Fields = append(body.Attachments[0].Fields, Field{
|
body.Attachments[0].Fields = append(body.Attachments[0].Fields, Field{
|
||||||
Title: "Condition results",
|
Title: "Condition results",
|
||||||
@@ -126,19 +163,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
|||||||
return bodyAsJSON
|
return bodyAsJSON
|
||||||
}
|
}
|
||||||
|
|
||||||
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
|
|
||||||
func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
|
|
||||||
if provider.Overrides != nil {
|
|
||||||
for _, override := range provider.Overrides {
|
|
||||||
if group == override.Group {
|
|
||||||
return override.WebhookURL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return provider.WebhookURL
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,50 +11,50 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/test"
|
"github.com/TwiN/gatus/v5/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
invalidProvider := AlertProvider{WebhookURL: ""}
|
invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
|
||||||
if invalidProvider.IsValid() {
|
if err := invalidProvider.Validate(); err == nil {
|
||||||
t.Error("provider shouldn't have been valid")
|
t.Error("provider shouldn't have been valid")
|
||||||
}
|
}
|
||||||
validProvider := AlertProvider{WebhookURL: "https://example.com"}
|
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "https://example.com"}}
|
||||||
if !validProvider.IsValid() {
|
if err := validProvider.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||||
providerWithInvalidOverrideGroup := AlertProvider{
|
providerWithInvalidOverrideGroup := AlertProvider{
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
WebhookURL: "http://example.com",
|
Config: Config{WebhookURL: "http://example.com"},
|
||||||
Group: "",
|
Group: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if providerWithInvalidOverrideGroup.IsValid() {
|
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||||
t.Error("provider Group shouldn't have been valid")
|
t.Error("provider Group shouldn't have been valid")
|
||||||
}
|
}
|
||||||
providerWithInvalidOverrideTo := AlertProvider{
|
providerWithInvalidOverrideTo := AlertProvider{
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
WebhookURL: "",
|
Config: Config{WebhookURL: ""},
|
||||||
Group: "group",
|
Group: "group",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if providerWithInvalidOverrideTo.IsValid() {
|
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
|
||||||
t.Error("provider integration key shouldn't have been valid")
|
t.Error("provider integration key shouldn't have been valid")
|
||||||
}
|
}
|
||||||
providerWithValidOverride := AlertProvider{
|
providerWithValidOverride := AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
WebhookURL: "http://example.com",
|
Config: Config{WebhookURL: "http://example.com"},
|
||||||
Group: "group",
|
Group: "group",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if !providerWithValidOverride.IsValid() {
|
if err := providerWithValidOverride.Validate(); err != nil {
|
||||||
t.Error("provider should've been valid")
|
t.Error("provider should've been valid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -83,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "triggered-error",
|
Name: "triggered-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -93,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -103,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved-error",
|
Name: "resolved-error",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
@@ -150,7 +150,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "triggered",
|
Name: "triggered",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Endpoint: endpoint.Endpoint{Name: "name"},
|
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
@@ -158,7 +158,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "triggered-with-group",
|
Name: "triggered-with-group",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
@@ -167,7 +167,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
{
|
{
|
||||||
Name: "triggered-with-no-conditions",
|
Name: "triggered-with-no-conditions",
|
||||||
NoConditions: true,
|
NoConditions: true,
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Endpoint: endpoint.Endpoint{Name: "name"},
|
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
@@ -175,7 +175,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved",
|
Name: "resolved",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Endpoint: endpoint.Endpoint{Name: "name"},
|
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
@@ -183,12 +183,20 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "resolved-with-group",
|
Name: "resolved-with-group",
|
||||||
Provider: AlertProvider{},
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||||
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
||||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
Resolved: true,
|
Resolved: true,
|
||||||
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
|
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "resolved-with-group-and-custom-title",
|
||||||
|
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com", Title: "custom title"}},
|
||||||
|
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
||||||
|
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
Resolved: true,
|
||||||
|
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\"custom title\",\"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}]}]}",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
t.Run(scenario.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
@@ -199,7 +207,12 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
|||||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
cfg, err := scenario.Provider.GetConfig(scenario.Endpoint.Group, &scenario.Alert)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("couldn't get config:", err.Error())
|
||||||
|
}
|
||||||
body := scenario.Provider.buildRequestBody(
|
body := scenario.Provider.buildRequestBody(
|
||||||
|
cfg,
|
||||||
&scenario.Endpoint,
|
&scenario.Endpoint,
|
||||||
&scenario.Alert,
|
&scenario.Alert,
|
||||||
&endpoint.Result{
|
&endpoint.Result{
|
||||||
@@ -227,64 +240,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
|
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||||
tests := []struct {
|
scenarios := []struct {
|
||||||
Name string
|
Name string
|
||||||
Provider AlertProvider
|
Provider AlertProvider
|
||||||
InputGroup string
|
InputGroup string
|
||||||
ExpectedOutput string
|
InputAlert alert.Alert
|
||||||
|
ExpectedOutput Config
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "provider-no-override-specify-no-group-should-default",
|
Name: "provider-no-override-specify-no-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: nil,
|
Overrides: nil,
|
||||||
},
|
},
|
||||||
InputGroup: "",
|
InputGroup: "",
|
||||||
ExpectedOutput: "http://example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-no-override-specify-group-should-default",
|
Name: "provider-no-override-specify-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: nil,
|
Overrides: nil,
|
||||||
},
|
},
|
||||||
InputGroup: "group",
|
InputGroup: "group",
|
||||||
ExpectedOutput: "http://example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-with-override-specify-no-group-should-default",
|
Name: "provider-with-override-specify-no-group-should-default",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
WebhookURL: "http://example01.com",
|
Config: Config{WebhookURL: "http://example01.com"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InputGroup: "",
|
InputGroup: "",
|
||||||
ExpectedOutput: "http://example.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "provider-with-override-specify-group-should-override",
|
Name: "provider-with-override-specify-group-should-override",
|
||||||
Provider: AlertProvider{
|
Provider: AlertProvider{
|
||||||
WebhookURL: "http://example.com",
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
Overrides: []Override{
|
Overrides: []Override{
|
||||||
{
|
{
|
||||||
Group: "group",
|
Group: "group",
|
||||||
WebhookURL: "http://example01.com",
|
Config: Config{WebhookURL: "http://group-example.com"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InputGroup: "group",
|
InputGroup: "group",
|
||||||
ExpectedOutput: "http://example01.com",
|
InputAlert: alert.Alert{},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://group-example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||||
|
Provider: AlertProvider{
|
||||||
|
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||||
|
Overrides: []Override{
|
||||||
|
{
|
||||||
|
Group: "group",
|
||||||
|
Config: Config{WebhookURL: "http://group-example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputGroup: "group",
|
||||||
|
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
|
||||||
|
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, scenario := range scenarios {
|
||||||
t.Run(tt.Name, func(t *testing.T) {
|
t.Run(scenario.Name, func(t *testing.T) {
|
||||||
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
|
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||||
t.Errorf("AlertProvider.getWebhookURLForGroup() = %v, want %v", got, tt.ExpectedOutput)
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
|
||||||
|
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
|
||||||
|
}
|
||||||
|
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||||
|
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||||
|
t.Errorf("unexpected error: %s", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
220
alerting/provider/splunk/splunk.go
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
package splunk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrHecURLNotSet = errors.New("hec-url not set")
|
||||||
|
ErrHecTokenNotSet = errors.New("hec-token not set")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
HecURL string `yaml:"hec-url"` // Splunk HEC (HTTP Event Collector) URL
|
||||||
|
HecToken string `yaml:"hec-token"` // Splunk HEC token
|
||||||
|
Source string `yaml:"source,omitempty"` // Event source
|
||||||
|
SourceType string `yaml:"sourcetype,omitempty"` // Event source type
|
||||||
|
Index string `yaml:"index,omitempty"` // Splunk index
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.HecURL) == 0 {
|
||||||
|
return ErrHecURLNotSet
|
||||||
|
}
|
||||||
|
if len(cfg.HecToken) == 0 {
|
||||||
|
return ErrHecTokenNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.HecURL) > 0 {
|
||||||
|
cfg.HecURL = override.HecURL
|
||||||
|
}
|
||||||
|
if len(override.HecToken) > 0 {
|
||||||
|
cfg.HecToken = override.HecToken
|
||||||
|
}
|
||||||
|
if len(override.Source) > 0 {
|
||||||
|
cfg.Source = override.Source
|
||||||
|
}
|
||||||
|
if len(override.SourceType) > 0 {
|
||||||
|
cfg.SourceType = override.SourceType
|
||||||
|
}
|
||||||
|
if len(override.Index) > 0 {
|
||||||
|
cfg.Index = override.Index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using Splunk
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
|
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||||
|
Overrides []Override `yaml:"overrides,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override is a case under which the default integration is overridden
|
||||||
|
type Override struct {
|
||||||
|
Group string `yaml:"group"`
|
||||||
|
Config `yaml:",inline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the provider's configuration
|
||||||
|
func (provider *AlertProvider) Validate() error {
|
||||||
|
registeredGroups := make(map[string]bool)
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||||
|
return ErrDuplicateGroupOverride
|
||||||
|
}
|
||||||
|
registeredGroups[override.Group] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return provider.DefaultConfig.Validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send an alert using the provider
|
||||||
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body, err := provider.buildRequestBody(cfg, ep, alert, result, resolved)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(body)
|
||||||
|
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/services/collector/event", cfg.HecURL), buffer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set("Authorization", fmt.Sprintf("Splunk %s", cfg.HecToken))
|
||||||
|
response, err := client.GetHTTPClient(nil).Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode >= 400 {
|
||||||
|
body, _ := io.ReadAll(response.Body)
|
||||||
|
return fmt.Errorf("call to splunk alert returned status code %d: %s", response.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Body struct {
|
||||||
|
Time int64 `json:"time"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
SourceType string `json:"sourcetype,omitempty"`
|
||||||
|
Index string `json:"index,omitempty"`
|
||||||
|
Event Event `json:"event"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Event struct {
|
||||||
|
AlertType string `json:"alert_type"`
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
Group string `json:"group,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Conditions []*endpoint.ConditionResult `json:"conditions,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestBody builds the request body for the provider
|
||||||
|
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
|
||||||
|
var alertType, status, message string
|
||||||
|
if resolved {
|
||||||
|
alertType = "resolved"
|
||||||
|
status = "ok"
|
||||||
|
message = fmt.Sprintf("Alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||||
|
} else {
|
||||||
|
alertType = "triggered"
|
||||||
|
status = "critical"
|
||||||
|
message = fmt.Sprintf("Alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||||
|
}
|
||||||
|
event := Event{
|
||||||
|
AlertType: alertType,
|
||||||
|
Endpoint: ep.DisplayName(),
|
||||||
|
Group: ep.Group,
|
||||||
|
Status: status,
|
||||||
|
Message: message,
|
||||||
|
Description: alert.GetDescription(),
|
||||||
|
}
|
||||||
|
if len(result.ConditionResults) > 0 {
|
||||||
|
event.Conditions = result.ConditionResults
|
||||||
|
}
|
||||||
|
body := Body{
|
||||||
|
Time: time.Now().Unix(),
|
||||||
|
Event: event,
|
||||||
|
}
|
||||||
|
// Set optional fields
|
||||||
|
if cfg.Source != "" {
|
||||||
|
body.Source = cfg.Source
|
||||||
|
} else {
|
||||||
|
body.Source = "gatus"
|
||||||
|
}
|
||||||
|
if cfg.SourceType != "" {
|
||||||
|
body.SourceType = cfg.SourceType
|
||||||
|
} else {
|
||||||
|
body.SourceType = "gatus:alert"
|
||||||
|
}
|
||||||
|
if cfg.Index != "" {
|
||||||
|
body.Index = cfg.Index
|
||||||
|
}
|
||||||
|
bodyAsJSON, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return bodyAsJSON, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
|
return provider.DefaultAlert
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
155
alerting/provider/splunk/splunk_test.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package splunk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/TwiN/gatus/v5/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
provider AlertProvider
|
||||||
|
expected error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088", HecToken: "token123"}},
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid-with-index",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088", HecToken: "token123", Index: "main"}},
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-hec-url",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{HecToken: "token123"}},
|
||||||
|
expected: ErrHecURLNotSet,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-hec-token",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088"}},
|
||||||
|
expected: ErrHecTokenNotSet,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
|
err := scenario.provider.Validate()
|
||||||
|
if err != scenario.expected {
|
||||||
|
t.Errorf("expected %v, got %v", scenario.expected, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
|
defer client.InjectHTTPClient(nil)
|
||||||
|
firstDescription := "description-1"
|
||||||
|
secondDescription := "description-2"
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
provider AlertProvider
|
||||||
|
alert alert.Alert
|
||||||
|
resolved bool
|
||||||
|
mockRoundTripper test.MockRoundTripper
|
||||||
|
expectedError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "triggered",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088", HecToken: "token123"}},
|
||||||
|
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
resolved: false,
|
||||||
|
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
if r.URL.Path != "/services/collector/event" {
|
||||||
|
t.Errorf("expected path /services/collector/event, got %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.Header.Get("Authorization") != "Splunk token123" {
|
||||||
|
t.Errorf("expected Authorization header to be 'Splunk token123', got %s", r.Header.Get("Authorization"))
|
||||||
|
}
|
||||||
|
body := make(map[string]interface{})
|
||||||
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
if body["time"] == nil {
|
||||||
|
t.Error("expected 'time' field in request body")
|
||||||
|
}
|
||||||
|
event := body["event"].(map[string]interface{})
|
||||||
|
if event["alert_type"] != "triggered" {
|
||||||
|
t.Errorf("expected alert_type to be 'triggered', got %v", event["alert_type"])
|
||||||
|
}
|
||||||
|
if event["status"] != "critical" {
|
||||||
|
t.Errorf("expected status to be 'critical', got %v", event["status"])
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
expectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "resolved",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088", HecToken: "token123", Index: "main"}},
|
||||||
|
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
resolved: true,
|
||||||
|
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
body := make(map[string]interface{})
|
||||||
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
if body["index"] != "main" {
|
||||||
|
t.Errorf("expected index to be 'main', got %v", body["index"])
|
||||||
|
}
|
||||||
|
event := body["event"].(map[string]interface{})
|
||||||
|
if event["alert_type"] != "resolved" {
|
||||||
|
t.Errorf("expected alert_type to be 'resolved', got %v", event["alert_type"])
|
||||||
|
}
|
||||||
|
if event["status"] != "ok" {
|
||||||
|
t.Errorf("expected status to be 'ok', got %v", event["status"])
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
expectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "error-response",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088", HecToken: "token123"}},
|
||||||
|
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.StatusForbidden, 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_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")
|
||||||
|
}
|
||||||
|
}
|
||||||
190
alerting/provider/squadcast/squadcast.go
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
package squadcast
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrWebhookURLNotSet = errors.New("webhook-url not set")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
WebhookURL string `yaml:"webhook-url"` // Squadcast webhook URL
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.WebhookURL) == 0 {
|
||||||
|
return ErrWebhookURLNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if len(override.WebhookURL) > 0 {
|
||||||
|
cfg.WebhookURL = override.WebhookURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertProvider is the configuration necessary for sending an alert using Squadcast
|
||||||
|
type AlertProvider struct {
|
||||||
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
|
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||||
|
Overrides []Override `yaml:"overrides,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override is a case under which the default integration is overridden
|
||||||
|
type Override struct {
|
||||||
|
Group string `yaml:"group"`
|
||||||
|
Config `yaml:",inline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the provider's configuration
|
||||||
|
func (provider *AlertProvider) Validate() error {
|
||||||
|
registeredGroups := make(map[string]bool)
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||||
|
return ErrDuplicateGroupOverride
|
||||||
|
}
|
||||||
|
registeredGroups[override.Group] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return provider.DefaultConfig.Validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send an alert using the provider
|
||||||
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body, err := provider.buildRequestBody(ep, alert, result, resolved)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(body)
|
||||||
|
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
response, err := client.GetHTTPClient(nil).Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode >= 400 {
|
||||||
|
body, _ := io.ReadAll(response.Body)
|
||||||
|
return fmt.Errorf("call to squadcast alert returned status code %d: %s", response.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Body struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
EventID string `json:"event_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Tags map[string]string `json:"tags,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestBody builds the request body for the provider
|
||||||
|
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
|
||||||
|
var message, status string
|
||||||
|
eventID := fmt.Sprintf("gatus-%s", ep.Key())
|
||||||
|
if resolved {
|
||||||
|
message = fmt.Sprintf("RESOLVED: %s", ep.DisplayName())
|
||||||
|
status = "resolve"
|
||||||
|
} else {
|
||||||
|
message = fmt.Sprintf("ALERT: %s", ep.DisplayName())
|
||||||
|
status = "trigger"
|
||||||
|
}
|
||||||
|
description := fmt.Sprintf("Endpoint: %s\n", ep.DisplayName())
|
||||||
|
if resolved {
|
||||||
|
description += fmt.Sprintf("Alert has been resolved after passing successfully %d time(s) in a row\n", alert.SuccessThreshold)
|
||||||
|
} else {
|
||||||
|
description += fmt.Sprintf("Endpoint has failed %d time(s) in a row\n", alert.FailureThreshold)
|
||||||
|
}
|
||||||
|
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||||
|
description += fmt.Sprintf("\nDescription: %s", alertDescription)
|
||||||
|
}
|
||||||
|
if len(result.ConditionResults) > 0 {
|
||||||
|
description += "\n\nCondition Results:"
|
||||||
|
for _, conditionResult := range result.ConditionResults {
|
||||||
|
var status string
|
||||||
|
if conditionResult.Success {
|
||||||
|
status = "✅"
|
||||||
|
} else {
|
||||||
|
status = "❌"
|
||||||
|
}
|
||||||
|
description += fmt.Sprintf("\n%s %s", status, conditionResult.Condition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body := Body{
|
||||||
|
Message: message,
|
||||||
|
Description: description,
|
||||||
|
EventID: eventID,
|
||||||
|
Status: status,
|
||||||
|
Tags: map[string]string{
|
||||||
|
"endpoint": ep.Name,
|
||||||
|
"group": ep.Group,
|
||||||
|
"source": "gatus",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
bodyAsJSON, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return bodyAsJSON, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
|
return provider.DefaultAlert
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
141
alerting/provider/squadcast/squadcast_test.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package squadcast
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
|
"github.com/TwiN/gatus/v5/client"
|
||||||
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"github.com/TwiN/gatus/v5/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertProvider_Validate(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
provider AlertProvider
|
||||||
|
expected error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://api.squadcast.com/v3/incidents/api/abcd1234"}},
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-webhook-url",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{}},
|
||||||
|
expected: ErrWebhookURLNotSet,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.name, func(t *testing.T) {
|
||||||
|
err := scenario.provider.Validate()
|
||||||
|
if err != scenario.expected {
|
||||||
|
t.Errorf("expected %v, got %v", scenario.expected, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertProvider_Send(t *testing.T) {
|
||||||
|
defer client.InjectHTTPClient(nil)
|
||||||
|
firstDescription := "description-1"
|
||||||
|
secondDescription := "description-2"
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
provider AlertProvider
|
||||||
|
alert alert.Alert
|
||||||
|
resolved bool
|
||||||
|
mockRoundTripper test.MockRoundTripper
|
||||||
|
expectedError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "triggered",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://api.squadcast.com/v3/incidents/api/abcd1234"}},
|
||||||
|
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
resolved: false,
|
||||||
|
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
body := make(map[string]interface{})
|
||||||
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
if body["status"] != "trigger" {
|
||||||
|
t.Errorf("expected status to be 'trigger', got %v", body["status"])
|
||||||
|
}
|
||||||
|
if body["event_id"] == nil {
|
||||||
|
t.Error("expected 'event_id' field in request body")
|
||||||
|
}
|
||||||
|
message := body["message"].(string)
|
||||||
|
if !strings.Contains(message, "ALERT") {
|
||||||
|
t.Errorf("expected message to contain 'ALERT', got %s", message)
|
||||||
|
}
|
||||||
|
description := body["description"].(string)
|
||||||
|
if !strings.Contains(description, "failed 3 time(s)") {
|
||||||
|
t.Errorf("expected description to contain failure count, got %s", description)
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
expectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "resolved",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://api.squadcast.com/v3/incidents/api/abcd1234"}},
|
||||||
|
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||||
|
resolved: true,
|
||||||
|
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||||
|
body := make(map[string]interface{})
|
||||||
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
if body["status"] != "resolve" {
|
||||||
|
t.Errorf("expected status to be 'resolve', got %v", body["status"])
|
||||||
|
}
|
||||||
|
message := body["message"].(string)
|
||||||
|
if !strings.Contains(message, "RESOLVED") {
|
||||||
|
t.Errorf("expected message to contain 'RESOLVED', got %s", message)
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||||
|
}),
|
||||||
|
expectedError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "error-response",
|
||||||
|
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://api.squadcast.com/v3/incidents/api/abcd1234"}},
|
||||||
|
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.StatusUnauthorized, 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_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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package teams
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -10,51 +11,85 @@ import (
|
|||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
"github.com/TwiN/gatus/v5/client"
|
"github.com/TwiN/gatus/v5/client"
|
||||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrWebhookURLNotSet = errors.New("webhook-url not set")
|
||||||
|
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
WebhookURL string `yaml:"webhook-url"`
|
||||||
|
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
|
||||||
|
|
||||||
|
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
||||||
|
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Validate() error {
|
||||||
|
if len(cfg.WebhookURL) == 0 {
|
||||||
|
return ErrWebhookURLNotSet
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Merge(override *Config) {
|
||||||
|
if override.ClientConfig != nil {
|
||||||
|
cfg.ClientConfig = override.ClientConfig
|
||||||
|
}
|
||||||
|
if len(override.WebhookURL) > 0 {
|
||||||
|
cfg.WebhookURL = override.WebhookURL
|
||||||
|
}
|
||||||
|
if len(override.Title) > 0 {
|
||||||
|
cfg.Title = override.Title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// AlertProvider is the configuration necessary for sending an alert using Teams
|
// AlertProvider is the configuration necessary for sending an alert using Teams
|
||||||
type AlertProvider struct {
|
type AlertProvider struct {
|
||||||
WebhookURL string `yaml:"webhook-url"`
|
DefaultConfig Config `yaml:",inline"`
|
||||||
|
|
||||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||||
|
|
||||||
// Overrides is a list of Override that may be prioritized over the default configuration
|
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||||
Overrides []Override `yaml:"overrides,omitempty"`
|
Overrides []Override `yaml:"overrides,omitempty"`
|
||||||
|
|
||||||
// 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
|
// Override is a case under which the default integration is overridden
|
||||||
type Override struct {
|
type Override struct {
|
||||||
Group string `yaml:"group"`
|
Group string `yaml:"group"`
|
||||||
WebhookURL string `yaml:"webhook-url"`
|
Config `yaml:",inline"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValid returns whether the provider's configuration is valid
|
// Validate the provider's configuration
|
||||||
func (provider *AlertProvider) IsValid() bool {
|
func (provider *AlertProvider) Validate() error {
|
||||||
registeredGroups := make(map[string]bool)
|
registeredGroups := make(map[string]bool)
|
||||||
if provider.Overrides != nil {
|
if provider.Overrides != nil {
|
||||||
for _, override := range provider.Overrides {
|
for _, override := range provider.Overrides {
|
||||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
|
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||||
return false
|
return ErrDuplicateGroupOverride
|
||||||
}
|
}
|
||||||
registeredGroups[override.Group] = true
|
registeredGroups[override.Group] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return len(provider.WebhookURL) > 0
|
return provider.DefaultConfig.Validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send an alert using the provider
|
// Send an alert using the provider
|
||||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||||
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||||
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer)
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||||
|
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
request.Header.Set("Content-Type", "application/json")
|
request.Header.Set("Content-Type", "application/json")
|
||||||
response, err := client.GetHTTPClient(nil).Do(request)
|
response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -81,7 +116,7 @@ type Section struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildRequestBody builds the request body for the provider
|
// buildRequestBody builds the request body for the provider
|
||||||
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||||
var message, color string
|
var message, color string
|
||||||
if resolved {
|
if resolved {
|
||||||
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.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)
|
||||||
@@ -108,7 +143,7 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
|||||||
Type: "MessageCard",
|
Type: "MessageCard",
|
||||||
Context: "http://schema.org/extensions",
|
Context: "http://schema.org/extensions",
|
||||||
ThemeColor: color,
|
ThemeColor: color,
|
||||||
Title: provider.Title,
|
Title: cfg.Title,
|
||||||
Text: message + description,
|
Text: message + description,
|
||||||
}
|
}
|
||||||
if len(body.Title) == 0 {
|
if len(body.Title) == 0 {
|
||||||
@@ -124,19 +159,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
|||||||
return bodyAsJSON
|
return bodyAsJSON
|
||||||
}
|
}
|
||||||
|
|
||||||
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
|
|
||||||
func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
|
|
||||||
if provider.Overrides != nil {
|
|
||||||
for _, override := range provider.Overrides {
|
|
||||||
if group == override.Group {
|
|
||||||
return override.WebhookURL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return provider.WebhookURL
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDefaultAlert returns the provider's default alert configuration
|
// GetDefaultAlert returns the provider's default alert configuration
|
||||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||||
return provider.DefaultAlert
|
return provider.DefaultAlert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the configuration for the provider with the overrides applied
|
||||||
|
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||||
|
cfg := provider.DefaultConfig
|
||||||
|
// Handle group overrides
|
||||||
|
if provider.Overrides != nil {
|
||||||
|
for _, override := range provider.Overrides {
|
||||||
|
if group == override.Group {
|
||||||
|
cfg.Merge(&override.Config)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle alert overrides
|
||||||
|
if len(alert.ProviderOverride) != 0 {
|
||||||
|
overrideConfig := Config{}
|
||||||
|
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Merge(&overrideConfig)
|
||||||
|
}
|
||||||
|
// Validate the configuration
|
||||||
|
err := cfg.Validate()
|
||||||
|
return &cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||||
|
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||||
|
_, err := provider.GetConfig(group, alert)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||