Compare commits
125 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -1,5 +1,4 @@
|
||||
metrics: true
|
||||
debug: false
|
||||
ui:
|
||||
header: Example Company
|
||||
link: https://example.org
|
||||
|
||||
2
.github/codecov.yml
vendored
2
.github/codecov.yml
vendored
@@ -1,7 +1,7 @@
|
||||
ignore:
|
||||
- "watchdog/watchdog.go"
|
||||
- "storage/store/sql/specific_postgres.go" # Can't test for postgres
|
||||
|
||||
comment: false
|
||||
coverage:
|
||||
status:
|
||||
patch: off
|
||||
|
||||
2
.github/dependabot.yml
vendored
2
.github/dependabot.yml
vendored
@@ -7,7 +7,7 @@ updates:
|
||||
interval: "daily"
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
open-pull-requests-limit: 1
|
||||
open-pull-requests-limit: 3
|
||||
labels: ["dependencies"]
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
2
.github/workflows/benchmark.yml
vendored
2
.github/workflows/benchmark.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 1.22.2
|
||||
go-version: 1.24.1
|
||||
repository: "${{ github.event.inputs.repository || 'TwiN/gatus' }}"
|
||||
ref: "${{ github.event.inputs.ref || 'master' }}"
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
42
.github/workflows/labeler.yml
vendored
Normal file
42
.github/workflows/labeler.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
name: labeler
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
issues:
|
||||
types:
|
||||
- opened
|
||||
jobs:
|
||||
labeler:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Label
|
||||
continue-on-error: true
|
||||
env:
|
||||
TITLE: ${{ github.event.issue.title }}${{ github.event.pull_request.title }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
NUMBER: ${{ github.event.issue.number }}${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
if [[ $TITLE == "feat"* ]]; then
|
||||
gh issue edit "$NUMBER" --add-label "feature"
|
||||
elif [[ $TITLE == "fix"* ]]; then
|
||||
gh issue edit "$NUMBER" --add-label "bug"
|
||||
fi
|
||||
if [[ $TITLE == *"alerting"* || $TITLE == *"provider"* || $TITLE == *"alert"* ]]; then
|
||||
gh issue edit "$NUMBER" --add-label "area/alerting"
|
||||
fi
|
||||
if [[ $TITLE == *"storage"* || $TITLE == *"postgres"* || $TITLE == *"sqlite"* ]]; then
|
||||
gh issue edit "$NUMBER" --add-label "area/storage"
|
||||
fi
|
||||
if [[ $TITLE == *"security"* || $TITLE == *"oidc"* || $TITLE == *"oauth2"* ]]; then
|
||||
gh issue edit "$NUMBER" --add-label "area/security"
|
||||
fi
|
||||
if [[ $TITLE == *"metric"* || $TITLE == *"prometheus"* ]]; then
|
||||
gh issue edit "$NUMBER" --add-label "area/metrics"
|
||||
fi
|
||||
|
||||
40
.github/workflows/publish-custom.yml
vendored
Normal file
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: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Get image repository
|
||||
run: echo 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 }}
|
||||
10
.github/workflows/publish-experimental.yml
vendored
10
.github/workflows/publish-experimental.yml
vendored
@@ -17,10 +17,18 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE_REPOSITORY }}
|
||||
tags: |
|
||||
type=raw,value=experimental
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
platforms: linux/amd64
|
||||
pull: true
|
||||
push: true
|
||||
tags: ${{ env.IMAGE_REPOSITORY }}:experimental
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
38
.github/workflows/publish-latest-to-ghcr.yml
vendored
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
|
||||
24
.github/workflows/publish-latest.yml
vendored
24
.github/workflows/publish-latest.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
publish-latest:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ (github.event.workflow_run.conclusion == 'success') && (github.event.workflow_run.head_repository.full_name == github.repository) }}
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
@@ -19,16 +19,34 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Get image repository
|
||||
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
run: |
|
||||
echo DOCKER_IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
echo GHCR_IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
- name: Login to Docker Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.DOCKER_IMAGE_REPOSITORY }}
|
||||
${{ env.GHCR_IMAGE_REPOSITORY }}
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
tags: ${{ env.IMAGE_REPOSITORY }}:latest
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
37
.github/workflows/publish-release-to-ghcr.yml
vendored
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
|
||||
27
.github/workflows/publish-release.yml
vendored
27
.github/workflows/publish-release.yml
vendored
@@ -14,7 +14,9 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Get image repository
|
||||
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
run: |
|
||||
echo DOCKER_IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
echo GHCR_IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
- name: Get the release
|
||||
run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV
|
||||
- name: Login to Docker Registry
|
||||
@@ -22,13 +24,28 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.DOCKER_IMAGE_REPOSITORY }}
|
||||
${{ env.GHCR_IMAGE_REPOSITORY }}
|
||||
tags: |
|
||||
type=raw,value=${{ env.RELEASE }}
|
||||
type=raw,value=stable
|
||||
type=raw,value=latest
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE_REPOSITORY }}:${{ env.RELEASE }}
|
||||
${{ env.IMAGE_REPOSITORY }}:stable
|
||||
${{ env.IMAGE_REPOSITORY }}:latest
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
18
.github/workflows/test-ui.yml
vendored
Normal file
18
.github/workflows/test-ui.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
name: test-ui
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'web/**'
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- 'web/**'
|
||||
jobs:
|
||||
test-ui:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: make frontend-install-dependencies
|
||||
- run: make frontend-build
|
||||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 1.22.2
|
||||
go-version: 1.24.1
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build binary to make sure it works
|
||||
run: go build
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
# was configured by the "Set up Go" step (otherwise, it'd use sudo's "go" executable)
|
||||
run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test ./... -race -coverprofile=coverage.txt -covermode=atomic
|
||||
- name: Codecov
|
||||
uses: codecov/codecov-action@v4.6.0
|
||||
uses: codecov/codecov-action@v5.4.3
|
||||
with:
|
||||
files: ./coverage.txt
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -17,4 +17,5 @@ node_modules
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
gatus
|
||||
config/config.yml
|
||||
config/config.yml
|
||||
config.yaml
|
||||
@@ -1,10 +1,11 @@
|
||||
# Build the go application into a binary
|
||||
FROM golang:alpine as builder
|
||||
RUN apk --update add ca-certificates
|
||||
FROM golang:alpine AS builder
|
||||
RUN apk --update add ca-certificates libcap-setcap
|
||||
WORKDIR /app
|
||||
COPY . ./
|
||||
RUN go mod tidy
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o gatus .
|
||||
RUN setcap CAP_NET_RAW+ep gatus
|
||||
|
||||
# Run Tests inside docker image if you don't have a configured go environment
|
||||
#RUN apk update && apk add --virtual build-dependencies build-base gcc
|
||||
@@ -15,6 +16,8 @@ FROM scratch
|
||||
COPY --from=builder /app/gatus .
|
||||
COPY --from=builder /app/config.yaml ./config/config.yaml
|
||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
ENV PORT=8080
|
||||
ENV GATUS_CONFIG_PATH=""
|
||||
ENV GATUS_LOG_LEVEL="INFO"
|
||||
ENV PORT="8080"
|
||||
EXPOSE ${PORT}
|
||||
ENTRYPOINT ["/gatus"]
|
||||
|
||||
9
Makefile
9
Makefile
@@ -6,7 +6,11 @@ install:
|
||||
|
||||
.PHONY: 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
|
||||
clean:
|
||||
@@ -34,6 +38,9 @@ docker-build-and-run: docker-build docker-run
|
||||
# Front end #
|
||||
#############
|
||||
|
||||
frontend-install-dependencies:
|
||||
npm --prefix web/app install
|
||||
|
||||
frontend-build:
|
||||
npm --prefix web/app run build
|
||||
|
||||
|
||||
614
README.md
614
README.md
@@ -1,6 +1,6 @@
|
||||
[](https://gatus.io)
|
||||
|
||||

|
||||

|
||||
[](https://goreportcard.com/report/github.com/TwiN/gatus)
|
||||
[](https://codecov.io/gh/TwiN/gatus)
|
||||
[](https://github.com/TwiN/gatus)
|
||||
@@ -21,11 +21,11 @@ _Looking for a managed solution? Check out [Gatus.io](https://gatus.io)._
|
||||
<summary><b>Quick start</b></summary>
|
||||
|
||||
```console
|
||||
docker run -p 8080:8080 --name gatus twinproduction/gatus
|
||||
docker run -p 8080:8080 --name gatus twinproduction/gatus:stable
|
||||
```
|
||||
You can also use GitHub Container Registry if you prefer:
|
||||
```console
|
||||
docker run -p 8080:8080 --name gatus ghcr.io/twin/gatus
|
||||
docker run -p 8080:8080 --name gatus ghcr.io/twin/gatus:stable
|
||||
```
|
||||
For more details, see [Usage](#usage)
|
||||
</details>
|
||||
@@ -51,6 +51,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
|
||||
- [Storage](#storage)
|
||||
- [Client configuration](#client-configuration)
|
||||
- [Alerting](#alerting)
|
||||
- [Configuring AWS SES alerts](#configuring-aws-ses-alerts)
|
||||
- [Configuring Discord alerts](#configuring-discord-alerts)
|
||||
- [Configuring Email alerts](#configuring-email-alerts)
|
||||
- [Configuring Gitea alerts](#configuring-gitea-alerts)
|
||||
@@ -58,6 +59,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
|
||||
- [Configuring GitLab alerts](#configuring-gitlab-alerts)
|
||||
- [Configuring Google Chat alerts](#configuring-google-chat-alerts)
|
||||
- [Configuring Gotify alerts](#configuring-gotify-alerts)
|
||||
- [Configuring Incident.io alerts](#configuring-incidentio-alerts)
|
||||
- [Configuring JetBrains Space alerts](#configuring-jetbrains-space-alerts)
|
||||
- [Configuring Matrix alerts](#configuring-matrix-alerts)
|
||||
- [Configuring Mattermost alerts](#configuring-mattermost-alerts)
|
||||
@@ -71,9 +73,8 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
|
||||
- [Configuring Teams Workflow alerts](#configuring-teams-workflow-alerts)
|
||||
- [Configuring Telegram alerts](#configuring-telegram-alerts)
|
||||
- [Configuring Twilio alerts](#configuring-twilio-alerts)
|
||||
- [Configuring AWS SES alerts](#configuring-aws-ses-alerts)
|
||||
- [Configuring custom alerts](#configuring-custom-alerts)
|
||||
- [Configuring Zulip alerts](#configuring-zulip-alerts)
|
||||
- [Configuring custom alerts](#configuring-custom-alerts)
|
||||
- [Setting a default alert](#setting-a-default-alert)
|
||||
- [Maintenance](#maintenance)
|
||||
- [Security](#security)
|
||||
@@ -117,8 +118,12 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
|
||||
- [Health](#health)
|
||||
- [Health (Shields.io)](#health-shieldsio)
|
||||
- [Response time](#response-time)
|
||||
- [Response time (chart)](#response-time-chart)
|
||||
- [How to change the color thresholds of the response time badge](#how-to-change-the-color-thresholds-of-the-response-time-badge)
|
||||
- [API](#api)
|
||||
- [Raw Data](#raw-data)
|
||||
- [Uptime](#uptime-1)
|
||||
- [Response Time](#response-time-1)
|
||||
- [Installing as binary](#installing-as-binary)
|
||||
- [High level design overview](#high-level-design-overview)
|
||||
|
||||
@@ -202,7 +207,7 @@ If `GATUS_CONFIG_PATH` points to a directory, all `*.yaml` and `*.yml` files ins
|
||||
subdirectories are merged like so:
|
||||
- All maps/objects are deep merged (i.e. you could define `alerting.slack` in one file and `alerting.pagerduty` in another file)
|
||||
- All slices/arrays are appended (i.e. you can define `endpoints` in multiple files and each endpoint will be added to the final list of endpoints)
|
||||
- Parameters with a primitive value (e.g. `debug`, `metrics`, `alerting.slack.webhook-url`, etc.) may only be defined once to forcefully avoid any ambiguity
|
||||
- Parameters with a primitive value (e.g. `metrics`, `alerting.slack.webhook-url`, etc.) may only be defined once to forcefully avoid any ambiguity
|
||||
- To clarify, this also means that you could not define `alerting.slack.webhook-url` in two files with different values. All files are merged into one before they are processed. This is by design.
|
||||
|
||||
> 💡 You can also use environment variables in the configuration file (e.g. `$DOMAIN`, `${DOMAIN}`)
|
||||
@@ -215,7 +220,6 @@ If you want to test it locally, see [Docker](#docker).
|
||||
## Configuration
|
||||
| Parameter | Description | Default |
|
||||
|:-----------------------------|:-------------------------------------------------------------------------------------------------------------------------------------|:---------------------------|
|
||||
| `debug` | Whether to enable debug logs. | `false` |
|
||||
| `metrics` | Whether to expose metrics at `/metrics`. | `false` |
|
||||
| `storage` | [Storage configuration](#storage). | `{}` |
|
||||
| `alerting` | [Alerting configuration](#alerting). | `{}` |
|
||||
@@ -239,8 +243,13 @@ If you want to test it locally, see [Docker](#docker).
|
||||
| `ui.buttons` | List of buttons to display below the header. | `[]` |
|
||||
| `ui.buttons[].name` | Text to display on the button. | Required `""` |
|
||||
| `ui.buttons[].link` | Link to open when the button is clicked. | Required `""` |
|
||||
| `ui.custom-css` | Custom CSS | `""` |
|
||||
| `ui.dark-mode` | Whether to enable dark mode by default. Note that this is superseded by the user's operating system theme preferences. | `true` |
|
||||
| `maintenance` | [Maintenance configuration](#maintenance). | `{}` |
|
||||
|
||||
If you want more verbose logging, you may set the `GATUS_LOG_LEVEL` environment variable to `DEBUG`.
|
||||
Conversely, if you want less verbose logging, you can set the aforementioned environment variable to `WARN`, `ERROR` or `FATAL`.
|
||||
The default value for `GATUS_LOG_LEVEL` is `INFO`.
|
||||
|
||||
### Endpoints
|
||||
Endpoints are URLs, applications, or services that you want to monitor. Each endpoint has a list of conditions that are
|
||||
@@ -267,13 +276,15 @@ You can then configure alerts to be triggered when an endpoint is unhealthy once
|
||||
| `endpoints[].ssh.username` | SSH username (e.g. example). | Required `""` |
|
||||
| `endpoints[].ssh.password` | SSH password (e.g. password). | Required `""` |
|
||||
| `endpoints[].alerts` | List of all alerts for a given endpoint. <br />See [Alerting](#alerting). | `[]` |
|
||||
| `endpoints[].maintenance-windows` | List of all maintenance windows for a given endpoint. <br />See [Maintenance](#maintenance). | `[]` |
|
||||
| `endpoints[].client` | [Client configuration](#client-configuration). | `{}` |
|
||||
| `endpoints[].ui` | UI configuration at the endpoint level. | `{}` |
|
||||
| `endpoints[].ui.hide-conditions` | Whether to hide conditions from the results. Note that this only hides conditions from results evaluated from the moment this was enabled. | `false` |
|
||||
| `endpoints[].ui.hide-hostname` | Whether to hide the hostname in the result. | `false` |
|
||||
| `endpoints[].ui.hide-url` | Whether to ensure the URL is not displayed in the results. Useful if the URL contains a token. | `false` |
|
||||
| `endpoints[].ui.hide-hostname` | Whether to hide the hostname from the results. | `false` |
|
||||
| `endpoints[].ui.hide-port` | Whether to hide the port from the results. | `false` |
|
||||
| `endpoints[].ui.hide-url` | Whether to hide the URL from the results. Useful if the URL contains a token. | `false` |
|
||||
| `endpoints[].ui.dont-resolve-failed-conditions` | Whether to resolve failed conditions for the UI. | `false` |
|
||||
| `endpoints[].ui.badge.reponse-time` | List of response time thresholds. Each time a threshold is reached, the badge has a different color. | `[50, 200, 300, 500, 750]` |
|
||||
| `endpoints[].ui.badge.response-time` | List of response time thresholds. Each time a threshold is reached, the badge has a different color. | `[50, 200, 300, 500, 750]` |
|
||||
|
||||
|
||||
### External Endpoints
|
||||
@@ -311,7 +322,7 @@ To push the status of an external endpoint, the request would have to look like
|
||||
POST /api/v1/endpoints/{key}/external?success={success}&error={error}
|
||||
```
|
||||
Where:
|
||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`.
|
||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
|
||||
- Using the example configuration above, the key would be `core_ext-ep-test`.
|
||||
- `{success}` is a boolean (`true` or `false`) value indicating whether the health check was successful or not.
|
||||
- `{error}`: a string describing the reason for a failed health check. If {success} is false, this should contain the error message; if the check is successful, it can be omitted or left empty.
|
||||
@@ -371,12 +382,14 @@ Here are some examples of conditions you can use:
|
||||
|
||||
|
||||
### Storage
|
||||
| Parameter | Description | Default |
|
||||
|:------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------|:-----------|
|
||||
| `storage` | Storage configuration | `{}` |
|
||||
| `storage.path` | Path to persist the data in. Only supported for types `sqlite` and `postgres`. | `""` |
|
||||
| `storage.type` | Type of storage. Valid types: `memory`, `sqlite`, `postgres`. | `"memory"` |
|
||||
| `storage.caching` | Whether to use write-through caching. Improves loading time for large dashboards. <br />Only supported if `storage.type` is `sqlite` or `postgres` | `false` |
|
||||
| Parameter | Description | Default |
|
||||
|:------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------|:-----------|
|
||||
| `storage` | Storage configuration | `{}` |
|
||||
| `storage.path` | Path to persist the data in. Only supported for types `sqlite` and `postgres`. | `""` |
|
||||
| `storage.type` | Type of storage. Valid types: `memory`, `sqlite`, `postgres`. | `"memory"` |
|
||||
| `storage.caching` | Whether to use write-through caching. Improves loading time for large dashboards. <br />Only supported if `storage.type` is `sqlite` or `postgres` | `false` |
|
||||
| `storage.maximum-number-of-results` | The maximum number of results that an endpoint can have | `100` |
|
||||
| `storage.maximum-number-of-events` | The maximum number of events that an endpoint can have | `50` |
|
||||
|
||||
The results for each endpoint health check as well as the data for uptime and the past events must be persisted
|
||||
so that they can be displayed on the dashboard. These parameters allow you to configure the storage in question.
|
||||
@@ -387,6 +400,8 @@ so that they can be displayed on the dashboard. These parameters allow you to co
|
||||
# Because the data is stored in memory, the data will not survive a restart.
|
||||
storage:
|
||||
type: memory
|
||||
maximum-number-of-results: 200
|
||||
maximum-number-of-events: 5
|
||||
```
|
||||
- If `storage.type` is `sqlite`, `storage.path` must not be blank:
|
||||
```yaml
|
||||
@@ -500,7 +515,7 @@ endpoints:
|
||||
|
||||
> 📝 Note that Gatus will use the [gcloud default credentials](https://cloud.google.com/docs/authentication/application-default-credentials) within its environment to generate the token.
|
||||
|
||||
This example shows you how you cna use the `client.tls` configuration to perform an mTLS query to a backend API:
|
||||
This example shows you how you can use the `client.tls` configuration to perform an mTLS query to a backend API:
|
||||
|
||||
```yaml
|
||||
endpoints:
|
||||
@@ -532,6 +547,7 @@ Alerts are configured at the endpoint level like so:
|
||||
| `alerts[].success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved. | `2` |
|
||||
| `alerts[].send-on-resolved` | Whether to send a notification once a triggered alert is marked as resolved. | `false` |
|
||||
| `alerts[].description` | Description of the alert. Will be included in the alert sent. | `""` |
|
||||
| `alerts[].provider-override` | Alerting provider configuration override for the given alert type | `{}` |
|
||||
|
||||
Here's an example of what an alert configuration might look like at the endpoint level:
|
||||
```yaml
|
||||
@@ -546,43 +562,100 @@ endpoints:
|
||||
send-on-resolved: true
|
||||
```
|
||||
|
||||
You can also override global provider configuration by using `alerts[].provider-override`, like so:
|
||||
```yaml
|
||||
endpoints:
|
||||
- name: example
|
||||
url: "https://example.org"
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
alerts:
|
||||
- type: slack
|
||||
provider-override:
|
||||
webhook-url: "https://hooks.slack.com/services/**********/**********/**********"
|
||||
```
|
||||
|
||||
> 📝 If an alerting provider is not properly configured, all alerts configured with the provider's type will be
|
||||
> ignored.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|:--------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------|:--------|
|
||||
| `alerting.custom` | Configuration for custom actions on failure or alerts. <br />See [Configuring Custom alerts](#configuring-custom-alerts). | `{}` |
|
||||
| `alerting.discord` | Configuration for alerts of type `discord`. <br />See [Configuring Discord alerts](#configuring-discord-alerts). | `{}` |
|
||||
| `alerting.email` | Configuration for alerts of type `email`. <br />See [Configuring Email alerts](#configuring-email-alerts). | `{}` |
|
||||
| `alerting.github` | Configuration for alerts of type `github`. <br />See [Configuring GitHub alerts](#configuring-github-alerts). | `{}` |
|
||||
| `alerting.gitlab` | Configuration for alerts of type `gitlab`. <br />See [Configuring GitLab alerts](#configuring-gitlab-alerts). | `{}` |
|
||||
| `alerting.googlechat` | Configuration for alerts of type `googlechat`. <br />See [Configuring Google Chat alerts](#configuring-google-chat-alerts). | `{}` |
|
||||
| `alerting.gotify` | Configuration for alerts of type `gotify`. <br />See [Configuring Gotify alerts](#configuring-gotify-alerts). | `{}` |
|
||||
| `alerting.jetbrainsspace` | Configuration for alerts of type `jetbrainsspace`. <br />See [Configuring JetBrains Space alerts](#configuring-jetbrains-space-alerts). | `{}` |
|
||||
| `alerting.matrix` | Configuration for alerts of type `matrix`. <br />See [Configuring Matrix alerts](#configuring-matrix-alerts). | `{}` |
|
||||
| `alerting.mattermost` | Configuration for alerts of type `mattermost`. <br />See [Configuring Mattermost alerts](#configuring-mattermost-alerts). | `{}` |
|
||||
| `alerting.messagebird` | Configuration for alerts of type `messagebird`. <br />See [Configuring Messagebird alerts](#configuring-messagebird-alerts). | `{}` |
|
||||
| `alerting.ntfy` | Configuration for alerts of type `ntfy`. <br />See [Configuring Ntfy alerts](#configuring-ntfy-alerts). | `{}` |
|
||||
| `alerting.opsgenie` | Configuration for alerts of type `opsgenie`. <br />See [Configuring Opsgenie alerts](#configuring-opsgenie-alerts). | `{}` |
|
||||
| `alerting.pagerduty` | Configuration for alerts of type `pagerduty`. <br />See [Configuring PagerDuty alerts](#configuring-pagerduty-alerts). | `{}` |
|
||||
| `alerting.pushover` | Configuration for alerts of type `pushover`. <br />See [Configuring Pushover alerts](#configuring-pushover-alerts). | `{}` |
|
||||
| `alerting.slack` | Configuration for alerts of type `slack`. <br />See [Configuring Slack alerts](#configuring-slack-alerts). | `{}` |
|
||||
| `alerting.teams` | Configuration for alerts of type `teams`. *(Deprecated)* <br />See [Configuring Teams alerts](#configuring-teams-alerts-deprecated). | `{}` |
|
||||
| `alerting.teams-workflows` | Configuration for alerts of type `teams-workflows`. <br />See [Configuring Teams Workflow alerts](#configuring-teams-workflow-alerts). | `{}` |
|
||||
| `alerting.telegram` | Configuration for alerts of type `telegram`. <br />See [Configuring Telegram alerts](#configuring-telegram-alerts). | `{}` |
|
||||
| `alerting.twilio` | Settings for alerts of type `twilio`. <br />See [Configuring Twilio alerts](#configuring-twilio-alerts). | `{}` |
|
||||
| Parameter | Description | Default |
|
||||
|:---------------------------|:----------------------------------------------------------------------------------------------------------------------------------------|:--------|
|
||||
| `alerting.awsses` | Configuration for alerts of type `awsses`. <br />See [Configuring AWS SES alerts](#configuring-aws-ses-alerts). | `{}` |
|
||||
| `alerting.custom` | Configuration for custom actions on failure or alerts. <br />See [Configuring Custom alerts](#configuring-custom-alerts). | `{}` |
|
||||
| `alerting.discord` | Configuration for alerts of type `discord`. <br />See [Configuring Discord alerts](#configuring-discord-alerts). | `{}` |
|
||||
| `alerting.email` | Configuration for alerts of type `email`. <br />See [Configuring Email alerts](#configuring-email-alerts). | `{}` |
|
||||
| `alerting.gitea` | Configuration for alerts of type `gitea`. <br />See [Configuring Gitea alerts](#configuring-gitea-alerts). | `{}` |
|
||||
| `alerting.github` | Configuration for alerts of type `github`. <br />See [Configuring GitHub alerts](#configuring-github-alerts). | `{}` |
|
||||
| `alerting.gitlab` | Configuration for alerts of type `gitlab`. <br />See [Configuring GitLab alerts](#configuring-gitlab-alerts). | `{}` |
|
||||
| `alerting.googlechat` | Configuration for alerts of type `googlechat`. <br />See [Configuring Google Chat alerts](#configuring-google-chat-alerts). | `{}` |
|
||||
| `alerting.gotify` | Configuration for alerts of type `gotify`. <br />See [Configuring Gotify alerts](#configuring-gotify-alerts). | `{}` |
|
||||
| `alerting.incident-io` | Configuration for alerts of type `incident-io`. <br />See [Configuring Incident.io alerts](#configuring-incidentio-alerts). | `{}` |
|
||||
| `alerting.jetbrainsspace` | Configuration for alerts of type `jetbrainsspace`. <br />See [Configuring JetBrains Space alerts](#configuring-jetbrains-space-alerts). | `{}` |
|
||||
| `alerting.matrix` | Configuration for alerts of type `matrix`. <br />See [Configuring Matrix alerts](#configuring-matrix-alerts). | `{}` |
|
||||
| `alerting.mattermost` | Configuration for alerts of type `mattermost`. <br />See [Configuring Mattermost alerts](#configuring-mattermost-alerts). | `{}` |
|
||||
| `alerting.messagebird` | Configuration for alerts of type `messagebird`. <br />See [Configuring Messagebird alerts](#configuring-messagebird-alerts). | `{}` |
|
||||
| `alerting.ntfy` | Configuration for alerts of type `ntfy`. <br />See [Configuring Ntfy alerts](#configuring-ntfy-alerts). | `{}` |
|
||||
| `alerting.opsgenie` | Configuration for alerts of type `opsgenie`. <br />See [Configuring Opsgenie alerts](#configuring-opsgenie-alerts). | `{}` |
|
||||
| `alerting.pagerduty` | Configuration for alerts of type `pagerduty`. <br />See [Configuring PagerDuty alerts](#configuring-pagerduty-alerts). | `{}` |
|
||||
| `alerting.pushover` | Configuration for alerts of type `pushover`. <br />See [Configuring Pushover alerts](#configuring-pushover-alerts). | `{}` |
|
||||
| `alerting.slack` | Configuration for alerts of type `slack`. <br />See [Configuring Slack alerts](#configuring-slack-alerts). | `{}` |
|
||||
| `alerting.teams` | Configuration for alerts of type `teams`. *(Deprecated)* <br />See [Configuring Teams alerts](#configuring-teams-alerts-deprecated). | `{}` |
|
||||
| `alerting.teams-workflows` | Configuration for alerts of type `teams-workflows`. <br />See [Configuring Teams Workflow alerts](#configuring-teams-workflow-alerts). | `{}` |
|
||||
| `alerting.telegram` | Configuration for alerts of type `telegram`. <br />See [Configuring Telegram alerts](#configuring-telegram-alerts). | `{}` |
|
||||
| `alerting.twilio` | Settings for alerts of type `twilio`. <br />See [Configuring Twilio alerts](#configuring-twilio-alerts). | `{}` |
|
||||
| `alerting.zulip` | Configuration for alerts of type `zulip`. <br />See [Configuring Zulip alerts](#configuring-zulip-alerts). | `{}` |
|
||||
|
||||
|
||||
#### Configuring AWS SES alerts
|
||||
| Parameter | Description | Default |
|
||||
|:-------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
|
||||
| `alerting.aws-ses` | Settings for alerts of type `aws-ses` | `{}` |
|
||||
| `alerting.aws-ses.access-key-id` | AWS Access Key ID | Optional `""` |
|
||||
| `alerting.aws-ses.secret-access-key` | AWS Secret Access Key | Optional `""` |
|
||||
| `alerting.aws-ses.region` | AWS Region | Required `""` |
|
||||
| `alerting.aws-ses.from` | The Email address to send the emails from (should be registered in SES) | Required `""` |
|
||||
| `alerting.aws-ses.to` | Comma separated list of email address to notify | Required `""` |
|
||||
| `alerting.aws-ses.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
aws-ses:
|
||||
access-key-id: "..."
|
||||
secret-access-key: "..."
|
||||
region: "us-east-1"
|
||||
from: "status@example.com"
|
||||
to: "user@example.com"
|
||||
|
||||
endpoints:
|
||||
- name: website
|
||||
interval: 30s
|
||||
url: "https://twin.sh/health"
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
- "[RESPONSE_TIME] < 300"
|
||||
alerts:
|
||||
- type: aws-ses
|
||||
failure-threshold: 5
|
||||
send-on-resolved: true
|
||||
description: "healthcheck failed"
|
||||
```
|
||||
|
||||
If the `access-key-id` and `secret-access-key` are not defined Gatus will fall back to IAM authentication.
|
||||
|
||||
Make sure you have the ability to use `ses:SendEmail`.
|
||||
|
||||
|
||||
#### Configuring Discord alerts
|
||||
| Parameter | Description | Default |
|
||||
|:-------------------------------------------|:-------------------------------------------------------------------------------------------|:------------------------------------|
|
||||
| `alerting.discord` | Configuration for alerts of type `discord` | `{}` |
|
||||
| `alerting.discord.webhook-url` | Discord Webhook URL | Required `""` |
|
||||
| `alerting.discord.title` | Title of the notification | `":helmet_with_white_cross: Gatus"` |
|
||||
| `alerting.discord.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| `alerting.discord.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||
| `alerting.discord.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| `alerting.discord.overrides[].webhook-url` | Discord Webhook URL | `""` |
|
||||
| Parameter | Description | Default |
|
||||
|:-------------------------------------|:-------------------------------------------------------------------------------------------|:------------------------------------|
|
||||
| `alerting.discord` | Configuration for alerts of type `discord` | `{}` |
|
||||
| `alerting.discord.webhook-url` | Discord Webhook URL | Required `""` |
|
||||
| `alerting.discord.title` | Title of the notification | `":helmet_with_white_cross: Gatus"` |
|
||||
| `alerting.discord.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| `alerting.discord.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||
| `alerting.discord.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| `alerting.discord.overrides[].*` | See `alerting.discord.*` parameters | `{}` |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
@@ -618,7 +691,7 @@ endpoints:
|
||||
| `alerting.email.client.insecure` | Whether to skip TLS verification | `false` |
|
||||
| `alerting.email.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||
| `alerting.email.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| `alerting.email.overrides[].to` | Email(s) to send the alerts to | `""` |
|
||||
| `alerting.email.overrides[].*` | See `alerting.email.*` parameters | `{}` |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
@@ -667,12 +740,12 @@ endpoints:
|
||||
|
||||
#### Configuring Gitea alerts
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|:---------------------------------|:-----------------------------------------------------------------------------------------------------------|:--------------|
|
||||
| `alerting.gitea` | Configuration for alerts of type `gitea` | `{}` |
|
||||
| `alerting.gitea.repository-url` | Gitea repository URL (e.g. `https://gitea.com/TwiN/example`) | Required `""` |
|
||||
| Parameter | Description | Default |
|
||||
|:--------------------------------|:-----------------------------------------------------------------------------------------------------------|:--------------|
|
||||
| `alerting.gitea` | Configuration for alerts of type `gitea` | `{}` |
|
||||
| `alerting.gitea.repository-url` | Gitea repository URL (e.g. `https://gitea.com/TwiN/example`) | Required `""` |
|
||||
| `alerting.gitea.token` | Personal access token to use for authentication. <br />Must have at least RW on issues and RO on metadata. | Required `""` |
|
||||
| `alerting.github.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A |
|
||||
| `alerting.github.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A |
|
||||
|
||||
The Gitea alerting provider creates an issue prefixed with `alert(gatus):` and suffixed with the endpoint's display
|
||||
name for each alert. If `send-on-resolved` is set to `true` on the endpoint alert, the issue will be automatically
|
||||
@@ -782,15 +855,15 @@ endpoints:
|
||||
|
||||
|
||||
#### Configuring Google Chat alerts
|
||||
| Parameter | Description | Default |
|
||||
|:----------------------------------------------|:--------------------------------------------------------------------------------------------|:--------------|
|
||||
| `alerting.googlechat` | Configuration for alerts of type `googlechat` | `{}` |
|
||||
| `alerting.googlechat.webhook-url` | Google Chat Webhook URL | Required `""` |
|
||||
| `alerting.googlechat.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
|
||||
| `alerting.googlechat.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A |
|
||||
| `alerting.googlechat.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||
| `alerting.googlechat.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| `alerting.googlechat.overrides[].webhook-url` | Google Chat Webhook URL | `""` |
|
||||
| Parameter | Description | Default |
|
||||
|:----------------------------------------|:--------------------------------------------------------------------------------------------|:--------------|
|
||||
| `alerting.googlechat` | Configuration for alerts of type `googlechat` | `{}` |
|
||||
| `alerting.googlechat.webhook-url` | Google Chat Webhook URL | Required `""` |
|
||||
| `alerting.googlechat.client` | Client configuration. <br />See [Client configuration](#client-configuration). | `{}` |
|
||||
| `alerting.googlechat.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A |
|
||||
| `alerting.googlechat.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||
| `alerting.googlechat.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| `alerting.googlechat.overrides[].*` | See `alerting.googlechat.*` parameters | `{}` |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
@@ -847,16 +920,53 @@ Here's an example of what the notifications look like:
|
||||

|
||||
|
||||
|
||||
#### Configuring Incident.io alerts
|
||||
| Parameter | Description | Default |
|
||||
|:-----------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
|
||||
| `alerting.incident-io` | Configuration for alerts of type `incident-io` | `{}` |
|
||||
| `alerting.incident-io.url` | url to trigger an alert event. | Required `""` |
|
||||
| `alerting.incident-io.auth-token` | Token that is used for authentication. | Required `""` |
|
||||
| `alerting.incident-io.source-url` | Source URL | `""` |
|
||||
| `alerting.incident-io.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| `alerting.incident-io.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||
| `alerting.incident-io.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| `alerting.incident-io.overrides[].*` | See `alerting.incident-io.*` parameters | `{}` |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
incident-io:
|
||||
url: "*****************"
|
||||
auth-token: "********************************************"
|
||||
|
||||
endpoints:
|
||||
- name: website
|
||||
url: "https://twin.sh/health"
|
||||
interval: 30s
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
- "[RESPONSE_TIME] < 300"
|
||||
alerts:
|
||||
- type: incident-io
|
||||
description: "healthcheck failed"
|
||||
send-on-resolved: true
|
||||
```
|
||||
In order to get the required alert source config id and authentication token, you must configure an HTTP alert source.
|
||||
|
||||
> **_NOTE:_** the source config id is of the form `https://api.incident.io/v2/alert_events/http/$ID` and the token is expected to be passed as a bearer token like so: `Authorization: Bearer $TOKEN`
|
||||
|
||||
|
||||
#### Configuring JetBrains Space alerts
|
||||
| Parameter | Description | Default |
|
||||
|:---------------------------------------------------|:--------------------------------------------------------------------------------------------|:-----------------------|
|
||||
| `alerting.jetbrainsspace` | Configuration for alerts of type `jetbrainsspace` | `{}` |
|
||||
| `alerting.jetbrainsspace.project` | JetBrains Space project name | Required `""` |
|
||||
| `alerting.jetbrainsspace.channel-id` | JetBrains Space Chat Channel ID | Required `""` |
|
||||
| `alerting.jetbrainsspace.token` | Token that is used for authentication. | Required `""` |
|
||||
| `alerting.jetbrainsspace.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| `alerting.jetbrainsspace.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||
| `alerting.jetbrainsspace.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| Parameter | Description | Default |
|
||||
|:--------------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
|
||||
| `alerting.jetbrainsspace` | Configuration for alerts of type `jetbrainsspace` | `{}` |
|
||||
| `alerting.jetbrainsspace.project` | JetBrains Space project name | Required `""` |
|
||||
| `alerting.jetbrainsspace.channel-id` | JetBrains Space Chat Channel ID | Required `""` |
|
||||
| `alerting.jetbrainsspace.token` | Token that is used for authentication. | Required `""` |
|
||||
| `alerting.jetbrainsspace.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| `alerting.jetbrainsspace.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||
| `alerting.jetbrainsspace.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| `alerting.jetbrainsspace.overrides[].*` | See `alerting.jetbrainsspace.*` parameters | `{}` |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
@@ -923,7 +1033,7 @@ endpoints:
|
||||
| `alerting.mattermost.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A |
|
||||
| `alerting.mattermost.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||
| `alerting.mattermost.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| `alerting.mattermist.overrides[].webhook-url` | Mattermost Webhook URL | `""` |
|
||||
| `alerting.mattermost.overrides[].*` | See `alerting.mattermost.*` parameters | `{}` |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
@@ -985,18 +1095,21 @@ endpoints:
|
||||
|
||||
|
||||
#### Configuring Ntfy alerts
|
||||
| Parameter | Description | Default |
|
||||
|:---------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------|:------------------|
|
||||
| `alerting.ntfy` | Configuration for alerts of type `ntfy` | `{}` |
|
||||
| `alerting.ntfy.topic` | Topic at which the alert will be sent | Required `""` |
|
||||
| `alerting.ntfy.url` | The URL of the target server | `https://ntfy.sh` |
|
||||
| `alerting.ntfy.token` | [Access token](https://docs.ntfy.sh/publish/#access-tokens) for restricted topics | `""` |
|
||||
| `alerting.ntfy.email` | E-mail address for additional e-mail notifications | `""` |
|
||||
| `alerting.ntfy.click` | Website opened when notification is clicked | `""` |
|
||||
| `alerting.ntfy.priority` | The priority of the alert | `3` |
|
||||
| `alerting.ntfy.disable-firebase` | Whether message push delivery via firebase should be disabled. [ntfy.sh defaults to enabled](https://docs.ntfy.sh/publish/#disable-firebase) | `false` |
|
||||
| `alerting.ntfy.disable-cache` | Whether server side message caching should be disabled. [ntfy.sh defaults to enabled](https://docs.ntfy.sh/publish/#message-caching) | `false` |
|
||||
| `alerting.ntfy.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| Parameter | Description | Default |
|
||||
|:-------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------|:------------------|
|
||||
| `alerting.ntfy` | Configuration for alerts of type `ntfy` | `{}` |
|
||||
| `alerting.ntfy.topic` | Topic at which the alert will be sent | Required `""` |
|
||||
| `alerting.ntfy.url` | The URL of the target server | `https://ntfy.sh` |
|
||||
| `alerting.ntfy.token` | [Access token](https://docs.ntfy.sh/publish/#access-tokens) for restricted topics | `""` |
|
||||
| `alerting.ntfy.email` | E-mail address for additional e-mail notifications | `""` |
|
||||
| `alerting.ntfy.click` | Website opened when notification is clicked | `""` |
|
||||
| `alerting.ntfy.priority` | The priority of the alert | `3` |
|
||||
| `alerting.ntfy.disable-firebase` | Whether message push delivery via firebase should be disabled. [ntfy.sh defaults to enabled](https://docs.ntfy.sh/publish/#disable-firebase) | `false` |
|
||||
| `alerting.ntfy.disable-cache` | Whether server side message caching should be disabled. [ntfy.sh defaults to enabled](https://docs.ntfy.sh/publish/#message-caching) | `false` |
|
||||
| `alerting.ntfy.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| `alerting.ntfy.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||
| `alerting.ntfy.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| `alerting.ntfy.overrides[].*` | See `alerting.ntfy.*` parameters | `{}` |
|
||||
|
||||
[ntfy](https://github.com/binwiederhier/ntfy) is an amazing project that allows you to subscribe to desktop
|
||||
and mobile notifications, making it an awesome addition to Gatus.
|
||||
@@ -1011,6 +1124,13 @@ alerting:
|
||||
default-alert:
|
||||
failure-threshold: 3
|
||||
send-on-resolved: true
|
||||
# You can also add group-specific to keys, which will
|
||||
# override the to key above for the specified groups
|
||||
overrides:
|
||||
- group: "other"
|
||||
topic: "gatus-other-test-topic"
|
||||
priority: 4
|
||||
click: "https://example.com"
|
||||
|
||||
endpoints:
|
||||
- name: website
|
||||
@@ -1022,6 +1142,16 @@ endpoints:
|
||||
- "[RESPONSE_TIME] < 300"
|
||||
alerts:
|
||||
- type: ntfy
|
||||
- name: other example
|
||||
group: other
|
||||
interval: 30m
|
||||
url: "https://example.com"
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
alerts:
|
||||
- type: ntfy
|
||||
description: example
|
||||
```
|
||||
|
||||
|
||||
@@ -1047,14 +1177,14 @@ alerting:
|
||||
|
||||
|
||||
#### Configuring PagerDuty alerts
|
||||
| Parameter | Description | Default |
|
||||
|:-------------------------------------------------|:-------------------------------------------------------------------------------------------|:--------|
|
||||
| `alerting.pagerduty` | Configuration for alerts of type `pagerduty` | `{}` |
|
||||
| `alerting.pagerduty.integration-key` | PagerDuty Events API v2 integration key | `""` |
|
||||
| `alerting.pagerduty.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||
| `alerting.pagerduty.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| `alerting.pagerduty.overrides[].integration-key` | PagerDuty Events API v2 integration key | `""` |
|
||||
| `alerting.pagerduty.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| Parameter | Description | Default |
|
||||
|:---------------------------------------|:-------------------------------------------------------------------------------------------|:--------|
|
||||
| `alerting.pagerduty` | Configuration for alerts of type `pagerduty` | `{}` |
|
||||
| `alerting.pagerduty.integration-key` | PagerDuty Events API v2 integration key | `""` |
|
||||
| `alerting.pagerduty.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| `alerting.pagerduty.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||
| `alerting.pagerduty.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| `alerting.pagerduty.overrides[].*` | See `alerting.pagerduty.*` parameters | `{}` |
|
||||
|
||||
It is highly recommended to set `endpoints[].alerts[].send-on-resolved` to `true` for alerts
|
||||
of type `pagerduty`, because unlike other alerts, the operation resulting from setting said
|
||||
@@ -1107,15 +1237,18 @@ endpoints:
|
||||
|
||||
|
||||
#### Configuring Pushover alerts
|
||||
| Parameter | Description | Default |
|
||||
|:---------------------------------------|:------------------------------------------------------------------------------------------------|:-----------------------------|
|
||||
| `alerting.pushover` | Configuration for alerts of type `pushover` | `{}` |
|
||||
| `alerting.pushover.application-token` | Pushover application token | `""` |
|
||||
| `alerting.pushover.user-key` | User or group key | `""` |
|
||||
| `alerting.pushover.title` | Fixed title for all messages sent via Pushover | Name of your App in Pushover |
|
||||
| `alerting.pushover.priority` | Priority of all messages, ranging from -2 (very low) to 2 (emergency) | `0` |
|
||||
| `alerting.pushover.sound` | Sound of all messages<br />See [sounds](https://pushover.net/api#sounds) for all valid choices. | `""` |
|
||||
| `alerting.pushover.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| Parameter | Description | Default |
|
||||
|:--------------------------------------|:---------------------------------------------------------------------------------------------------------|:----------------------------|
|
||||
| `alerting.pushover` | Configuration for alerts of type `pushover` | `{}` |
|
||||
| `alerting.pushover.application-token` | Pushover application token | `""` |
|
||||
| `alerting.pushover.user-key` | User or group key | `""` |
|
||||
| `alerting.pushover.title` | Fixed title for all messages sent via Pushover | `"Gatus: <endpoint>"` |
|
||||
| `alerting.pushover.priority` | Priority of all messages, ranging from -2 (very low) to 2 (emergency) | `0` |
|
||||
| `alerting.pushover.resolved-priority` | Override the priority of messages on resolved, ranging from -2 (very low) to 2 (emergency) | `0` |
|
||||
| `alerting.pushover.sound` | Sound of all messages<br />See [sounds](https://pushover.net/api#sounds) for all valid choices. | `""` |
|
||||
| `alerting.pushover.ttl` | Set the Time-to-live of the message to be automatically deleted from pushover notifications | `0` |
|
||||
| `alerting.pushover.device` | Device to send the message to (optional)<br/>See [devices](https://pushover.net/api#identifiers) for details | `""` (all devices)|
|
||||
| `alerting.pushover.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
@@ -1141,14 +1274,14 @@ endpoints:
|
||||
|
||||
|
||||
#### Configuring Slack alerts
|
||||
| Parameter | Description | Default |
|
||||
|:------------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
|
||||
| `alerting.slack` | Configuration for alerts of type `slack` | `{}` |
|
||||
| `alerting.slack.webhook-url` | Slack Webhook URL | Required `""` |
|
||||
| `alerting.slack.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| `alerting.slack.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||
| `alerting.slack.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| `alerting.slack.overrides[].webhook-url` | Slack Webhook URL | `""` |
|
||||
| Parameter | Description | Default |
|
||||
|:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
|
||||
| `alerting.slack` | Configuration for alerts of type `slack` | `{}` |
|
||||
| `alerting.slack.webhook-url` | Slack Webhook URL | Required `""` |
|
||||
| `alerting.slack.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| `alerting.slack.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||
| `alerting.slack.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| `alerting.slack.overrides[].*` | See `alerting.slack.*` parameters | `{}` |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
@@ -1184,16 +1317,16 @@ Here's an example of what the notifications look like:
|
||||
> **Deprecated:** Office 365 Connectors within Microsoft Teams are being retired ([Source: Microsoft DevBlog](https://devblogs.microsoft.com/microsoft365dev/retirement-of-office-365-connectors-within-microsoft-teams/)).
|
||||
> Existing connectors will continue to work until December 2025. The new [Teams Workflow Alerts](#configuring-teams-workflow-alerts) should be used with Microsoft Workflows instead of this legacy configuration.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|:-----------------------------------------|:-------------------------------------------------------------------------------------------|:--------------------|
|
||||
| `alerting.teams` | Configuration for alerts of type `teams` | `{}` |
|
||||
| `alerting.teams.webhook-url` | Teams Webhook URL | Required `""` |
|
||||
| `alerting.teams.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| `alerting.teams.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||
| `alerting.teams.title` | Title of the notification | `"🚨 Gatus"` |
|
||||
| `alerting.teams.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| `alerting.teams.overrides[].webhook-url` | Teams Webhook URL | `""` |
|
||||
| `alerting.teams.client.insecure` | Whether to skip TLS verification | `false` |
|
||||
| Parameter | Description | Default |
|
||||
|:-----------------------------------|:-------------------------------------------------------------------------------------------|:--------------------|
|
||||
| `alerting.teams` | Configuration for alerts of type `teams` | `{}` |
|
||||
| `alerting.teams.webhook-url` | Teams Webhook URL | Required `""` |
|
||||
| `alerting.teams.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| `alerting.teams.title` | Title of the notification | `"🚨 Gatus"` |
|
||||
| `alerting.teams.client.insecure` | Whether to skip TLS verification | `false` |
|
||||
| `alerting.teams.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||
| `alerting.teams.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| `alerting.teams.overrides[].*` | See `alerting.teams.*` parameters | `{}` |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
@@ -1242,15 +1375,15 @@ Here's an example of what the notifications look like:
|
||||
> [!NOTE]
|
||||
> This alert is compatible with Workflows for Microsoft Teams. To setup the workflow and get the webhook URL, follow the [Microsoft Documentation](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498).
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|:---------------------------------------------------|:-------------------------------------------------------------------------------------------|:-------------------|
|
||||
| `alerting.teams-workflows` | Configuration for alerts of type `teams` | `{}` |
|
||||
| `alerting.teams-workflows.webhook-url` | Teams Webhook URL | Required `""` |
|
||||
| `alerting.teams-workflows.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| `alerting.teams-workflows.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||
| `alerting.teams-workflows.title` | Title of the notification | `"⛑ Gatus"` |
|
||||
| `alerting.teams-workflows.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| `alerting.teams-workflows.overrides[].webhook-url` | Teams WorkFlow Webhook URL | `""` |
|
||||
| Parameter | Description | Default |
|
||||
|:---------------------------------------------|:-------------------------------------------------------------------------------------------|:-------------------|
|
||||
| `alerting.teams-workflows` | Configuration for alerts of type `teams` | `{}` |
|
||||
| `alerting.teams-workflows.webhook-url` | Teams Webhook URL | Required `""` |
|
||||
| `alerting.teams-workflows.title` | Title of the notification | `"⛑ Gatus"` |
|
||||
| `alerting.teams-workflows.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| `alerting.teams-workflows.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||
| `alerting.teams-workflows.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| `alerting.teams-workflows.overrides[].*` | See `alerting.teams-workflows.*` parameters | `{}` |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
@@ -1304,8 +1437,7 @@ Here's an example of what the notifications look like:
|
||||
| `alerting.telegram.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
| `alerting.telegram.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||
| `alerting.telegram.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| `alerting.telegram.overrides[].token` | Telegram Bot Token for override default value | `""` |
|
||||
| `alerting.telegram.overrides[].id` | Telegram User ID for override default value | `""` |
|
||||
| `alerting.telegram.overrides[].*` | See `alerting.telegram.*` parameters | `{}` |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
@@ -1364,45 +1496,40 @@ endpoints:
|
||||
```
|
||||
|
||||
|
||||
#### Configuring AWS SES alerts
|
||||
| Parameter | Description | Default |
|
||||
|:-------------------------------------|:-------------------------------------------------------------------------------------------|:--------------|
|
||||
| `alerting.aws-ses` | Settings for alerts of type `aws-ses` | `{}` |
|
||||
| `alerting.aws-ses.access-key-id` | AWS Access Key ID | Optional `""` |
|
||||
| `alerting.aws-ses.secret-access-key` | AWS Secret Access Key | Optional `""` |
|
||||
| `alerting.aws-ses.region` | AWS Region | Required `""` |
|
||||
| `alerting.aws-ses.from` | The Email address to send the emails from (should be registered in SES) | Required `""` |
|
||||
| `alerting.aws-ses.to` | Comma separated list of email address to notify | Required `""` |
|
||||
| `alerting.aws-ses.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert) | N/A |
|
||||
#### Configuring Zulip alerts
|
||||
| Parameter | Description | Default |
|
||||
|:-----------------------------------|:------------------------------------------------------------------------------------|:--------------|
|
||||
| `alerting.zulip` | Configuration for alerts of type `discord` | `{}` |
|
||||
| `alerting.zulip.bot-email` | Bot Email | Required `""` |
|
||||
| `alerting.zulip.bot-api-key` | Bot API key | Required `""` |
|
||||
| `alerting.zulip.domain` | Full organization domain (e.g.: yourZulipDomain.zulipchat.com) | Required `""` |
|
||||
| `alerting.zulip.channel-id` | The channel ID where Gatus will send the alerts | Required `""` |
|
||||
| `alerting.zulip.overrides` | List of overrides that may be prioritized over the default configuration | `[]` |
|
||||
| `alerting.zulip.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| `alerting.zulip.overrides[].*` | See `alerting.zulip.*` parameters | `{}` |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
aws-ses:
|
||||
access-key-id: "..."
|
||||
secret-access-key: "..."
|
||||
region: "us-east-1"
|
||||
from: "status@example.com"
|
||||
to: "user@example.com"
|
||||
zulip:
|
||||
bot-email: gatus-bot@some.zulip.org
|
||||
bot-api-key: "********************************"
|
||||
domain: some.zulip.org
|
||||
channel-id: 123456
|
||||
|
||||
endpoints:
|
||||
- name: website
|
||||
interval: 30s
|
||||
url: "https://twin.sh/health"
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
- "[RESPONSE_TIME] < 300"
|
||||
alerts:
|
||||
- type: aws-ses
|
||||
failure-threshold: 5
|
||||
send-on-resolved: true
|
||||
- type: zulip
|
||||
description: "healthcheck failed"
|
||||
send-on-resolved: true
|
||||
```
|
||||
|
||||
If the `access-key-id` and `secret-access-key` are not defined Gatus will fall back to IAM authentication.
|
||||
|
||||
Make sure you have the ability to use `ses:SendEmail`.
|
||||
|
||||
|
||||
#### Configuring custom alerts
|
||||
| Parameter | Description | Default |
|
||||
@@ -1563,54 +1690,18 @@ endpoints:
|
||||
- type: pagerduty
|
||||
```
|
||||
|
||||
#### Configuring Zulip alerts
|
||||
| Parameter | Description | Default |
|
||||
|:-----------------------------------------|:------------------------------------------------------------------------------------|:------------------------------------|
|
||||
| `alerting.zulip` | Configuration for alerts of type `discord` | `{}` |
|
||||
| `alerting.zulip.bot-email` | Bot Email | Required `""` |
|
||||
| `alerting.zulip.bot-api-key` | Bot API key | Required `""` |
|
||||
| `alerting.zulip.domain` | Full organization domain (e.g.: yourZulipDomain.zulipchat.com) | Required `""` |
|
||||
| `alerting.zulip.channel-id` | The channel ID where Gatus will send the alerts | Required `""` |
|
||||
| `alerting.zulip.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` |
|
||||
| `alerting.zulip.overrides[].bot-email` | . | `""` |
|
||||
| `alerting.zulip.overrides[].bot-api-key` | . | `""` |
|
||||
| `alerting.zulip.overrides[].domain` | . | `""` |
|
||||
| `alerting.zulip.overrides[].channel-id` | . | `""` |
|
||||
|
||||
```yaml
|
||||
alerting:
|
||||
zulip:
|
||||
bot-email: gatus-bot@some.zulip.org
|
||||
bot-api-key: "********************************"
|
||||
domain: some.zulip.org
|
||||
channel-id: 123456
|
||||
|
||||
endpoints:
|
||||
- name: website
|
||||
url: "https://twin.sh/health"
|
||||
interval: 5m
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].status == UP"
|
||||
- "[RESPONSE_TIME] < 300"
|
||||
alerts:
|
||||
- type: zulip
|
||||
description: "healthcheck failed"
|
||||
send-on-resolved: true
|
||||
```
|
||||
|
||||
|
||||
### Maintenance
|
||||
If you have maintenance windows, you may not want to be annoyed by alerts.
|
||||
To do that, you'll have to use the maintenance configuration:
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|:-----------------------|:---------------------------------------------------------------------------------------------------------------------------------------|:--------------|
|
||||
| `maintenance.enabled` | Whether the maintenance period is enabled | `true` |
|
||||
| `maintenance.start` | Time at which the maintenance window starts in `hh:mm` format (e.g. `23:00`) | Required `""` |
|
||||
| `maintenance.duration` | Duration of the maintenance window (e.g. `1h`, `30m`) | Required `""` |
|
||||
| `maintenance.timezone` | Timezone of the maintenance window format (e.g. `Europe/Amsterdam`).<br />See [List of tz database time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for more info | `UTC` |
|
||||
| `maintenance.every` | Days on which the maintenance period applies (e.g. `[Monday, Thursday]`).<br />If left empty, the maintenance window applies every day | `[]` |
|
||||
| Parameter | Description | Default |
|
||||
|:-----------------------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:--------------|
|
||||
| `maintenance.enabled` | Whether the maintenance period is enabled | `true` |
|
||||
| `maintenance.start` | Time at which the maintenance window starts in `hh:mm` format (e.g. `23:00`) | Required `""` |
|
||||
| `maintenance.duration` | Duration of the maintenance window (e.g. `1h`, `30m`) | Required `""` |
|
||||
| `maintenance.timezone` | Timezone of the maintenance window format (e.g. `Europe/Amsterdam`).<br />See [List of tz database time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for more info | `UTC` |
|
||||
| `maintenance.every` | Days on which the maintenance period applies (e.g. `[Monday, Thursday]`).<br />If left empty, the maintenance window applies every day | `[]` |
|
||||
|
||||
Here's an example:
|
||||
```yaml
|
||||
@@ -1630,6 +1721,19 @@ maintenance:
|
||||
- Monday
|
||||
- Thursday
|
||||
```
|
||||
You can also specify maintenance windows on a per-endpoint basis:
|
||||
```yaml
|
||||
endpoints:
|
||||
- name: endpoint-1
|
||||
url: "https://example.org"
|
||||
maintenance-windows:
|
||||
- start: "07:30"
|
||||
duration: 40m
|
||||
timezone: "Europe/Berlin"
|
||||
- start: "14:30"
|
||||
duration: 1h
|
||||
timezone: "Europe/Berlin"
|
||||
```
|
||||
|
||||
|
||||
### Security
|
||||
@@ -1655,7 +1759,7 @@ security:
|
||||
password-bcrypt-base64: "JDJhJDEwJHRiMnRFakxWazZLdXBzRERQazB1TE8vckRLY05Yb1hSdnoxWU0yQ1FaYXZRSW1McmladDYu"
|
||||
```
|
||||
|
||||
> ⚠ Make sure to carefully select to cost of the bcrypt hash. The higher the cost, the longer it takes to compute the hash,
|
||||
> ⚠ Make sure to carefully select the cost of the bcrypt hash. The higher the cost, the longer it takes to compute the hash,
|
||||
> and basic auth verifies the password against the hash on every request. As of 2023-01-06, I suggest a cost of 9.
|
||||
|
||||
|
||||
@@ -1704,11 +1808,12 @@ endpoint on the same port your application is configured to run on (`web.port`).
|
||||
|
||||
| Metric name | Type | Description | Labels | Relevant endpoint types |
|
||||
|:---------------------------------------------|:--------|:---------------------------------------------------------------------------|:--------------------------------|:------------------------|
|
||||
| gatus_results_total | counter | Number of results per endpoint | key, group, name, type, success | All |
|
||||
| gatus_results_total | counter | Number of results per endpoint per success state | key, group, name, type, success | All |
|
||||
| gatus_results_code_total | counter | Total number of results by code | key, group, name, type, code | DNS, HTTP |
|
||||
| gatus_results_connected_total | counter | Total number of results in which a connection was successfully established | key, group, name, type | All |
|
||||
| gatus_results_duration_seconds | gauge | Duration of the request in seconds | key, group, name, type | All |
|
||||
| gatus_results_certificate_expiration_seconds | gauge | Number of seconds until the certificate expires | key, group, name, type | HTTP, STARTTLS |
|
||||
| gatus_results_endpoint_success | gauge | Displays whether or not the endpoint was a success (0 failure, 1 success) | key, group, name, type | All |
|
||||
|
||||
See [examples/docker-compose-grafana-prometheus](.examples/docker-compose-grafana-prometheus) for further documentation as well as an example.
|
||||
|
||||
@@ -1890,8 +1995,7 @@ To modify the timeout, see [Client configuration](#client-configuration).
|
||||
|
||||
|
||||
### Monitoring a TCP endpoint
|
||||
By prefixing `endpoints[].url` with `tcp:\\`, you can monitor TCP endpoints at a very basic level:
|
||||
|
||||
By prefixing `endpoints[].url` with `tcp://`, you can monitor TCP endpoints at a very basic level:
|
||||
```yaml
|
||||
endpoints:
|
||||
- name: redis
|
||||
@@ -1912,8 +2016,7 @@ This works for applications such as databases (Postgres, MySQL, etc.) and caches
|
||||
|
||||
|
||||
### Monitoring a UDP endpoint
|
||||
By prefixing `endpoints[].url` with `udp:\\`, you can monitor UDP endpoints at a very basic level:
|
||||
|
||||
By prefixing `endpoints[].url` with `udp://`, you can monitor UDP endpoints at a very basic level:
|
||||
```yaml
|
||||
endpoints:
|
||||
- name: example
|
||||
@@ -1929,8 +2032,7 @@ This works for UDP based application.
|
||||
|
||||
|
||||
### Monitoring a SCTP endpoint
|
||||
By prefixing `endpoints[].url` with `sctp:\\`, you can monitor Stream Control Transmission Protocol (SCTP) endpoints at a very basic level:
|
||||
|
||||
By prefixing `endpoints[].url` with `sctp://`, you can monitor Stream Control Transmission Protocol (SCTP) endpoints at a very basic level:
|
||||
```yaml
|
||||
endpoints:
|
||||
- name: example
|
||||
@@ -1947,7 +2049,6 @@ This works for SCTP based application.
|
||||
|
||||
### Monitoring a WebSocket endpoint
|
||||
By prefixing `endpoints[].url` with `ws://` or `wss://`, you can monitor WebSocket endpoints at a very basic level:
|
||||
|
||||
```yaml
|
||||
endpoints:
|
||||
- name: example
|
||||
@@ -1963,9 +2064,8 @@ shows whether the connection was successfully established.
|
||||
|
||||
|
||||
### Monitoring an endpoint using ICMP
|
||||
By prefixing `endpoints[].url` with `icmp:\\`, you can monitor endpoints at a very basic level using ICMP, or more
|
||||
By prefixing `endpoints[].url` with `icmp://`, you can monitor endpoints at a very basic level using ICMP, or more
|
||||
commonly known as "ping" or "echo":
|
||||
|
||||
```yaml
|
||||
endpoints:
|
||||
- name: ping-example
|
||||
@@ -2002,7 +2102,7 @@ There are two placeholders that can be used in the conditions for endpoints of t
|
||||
|
||||
|
||||
### Monitoring an endpoint using SSH
|
||||
You can monitor endpoints using SSH by prefixing `endpoints[].url` with `ssh:\\`:
|
||||
You can monitor endpoints using SSH by prefixing `endpoints[].url` with `ssh://`:
|
||||
```yaml
|
||||
endpoints:
|
||||
- name: ssh-example
|
||||
@@ -2020,6 +2120,23 @@ endpoints:
|
||||
- "[STATUS] == 0"
|
||||
```
|
||||
|
||||
you can also use no authentication to monitor the endpoint by not specifying the username
|
||||
and password fields.
|
||||
|
||||
```yaml
|
||||
endpoints:
|
||||
- name: ssh-example
|
||||
url: "ssh://example.com:22" # port is optional. Default is 22.
|
||||
ssh:
|
||||
username: ""
|
||||
password: ""
|
||||
|
||||
interval: 1m
|
||||
conditions:
|
||||
- "[CONNECTED] == true"
|
||||
- "[STATUS] == 0"
|
||||
```
|
||||
|
||||
The following placeholders are supported for endpoints of type SSH:
|
||||
- `[CONNECTED]` resolves to `true` if the SSH connection was successful, `false` otherwise
|
||||
- `[STATUS]` resolves the exit code of the command executed on the remote server (e.g. `0` for success)
|
||||
@@ -2076,7 +2193,7 @@ endpoints:
|
||||
|
||||
|
||||
### disable-monitoring-lock
|
||||
Setting `disable-monitoring-lock` to `true` means that multiple endpoints could be monitored at the same time.
|
||||
Setting `disable-monitoring-lock` to `true` means that multiple endpoints could be monitored at the same time (i.e. parallel execution).
|
||||
|
||||
While this behavior wouldn't generally be harmful, conditions using the `[RESPONSE_TIME]` placeholder could be impacted
|
||||
by the evaluation of multiple endpoints at the same time, therefore, the default value for this parameter is `false`.
|
||||
@@ -2221,7 +2338,6 @@ endpoints:
|
||||
|
||||
|
||||
### Proxy client configuration
|
||||
|
||||
You can configure a proxy for the client to use by setting the `proxy-url` parameter in the client configuration.
|
||||
|
||||
```yaml
|
||||
@@ -2234,19 +2350,6 @@ endpoints:
|
||||
- "[STATUS] == 200"
|
||||
```
|
||||
|
||||
### Proxy client configuration
|
||||
|
||||
You can configure a proxy for the client to use by setting the `proxy-url` parameter in the client configuration.
|
||||
|
||||
```yaml
|
||||
endpoints:
|
||||
- name: website
|
||||
url: "https://twin.sh/health"
|
||||
client:
|
||||
proxy-url: http://proxy.example.com:8080
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
```
|
||||
|
||||
### How to fix 431 Request Header Fields Too Large error
|
||||
Depending on where your environment is deployed and what kind of middleware or reverse proxy sits in front of Gatus,
|
||||
@@ -2264,6 +2367,7 @@ web:
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
Gatus can automatically generate an SVG badge for one of your monitored endpoints.
|
||||
This allows you to put badges in your individual applications' README or even create your own status page if you
|
||||
@@ -2274,8 +2378,8 @@ The path to generate a badge is the following:
|
||||
/api/v1/endpoints/{key}/uptimes/{duration}/badge.svg
|
||||
```
|
||||
Where:
|
||||
- `{duration}` is `30d` (alpha), `7d`, `24h` or `1h`
|
||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`.
|
||||
- `{duration}` is `30d`, `7d`, `24h` or `1h`
|
||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
|
||||
|
||||
For instance, if you want the uptime during the last 24 hours from the endpoint `frontend` in the group `core`,
|
||||
the URL would look like this:
|
||||
@@ -2301,7 +2405,7 @@ The path to generate a badge is the following:
|
||||
/api/v1/endpoints/{key}/health/badge.svg
|
||||
```
|
||||
Where:
|
||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`.
|
||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
|
||||
|
||||
For instance, if you want the current status of the endpoint `frontend` in the group `core`,
|
||||
the URL would look like this:
|
||||
@@ -2318,7 +2422,7 @@ The path to generate a badge is the following:
|
||||
/api/v1/endpoints/{key}/health/badge.shields
|
||||
```
|
||||
Where:
|
||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`.
|
||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
|
||||
|
||||
For instance, if you want the current status of the endpoint `frontend` in the group `core`,
|
||||
the URL would look like this:
|
||||
@@ -2333,22 +2437,35 @@ See more information about the Shields.io badge endpoint [here](https://shields.
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
The endpoint to generate a badge is the following:
|
||||
```
|
||||
/api/v1/endpoints/{key}/response-times/{duration}/badge.svg
|
||||
```
|
||||
Where:
|
||||
- `{duration}` is `30d` (alpha), `7d`, `24h` or `1h`
|
||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`.
|
||||
- `{duration}` is `30d`, `7d`, `24h` or `1h`
|
||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
|
||||
|
||||
#### Response time (chart)
|
||||

|
||||

|
||||

|
||||
|
||||
The endpoint to generate a response time chart is the following:
|
||||
```
|
||||
/api/v1/endpoints/{key}/response-times/{duration}/chart.svg
|
||||
```
|
||||
Where:
|
||||
- `{duration}` is `30d`, `7d`, or `24h`
|
||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
|
||||
|
||||
##### How to change the color thresholds of the response time badge
|
||||
To change the response time badges' threshold, a corresponding configuration can be added to an endpoint.
|
||||
The values in the array correspond to the levels [Awesome, Great, Good, Passable, Bad]
|
||||
All five values must be given in milliseconds (ms).
|
||||
|
||||
```
|
||||
```yaml
|
||||
endpoints:
|
||||
- name: nas
|
||||
group: internal
|
||||
@@ -2383,6 +2500,37 @@ Gzip compression will be used if the `Accept-Encoding` HTTP header contains `gzi
|
||||
The API will return a JSON payload with the `Content-Type` response header set to `application/json`.
|
||||
No such header is required to query the API.
|
||||
|
||||
#### Raw Data
|
||||
Gatus exposes the raw data for one of your monitored endpoints.
|
||||
This allows you to track and aggregate data in your own applications for monitored endpoints. For instance if you want to track uptime for a period longer than 7 days.
|
||||
|
||||
##### Uptime
|
||||
The path to get raw uptime data for an endpoint is:
|
||||
```
|
||||
/api/v1/endpoints/{key}/uptimes/{duration}
|
||||
```
|
||||
Where:
|
||||
- `{duration}` is `30d`, `7d`, `24h` or `1h`
|
||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
|
||||
|
||||
For instance, if you want the raw uptime data for the last 24 hours from the endpoint `frontend` in the group `core`, the URL would look like this:
|
||||
```
|
||||
https://example.com/api/v1/endpoints/core_frontend/uptimes/24h
|
||||
```
|
||||
|
||||
##### Response Time
|
||||
The path to get raw response time data for an endpoint is:
|
||||
```
|
||||
/api/v1/endpoints/{key}/response-times/{duration}
|
||||
```
|
||||
Where:
|
||||
- `{duration}` is `30d`, `7d`, `24h` or `1h`
|
||||
- `{key}` has the pattern `<GROUP_NAME>_<ENDPOINT_NAME>` in which both variables have ` `, `/`, `_`, `,`, `.` and `#` replaced by `-`.
|
||||
|
||||
For instance, if you want the raw response time data for the last 24 hours from the endpoint `frontend` in the group `core`, the URL would look like this:
|
||||
```
|
||||
https://example.com/api/v1/endpoints/core_frontend/response-times/24h
|
||||
```
|
||||
|
||||
### Installing as binary
|
||||
You can download Gatus as a binary using the following command:
|
||||
|
||||
@@ -6,6 +6,9 @@ import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/TwiN/logr"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -13,7 +16,7 @@ var (
|
||||
ErrAlertWithInvalidDescription = errors.New("alert description must not have \" or \\")
|
||||
)
|
||||
|
||||
// Alert is a endpoint.Endpoint's alert configuration
|
||||
// Alert is endpoint.Endpoint's alert configuration
|
||||
type Alert struct {
|
||||
// Type of alert (required)
|
||||
Type Type `yaml:"type"`
|
||||
@@ -36,13 +39,17 @@ type Alert struct {
|
||||
//
|
||||
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
|
||||
// or not for provider.ParseWithDefaultAlert to work.
|
||||
Description *string `yaml:"description"`
|
||||
Description *string `yaml:"description,omitempty"`
|
||||
|
||||
// SendOnResolved defines whether to send a second notification when the issue has been resolved
|
||||
//
|
||||
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
|
||||
// or not for provider.ParseWithDefaultAlert to work. Use Alert.IsSendingOnResolved() for a non-pointer
|
||||
SendOnResolved *bool `yaml:"send-on-resolved"`
|
||||
SendOnResolved *bool `yaml:"send-on-resolved,omitempty"`
|
||||
|
||||
// ProviderOverride is an optional field that can be used to override the provider's configuration
|
||||
// It is freeform so that it can be used for any provider-specific configuration.
|
||||
ProviderOverride map[string]any `yaml:"provider-override,omitempty"`
|
||||
|
||||
// ResolveKey is an optional field that is used by some providers (i.e. PagerDuty's dedup_key) to resolve
|
||||
// ongoing/triggered incidents
|
||||
@@ -111,3 +118,11 @@ func (alert *Alert) Checksum() string {
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ const (
|
||||
// TypeGotify is the Type for the gotify alerting provider
|
||||
TypeGotify Type = "gotify"
|
||||
|
||||
// TypeIncidentIO is the Type for the incident-io alerting provider
|
||||
TypeIncidentIO Type = "incident-io"
|
||||
|
||||
// TypeJetBrainsSpace is the Type for the jetbrains alerting provider
|
||||
TypeJetBrainsSpace Type = "jetbrainsspace"
|
||||
|
||||
@@ -62,6 +65,9 @@ const (
|
||||
// TypeTeams is the Type for the teams alerting provider
|
||||
TypeTeams Type = "teams"
|
||||
|
||||
// TypeTeamsWorkflows is the Type for the teams-workflows alerting provider
|
||||
TypeTeamsWorkflows Type = "teams-workflows"
|
||||
|
||||
// TypeTelegram is the Type for the telegram alerting provider
|
||||
TypeTelegram Type = "telegram"
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package alerting
|
||||
|
||||
import (
|
||||
"log"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
@@ -16,6 +15,7 @@ import (
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/gotify"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/zulip"
|
||||
"github.com/TwiN/logr"
|
||||
)
|
||||
|
||||
// Config is the configuration for alerting providers
|
||||
@@ -61,6 +62,9 @@ type Config struct {
|
||||
// Gotify is the configuration for the gotify alerting provider
|
||||
Gotify *gotify.AlertProvider `yaml:"gotify,omitempty"`
|
||||
|
||||
// IncidentIO is the configuration for the incident-io alerting provider
|
||||
IncidentIO *incidentio.AlertProvider `yaml:"incident-io,omitempty"`
|
||||
|
||||
// JetBrainsSpace is the configuration for the jetbrains space alerting provider
|
||||
JetBrainsSpace *jetbrainsspace.AlertProvider `yaml:"jetbrainsspace,omitempty"`
|
||||
|
||||
@@ -118,7 +122,7 @@ func (config *Config) GetAlertingProviderByAlertType(alertType alert.Type) provi
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,30 +1,73 @@
|
||||
package awsses
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/logr"
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/awserr"
|
||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
"github.com/aws/aws-sdk-go/service/ses"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
CharSet = "UTF-8"
|
||||
)
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using AWS Simple Email Service
|
||||
type AlertProvider struct {
|
||||
var (
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
ErrMissingFromOrToFields = errors.New("from and to fields are required")
|
||||
ErrInvalidAWSAuthConfig = errors.New("either both or neither of access-key-id and secret-access-key must be specified")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
AccessKeyID string `yaml:"access-key-id"`
|
||||
SecretAccessKey string `yaml:"secret-access-key"`
|
||||
Region string `yaml:"region"`
|
||||
|
||||
From string `yaml:"from"`
|
||||
To string `yaml:"to"`
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.From) == 0 || len(cfg.To) == 0 {
|
||||
return ErrMissingFromOrToFields
|
||||
}
|
||||
if !((len(cfg.AccessKeyID) == 0 && len(cfg.SecretAccessKey) == 0) || (len(cfg.AccessKeyID) > 0 && len(cfg.SecretAccessKey) > 0)) {
|
||||
// if both AccessKeyID and SecretAccessKey are specified, we'll use these to authenticate,
|
||||
// otherwise if neither are specified, then we'll fall back on IAM authentication.
|
||||
return ErrInvalidAWSAuthConfig
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.AccessKeyID) > 0 {
|
||||
cfg.AccessKeyID = override.AccessKeyID
|
||||
}
|
||||
if len(override.SecretAccessKey) > 0 {
|
||||
cfg.SecretAccessKey = override.SecretAccessKey
|
||||
}
|
||||
if len(override.Region) > 0 {
|
||||
cfg.Region = override.Region
|
||||
}
|
||||
if len(override.From) > 0 {
|
||||
cfg.From = override.From
|
||||
}
|
||||
if len(override.To) > 0 {
|
||||
cfg.To = override.To
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using AWS Simple Email Service
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
@@ -35,36 +78,37 @@ type AlertProvider struct {
|
||||
|
||||
// Override is a case under which the default integration is overridden
|
||||
type Override struct {
|
||||
Group string `yaml:"group"`
|
||||
To string `yaml:"to"`
|
||||
Group string `yaml:"group"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.To) == 0 {
|
||||
return false
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
// 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 len(provider.From) > 0 && len(provider.To) > 0 &&
|
||||
((len(provider.AccessKeyID) == 0 && len(provider.SecretAccessKey) == 0) || (len(provider.AccessKeyID) > 0 && len(provider.SecretAccessKey) > 0))
|
||||
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 {
|
||||
sess, err := provider.createSession()
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
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)
|
||||
emails := strings.Split(provider.getToForGroup(ep.Group), ",")
|
||||
emails := strings.Split(cfg.To, ",")
|
||||
|
||||
input := &ses.SendEmailInput{
|
||||
Destination: &ses.Destination{
|
||||
@@ -82,26 +126,24 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
||||
Data: aws.String(subject),
|
||||
},
|
||||
},
|
||||
Source: aws.String(provider.From),
|
||||
Source: aws.String(cfg.From),
|
||||
}
|
||||
_, err = svc.SendEmail(input)
|
||||
|
||||
if err != nil {
|
||||
if _, err = svc.SendEmail(input); err != nil {
|
||||
if aerr, ok := err.(awserr.Error); ok {
|
||||
switch aerr.Code() {
|
||||
case ses.ErrCodeMessageRejected:
|
||||
fmt.Println(ses.ErrCodeMessageRejected, aerr.Error())
|
||||
logr.Error(ses.ErrCodeMessageRejected + ": " + aerr.Error())
|
||||
case ses.ErrCodeMailFromDomainNotVerifiedException:
|
||||
fmt.Println(ses.ErrCodeMailFromDomainNotVerifiedException, aerr.Error())
|
||||
logr.Error(ses.ErrCodeMailFromDomainNotVerifiedException + ": " + aerr.Error())
|
||||
case ses.ErrCodeConfigurationSetDoesNotExistException:
|
||||
fmt.Println(ses.ErrCodeConfigurationSetDoesNotExistException, aerr.Error())
|
||||
logr.Error(ses.ErrCodeConfigurationSetDoesNotExistException + ": " + aerr.Error())
|
||||
default:
|
||||
fmt.Println(aerr.Error())
|
||||
logr.Error(aerr.Error())
|
||||
}
|
||||
} else {
|
||||
// Print the error, cast err to awserr.Error to get the Code and
|
||||
// Message from an error.
|
||||
fmt.Println(err.Error())
|
||||
logr.Error(err.Error())
|
||||
}
|
||||
|
||||
return err
|
||||
@@ -109,6 +151,16 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) createSession(cfg *Config) (*session.Session, error) {
|
||||
awsConfig := &aws.Config{
|
||||
Region: aws.String(cfg.Region),
|
||||
}
|
||||
if len(cfg.AccessKeyID) > 0 && len(cfg.SecretAccessKey) > 0 {
|
||||
awsConfig.Credentials = credentials.NewStaticCredentials(cfg.AccessKeyID, cfg.SecretAccessKey, "")
|
||||
}
|
||||
return session.NewSession(awsConfig)
|
||||
}
|
||||
|
||||
// buildMessageSubjectAndBody builds the message subject and body
|
||||
func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) {
|
||||
var subject, message string
|
||||
@@ -139,29 +191,38 @@ func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint,
|
||||
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
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) createSession() (*session.Session, error) {
|
||||
config := &aws.Config{
|
||||
Region: aws.String(provider.Region),
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(provider.AccessKeyID) > 0 && len(provider.SecretAccessKey) > 0 {
|
||||
config.Credentials = credentials.NewStaticCredentials(provider.AccessKeyID, provider.SecretAccessKey, "")
|
||||
// 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)
|
||||
}
|
||||
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"
|
||||
)
|
||||
|
||||
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
invalidProvider := AlertProvider{}
|
||||
if invalidProvider.IsValid() {
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
invalidProviderWithOneKey := AlertProvider{From: "from@example.com", To: "to@example.com", AccessKeyID: "1"}
|
||||
if invalidProviderWithOneKey.IsValid() {
|
||||
invalidProviderWithOneKey := AlertProvider{DefaultConfig: Config{From: "from@example.com", To: "to@example.com", AccessKeyID: "1"}}
|
||||
if err := invalidProviderWithOneKey.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{From: "from@example.com", To: "to@example.com"}
|
||||
if !validProvider.IsValid() {
|
||||
validProvider := AlertProvider{DefaultConfig: Config{From: "from@example.com", To: "to@example.com"}}
|
||||
if err := validProvider.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
validProviderWithKeys := AlertProvider{From: "from@example.com", To: "to@example.com", AccessKeyID: "1", SecretAccessKey: "1"}
|
||||
if !validProviderWithKeys.IsValid() {
|
||||
validProviderWithKeys := AlertProvider{DefaultConfig: Config{From: "from@example.com", To: "to@example.com", AccessKeyID: "1", SecretAccessKey: "1"}}
|
||||
if err := validProviderWithKeys.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
||||
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||
providerWithInvalidOverrideGroup := AlertProvider{
|
||||
Overrides: []Override{
|
||||
{
|
||||
To: "to@example.com",
|
||||
Group: "",
|
||||
Config: Config{To: "to@example.com"},
|
||||
Group: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
if providerWithInvalidOverrideGroup.IsValid() {
|
||||
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||
t.Error("provider Group shouldn't have been valid")
|
||||
}
|
||||
providerWithInvalidOverrideTo := AlertProvider{
|
||||
Overrides: []Override{
|
||||
{
|
||||
To: "",
|
||||
Group: "group",
|
||||
Config: Config{To: ""},
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if providerWithInvalidOverrideTo.IsValid() {
|
||||
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
|
||||
t.Error("provider integration key shouldn't have been valid")
|
||||
}
|
||||
providerWithValidOverride := AlertProvider{
|
||||
From: "from@example.com",
|
||||
To: "to@example.com",
|
||||
DefaultConfig: Config{
|
||||
From: "from@example.com",
|
||||
To: "to@example.com",
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
To: "to@example.com",
|
||||
Group: "group",
|
||||
Config: Config{To: "to@example.com"},
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if !providerWithValidOverride.IsValid() {
|
||||
if err := providerWithValidOverride.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
@@ -124,64 +126,124 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_getToForGroup(t *testing.T) {
|
||||
tests := []struct {
|
||||
func TestAlertProvider_getConfigWithOverrides(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
InputGroup string
|
||||
ExpectedOutput string
|
||||
InputAlert alert.Alert
|
||||
ExpectedOutput Config
|
||||
}{
|
||||
{
|
||||
Name: "provider-no-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
To: "to@example.com",
|
||||
DefaultConfig: Config{
|
||||
From: "from@example.com",
|
||||
To: "to@example.com",
|
||||
},
|
||||
Overrides: nil,
|
||||
},
|
||||
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",
|
||||
Provider: AlertProvider{
|
||||
To: "to@example.com",
|
||||
DefaultConfig: Config{
|
||||
From: "from@example.com",
|
||||
To: "to@example.com",
|
||||
},
|
||||
Overrides: nil,
|
||||
},
|
||||
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",
|
||||
Provider: AlertProvider{
|
||||
To: "to@example.com",
|
||||
DefaultConfig: Config{
|
||||
From: "from@example.com",
|
||||
To: "to@example.com",
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
To: "to01@example.com",
|
||||
Group: "group",
|
||||
Config: Config{To: "groupto@example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
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",
|
||||
Provider: AlertProvider{
|
||||
To: "to@example.com",
|
||||
DefaultConfig: Config{
|
||||
From: "from@example.com",
|
||||
To: "to@example.com",
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
To: "to01@example.com",
|
||||
Group: "group",
|
||||
Config: Config{To: "groupto@example.com", SecretAccessKey: "wow", AccessKeyID: "noway"},
|
||||
},
|
||||
},
|
||||
},
|
||||
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 {
|
||||
t.Run(tt.Name, func(t *testing.T) {
|
||||
if got := tt.Provider.getToForGroup(tt.InputGroup); got != tt.ExpectedOutput {
|
||||
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
if got.From != scenario.ExpectedOutput.From {
|
||||
t.Errorf("expected From to be %s, got %s", scenario.ExpectedOutput.From, got.From)
|
||||
}
|
||||
if got.To != scenario.ExpectedOutput.To {
|
||||
t.Errorf("expected To to be %s, got %s", scenario.ExpectedOutput.To, got.To)
|
||||
}
|
||||
if got.AccessKeyID != scenario.ExpectedOutput.AccessKeyID {
|
||||
t.Errorf("expected AccessKeyID to be %s, got %s", scenario.ExpectedOutput.AccessKeyID, got.AccessKeyID)
|
||||
}
|
||||
if got.SecretAccessKey != scenario.ExpectedOutput.SecretAccessKey {
|
||||
t.Errorf("expected SecretAccessKey to be %s, got %s", scenario.ExpectedOutput.SecretAccessKey, got.SecretAccessKey)
|
||||
}
|
||||
if got.Region != scenario.ExpectedOutput.Region {
|
||||
t.Errorf("expected Region to be %s, got %s", scenario.ExpectedOutput.Region, got.Region)
|
||||
}
|
||||
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||
t.Errorf("unexpected error: %s", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package custom
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -10,11 +11,14 @@ import (
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"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
|
||||
// Technically, all alert providers should be reachable using the custom alert provider
|
||||
type AlertProvider struct {
|
||||
var (
|
||||
ErrURLNotSet = errors.New("url not set")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
URL string `yaml:"url"`
|
||||
Method string `yaml:"method,omitempty"`
|
||||
Body string `yaml:"body,omitempty"`
|
||||
@@ -23,66 +27,66 @@ type AlertProvider struct {
|
||||
|
||||
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
||||
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.URL) == 0 {
|
||||
return ErrURLNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if override.ClientConfig != nil {
|
||||
cfg.ClientConfig = override.ClientConfig
|
||||
}
|
||||
if len(override.URL) > 0 {
|
||||
cfg.URL = override.URL
|
||||
}
|
||||
if len(override.Method) > 0 {
|
||||
cfg.Method = override.Method
|
||||
}
|
||||
if len(override.Body) > 0 {
|
||||
cfg.Body = override.Body
|
||||
}
|
||||
if len(override.Headers) > 0 {
|
||||
cfg.Headers = override.Headers
|
||||
}
|
||||
if len(override.Placeholders) > 0 {
|
||||
cfg.Placeholders = override.Placeholders
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using a custom HTTP request
|
||||
// Technically, all alert providers should be reachable using the custom alert provider
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
|
||||
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||
Overrides []Override `yaml:"overrides,omitempty"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
if provider.ClientConfig == nil {
|
||||
provider.ClientConfig = client.GetDefaultConfig()
|
||||
}
|
||||
return len(provider.URL) > 0 && provider.ClientConfig != nil
|
||||
// Override is a case under which the default integration is overridden
|
||||
type Override struct {
|
||||
Group string `yaml:"group"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// GetAlertStatePlaceholderValue returns the Placeholder value for ALERT_TRIGGERED_OR_RESOLVED if configured
|
||||
func (provider *AlertProvider) GetAlertStatePlaceholderValue(resolved bool) string {
|
||||
status := "TRIGGERED"
|
||||
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, result *endpoint.Result, resolved bool) *http.Request {
|
||||
body, url, method := provider.Body, provider.URL, provider.Method
|
||||
body = strings.ReplaceAll(body, "[ALERT_DESCRIPTION]", alert.GetDescription())
|
||||
url = strings.ReplaceAll(url, "[ALERT_DESCRIPTION]", alert.GetDescription())
|
||||
body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", ep.Name)
|
||||
url = strings.ReplaceAll(url, "[ENDPOINT_NAME]", ep.Name)
|
||||
body = strings.ReplaceAll(body, "[ENDPOINT_GROUP]", ep.Group)
|
||||
url = strings.ReplaceAll(url, "[ENDPOINT_GROUP]", ep.Group)
|
||||
body = strings.ReplaceAll(body, "[ENDPOINT_URL]", ep.URL)
|
||||
url = strings.ReplaceAll(url, "[ENDPOINT_URL]", ep.URL)
|
||||
body = strings.ReplaceAll(body, "[RESULT_ERRORS]", strings.Join(result.Errors, ","))
|
||||
url = strings.ReplaceAll(url, "[RESULT_ERRORS]", strings.Join(result.Errors, ","))
|
||||
if resolved {
|
||||
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
|
||||
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
|
||||
} 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
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
request := provider.buildHTTPRequest(ep, alert, result, resolved)
|
||||
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request := provider.buildHTTPRequest(cfg, ep, alert, result, resolved)
|
||||
response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -94,7 +98,83 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -12,24 +12,18 @@ import (
|
||||
"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) {
|
||||
invalidProvider := AlertProvider{URL: ""}
|
||||
if invalidProvider.IsValid() {
|
||||
invalidProvider := AlertProvider{DefaultConfig: Config{URL: ""}}
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
})
|
||||
t.Run("valid-provider", func(t *testing.T) {
|
||||
validProvider := AlertProvider{URL: "https://example.com"}
|
||||
if validProvider.ClientConfig != nil {
|
||||
t.Error("provider client config should have been nil prior to IsValid() being executed")
|
||||
}
|
||||
if !validProvider.IsValid() {
|
||||
validProvider := AlertProvider{DefaultConfig: Config{URL: "https://example.com"}}
|
||||
if err := validProvider.Validate(); err != nil {
|
||||
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",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -57,7 +51,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -67,7 +61,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -77,7 +71,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved-error",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -111,9 +105,11 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildHTTPRequest(t *testing.T) {
|
||||
customAlertProvider := &AlertProvider{
|
||||
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]",
|
||||
alertProvider := &AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]&url=[ENDPOINT_URL]",
|
||||
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ENDPOINT_URL],[ALERT_TRIGGERED_OR_RESOLVED]",
|
||||
},
|
||||
}
|
||||
alertDescription := "alert-description"
|
||||
scenarios := []struct {
|
||||
@@ -123,13 +119,13 @@ func TestAlertProvider_buildHTTPRequest(t *testing.T) {
|
||||
ExpectedBody string
|
||||
}{
|
||||
{
|
||||
AlertProvider: customAlertProvider,
|
||||
AlertProvider: alertProvider,
|
||||
Resolved: true,
|
||||
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=RESOLVED&description=alert-description&url=https://example.com",
|
||||
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,RESOLVED",
|
||||
},
|
||||
{
|
||||
AlertProvider: customAlertProvider,
|
||||
AlertProvider: alertProvider,
|
||||
Resolved: false,
|
||||
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=TRIGGERED&description=alert-description&url=https://example.com",
|
||||
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,TRIGGERED",
|
||||
@@ -137,7 +133,8 @@ func TestAlertProvider_buildHTTPRequest(t *testing.T) {
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(fmt.Sprintf("resolved-%v-with-default-placeholders", scenario.Resolved), func(t *testing.T) {
|
||||
request := customAlertProvider.buildHTTPRequest(
|
||||
request := alertProvider.buildHTTPRequest(
|
||||
&alertProvider.DefaultConfig,
|
||||
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group", URL: "https://example.com"},
|
||||
&alert.Alert{Description: &alertDescription},
|
||||
&endpoint.Result{Errors: []string{}},
|
||||
@@ -155,9 +152,11 @@ func TestAlertProvider_buildHTTPRequest(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlertProviderWithResultErrors_buildHTTPRequest(t *testing.T) {
|
||||
customAlertWithErrorsProvider := &AlertProvider{
|
||||
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]&url=[ENDPOINT_URL]&error=[RESULT_ERRORS]",
|
||||
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ENDPOINT_URL],[ALERT_TRIGGERED_OR_RESOLVED],[RESULT_ERRORS]",
|
||||
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 {
|
||||
@@ -168,22 +167,30 @@ func TestAlertProviderWithResultErrors_buildHTTPRequest(t *testing.T) {
|
||||
Errors []string
|
||||
}{
|
||||
{
|
||||
AlertProvider: customAlertWithErrorsProvider,
|
||||
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: customAlertWithErrorsProvider,
|
||||
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 := customAlertWithErrorsProvider.buildHTTPRequest(
|
||||
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},
|
||||
@@ -201,14 +208,16 @@ func TestAlertProviderWithResultErrors_buildHTTPRequest(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
|
||||
customAlertProvider := &AlertProvider{
|
||||
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
||||
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
||||
Headers: nil,
|
||||
Placeholders: map[string]map[string]string{
|
||||
"ALERT_TRIGGERED_OR_RESOLVED": {
|
||||
"RESOLVED": "fixed",
|
||||
"TRIGGERED": "boom",
|
||||
alertProvider := &AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
||||
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
||||
Headers: nil,
|
||||
Placeholders: map[string]map[string]string{
|
||||
"ALERT_TRIGGERED_OR_RESOLVED": {
|
||||
"RESOLVED": "fixed",
|
||||
"TRIGGERED": "boom",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -220,13 +229,13 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
|
||||
ExpectedBody string
|
||||
}{
|
||||
{
|
||||
AlertProvider: customAlertProvider,
|
||||
AlertProvider: alertProvider,
|
||||
Resolved: true,
|
||||
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=fixed&description=alert-description",
|
||||
ExpectedBody: "endpoint-name,endpoint-group,alert-description,fixed",
|
||||
},
|
||||
{
|
||||
AlertProvider: customAlertProvider,
|
||||
AlertProvider: alertProvider,
|
||||
Resolved: false,
|
||||
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=boom&description=alert-description",
|
||||
ExpectedBody: "endpoint-name,endpoint-group,alert-description,boom",
|
||||
@@ -234,7 +243,8 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(fmt.Sprintf("resolved-%v-with-custom-placeholders", scenario.Resolved), func(t *testing.T) {
|
||||
request := customAlertProvider.buildHTTPRequest(
|
||||
request := alertProvider.buildHTTPRequest(
|
||||
&alertProvider.DefaultConfig,
|
||||
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||
&alert.Alert{Description: &alertDescription},
|
||||
&endpoint.Result{},
|
||||
@@ -252,15 +262,17 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
|
||||
customAlertProvider := &AlertProvider{
|
||||
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]",
|
||||
alertProvider := &AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
|
||||
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
|
||||
},
|
||||
}
|
||||
if customAlertProvider.GetAlertStatePlaceholderValue(true) != "RESOLVED" {
|
||||
t.Error("expected RESOLVED, got", customAlertProvider.GetAlertStatePlaceholderValue(true))
|
||||
if alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, true) != "RESOLVED" {
|
||||
t.Error("expected RESOLVED, got", alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, true))
|
||||
}
|
||||
if customAlertProvider.GetAlertStatePlaceholderValue(false) != "TRIGGERED" {
|
||||
t.Error("expected TRIGGERED, got", customAlertProvider.GetAlertStatePlaceholderValue(false))
|
||||
if alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, false) != "TRIGGERED" {
|
||||
t.Error("expected TRIGGERED, got", alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, false))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,3 +284,119 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
InputGroup string
|
||||
InputAlert alert.Alert
|
||||
ExpectedOutput Config
|
||||
}{
|
||||
{
|
||||
Name: "provider-no-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{URL: "http://example.com", Body: "default-body"},
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{URL: "http://example.com", Body: "default-body"},
|
||||
},
|
||||
{
|
||||
Name: "provider-no-override-specify-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{URL: "http://example.com"},
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "group",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{URL: "http://example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{URL: "http://example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
Config: Config{URL: "http://group-example.com", Headers: map[string]string{"Cache": "true"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{URL: "http://example.com", Headers: map[string]string{"Cache": "true"}},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-group-should-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{URL: "http://example.com", Body: "default-body"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
Config: Config{URL: "http://group-example.com", Body: "group-body"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "group",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{URL: "http://group-example.com", Body: "group-body"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{URL: "http://example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
Config: Config{URL: "http://group-example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "group",
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{"url": "http://alert-example.com", "body": "alert-body"}},
|
||||
ExpectedOutput: Config{URL: "http://alert-example.com", Body: "alert-body"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-partial-overrides",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{URL: "http://example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
Config: Config{Method: "POST"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "group",
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{"body": "alert-body"}},
|
||||
ExpectedOutput: Config{URL: "http://example.com", Body: "alert-body", Method: "POST"},
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
if got.URL != scenario.ExpectedOutput.URL {
|
||||
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.URL, got.URL)
|
||||
}
|
||||
if got.Body != scenario.ExpectedOutput.Body {
|
||||
t.Errorf("expected body to be %s, got %s", scenario.ExpectedOutput.Body, got.Body)
|
||||
}
|
||||
if got.Headers != nil {
|
||||
for key, value := range scenario.ExpectedOutput.Headers {
|
||||
if got.Headers[key] != value {
|
||||
t.Errorf("expected header %s to be %s, got %s", key, value, got.Headers[key])
|
||||
}
|
||||
}
|
||||
}
|
||||
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||
t.Errorf("unexpected error: %s", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package discord
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -10,46 +11,73 @@ import (
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrWebhookURLNotSet = errors.New("webhook-url not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
WebhookURL string `yaml:"webhook-url"`
|
||||
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.WebhookURL) == 0 {
|
||||
return ErrWebhookURLNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.WebhookURL) > 0 {
|
||||
cfg.WebhookURL = override.WebhookURL
|
||||
}
|
||||
if len(override.Title) > 0 {
|
||||
cfg.Title = override.Title
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Discord
|
||||
type AlertProvider struct {
|
||||
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 *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
|
||||
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||
Overrides []Override `yaml:"overrides,omitempty"`
|
||||
|
||||
// Title is the title of the message that will be sent
|
||||
Title string `yaml:"title,omitempty"`
|
||||
}
|
||||
|
||||
// Override is a case under which the default integration is overridden
|
||||
type Override struct {
|
||||
Group string `yaml:"group"`
|
||||
WebhookURL string `yaml:"webhook-url"`
|
||||
Group string `yaml:"group"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
|
||||
return false
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return len(provider.WebhookURL) > 0
|
||||
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 {
|
||||
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
||||
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer)
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -85,7 +113,7 @@ type Field struct {
|
||||
}
|
||||
|
||||
// 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 colorCode int
|
||||
if resolved {
|
||||
@@ -110,8 +138,8 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
||||
description = ":\n> " + alertDescription
|
||||
}
|
||||
title := ":helmet_with_white_cross: Gatus"
|
||||
if provider.Title != "" {
|
||||
title = provider.Title
|
||||
if cfg.Title != "" {
|
||||
title = cfg.Title
|
||||
}
|
||||
body := Body{
|
||||
Content: "",
|
||||
@@ -134,19 +162,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
||||
return bodyAsJSON
|
||||
}
|
||||
|
||||
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
|
||||
func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
return override.WebhookURL
|
||||
}
|
||||
}
|
||||
}
|
||||
return provider.WebhookURL
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// 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"
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
invalidProvider := AlertProvider{WebhookURL: ""}
|
||||
if invalidProvider.IsValid() {
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{WebhookURL: "http://example.com"}
|
||||
if !validProvider.IsValid() {
|
||||
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
|
||||
if err := validProvider.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
||||
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||
providerWithInvalidOverrideGroup := AlertProvider{
|
||||
Overrides: []Override{
|
||||
{
|
||||
WebhookURL: "http://example.com",
|
||||
Group: "",
|
||||
Config: Config{WebhookURL: "http://example.com"},
|
||||
Group: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
if providerWithInvalidOverrideGroup.IsValid() {
|
||||
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||
t.Error("provider Group shouldn't have been valid")
|
||||
}
|
||||
providerWithInvalidOverrideTo := AlertProvider{
|
||||
Overrides: []Override{
|
||||
{
|
||||
WebhookURL: "",
|
||||
Group: "group",
|
||||
Config: Config{WebhookURL: ""},
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if providerWithInvalidOverrideTo.IsValid() {
|
||||
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
|
||||
t.Error("provider integration key shouldn't have been valid")
|
||||
}
|
||||
providerWithValidOverride := AlertProvider{
|
||||
WebhookURL: "http://example.com",
|
||||
DefaultConfig: Config{
|
||||
WebhookURL: "http://example.com",
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
WebhookURL: "http://example.com",
|
||||
Group: "group",
|
||||
Config: Config{WebhookURL: "http://example.com"},
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if !providerWithValidOverride.IsValid() {
|
||||
if err := providerWithValidOverride.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
@@ -74,7 +76,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -84,7 +86,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -94,7 +96,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -104,7 +106,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved-error",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -114,7 +116,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
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},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -122,6 +124,16 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "triggered-with-webhook-override",
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3, ProviderOverride: map[string]any{"webhook-url": "http://example01.com"}},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
@@ -175,7 +187,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "triggered-with-modified-title",
|
||||
Provider: AlertProvider{Title: title},
|
||||
Provider: AlertProvider{DefaultConfig: Config{Title: title}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\"provider-title\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332,\"fields\":[{\"name\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n:x: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}",
|
||||
@@ -183,7 +195,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
{
|
||||
Name: "triggered-with-no-conditions",
|
||||
NoConditions: true,
|
||||
Provider: AlertProvider{Title: title},
|
||||
Provider: AlertProvider{DefaultConfig: Config{Title: title}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\"provider-title\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332}]}",
|
||||
@@ -200,6 +212,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
&scenario.Provider.DefaultConfig,
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
@@ -227,64 +240,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
|
||||
tests := []struct {
|
||||
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
InputGroup string
|
||||
ExpectedOutput string
|
||||
InputAlert alert.Alert
|
||||
ExpectedOutput Config
|
||||
}{
|
||||
{
|
||||
Name: "provider-no-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
WebhookURL: "http://example.com",
|
||||
Overrides: nil,
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "",
|
||||
ExpectedOutput: "http://example.com",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-no-override-specify-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
WebhookURL: "http://example.com",
|
||||
Overrides: nil,
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "group",
|
||||
ExpectedOutput: "http://example.com",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
WebhookURL: "http://example.com",
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
WebhookURL: "http://example01.com",
|
||||
Group: "group",
|
||||
Config: Config{WebhookURL: "http://group-example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "",
|
||||
ExpectedOutput: "http://example.com",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-group-should-override",
|
||||
Provider: AlertProvider{
|
||||
WebhookURL: "http://example.com",
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
WebhookURL: "http://example01.com",
|
||||
Group: "group",
|
||||
Config: Config{WebhookURL: "http://group-example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
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 {
|
||||
t.Run(tt.Name, func(t *testing.T) {
|
||||
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
|
||||
t.Errorf("AlertProvider.getWebhookURLForGroup() = %v, want %v", got, tt.ExpectedOutput)
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
|
||||
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
|
||||
}
|
||||
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||
t.Errorf("unexpected error: %s", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package email
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
@@ -10,10 +11,17 @@ import (
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
gomail "gopkg.in/mail.v2"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using SMTP
|
||||
type AlertProvider struct {
|
||||
var (
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
ErrMissingFromOrToFields = errors.New("from and to fields are required")
|
||||
ErrInvalidPort = errors.New("port must be between 1 and 65535 inclusively")
|
||||
ErrMissingHost = errors.New("host is required")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
From string `yaml:"from"`
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
@@ -23,6 +31,48 @@ type AlertProvider struct {
|
||||
|
||||
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
||||
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.From) == 0 || len(cfg.To) == 0 {
|
||||
return ErrMissingFromOrToFields
|
||||
}
|
||||
if cfg.Port < 1 || cfg.Port > math.MaxUint16 {
|
||||
return ErrInvalidPort
|
||||
}
|
||||
if len(cfg.Host) == 0 {
|
||||
return ErrMissingHost
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if override.ClientConfig != nil {
|
||||
cfg.ClientConfig = override.ClientConfig
|
||||
}
|
||||
if len(override.From) > 0 {
|
||||
cfg.From = override.From
|
||||
}
|
||||
if len(override.Username) > 0 {
|
||||
cfg.Username = override.Username
|
||||
}
|
||||
if len(override.Password) > 0 {
|
||||
cfg.Password = override.Password
|
||||
}
|
||||
if len(override.Host) > 0 {
|
||||
cfg.Host = override.Host
|
||||
}
|
||||
if override.Port > 0 {
|
||||
cfg.Port = override.Port
|
||||
}
|
||||
if len(override.To) > 0 {
|
||||
cfg.To = override.To
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using SMTP
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
@@ -33,54 +83,57 @@ type AlertProvider struct {
|
||||
|
||||
// Override is a case under which the default integration is overridden
|
||||
type Override struct {
|
||||
Group string `yaml:"group"`
|
||||
To string `yaml:"to"`
|
||||
Group string `yaml:"group"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.To) == 0 {
|
||||
return false
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
|
||||
return len(provider.From) > 0 && len(provider.Host) > 0 && len(provider.To) > 0 && provider.Port > 0 && provider.Port < math.MaxUint16
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var username string
|
||||
if len(provider.Username) > 0 {
|
||||
username = provider.Username
|
||||
if len(cfg.Username) > 0 {
|
||||
username = cfg.Username
|
||||
} else {
|
||||
username = provider.From
|
||||
username = cfg.From
|
||||
}
|
||||
subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
|
||||
m := gomail.NewMessage()
|
||||
m.SetHeader("From", provider.From)
|
||||
m.SetHeader("To", strings.Split(provider.getToForGroup(ep.Group), ",")...)
|
||||
m.SetHeader("From", cfg.From)
|
||||
m.SetHeader("To", strings.Split(cfg.To, ",")...)
|
||||
m.SetHeader("Subject", subject)
|
||||
m.SetBody("text/plain", body)
|
||||
var d *gomail.Dialer
|
||||
if len(provider.Password) == 0 {
|
||||
if len(cfg.Password) == 0 {
|
||||
// Get the domain in the From address
|
||||
localName := "localhost"
|
||||
fromParts := strings.Split(provider.From, `@`)
|
||||
fromParts := strings.Split(cfg.From, `@`)
|
||||
if len(fromParts) == 2 {
|
||||
localName = fromParts[1]
|
||||
}
|
||||
// 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 {
|
||||
// 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}
|
||||
}
|
||||
return d.DialAndSend(m)
|
||||
@@ -116,19 +169,38 @@ func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint,
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -7,61 +7,63 @@ import (
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
)
|
||||
|
||||
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
invalidProvider := AlertProvider{}
|
||||
if invalidProvider.IsValid() {
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
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"}
|
||||
if !validProvider.IsValid() {
|
||||
validProvider := AlertProvider{DefaultConfig: Config{From: "from@example.com", Password: "password", Host: "smtp.gmail.com", Port: 587, To: "to@example.com"}}
|
||||
if err := validProvider.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_IsValidWithNoCredentials(t *testing.T) {
|
||||
validProvider := AlertProvider{From: "from@example.com", Host: "smtp-relay.gmail.com", Port: 587, To: "to@example.com"}
|
||||
if !validProvider.IsValid() {
|
||||
func TestAlertProvider_ValidateWithNoCredentials(t *testing.T) {
|
||||
validProvider := AlertProvider{DefaultConfig: Config{From: "from@example.com", Host: "smtp-relay.gmail.com", Port: 587, To: "to@example.com"}}
|
||||
if err := validProvider.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
||||
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||
providerWithInvalidOverrideGroup := AlertProvider{
|
||||
Overrides: []Override{
|
||||
{
|
||||
To: "to@example.com",
|
||||
Group: "",
|
||||
Config: Config{To: "to@example.com"},
|
||||
Group: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
if providerWithInvalidOverrideGroup.IsValid() {
|
||||
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||
t.Error("provider Group shouldn't have been valid")
|
||||
}
|
||||
providerWithInvalidOverrideTo := AlertProvider{
|
||||
Overrides: []Override{
|
||||
{
|
||||
To: "",
|
||||
Group: "group",
|
||||
Config: Config{To: ""},
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if providerWithInvalidOverrideTo.IsValid() {
|
||||
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
|
||||
t.Error("provider integration key shouldn't have been valid")
|
||||
}
|
||||
providerWithValidOverride := AlertProvider{
|
||||
From: "from@example.com",
|
||||
Password: "password",
|
||||
Host: "smtp.gmail.com",
|
||||
Port: 587,
|
||||
To: "to@example.com",
|
||||
DefaultConfig: Config{
|
||||
From: "from@example.com",
|
||||
Password: "password",
|
||||
Host: "smtp.gmail.com",
|
||||
Port: 587,
|
||||
To: "to@example.com",
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
To: "to@example.com",
|
||||
Group: "group",
|
||||
Config: Config{To: "to@example.com"},
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if !providerWithValidOverride.IsValid() {
|
||||
if err := providerWithValidOverride.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
@@ -126,64 +128,104 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_getToForGroup(t *testing.T) {
|
||||
tests := []struct {
|
||||
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
InputGroup string
|
||||
ExpectedOutput string
|
||||
InputAlert alert.Alert
|
||||
ExpectedOutput Config
|
||||
}{
|
||||
{
|
||||
Name: "provider-no-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
To: "to@example.com",
|
||||
Overrides: nil,
|
||||
DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
|
||||
Overrides: nil,
|
||||
},
|
||||
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",
|
||||
Provider: AlertProvider{
|
||||
To: "to@example.com",
|
||||
Overrides: nil,
|
||||
DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
|
||||
Overrides: nil,
|
||||
},
|
||||
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",
|
||||
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{
|
||||
{
|
||||
Group: "group",
|
||||
To: "to01@example.com",
|
||||
Group: "group",
|
||||
Config: Config{To: "to01@example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
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",
|
||||
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{
|
||||
{
|
||||
Group: "group",
|
||||
To: "to01@example.com",
|
||||
Group: "group",
|
||||
Config: Config{To: "group-to@example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
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 {
|
||||
t.Run(tt.Name, func(t *testing.T) {
|
||||
if got := tt.Provider.getToForGroup(tt.InputGroup); got != tt.ExpectedOutput {
|
||||
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
if got.From != scenario.ExpectedOutput.From {
|
||||
t.Errorf("expected from to be %s, got %s", scenario.ExpectedOutput.From, got.From)
|
||||
}
|
||||
if got.To != scenario.ExpectedOutput.To {
|
||||
t.Errorf("expected to be %s, got %s", scenario.ExpectedOutput.To, got.To)
|
||||
}
|
||||
if got.Host != scenario.ExpectedOutput.Host {
|
||||
t.Errorf("expected host to be %s, got %s", scenario.ExpectedOutput.Host, got.Host)
|
||||
}
|
||||
if got.Port != scenario.ExpectedOutput.Port {
|
||||
t.Errorf("expected port to be %d, got %d", scenario.ExpectedOutput.Port, got.Port)
|
||||
}
|
||||
if got.Password != scenario.ExpectedOutput.Password {
|
||||
t.Errorf("expected password to be %s, got %s", scenario.ExpectedOutput.Password, got.Password)
|
||||
}
|
||||
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||
t.Errorf("unexpected error: %s", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package gitea
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -11,55 +12,56 @@ import (
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Discord
|
||||
type AlertProvider struct {
|
||||
RepositoryURL string `yaml:"repository-url"` // The URL of the Gitea repository to create issues in
|
||||
Token string `yaml:"token"` // Token requires at least RW on issues and RO on metadata
|
||||
var (
|
||||
ErrRepositoryURLNotSet = errors.New("repository-url not set")
|
||||
ErrInvalidRepositoryURL = errors.New("invalid repository-url")
|
||||
ErrTokenNotSet = errors.New("token not set")
|
||||
)
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
|
||||
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
||||
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||
|
||||
// Assignees is a list of users to assign the issue to
|
||||
Assignees []string `yaml:"assignees,omitempty"`
|
||||
type Config struct {
|
||||
RepositoryURL string `yaml:"repository-url"` // The URL of the Gitea repository to create issues in
|
||||
Token string `yaml:"token"` // Token requires at least RW on issues and RO on metadata
|
||||
Assignees []string `yaml:"assignees,omitempty"` // Assignees is a list of users to assign the issue to
|
||||
|
||||
username string
|
||||
repositoryOwner string
|
||||
repositoryName string
|
||||
giteaClient *gitea.Client
|
||||
|
||||
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
||||
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
if provider.ClientConfig == nil {
|
||||
provider.ClientConfig = client.GetDefaultConfig()
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.RepositoryURL) == 0 {
|
||||
return ErrRepositoryURLNotSet
|
||||
}
|
||||
|
||||
if len(provider.Token) == 0 || len(provider.RepositoryURL) == 0 {
|
||||
return false
|
||||
if len(cfg.Token) == 0 {
|
||||
return ErrTokenNotSet
|
||||
}
|
||||
// Validate format of the repository URL
|
||||
repositoryURL, err := url.Parse(provider.RepositoryURL)
|
||||
repositoryURL, err := url.Parse(cfg.RepositoryURL)
|
||||
if err != nil {
|
||||
return false
|
||||
return err
|
||||
}
|
||||
baseURL := repositoryURL.Scheme + "://" + repositoryURL.Host
|
||||
pathParts := strings.Split(repositoryURL.Path, "/")
|
||||
if len(pathParts) != 3 {
|
||||
return false
|
||||
return ErrInvalidRepositoryURL
|
||||
}
|
||||
provider.repositoryOwner = pathParts[1]
|
||||
provider.repositoryName = pathParts[2]
|
||||
|
||||
if cfg.repositoryOwner == pathParts[1] && cfg.repositoryName == pathParts[2] && cfg.giteaClient != nil {
|
||||
// Already validated, let's skip the rest of the validation to avoid unnecessary API calls
|
||||
return nil
|
||||
}
|
||||
cfg.repositoryOwner = pathParts[1]
|
||||
cfg.repositoryName = pathParts[2]
|
||||
opts := []gitea.ClientOption{
|
||||
gitea.SetToken(provider.Token),
|
||||
gitea.SetToken(cfg.Token),
|
||||
}
|
||||
|
||||
if provider.ClientConfig != nil && provider.ClientConfig.Insecure {
|
||||
if cfg.ClientConfig != nil && cfg.ClientConfig.Insecure {
|
||||
// add new http client for skip verify
|
||||
httpClient := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
@@ -68,34 +70,62 @@ func (provider *AlertProvider) IsValid() bool {
|
||||
}
|
||||
opts = append(opts, gitea.SetHTTPClient(httpClient))
|
||||
}
|
||||
|
||||
provider.giteaClient, err = gitea.NewClient(baseURL, opts...)
|
||||
cfg.giteaClient, err = gitea.NewClient(baseURL, opts...)
|
||||
if err != nil {
|
||||
return false
|
||||
return err
|
||||
}
|
||||
|
||||
user, _, err := provider.giteaClient.GetMyUserInfo()
|
||||
user, _, err := cfg.giteaClient.GetMyUserInfo()
|
||||
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,
|
||||
// or closes the relevant issue(s) if the resolved parameter passed is true.
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
title := "alert(gatus): " + ep.DisplayName()
|
||||
if !resolved {
|
||||
_, _, err := provider.giteaClient.CreateIssue(
|
||||
provider.repositoryOwner,
|
||||
provider.repositoryName,
|
||||
_, _, err = cfg.giteaClient.CreateIssue(
|
||||
cfg.repositoryOwner,
|
||||
cfg.repositoryName,
|
||||
gitea.CreateIssueOption{
|
||||
Title: title,
|
||||
Body: provider.buildIssueBody(ep, alert, result),
|
||||
Assignees: provider.Assignees,
|
||||
Assignees: cfg.Assignees,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
@@ -103,29 +133,27 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
issues, _, err := provider.giteaClient.ListRepoIssues(
|
||||
provider.repositoryOwner,
|
||||
provider.repositoryName,
|
||||
issues, _, err := cfg.giteaClient.ListRepoIssues(
|
||||
cfg.repositoryOwner,
|
||||
cfg.repositoryName,
|
||||
gitea.ListIssueOption{
|
||||
State: gitea.StateOpen,
|
||||
CreatedBy: provider.username,
|
||||
CreatedBy: cfg.username,
|
||||
ListOptions: gitea.ListOptions{
|
||||
Page: 100,
|
||||
PageSize: 100,
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list issues: %w", err)
|
||||
}
|
||||
|
||||
for _, issue := range issues {
|
||||
if issue.Title == title {
|
||||
stateClosed := gitea.StateClosed
|
||||
_, _, err = provider.giteaClient.EditIssue(
|
||||
provider.repositoryOwner,
|
||||
provider.repositoryName,
|
||||
issue.ID,
|
||||
_, _, err = cfg.giteaClient.EditIssue(
|
||||
cfg.repositoryOwner,
|
||||
cfg.repositoryName,
|
||||
issue.Index,
|
||||
gitea.EditIssueOption{
|
||||
State: &stateClosed,
|
||||
},
|
||||
@@ -165,3 +193,25 @@ func (provider *AlertProvider) buildIssueBody(ep *endpoint.Endpoint, alert *aler
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration (we're returning the cfg here even if there's an error mostly for testing purposes)
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -12,42 +12,46 @@ import (
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Expected bool
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
ExpectedError bool
|
||||
}{
|
||||
{
|
||||
Name: "invalid",
|
||||
Provider: AlertProvider{RepositoryURL: "", Token: ""},
|
||||
Expected: false,
|
||||
Name: "invalid",
|
||||
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "", Token: ""}},
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "invalid-token",
|
||||
Provider: AlertProvider{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"},
|
||||
Expected: false,
|
||||
Name: "invalid-token",
|
||||
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"}},
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "missing-repository-name",
|
||||
Provider: AlertProvider{RepositoryURL: "https://gitea.com/TwiN", Token: "12345"},
|
||||
Expected: false,
|
||||
Name: "missing-repository-name",
|
||||
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN", Token: "12345"}},
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "enterprise-client",
|
||||
Provider: AlertProvider{RepositoryURL: "https://gitea.example.com/TwiN/test", Token: "12345"},
|
||||
Expected: false,
|
||||
Name: "enterprise-client",
|
||||
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.example.com/TwiN/test", Token: "12345"}},
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "invalid-url",
|
||||
Provider: AlertProvider{RepositoryURL: "gitea.com/TwiN/test", Token: "12345"},
|
||||
Expected: false,
|
||||
Name: "invalid-url",
|
||||
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "gitea.com/TwiN/test", Token: "12345"}},
|
||||
ExpectedError: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
if scenario.Provider.IsValid() != scenario.Expected {
|
||||
t.Errorf("expected %t, got %t", scenario.Expected, scenario.Provider.IsValid())
|
||||
err := scenario.Provider.Validate()
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -67,14 +71,14 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
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},
|
||||
Resolved: false,
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
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},
|
||||
Resolved: true,
|
||||
ExpectedError: true,
|
||||
@@ -82,9 +86,13 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
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})
|
||||
err := scenario.Provider.Send(
|
||||
err = scenario.Provider.Send(
|
||||
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
@@ -167,3 +175,55 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
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 (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -11,69 +12,104 @@ import (
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/google/go-github/v48/github"
|
||||
"golang.org/x/oauth2"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Discord
|
||||
type AlertProvider struct {
|
||||
var (
|
||||
ErrRepositoryURLNotSet = errors.New("repository-url not set")
|
||||
ErrInvalidRepositoryURL = errors.New("invalid repository-url")
|
||||
ErrTokenNotSet = errors.New("token not set")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
RepositoryURL string `yaml:"repository-url"` // The URL of the GitHub repository to create issues in
|
||||
Token string `yaml:"token"` // Token requires at least RW on issues and RO on metadata
|
||||
|
||||
// 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
|
||||
repositoryOwner string
|
||||
repositoryName string
|
||||
githubClient *github.Client
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
if len(provider.Token) == 0 || len(provider.RepositoryURL) == 0 {
|
||||
return false
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.RepositoryURL) == 0 {
|
||||
return ErrRepositoryURLNotSet
|
||||
}
|
||||
if len(cfg.Token) == 0 {
|
||||
return ErrTokenNotSet
|
||||
}
|
||||
// Validate format of the repository URL
|
||||
repositoryURL, err := url.Parse(provider.RepositoryURL)
|
||||
repositoryURL, err := url.Parse(cfg.RepositoryURL)
|
||||
if err != nil {
|
||||
return false
|
||||
return err
|
||||
}
|
||||
baseURL := repositoryURL.Scheme + "://" + repositoryURL.Host
|
||||
pathParts := strings.Split(repositoryURL.Path, "/")
|
||||
if len(pathParts) != 3 {
|
||||
return false
|
||||
return ErrInvalidRepositoryURL
|
||||
}
|
||||
provider.repositoryOwner = pathParts[1]
|
||||
provider.repositoryName = pathParts[2]
|
||||
if cfg.repositoryOwner == pathParts[1] && cfg.repositoryName == pathParts[2] && cfg.githubClient != nil {
|
||||
// Already validated, let's skip the rest of the validation to avoid unnecessary API calls
|
||||
return nil
|
||||
}
|
||||
cfg.repositoryOwner = pathParts[1]
|
||||
cfg.repositoryName = pathParts[2]
|
||||
// Create oauth2 HTTP client with GitHub token
|
||||
httpClientWithStaticTokenSource := oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(&oauth2.Token{
|
||||
AccessToken: provider.Token,
|
||||
AccessToken: cfg.Token,
|
||||
}))
|
||||
// Create GitHub client
|
||||
if baseURL == "https://github.com" {
|
||||
provider.githubClient = github.NewClient(httpClientWithStaticTokenSource)
|
||||
cfg.githubClient = github.NewClient(httpClientWithStaticTokenSource)
|
||||
} else {
|
||||
provider.githubClient, err = github.NewEnterpriseClient(baseURL, baseURL, httpClientWithStaticTokenSource)
|
||||
cfg.githubClient, err = github.NewEnterpriseClient(baseURL, baseURL, httpClientWithStaticTokenSource)
|
||||
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
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
user, _, err := provider.githubClient.Users.Get(ctx, "")
|
||||
user, _, err := cfg.githubClient.Users.Get(ctx, "")
|
||||
if err != nil {
|
||||
return false
|
||||
return fmt.Errorf("failed to retrieve GitHub user: %w", err)
|
||||
}
|
||||
provider.username = *user.Login
|
||||
return true
|
||||
cfg.username = *user.Login
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.RepositoryURL) > 0 {
|
||||
cfg.RepositoryURL = override.RepositoryURL
|
||||
}
|
||||
if len(override.Token) > 0 {
|
||||
cfg.Token = override.Token
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Discord
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
}
|
||||
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
|
||||
// or closes the relevant issue(s) if the resolved parameter passed is true.
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
title := "alert(gatus): " + ep.DisplayName()
|
||||
if !resolved {
|
||||
_, _, err := 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),
|
||||
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)
|
||||
}
|
||||
} 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",
|
||||
Creator: provider.username,
|
||||
Creator: cfg.username,
|
||||
ListOptions: github.ListOptions{PerPage: 100},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -91,7 +127,7 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
||||
}
|
||||
for _, issue := range issues {
|
||||
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"),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -130,3 +166,25 @@ func (provider *AlertProvider) buildIssueBody(ep *endpoint.Endpoint, alert *aler
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration (we're returning the cfg here even if there's an error mostly for testing purposes)
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -12,42 +12,46 @@ import (
|
||||
"github.com/google/go-github/v48/github"
|
||||
)
|
||||
|
||||
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Expected bool
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
ExpectedError bool
|
||||
}{
|
||||
{
|
||||
Name: "invalid",
|
||||
Provider: AlertProvider{RepositoryURL: "", Token: ""},
|
||||
Expected: false,
|
||||
Name: "invalid",
|
||||
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "", Token: ""}},
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "invalid-token",
|
||||
Provider: AlertProvider{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"},
|
||||
Expected: false,
|
||||
Name: "invalid-token",
|
||||
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"}},
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "missing-repository-name",
|
||||
Provider: AlertProvider{RepositoryURL: "https://github.com/TwiN", Token: "12345"},
|
||||
Expected: false,
|
||||
Name: "missing-repository-name",
|
||||
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN", Token: "12345"}},
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "enterprise-client",
|
||||
Provider: AlertProvider{RepositoryURL: "https://github.example.com/TwiN/test", Token: "12345"},
|
||||
Expected: false,
|
||||
Name: "enterprise-client",
|
||||
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.example.com/TwiN/test", Token: "12345"}},
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "invalid-url",
|
||||
Provider: AlertProvider{RepositoryURL: "github.com/TwiN/test", Token: "12345"},
|
||||
Expected: false,
|
||||
Name: "invalid-url",
|
||||
Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "github.com/TwiN/test", Token: "12345"}},
|
||||
ExpectedError: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
if scenario.Provider.IsValid() != scenario.Expected {
|
||||
t.Errorf("expected %t, got %t", scenario.Expected, scenario.Provider.IsValid())
|
||||
err := scenario.Provider.Validate()
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -67,14 +71,14 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
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},
|
||||
Resolved: false,
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
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},
|
||||
Resolved: true,
|
||||
ExpectedError: true,
|
||||
@@ -82,9 +86,13 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
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})
|
||||
err := scenario.Provider.Send(
|
||||
err = scenario.Provider.Send(
|
||||
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
@@ -167,3 +175,47 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
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/config/endpoint"
|
||||
"github.com/google/uuid"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultSeverity = "critical"
|
||||
DefaultMonitoringTool = "gatus"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidWebhookURL = fmt.Errorf("invalid webhook-url")
|
||||
ErrAuthorizationKeyNotSet = fmt.Errorf("authorization-key not set")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
WebhookURL string `yaml:"webhook-url"` // The webhook url provided by GitLab
|
||||
AuthorizationKey string `yaml:"authorization-key"` // The authorization key provided by GitLab
|
||||
Severity string `yaml:"severity,omitempty"` // Severity can be one of: critical, high, medium, low, info, unknown. Defaults to critical
|
||||
MonitoringTool string `yaml:"monitoring-tool,omitempty"` // MonitoringTool overrides the name sent to gitlab. Defaults to gatus
|
||||
EnvironmentName string `yaml:"environment-name,omitempty"` // EnvironmentName is the name of the associated GitLab environment. Required to display alerts on a dashboard.
|
||||
Service string `yaml:"service,omitempty"` // Service affected. Defaults to the endpoint's display name
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.WebhookURL) == 0 {
|
||||
return ErrInvalidWebhookURL
|
||||
} else if _, err := url.Parse(cfg.WebhookURL); err != nil {
|
||||
return ErrInvalidWebhookURL
|
||||
}
|
||||
if len(cfg.AuthorizationKey) == 0 {
|
||||
return ErrAuthorizationKeyNotSet
|
||||
}
|
||||
if len(cfg.Severity) == 0 {
|
||||
cfg.Severity = DefaultSeverity
|
||||
}
|
||||
if len(cfg.MonitoringTool) == 0 {
|
||||
cfg.MonitoringTool = DefaultMonitoringTool
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.WebhookURL) > 0 {
|
||||
cfg.WebhookURL = override.WebhookURL
|
||||
}
|
||||
if len(override.AuthorizationKey) > 0 {
|
||||
cfg.AuthorizationKey = override.AuthorizationKey
|
||||
}
|
||||
if len(override.Severity) > 0 {
|
||||
cfg.Severity = override.Severity
|
||||
}
|
||||
if len(override.MonitoringTool) > 0 {
|
||||
cfg.MonitoringTool = override.MonitoringTool
|
||||
}
|
||||
if len(override.EnvironmentName) > 0 {
|
||||
cfg.EnvironmentName = override.EnvironmentName
|
||||
}
|
||||
if len(override.Service) > 0 {
|
||||
cfg.Service = override.Service
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using GitLab
|
||||
type AlertProvider struct {
|
||||
WebhookURL string `yaml:"webhook-url"` // The webhook url provided by GitLab
|
||||
AuthorizationKey string `yaml:"authorization-key"` // The authorization key provided by GitLab
|
||||
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"`
|
||||
|
||||
// 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
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
if len(provider.AuthorizationKey) == 0 || len(provider.WebhookURL) == 0 {
|
||||
return false
|
||||
}
|
||||
// Validate format of the repository URL
|
||||
_, err := url.Parse(provider.WebhookURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
|
||||
// or closes the relevant issue(s) if the resolved parameter passed is true.
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(alert.ResolveKey) == 0 {
|
||||
alert.ResolveKey = uuid.NewString()
|
||||
}
|
||||
buffer := bytes.NewBuffer(provider.buildAlertBody(ep, alert, result, resolved))
|
||||
request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer)
|
||||
buffer := bytes.NewBuffer(provider.buildAlertBody(cfg, ep, alert, result, resolved))
|
||||
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", provider.AuthorizationKey))
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.AuthorizationKey))
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
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.
|
||||
}
|
||||
|
||||
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
|
||||
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{
|
||||
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),
|
||||
Service: provider.service(ep),
|
||||
MonitoringTool: provider.monitoringTool(),
|
||||
Service: service,
|
||||
MonitoringTool: cfg.MonitoringTool,
|
||||
Hosts: ep.URL,
|
||||
GitlabEnvironmentName: provider.EnvironmentName,
|
||||
Severity: provider.Severity,
|
||||
GitlabEnvironmentName: cfg.EnvironmentName,
|
||||
Severity: cfg.Severity,
|
||||
Fingerprint: alert.ResolveKey,
|
||||
}
|
||||
if resolved {
|
||||
@@ -148,3 +180,25 @@ func (provider *AlertProvider) buildAlertBody(ep *endpoint.Endpoint, alert *aler
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration (we're returning the cfg here even if there's an error mostly for testing purposes)
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -11,37 +11,41 @@ import (
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Expected bool
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
ExpectedError bool
|
||||
}{
|
||||
{
|
||||
Name: "invalid",
|
||||
Provider: AlertProvider{WebhookURL: "", AuthorizationKey: ""},
|
||||
Expected: false,
|
||||
Name: "invalid",
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "", AuthorizationKey: ""}},
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "missing-webhook-url",
|
||||
Provider: AlertProvider{WebhookURL: "", AuthorizationKey: "12345"},
|
||||
Expected: false,
|
||||
Name: "missing-webhook-url",
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "", AuthorizationKey: "12345"}},
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "missing-authorization-key",
|
||||
Provider: AlertProvider{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: ""},
|
||||
Expected: false,
|
||||
Name: "missing-authorization-key",
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/whatever/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: ""}},
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "invalid-url",
|
||||
Provider: AlertProvider{WebhookURL: " http://foo.com", AuthorizationKey: "12345"},
|
||||
Expected: false,
|
||||
Name: "invalid-url",
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: " http://foo.com", AuthorizationKey: "12345"}},
|
||||
ExpectedError: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
if scenario.Provider.IsValid() != scenario.Expected {
|
||||
t.Errorf("expected %t, got %t", scenario.Expected, scenario.Provider.IsValid())
|
||||
err := scenario.Provider.Validate()
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -61,7 +65,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
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},
|
||||
Resolved: false,
|
||||
ExpectedError: false,
|
||||
@@ -71,7 +75,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
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},
|
||||
Resolved: true,
|
||||
ExpectedError: false,
|
||||
@@ -116,21 +120,26 @@ func TestAlertProvider_buildAlertBody(t *testing.T) {
|
||||
{
|
||||
Name: "triggered",
|
||||
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},
|
||||
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",
|
||||
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},
|
||||
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 {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
cfg, err := scenario.Provider.GetConfig("", &scenario.Alert)
|
||||
if err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
body := scenario.Provider.buildAlertBody(
|
||||
cfg,
|
||||
&scenario.Endpoint,
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
@@ -156,3 +165,59 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
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 (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -10,14 +11,38 @@ import (
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrWebhookURLNotSet = errors.New("webhook-url not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
WebhookURL string `yaml:"webhook-url"`
|
||||
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.WebhookURL) == 0 {
|
||||
return ErrWebhookURLNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if override.ClientConfig != nil {
|
||||
cfg.ClientConfig = override.ClientConfig
|
||||
}
|
||||
if len(override.WebhookURL) > 0 {
|
||||
cfg.WebhookURL = override.WebhookURL
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Google chat
|
||||
type AlertProvider struct {
|
||||
WebhookURL string `yaml:"webhook-url"`
|
||||
|
||||
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
||||
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||
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"`
|
||||
@@ -28,36 +53,37 @@ type AlertProvider struct {
|
||||
|
||||
// Override is a case under which the default integration is overridden
|
||||
type Override struct {
|
||||
Group string `yaml:"group"`
|
||||
WebhookURL string `yaml:"webhook-url"`
|
||||
Group string `yaml:"group"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
if provider.ClientConfig == nil {
|
||||
provider.ClientConfig = client.GetDefaultConfig()
|
||||
}
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
|
||||
return false
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return len(provider.WebhookURL) > 0
|
||||
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, provider.getWebhookURLForGroup(ep.Group), buffer)
|
||||
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(provider.ClientConfig).Do(request)
|
||||
response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -185,19 +211,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
||||
return bodyAsJSON
|
||||
}
|
||||
|
||||
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
|
||||
func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
return override.WebhookURL
|
||||
}
|
||||
}
|
||||
}
|
||||
return provider.WebhookURL
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// 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"
|
||||
)
|
||||
|
||||
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
||||
invalidProvider := AlertProvider{WebhookURL: ""}
|
||||
if invalidProvider.IsValid() {
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{WebhookURL: "http://example.com"}
|
||||
if !validProvider.IsValid() {
|
||||
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
|
||||
if err := validProvider.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
||||
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||
providerWithInvalidOverrideGroup := AlertProvider{
|
||||
Overrides: []Override{
|
||||
{
|
||||
WebhookURL: "http://example.com",
|
||||
Group: "",
|
||||
Config: Config{WebhookURL: "http://example.com"},
|
||||
Group: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
if providerWithInvalidOverrideGroup.IsValid() {
|
||||
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||
t.Error("provider Group shouldn't have been valid")
|
||||
}
|
||||
providerWithInvalidOverrideTo := AlertProvider{
|
||||
Overrides: []Override{
|
||||
{
|
||||
WebhookURL: "",
|
||||
Group: "group",
|
||||
Config: Config{WebhookURL: ""},
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if providerWithInvalidOverrideTo.IsValid() {
|
||||
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
|
||||
t.Error("provider integration key shouldn't have been valid")
|
||||
}
|
||||
providerWithValidOverride := AlertProvider{
|
||||
WebhookURL: "http://example.com",
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
WebhookURL: "http://example.com",
|
||||
Group: "group",
|
||||
Config: Config{WebhookURL: "http://example.com"},
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if !providerWithValidOverride.IsValid() {
|
||||
if err := providerWithValidOverride.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -83,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -93,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -103,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved-error",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -213,64 +213,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
|
||||
tests := []struct {
|
||||
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
InputGroup string
|
||||
ExpectedOutput string
|
||||
InputAlert alert.Alert
|
||||
ExpectedOutput Config
|
||||
}{
|
||||
{
|
||||
Name: "provider-no-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
WebhookURL: "http://example.com",
|
||||
Overrides: nil,
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "",
|
||||
ExpectedOutput: "http://example.com",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-no-override-specify-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
WebhookURL: "http://example.com",
|
||||
Overrides: nil,
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "group",
|
||||
ExpectedOutput: "http://example.com",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
WebhookURL: "http://example.com",
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
WebhookURL: "http://example01.com",
|
||||
Group: "group",
|
||||
Config: Config{WebhookURL: "http://example01.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "",
|
||||
ExpectedOutput: "http://example.com",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-group-should-override",
|
||||
Provider: AlertProvider{
|
||||
WebhookURL: "http://example.com",
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
WebhookURL: "http://example01.com",
|
||||
Group: "group",
|
||||
Config: Config{WebhookURL: "http://group-example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
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 {
|
||||
t.Run(tt.Name, func(t *testing.T) {
|
||||
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
|
||||
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
|
||||
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
|
||||
}
|
||||
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||
t.Errorf("unexpected error: %s", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package gotify
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -10,40 +11,72 @@ import (
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const DefaultPriority = 5
|
||||
|
||||
var (
|
||||
ErrServerURLNotSet = errors.New("server URL not set")
|
||||
ErrTokenNotSet = errors.New("token not set")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ServerURL string `yaml:"server-url"` // URL of the Gotify server
|
||||
Token string `yaml:"token"` // Token to use when sending a message to the Gotify server
|
||||
Priority int `yaml:"priority,omitempty"` // Priority of the message. Defaults to DefaultPriority.
|
||||
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if cfg.Priority == 0 {
|
||||
cfg.Priority = DefaultPriority
|
||||
}
|
||||
if len(cfg.ServerURL) == 0 {
|
||||
return ErrServerURLNotSet
|
||||
}
|
||||
if len(cfg.Token) == 0 {
|
||||
return ErrTokenNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.ServerURL) > 0 {
|
||||
cfg.ServerURL = override.ServerURL
|
||||
}
|
||||
if len(override.Token) > 0 {
|
||||
cfg.Token = override.Token
|
||||
}
|
||||
if override.Priority != 0 {
|
||||
cfg.Priority = override.Priority
|
||||
}
|
||||
if len(override.Title) > 0 {
|
||||
cfg.Title = override.Title
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Gotify
|
||||
type AlertProvider struct {
|
||||
// ServerURL is the URL of the Gotify server
|
||||
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
|
||||
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"`
|
||||
|
||||
// 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
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
if provider.Priority == 0 {
|
||||
provider.Priority = DefaultPriority
|
||||
}
|
||||
return len(provider.ServerURL) > 0 && len(provider.Token) > 0
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
||||
request, err := http.NewRequest(http.MethodPost, provider.ServerURL+"/message?token="+provider.Token, buffer)
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||
request, err := http.NewRequest(http.MethodPost, cfg.ServerURL+"/message?token="+cfg.Token, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -67,7 +100,7 @@ type Body struct {
|
||||
}
|
||||
|
||||
// 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
|
||||
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)
|
||||
@@ -89,13 +122,13 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
||||
}
|
||||
message += formattedConditionResults
|
||||
title := "Gatus: " + ep.DisplayName()
|
||||
if provider.Title != "" {
|
||||
title = provider.Title
|
||||
if cfg.Title != "" {
|
||||
title = cfg.Title
|
||||
}
|
||||
bodyAsJSON, _ := json.Marshal(Body{
|
||||
Message: message,
|
||||
Title: title,
|
||||
Priority: provider.Priority,
|
||||
Priority: cfg.Priority,
|
||||
})
|
||||
return bodyAsJSON
|
||||
}
|
||||
@@ -104,3 +137,25 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
@@ -17,29 +17,29 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"},
|
||||
provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "faketoken"}},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "invalid-server-url",
|
||||
provider: AlertProvider{ServerURL: "", Token: "faketoken"},
|
||||
provider: AlertProvider{DefaultConfig: Config{ServerURL: "", Token: "faketoken"}},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "invalid-app-token",
|
||||
provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: ""},
|
||||
provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: ""}},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
if scenario.provider.IsValid() != scenario.expected {
|
||||
t.Errorf("expected %t, got %t", scenario.expected, scenario.provider.IsValid())
|
||||
if err := scenario.provider.Validate(); (err == nil) != scenario.expected {
|
||||
t.Errorf("expected: %t, got: %t", scenario.expected, err == nil)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -60,21 +60,21 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
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},
|
||||
Resolved: false,
|
||||
ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been triggered due to having failed 3 time(s) in a row with the following description: %s\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpointName, description),
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{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},
|
||||
Resolved: true,
|
||||
ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been resolved after passing successfully 5 time(s) in a row with the following description: %s\\n✓ - [CONNECTED] == true\\n✓ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpointName, description),
|
||||
},
|
||||
{
|
||||
Name: "custom-title",
|
||||
Provider: AlertProvider{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},
|
||||
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),
|
||||
@@ -83,6 +83,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
&scenario.Provider.DefaultConfig,
|
||||
&endpoint.Endpoint{Name: endpointName},
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
210
alerting/provider/incidentio/incidentio.go
Normal file
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
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 (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -10,13 +11,50 @@ import (
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrProjectNotSet = errors.New("project not set")
|
||||
ErrChannelIDNotSet = errors.New("channel-id not set")
|
||||
ErrTokenNotSet = errors.New("token not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Project string `yaml:"project"` // Project name
|
||||
ChannelID string `yaml:"channel-id"` // Chat Channel ID
|
||||
Token string `yaml:"token"` // Bearer Token
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.Project) == 0 {
|
||||
return ErrProjectNotSet
|
||||
}
|
||||
if len(cfg.ChannelID) == 0 {
|
||||
return ErrChannelIDNotSet
|
||||
}
|
||||
if len(cfg.Token) == 0 {
|
||||
return ErrTokenNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.Project) > 0 {
|
||||
cfg.Project = override.Project
|
||||
}
|
||||
if len(override.ChannelID) > 0 {
|
||||
cfg.ChannelID = override.ChannelID
|
||||
}
|
||||
if len(override.Token) > 0 {
|
||||
cfg.Token = override.Token
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using JetBrains Space
|
||||
type AlertProvider struct {
|
||||
Project string `yaml:"project"` // JetBrains Space Project name
|
||||
ChannelID string `yaml:"channel-id"` // JetBrains Space Chat Channel ID
|
||||
Token string `yaml:"token"` // JetBrains Space Bearer Token
|
||||
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"`
|
||||
@@ -27,34 +65,38 @@ type AlertProvider struct {
|
||||
|
||||
// Override is a case under which the default integration is overridden
|
||||
type Override struct {
|
||||
Group string `yaml:"group"`
|
||||
ChannelID string `yaml:"channel-id"`
|
||||
Group string `yaml:"group"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.ChannelID) == 0 {
|
||||
return false
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
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
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
||||
url := fmt.Sprintf("https://%s.jetbrains.space/api/http/chats/messages/send-message", provider.Project)
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||
url := fmt.Sprintf("https://%s.jetbrains.space/api/http/chats/messages/send-message", cfg.Project)
|
||||
request, err := http.NewRequest(http.MethodPost, url, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", "Bearer "+provider.Token)
|
||||
request.Header.Set("Authorization", "Bearer "+cfg.Token)
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -103,9 +145,9 @@ type Icon struct {
|
||||
}
|
||||
|
||||
// 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{
|
||||
Channel: "id:" + provider.getChannelIDForGroup(ep.Group),
|
||||
Channel: "id:" + cfg.ChannelID,
|
||||
Content: Content{
|
||||
ClassName: "ChatMessage.Block",
|
||||
Sections: []Section{{
|
||||
@@ -144,19 +186,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -11,54 +11,56 @@ import (
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
||||
invalidProvider := AlertProvider{Project: ""}
|
||||
if invalidProvider.IsValid() {
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
invalidProvider := AlertProvider{DefaultConfig: Config{Project: ""}}
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{Project: "foo", ChannelID: "bar", Token: "baz"}
|
||||
if !validProvider.IsValid() {
|
||||
validProvider := AlertProvider{DefaultConfig: Config{Project: "foo", ChannelID: "bar", Token: "baz"}}
|
||||
if err := validProvider.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
||||
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||
providerWithInvalidOverrideGroup := AlertProvider{
|
||||
Project: "foobar",
|
||||
DefaultConfig: Config{Project: "foobar"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
ChannelID: "http://example.com",
|
||||
Group: "",
|
||||
Config: Config{ChannelID: "http://example.com"},
|
||||
Group: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
if providerWithInvalidOverrideGroup.IsValid() {
|
||||
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||
t.Error("provider Group shouldn't have been valid")
|
||||
}
|
||||
providerWithInvalidOverrideTo := AlertProvider{
|
||||
Project: "foobar",
|
||||
DefaultConfig: Config{Project: "foobar"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
ChannelID: "",
|
||||
Group: "group",
|
||||
Config: Config{ChannelID: ""},
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if providerWithInvalidOverrideTo.IsValid() {
|
||||
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
|
||||
t.Error("provider integration key shouldn't have been valid")
|
||||
}
|
||||
providerWithValidOverride := AlertProvider{
|
||||
Project: "foo",
|
||||
ChannelID: "bar",
|
||||
Token: "baz",
|
||||
DefaultConfig: Config{
|
||||
Project: "foo",
|
||||
ChannelID: "bar",
|
||||
Token: "baz",
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
ChannelID: "foobar",
|
||||
Group: "group",
|
||||
Config: Config{ChannelID: "foobar"},
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if !providerWithValidOverride.IsValid() {
|
||||
if err := providerWithValidOverride.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
@@ -77,7 +79,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -87,7 +89,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -97,7 +99,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -107,7 +109,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved-error",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -153,40 +155,41 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: `{"channel":"id:","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",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: `{"channel":"id:","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",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: `{"channel":"id:","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",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: `{"channel":"id:","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 {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
&scenario.Provider.DefaultConfig,
|
||||
&scenario.Endpoint,
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
@@ -217,62 +220,98 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_getChannelIDForGroup(t *testing.T) {
|
||||
tests := []struct {
|
||||
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
InputGroup string
|
||||
ExpectedOutput string
|
||||
InputAlert alert.Alert
|
||||
ExpectedOutput Config
|
||||
}{
|
||||
{
|
||||
Name: "provider-no-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
ChannelID: "bar",
|
||||
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "",
|
||||
ExpectedOutput: "bar",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||
},
|
||||
{
|
||||
Name: "provider-no-override-specify-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
ChannelID: "bar",
|
||||
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "group",
|
||||
ExpectedOutput: "bar",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
ChannelID: "bar",
|
||||
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
ChannelID: "foobar",
|
||||
Group: "group",
|
||||
Config: Config{ChannelID: "group-channel"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "",
|
||||
ExpectedOutput: "bar",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-group-should-override",
|
||||
Provider: AlertProvider{
|
||||
ChannelID: "bar",
|
||||
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
ChannelID: "foobar",
|
||||
Group: "group",
|
||||
Config: Config{ChannelID: "group-channel"},
|
||||
},
|
||||
},
|
||||
},
|
||||
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 {
|
||||
t.Run(tt.Name, func(t *testing.T) {
|
||||
if got := tt.Provider.getChannelIDForGroup(tt.InputGroup); got != tt.ExpectedOutput {
|
||||
t.Errorf("AlertProvider.getChannelIDForGroup() = %v, want %v", got, tt.ExpectedOutput)
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
if got.ChannelID != scenario.ExpectedOutput.ChannelID {
|
||||
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.ChannelID, got.ChannelID)
|
||||
}
|
||||
if got.Project != scenario.ExpectedOutput.Project {
|
||||
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.Project, got.Project)
|
||||
}
|
||||
if got.Token != scenario.ExpectedOutput.Token {
|
||||
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.Token, got.Token)
|
||||
}
|
||||
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||
t.Errorf("unexpected error: %s", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package matrix
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
@@ -13,29 +14,18 @@ import (
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"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"
|
||||
|
||||
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 string `yaml:"server-url"`
|
||||
|
||||
@@ -46,36 +36,78 @@ type ProviderConfig struct {
|
||||
InternalRoomID string `yaml:"internal-room-id"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.ServerURL) == 0 {
|
||||
cfg.ServerURL = defaultServerURL
|
||||
}
|
||||
if len(cfg.AccessToken) == 0 {
|
||||
return ErrAccessTokenNotSet
|
||||
}
|
||||
if len(cfg.InternalRoomID) == 0 {
|
||||
return ErrInternalRoomID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.ServerURL) > 0 {
|
||||
cfg.ServerURL = override.ServerURL
|
||||
}
|
||||
if len(override.AccessToken) > 0 {
|
||||
cfg.AccessToken = override.AccessToken
|
||||
}
|
||||
if len(override.InternalRoomID) > 0 {
|
||||
cfg.InternalRoomID = override.InternalRoomID
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Matrix
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
|
||||
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||
Overrides []Override `yaml:"overrides,omitempty"`
|
||||
}
|
||||
|
||||
// Override is a case under which the default integration is overridden
|
||||
type Override struct {
|
||||
Group string `yaml:"group"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.AccessToken) == 0 || len(override.InternalRoomID) == 0 {
|
||||
return false
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return len(provider.AccessToken) > 0 && len(provider.InternalRoomID) > 0
|
||||
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 {
|
||||
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
||||
config := provider.getConfigForGroup(ep.Group)
|
||||
if config.ServerURL == "" {
|
||||
config.ServerURL = defaultServerURL
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
||||
// The Matrix endpoint requires a unique transaction ID for each event sent
|
||||
txnId := randStringBytes(24)
|
||||
request, err := http.NewRequest(
|
||||
http.MethodPut,
|
||||
fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/send/m.room.message/%s?access_token=%s",
|
||||
config.ServerURL,
|
||||
url.PathEscape(config.InternalRoomID),
|
||||
cfg.ServerURL,
|
||||
url.PathEscape(cfg.InternalRoomID),
|
||||
txnId,
|
||||
url.QueryEscape(config.AccessToken),
|
||||
url.QueryEscape(cfg.AccessToken),
|
||||
),
|
||||
buffer,
|
||||
)
|
||||
@@ -167,18 +199,6 @@ func buildHTMLMessageBody(ep *endpoint.Endpoint, alert *alert.Alert, result *end
|
||||
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 {
|
||||
// All the compatible characters to use in a transaction ID
|
||||
const availableCharacterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
@@ -194,3 +214,34 @@ func randStringBytes(n int) string {
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle group overrides
|
||||
if provider.Overrides != nil {
|
||||
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"
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
invalidProvider := AlertProvider{
|
||||
ProviderConfig: ProviderConfig{
|
||||
DefaultConfig: Config{
|
||||
AccessToken: "",
|
||||
InternalRoomID: "",
|
||||
},
|
||||
}
|
||||
if invalidProvider.IsValid() {
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{
|
||||
ProviderConfig: ProviderConfig{
|
||||
DefaultConfig: Config{
|
||||
AccessToken: "1",
|
||||
InternalRoomID: "!a:example.com",
|
||||
},
|
||||
}
|
||||
if !validProvider.IsValid() {
|
||||
if err := validProvider.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
validProviderWithHomeserver := AlertProvider{
|
||||
ProviderConfig: ProviderConfig{
|
||||
DefaultConfig: Config{
|
||||
ServerURL: "https://example.com",
|
||||
AccessToken: "1",
|
||||
InternalRoomID: "!a:example.com",
|
||||
},
|
||||
}
|
||||
if !validProviderWithHomeserver.IsValid() {
|
||||
if err := validProviderWithHomeserver.Validate(); err != nil {
|
||||
t.Error("provider with homeserver should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
||||
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||
providerWithInvalidOverrideGroup := AlertProvider{
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "",
|
||||
ProviderConfig: ProviderConfig{
|
||||
Config: Config{
|
||||
AccessToken: "",
|
||||
InternalRoomID: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if providerWithInvalidOverrideGroup.IsValid() {
|
||||
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||
t.Error("provider Group shouldn't have been valid")
|
||||
}
|
||||
providerWithInvalidOverrideTo := AlertProvider{
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
ProviderConfig: ProviderConfig{
|
||||
Config: Config{
|
||||
AccessToken: "",
|
||||
InternalRoomID: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if providerWithInvalidOverrideTo.IsValid() {
|
||||
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
|
||||
t.Error("provider integration key shouldn't have been valid")
|
||||
}
|
||||
providerWithValidOverride := AlertProvider{
|
||||
ProviderConfig: ProviderConfig{
|
||||
DefaultConfig: Config{
|
||||
AccessToken: "1",
|
||||
InternalRoomID: "!a:example.com",
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
ProviderConfig: ProviderConfig{
|
||||
Config: Config{
|
||||
ServerURL: "https://example.com",
|
||||
AccessToken: "1",
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -105,18 +105,28 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
ExpectedError bool
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Name: "triggered-with-bad-config",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{DefaultConfig: Config{AccessToken: "1", InternalRoomID: "!a:example.com"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{},
|
||||
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 {
|
||||
@@ -126,7 +136,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{AccessToken: "1", InternalRoomID: "!a:example.com"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -136,7 +146,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved-error",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{AccessToken: "1", InternalRoomID: "!a:example.com"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -227,17 +237,18 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
||||
tests := []struct {
|
||||
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
InputGroup string
|
||||
ExpectedOutput ProviderConfig
|
||||
InputAlert alert.Alert
|
||||
ExpectedOutput Config
|
||||
}{
|
||||
{
|
||||
Name: "provider-no-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
ProviderConfig: ProviderConfig{
|
||||
DefaultConfig: Config{
|
||||
ServerURL: "https://example.com",
|
||||
AccessToken: "1",
|
||||
InternalRoomID: "!a:example.com",
|
||||
@@ -245,7 +256,8 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "",
|
||||
ExpectedOutput: ProviderConfig{
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{
|
||||
ServerURL: "https://example.com",
|
||||
AccessToken: "1",
|
||||
InternalRoomID: "!a:example.com",
|
||||
@@ -254,7 +266,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
||||
{
|
||||
Name: "provider-no-override-specify-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
ProviderConfig: ProviderConfig{
|
||||
DefaultConfig: Config{
|
||||
ServerURL: "https://example.com",
|
||||
AccessToken: "1",
|
||||
InternalRoomID: "!a:example.com",
|
||||
@@ -262,7 +274,8 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "group",
|
||||
ExpectedOutput: ProviderConfig{
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{
|
||||
ServerURL: "https://example.com",
|
||||
AccessToken: "1",
|
||||
InternalRoomID: "!a:example.com",
|
||||
@@ -271,7 +284,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
||||
{
|
||||
Name: "provider-with-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
ProviderConfig: ProviderConfig{
|
||||
DefaultConfig: Config{
|
||||
ServerURL: "https://example.com",
|
||||
AccessToken: "1",
|
||||
InternalRoomID: "!a:example.com",
|
||||
@@ -279,16 +292,17 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
ProviderConfig: ProviderConfig{
|
||||
ServerURL: "https://example01.com",
|
||||
Config: Config{
|
||||
ServerURL: "https://group-example.com",
|
||||
AccessToken: "12",
|
||||
InternalRoomID: "!a:example01.com",
|
||||
InternalRoomID: "!a:group-example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "",
|
||||
ExpectedOutput: ProviderConfig{
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{
|
||||
ServerURL: "https://example.com",
|
||||
AccessToken: "1",
|
||||
InternalRoomID: "!a:example.com",
|
||||
@@ -297,7 +311,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
||||
{
|
||||
Name: "provider-with-override-specify-group-should-override",
|
||||
Provider: AlertProvider{
|
||||
ProviderConfig: ProviderConfig{
|
||||
DefaultConfig: Config{
|
||||
ServerURL: "https://example.com",
|
||||
AccessToken: "1",
|
||||
InternalRoomID: "!a:example.com",
|
||||
@@ -305,8 +319,35 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
ProviderConfig: ProviderConfig{
|
||||
ServerURL: "https://example01.com",
|
||||
Config: Config{
|
||||
ServerURL: "https://group-example.com",
|
||||
AccessToken: "12",
|
||||
InternalRoomID: "!a:group-example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "group",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{
|
||||
ServerURL: "https://group-example.com",
|
||||
AccessToken: "12",
|
||||
InternalRoomID: "!a:group-example.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
ServerURL: "https://example.com",
|
||||
AccessToken: "1",
|
||||
InternalRoomID: "!a:example.com",
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
Config: Config{
|
||||
ServerURL: "https://group-example.com",
|
||||
AccessToken: "12",
|
||||
InternalRoomID: "!a:example01.com",
|
||||
},
|
||||
@@ -314,17 +355,32 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
||||
},
|
||||
},
|
||||
InputGroup: "group",
|
||||
ExpectedOutput: ProviderConfig{
|
||||
ServerURL: "https://example01.com",
|
||||
AccessToken: "12",
|
||||
InternalRoomID: "!a:example01.com",
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{"server-url": "https://alert-example.com", "access-token": "123", "internal-room-id": "!a:alert-example.com"}},
|
||||
ExpectedOutput: Config{
|
||||
ServerURL: "https://alert-example.com",
|
||||
AccessToken: "123",
|
||||
InternalRoomID: "!a:alert-example.com",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.Name, func(t *testing.T) {
|
||||
if got := tt.Provider.getConfigForGroup(tt.InputGroup); got != tt.ExpectedOutput {
|
||||
t.Errorf("AlertProvider.getConfigForGroup() = %v, want %v", got, tt.ExpectedOutput)
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
outputConfig, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got %v", err)
|
||||
}
|
||||
if outputConfig.ServerURL != scenario.ExpectedOutput.ServerURL {
|
||||
t.Errorf("expected ServerURL to be %s, got %s", scenario.ExpectedOutput.ServerURL, outputConfig.ServerURL)
|
||||
}
|
||||
if outputConfig.AccessToken != scenario.ExpectedOutput.AccessToken {
|
||||
t.Errorf("expected AccessToken to be %s, got %s", scenario.ExpectedOutput.AccessToken, outputConfig.AccessToken)
|
||||
}
|
||||
if outputConfig.InternalRoomID != scenario.ExpectedOutput.InternalRoomID {
|
||||
t.Errorf("expected InternalRoomID to be %s, got %s", scenario.ExpectedOutput.InternalRoomID, outputConfig.InternalRoomID)
|
||||
}
|
||||
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||
t.Errorf("unexpected error: %s", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package mattermost
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -10,17 +11,42 @@ import (
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrWebhookURLNotSet = errors.New("webhook URL not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
WebhookURL string `yaml:"webhook-url"`
|
||||
Channel string `yaml:"channel,omitempty"`
|
||||
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.WebhookURL) == 0 {
|
||||
return ErrWebhookURLNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if override.ClientConfig != nil {
|
||||
cfg.ClientConfig = override.ClientConfig
|
||||
}
|
||||
if len(override.WebhookURL) > 0 {
|
||||
cfg.WebhookURL = override.WebhookURL
|
||||
}
|
||||
if len(override.Channel) > 0 {
|
||||
cfg.Channel = override.Channel
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Mattermost
|
||||
type AlertProvider struct {
|
||||
WebhookURL string `yaml:"webhook-url"`
|
||||
|
||||
// Channel is the optional setting to override the default webhook's channel
|
||||
Channel string `yaml:"channel,omitempty"`
|
||||
|
||||
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
||||
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||
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"`
|
||||
@@ -31,36 +57,37 @@ type AlertProvider struct {
|
||||
|
||||
// Override is a case under which the default integration is overridden
|
||||
type Override struct {
|
||||
Group string `yaml:"group"`
|
||||
WebhookURL string `yaml:"webhook-url"`
|
||||
Group string `yaml:"group"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
if provider.ClientConfig == nil {
|
||||
provider.ClientConfig = client.GetDefaultConfig()
|
||||
}
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
if provider.Overrides != nil {
|
||||
registeredGroups := make(map[string]bool)
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
|
||||
return false
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return len(provider.WebhookURL) > 0
|
||||
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 {
|
||||
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(ep, alert, result, resolved)))
|
||||
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer)
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(cfg, ep, alert, result, resolved)))
|
||||
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
|
||||
response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -96,7 +123,7 @@ type Field struct {
|
||||
}
|
||||
|
||||
// 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
|
||||
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)
|
||||
@@ -122,7 +149,7 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
||||
description = ":\n> " + alertDescription
|
||||
}
|
||||
body := Body{
|
||||
Channel: provider.Channel,
|
||||
Channel: cfg.Channel,
|
||||
Text: "",
|
||||
Username: "gatus",
|
||||
IconURL: "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png",
|
||||
@@ -147,19 +174,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
||||
return bodyAsJSON
|
||||
}
|
||||
|
||||
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
|
||||
func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
return override.WebhookURL
|
||||
}
|
||||
}
|
||||
}
|
||||
return provider.WebhookURL
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// 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"
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
invalidProvider := AlertProvider{WebhookURL: ""}
|
||||
if invalidProvider.IsValid() {
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{WebhookURL: "http://example.com"}
|
||||
if !validProvider.IsValid() {
|
||||
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
|
||||
if err := validProvider.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
||||
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||
providerWithInvalidOverrideGroup := AlertProvider{
|
||||
Overrides: []Override{
|
||||
{
|
||||
WebhookURL: "http://example.com",
|
||||
Group: "",
|
||||
Config: Config{WebhookURL: "http://example.com"},
|
||||
Group: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if providerWithInvalidOverrideGroup.IsValid() {
|
||||
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||
t.Error("provider Group shouldn't have been valid")
|
||||
}
|
||||
|
||||
providerWithInvalidOverrideWebHookUrl := AlertProvider{
|
||||
Overrides: []Override{
|
||||
{
|
||||
|
||||
WebhookURL: "",
|
||||
Group: "group",
|
||||
Config: Config{WebhookURL: ""},
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if providerWithInvalidOverrideWebHookUrl.IsValid() {
|
||||
if err := providerWithInvalidOverrideWebHookUrl.Validate(); err == nil {
|
||||
t.Error("provider WebHookURL shouldn't have been valid")
|
||||
}
|
||||
|
||||
providerWithValidOverride := AlertProvider{
|
||||
WebhookURL: "http://example.com",
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
WebhookURL: "http://example.com",
|
||||
Group: "group",
|
||||
Config: Config{WebhookURL: "http://example.com"},
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if !providerWithValidOverride.IsValid() {
|
||||
if err := providerWithValidOverride.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
@@ -77,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -87,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -97,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -107,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved-error",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -168,6 +164,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
&scenario.Provider.DefaultConfig,
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
@@ -198,64 +195,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
|
||||
tests := []struct {
|
||||
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
InputGroup string
|
||||
ExpectedOutput string
|
||||
InputAlert alert.Alert
|
||||
ExpectedOutput Config
|
||||
}{
|
||||
{
|
||||
Name: "provider-no-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
WebhookURL: "http://example.com",
|
||||
Overrides: nil,
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "",
|
||||
ExpectedOutput: "http://example.com",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-no-override-specify-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
WebhookURL: "http://example.com",
|
||||
Overrides: nil,
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "group",
|
||||
ExpectedOutput: "http://example.com",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
WebhookURL: "http://example.com",
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
WebhookURL: "http://example01.com",
|
||||
Group: "group",
|
||||
Config: Config{WebhookURL: "http://example01.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "",
|
||||
ExpectedOutput: "http://example.com",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-group-should-override",
|
||||
Provider: AlertProvider{
|
||||
WebhookURL: "http://example.com",
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
WebhookURL: "http://example01.com",
|
||||
Group: "group",
|
||||
Config: Config{WebhookURL: "http://group-example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
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 {
|
||||
t.Run(tt.Name, func(t *testing.T) {
|
||||
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
|
||||
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
|
||||
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
|
||||
}
|
||||
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||
t.Errorf("unexpected error: %s", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package messagebird
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -10,37 +11,75 @@ import (
|
||||
"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://rest.messagebird.com/messages"
|
||||
const 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 AlertProvider struct {
|
||||
type Config struct {
|
||||
AccessKey string `yaml:"access-key"`
|
||||
Originator string `yaml:"originator"`
|
||||
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 *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
return len(provider.AccessKey) > 0 && len(provider.Originator) > 0 && len(provider.Recipients) > 0
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
// 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 {
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -60,7 +99,7 @@ type Body struct {
|
||||
}
|
||||
|
||||
// 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
|
||||
if resolved {
|
||||
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())
|
||||
}
|
||||
body, _ := json.Marshal(Body{
|
||||
Originator: provider.Originator,
|
||||
Recipients: provider.Recipients,
|
||||
Originator: cfg.Originator,
|
||||
Recipients: cfg.Recipients,
|
||||
Body: message,
|
||||
})
|
||||
return body
|
||||
@@ -79,3 +118,25 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -13,15 +13,17 @@ import (
|
||||
|
||||
func TestMessagebirdAlertProvider_IsValid(t *testing.T) {
|
||||
invalidProvider := AlertProvider{}
|
||||
if invalidProvider.IsValid() {
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{
|
||||
AccessKey: "1",
|
||||
Originator: "1",
|
||||
Recipients: "1",
|
||||
DefaultConfig: Config{
|
||||
AccessKey: "1",
|
||||
Originator: "1",
|
||||
Recipients: "1",
|
||||
},
|
||||
}
|
||||
if !validProvider.IsValid() {
|
||||
if err := validProvider.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
@@ -40,7 +42,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -50,7 +52,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -60,7 +62,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -70,7 +72,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved-error",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -115,14 +117,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
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},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\"originator\":\"2\",\"recipients\":\"3\",\"body\":\"TRIGGERED: endpoint-name - description-1\"}",
|
||||
},
|
||||
{
|
||||
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},
|
||||
Resolved: true,
|
||||
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 {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
&scenario.Provider.DefaultConfig,
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
@@ -145,7 +148,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
||||
}
|
||||
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())
|
||||
}
|
||||
})
|
||||
@@ -160,3 +163,50 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
InputAlert alert.Alert
|
||||
ExpectedOutput Config
|
||||
}{
|
||||
{
|
||||
Name: "provider-no-override-should-default",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"},
|
||||
},
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{AccessKey: "1", Originator: "2", Recipients: "3"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-alert-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"},
|
||||
},
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{"access-key": "4", "originator": "5", "recipients": "6"}},
|
||||
ExpectedOutput: Config{AccessKey: "4", Originator: "5", Recipients: "6"},
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
|
||||
if err != nil {
|
||||
t.Error("expected no error, got:", err.Error())
|
||||
}
|
||||
if got.AccessKey != scenario.ExpectedOutput.AccessKey {
|
||||
t.Errorf("expected access key to be %s, got %s", scenario.ExpectedOutput.AccessKey, got.AccessKey)
|
||||
}
|
||||
if got.Originator != scenario.ExpectedOutput.Originator {
|
||||
t.Errorf("expected originator to be %s, got %s", scenario.ExpectedOutput.Originator, got.Originator)
|
||||
}
|
||||
if got.Recipients != scenario.ExpectedOutput.Recipients {
|
||||
t.Errorf("expected recipients to be %s, got %s", scenario.ExpectedOutput.Recipients, got.Recipients)
|
||||
}
|
||||
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil {
|
||||
t.Errorf("unexpected error: %s", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package ntfy
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -12,15 +13,23 @@ import (
|
||||
"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 (
|
||||
DefaultURL = "https://ntfy.sh"
|
||||
DefaultPriority = 3
|
||||
TokenPrefix = "tk_"
|
||||
)
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Slack
|
||||
type AlertProvider struct {
|
||||
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
|
||||
@@ -29,41 +38,115 @@ type AlertProvider struct {
|
||||
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
|
||||
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"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
if len(provider.URL) == 0 {
|
||||
provider.URL = DefaultURL
|
||||
// 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 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
|
||||
}
|
||||
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
|
||||
|
||||
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 {
|
||||
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
||||
request, err := http.NewRequest(http.MethodPost, provider.URL, buffer)
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
if len(provider.Token) > 0 {
|
||||
request.Header.Set("Authorization", "Bearer "+provider.Token)
|
||||
if token := cfg.Token; len(token) > 0 {
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
if provider.DisableFirebase {
|
||||
if cfg.DisableFirebase {
|
||||
request.Header.Set("Firebase", "no")
|
||||
}
|
||||
if provider.DisableCache {
|
||||
if cfg.DisableCache {
|
||||
request.Header.Set("Cache", "no")
|
||||
}
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
@@ -89,7 +172,7 @@ type Body struct {
|
||||
}
|
||||
|
||||
// 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
|
||||
if resolved {
|
||||
tag = "white_check_mark"
|
||||
@@ -112,13 +195,13 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
||||
}
|
||||
message += formattedConditionResults
|
||||
body, _ := json.Marshal(Body{
|
||||
Topic: provider.Topic,
|
||||
Topic: cfg.Topic,
|
||||
Title: "Gatus: " + ep.DisplayName(),
|
||||
Message: message,
|
||||
Tags: []string{tag},
|
||||
Priority: provider.Priority,
|
||||
Email: provider.Email,
|
||||
Click: provider.Click,
|
||||
Priority: cfg.Priority,
|
||||
Email: cfg.Email,
|
||||
Click: cfg.Click,
|
||||
})
|
||||
return body
|
||||
}
|
||||
@@ -127,3 +210,34 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
||||
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
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
)
|
||||
|
||||
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
@@ -19,49 +19,78 @@ func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
name: "no-url-should-use-default-value",
|
||||
provider: AlertProvider{Topic: "example", Priority: 1},
|
||||
provider: AlertProvider{DefaultConfig: Config{Topic: "example", Priority: 1}},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
if scenario.provider.IsValid() != scenario.expected {
|
||||
t.Errorf("expected %t, got %t", scenario.expected, scenario.provider.IsValid())
|
||||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -79,36 +108,55 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
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},
|
||||
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}`,
|
||||
},
|
||||
{
|
||||
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},
|
||||
Resolved: true,
|
||||
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\n🟢 [CONNECTED] == true\n🟢 [STATUS] == 200","tags":["white_check_mark"],"priority":2}`,
|
||||
},
|
||||
{
|
||||
Name: "triggered-email",
|
||||
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com"},
|
||||
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{URL: "https://ntfy.sh", Topic: "example", Priority: 2, Email: "test@example.com", Click: "example.com"},
|
||||
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 {
|
||||
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{
|
||||
@@ -137,24 +185,39 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
Group string
|
||||
ExpectedBody string
|
||||
ExpectedHeaders map[string]string
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com"},
|
||||
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: "no firebase",
|
||||
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableFirebase: true},
|
||||
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",
|
||||
@@ -163,9 +226,10 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "no cache",
|
||||
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableCache: true},
|
||||
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",
|
||||
@@ -174,9 +238,10 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "neither firebase & cache",
|
||||
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableFirebase: true, DisableCache: true},
|
||||
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",
|
||||
@@ -184,6 +249,18 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
"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) {
|
||||
@@ -205,9 +282,9 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
// Close the server when test finishes
|
||||
defer server.Close()
|
||||
|
||||
scenario.Provider.URL = server.URL
|
||||
scenario.Provider.DefaultConfig.URL = server.URL
|
||||
err := scenario.Provider.Send(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&endpoint.Endpoint{Name: "endpoint-name", Group: scenario.Group},
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
@@ -220,8 +297,118 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
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 (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -12,13 +13,18 @@ import (
|
||||
"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 (
|
||||
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 string `yaml:"api-key"`
|
||||
|
||||
@@ -46,26 +52,74 @@ type AlertProvider struct {
|
||||
//
|
||||
// default: []
|
||||
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 *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
return len(provider.APIKey) > 0
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
//
|
||||
// Relevant: https://docs.opsgenie.com/docs/alert-api
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
if resolved {
|
||||
err = provider.closeAlert(ep, alert)
|
||||
err = provider.closeAlert(cfg, ep, alert)
|
||||
if err != nil {
|
||||
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
|
||||
alert.ResolveKey = ""
|
||||
} else {
|
||||
alert.ResolveKey = provider.alias(buildKey(ep))
|
||||
alert.ResolveKey = cfg.AliasPrefix + buildKey(ep)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) createAlert(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
payload := provider.buildCreateRequestBody(ep, alert, result, resolved)
|
||||
return provider.sendRequest(restAPI, http.MethodPost, payload)
|
||||
func (provider *AlertProvider) sendAlertRequest(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
payload := provider.buildCreateRequestBody(cfg, ep, alert, result, resolved)
|
||||
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)
|
||||
url := restAPI + "/" + provider.alias(buildKey(ep)) + "/close?identifierType=alias"
|
||||
return provider.sendRequest(url, http.MethodPost, payload)
|
||||
url := restAPI + "/" + cfg.AliasPrefix + buildKey(ep) + "/close?identifierType=alias"
|
||||
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)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -115,7 +169,7 @@ func (provider *AlertProvider) sendRequest(url, method string, payload interface
|
||||
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
|
||||
if resolved {
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", ep.Name, alert.GetDescription())
|
||||
@@ -158,11 +212,11 @@ func (provider *AlertProvider) buildCreateRequestBody(ep *endpoint.Endpoint, ale
|
||||
return alertCreateRequest{
|
||||
Message: message,
|
||||
Description: description,
|
||||
Source: provider.source(),
|
||||
Priority: provider.priority(),
|
||||
Alias: provider.alias(key),
|
||||
Entity: provider.entity(key),
|
||||
Tags: provider.Tags,
|
||||
Source: cfg.Source,
|
||||
Priority: cfg.Priority,
|
||||
Alias: cfg.AliasPrefix + key,
|
||||
Entity: cfg.EntityPrefix + key,
|
||||
Tags: cfg.Tags,
|
||||
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
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
|
||||
func buildKey(ep *endpoint.Endpoint) string {
|
||||
name := toKebabCase(ep.Name)
|
||||
if ep.Group == "" {
|
||||
|
||||
@@ -11,13 +11,13 @@ import (
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
invalidProvider := AlertProvider{APIKey: ""}
|
||||
if invalidProvider.IsValid() {
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
invalidProvider := AlertProvider{DefaultConfig: Config{APIKey: ""}}
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{APIKey: "00000000-0000-0000-0000-000000000000"}
|
||||
if !validProvider.IsValid() {
|
||||
validProvider := AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}}
|
||||
if err := validProvider.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
|
||||
Alert: alert.Alert{Description: &description, SuccessThreshold: 1, FailureThreshold: 1},
|
||||
Resolved: false,
|
||||
ExpectedError: false,
|
||||
@@ -45,7 +45,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
|
||||
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedError: true,
|
||||
@@ -55,7 +55,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
|
||||
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedError: false,
|
||||
@@ -65,7 +65,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved-error",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
|
||||
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedError: true,
|
||||
@@ -74,7 +74,6 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
@@ -113,7 +112,7 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
Name: "missing all params (unresolved)",
|
||||
Provider: &AlertProvider{},
|
||||
Provider: &AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
|
||||
Alert: &alert.Alert{},
|
||||
Endpoint: &endpoint.Endpoint{},
|
||||
Result: &endpoint.Result{},
|
||||
@@ -131,7 +130,7 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "missing all params (resolved)",
|
||||
Provider: &AlertProvider{},
|
||||
Provider: &AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
|
||||
Alert: &alert.Alert{},
|
||||
Endpoint: &endpoint.Endpoint{},
|
||||
Result: &endpoint.Result{},
|
||||
@@ -149,7 +148,7 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "with default options (unresolved)",
|
||||
Provider: &AlertProvider{},
|
||||
Provider: &AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
|
||||
Alert: &alert.Alert{
|
||||
Description: &description,
|
||||
FailureThreshold: 3,
|
||||
@@ -184,11 +183,13 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
|
||||
{
|
||||
Name: "with custom options (resolved)",
|
||||
Provider: &AlertProvider{
|
||||
Priority: "P5",
|
||||
EntityPrefix: "oompa-",
|
||||
AliasPrefix: "loompa-",
|
||||
Source: "gatus-hc",
|
||||
Tags: []string{"do-ba-dee-doo"},
|
||||
DefaultConfig: Config{
|
||||
Priority: "P5",
|
||||
EntityPrefix: "oompa-",
|
||||
AliasPrefix: "loompa-",
|
||||
Source: "gatus-hc",
|
||||
Tags: []string{"do-ba-dee-doo"},
|
||||
},
|
||||
},
|
||||
Alert: &alert.Alert{
|
||||
Description: &description,
|
||||
@@ -220,7 +221,7 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
|
||||
{
|
||||
Name: "with default options and details (unresolved)",
|
||||
Provider: &AlertProvider{
|
||||
Tags: []string{"foo"},
|
||||
DefaultConfig: Config{Tags: []string{"foo"}, APIKey: "00000000-0000-0000-0000-000000000000"},
|
||||
},
|
||||
Alert: &alert.Alert{
|
||||
Description: &description,
|
||||
@@ -265,8 +266,9 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
|
||||
for _, scenario := range scenarios {
|
||||
actual := scenario
|
||||
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) {
|
||||
t.Errorf("buildCreateRequestBody() = %v, want %v", got, actual.want)
|
||||
_ = scenario.Provider.Validate()
|
||||
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 {
|
||||
actual := scenario
|
||||
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 (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"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://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
|
||||
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 *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
@@ -30,30 +54,34 @@ type AlertProvider struct {
|
||||
|
||||
// Override is a case under which the default integration is overridden
|
||||
type Override struct {
|
||||
Group string `yaml:"group"`
|
||||
IntegrationKey string `yaml:"integration-key"`
|
||||
Group string `yaml:"group"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.IntegrationKey) != 32 {
|
||||
return false
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
// 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
|
||||
//
|
||||
// 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 {
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -74,11 +102,10 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
||||
alert.ResolveKey = ""
|
||||
} else {
|
||||
// We need to retrieve the resolve key from the response
|
||||
body, err := io.ReadAll(response.Body)
|
||||
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.
|
||||
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 {
|
||||
alert.ResolveKey = payload.DedupKey
|
||||
}
|
||||
@@ -101,7 +128,7 @@ type Payload struct {
|
||||
}
|
||||
|
||||
// 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
|
||||
if resolved {
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||
@@ -113,7 +140,7 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
||||
resolveKey = ""
|
||||
}
|
||||
body, _ := json.Marshal(Body{
|
||||
RoutingKey: provider.getIntegrationKeyForGroup(ep.Group),
|
||||
RoutingKey: cfg.IntegrationKey,
|
||||
DedupKey: resolveKey,
|
||||
EventAction: eventAction,
|
||||
Payload: Payload{
|
||||
@@ -125,23 +152,42 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
type pagerDutyResponsePayload struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
|
||||
@@ -11,50 +11,41 @@ import (
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
invalidProvider := AlertProvider{IntegrationKey: ""}
|
||||
if invalidProvider.IsValid() {
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
invalidProvider := AlertProvider{DefaultConfig: Config{IntegrationKey: ""}}
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{IntegrationKey: "00000000000000000000000000000000"}
|
||||
if !validProvider.IsValid() {
|
||||
validProvider := AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}}
|
||||
if err := validProvider.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
||||
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||
providerWithInvalidOverrideGroup := AlertProvider{
|
||||
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
IntegrationKey: "00000000000000000000000000000000",
|
||||
Group: "",
|
||||
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
|
||||
Group: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
if providerWithInvalidOverrideGroup.IsValid() {
|
||||
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||
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{
|
||||
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
IntegrationKey: "00000000000000000000000000000000",
|
||||
Group: "group",
|
||||
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if !providerWithValidOverride.IsValid() {
|
||||
t.Error("provider should've been valid")
|
||||
if err := providerWithValidOverride.Validate(); err != nil {
|
||||
t.Error("provider should've been valid, got error:", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +63,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -82,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -92,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -102,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved-error",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -146,14 +137,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"},
|
||||
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
|
||||
Alert: alert.Alert{Description: &description},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\"routing_key\":\"00000000000000000000000000000000\",\"dedup_key\":\"\",\"event_action\":\"trigger\",\"payload\":{\"summary\":\"TRIGGERED: endpoint-name - test\",\"source\":\"Gatus\",\"severity\":\"critical\"}}",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"},
|
||||
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
|
||||
Alert: alert.Alert{Description: &description, ResolveKey: "key"},
|
||||
Resolved: true,
|
||||
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 {
|
||||
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 {
|
||||
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) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == 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")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/github"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
||||
@@ -29,18 +30,26 @@ import (
|
||||
|
||||
// AlertProvider is the interface that each provider should implement
|
||||
type AlertProvider interface {
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
IsValid() bool
|
||||
// Validate the provider's configuration
|
||||
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() *alert.Alert
|
||||
|
||||
// Send an alert using the provider
|
||||
Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
ValidateOverrides(group string, alert *alert.Alert) error
|
||||
}
|
||||
|
||||
// ParseWithDefaultAlert parses an Endpoint alert by using the provider's default alert as a baseline
|
||||
func ParseWithDefaultAlert(providerDefaultAlert, endpointAlert *alert.Alert) {
|
||||
type Config[T any] interface {
|
||||
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 {
|
||||
return
|
||||
}
|
||||
@@ -62,14 +71,14 @@ func ParseWithDefaultAlert(providerDefaultAlert, endpointAlert *alert.Alert) {
|
||||
}
|
||||
|
||||
var (
|
||||
// Validate interface implementation on compile
|
||||
// Validate provider interface implementation on compile
|
||||
_ AlertProvider = (*awsses.AlertProvider)(nil)
|
||||
_ AlertProvider = (*custom.AlertProvider)(nil)
|
||||
_ AlertProvider = (*discord.AlertProvider)(nil)
|
||||
_ AlertProvider = (*email.AlertProvider)(nil)
|
||||
_ AlertProvider = (*gitea.AlertProvider)(nil)
|
||||
_ AlertProvider = (*github.AlertProvider)(nil)
|
||||
_ AlertProvider = (*gitlab.AlertProvider)(nil)
|
||||
_ AlertProvider = (*gitea.AlertProvider)(nil)
|
||||
_ AlertProvider = (*googlechat.AlertProvider)(nil)
|
||||
_ AlertProvider = (*jetbrainsspace.AlertProvider)(nil)
|
||||
_ AlertProvider = (*matrix.AlertProvider)(nil)
|
||||
@@ -85,4 +94,30 @@ var (
|
||||
_ AlertProvider = (*telegram.AlertProvider)(nil)
|
||||
_ AlertProvider = (*twilio.AlertProvider)(nil)
|
||||
_ AlertProvider = (*zulip.AlertProvider)(nil)
|
||||
_ AlertProvider = (*incidentio.AlertProvider)(nil)
|
||||
|
||||
// Validate config interface implementation on compile
|
||||
_ Config[awsses.Config] = (*awsses.Config)(nil)
|
||||
_ Config[custom.Config] = (*custom.Config)(nil)
|
||||
_ Config[discord.Config] = (*discord.Config)(nil)
|
||||
_ Config[email.Config] = (*email.Config)(nil)
|
||||
_ Config[gitea.Config] = (*gitea.Config)(nil)
|
||||
_ Config[github.Config] = (*github.Config)(nil)
|
||||
_ Config[gitlab.Config] = (*gitlab.Config)(nil)
|
||||
_ Config[googlechat.Config] = (*googlechat.Config)(nil)
|
||||
_ Config[incidentio.Config] = (*incidentio.Config)(nil)
|
||||
_ Config[jetbrainsspace.Config] = (*jetbrainsspace.Config)(nil)
|
||||
_ Config[matrix.Config] = (*matrix.Config)(nil)
|
||||
_ Config[mattermost.Config] = (*mattermost.Config)(nil)
|
||||
_ Config[messagebird.Config] = (*messagebird.Config)(nil)
|
||||
_ Config[ntfy.Config] = (*ntfy.Config)(nil)
|
||||
_ Config[opsgenie.Config] = (*opsgenie.Config)(nil)
|
||||
_ Config[pagerduty.Config] = (*pagerduty.Config)(nil)
|
||||
_ Config[pushover.Config] = (*pushover.Config)(nil)
|
||||
_ Config[slack.Config] = (*slack.Config)(nil)
|
||||
_ Config[teams.Config] = (*teams.Config)(nil)
|
||||
_ Config[teamsworkflows.Config] = (*teamsworkflows.Config)(nil)
|
||||
_ Config[telegram.Config] = (*telegram.Config)(nil)
|
||||
_ Config[twilio.Config] = (*twilio.Config)(nil)
|
||||
_ Config[zulip.Config] = (*zulip.Config)(nil)
|
||||
)
|
||||
|
||||
@@ -126,7 +126,7 @@ func TestParseWithDefaultAlert(t *testing.T) {
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
ParseWithDefaultAlert(scenario.DefaultAlert, scenario.EndpointAlert)
|
||||
MergeProviderDefaultAlertIntoEndpointAlert(scenario.DefaultAlert, scenario.EndpointAlert)
|
||||
if scenario.ExpectedOutputAlert == nil {
|
||||
if scenario.EndpointAlert != nil {
|
||||
t.Fail()
|
||||
|
||||
@@ -3,6 +3,7 @@ package pushover
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"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 (
|
||||
@@ -17,8 +19,14 @@ const (
|
||||
defaultPriority = 0
|
||||
)
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Pushover
|
||||
type AlertProvider struct {
|
||||
var (
|
||||
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
|
||||
// See "Your Applications" on the dashboard, or add a new one: https://pushover.net/apps/build
|
||||
ApplicationToken string `yaml:"application-token"`
|
||||
@@ -26,34 +34,102 @@ type AlertProvider struct {
|
||||
// Key of the user or group the messages should be sent to
|
||||
UserKey string `yaml:"user-key"`
|
||||
|
||||
// The title of your message, likely the application name
|
||||
// default: the name of your application in Pushover
|
||||
// The title of your message
|
||||
// default: "Gatus: <endpoint>""
|
||||
Title string `yaml:"title,omitempty"`
|
||||
|
||||
// Priority of all messages, ranging from -2 (very low) to 2 (Emergency)
|
||||
// default: 0
|
||||
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)
|
||||
// default: "" (pushover)
|
||||
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 *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
if provider.Priority == 0 {
|
||||
provider.Priority = defaultPriority
|
||||
}
|
||||
return len(provider.ApplicationToken) == 30 && len(provider.UserKey) == 30 && provider.Priority >= -2 && provider.Priority <= 2
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
// Reference doc for pushover: https://pushover.net/api
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -77,36 +153,76 @@ type Body struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Priority int `json:"priority"`
|
||||
Html int `json:"html"`
|
||||
Sound string `json:"sound,omitempty"`
|
||||
TTL int `json:"ttl,omitempty"`
|
||||
Device string `json:"device,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 {
|
||||
var message string
|
||||
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||
var message, formattedConditionResults string
|
||||
priority := cfg.Priority
|
||||
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 {
|
||||
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{
|
||||
Token: provider.ApplicationToken,
|
||||
User: provider.UserKey,
|
||||
Title: provider.Title,
|
||||
Token: cfg.ApplicationToken,
|
||||
User: cfg.UserKey,
|
||||
Title: title,
|
||||
Message: message,
|
||||
Priority: provider.priority(),
|
||||
Sound: provider.Sound,
|
||||
Priority: priority,
|
||||
Html: 1,
|
||||
Sound: cfg.Sound,
|
||||
TTL: cfg.TTL,
|
||||
Device: cfg.Device,
|
||||
})
|
||||
return body
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) priority() int {
|
||||
if provider.Priority == 0 {
|
||||
return defaultPriority
|
||||
}
|
||||
return provider.Priority
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -12,30 +12,38 @@ import (
|
||||
)
|
||||
|
||||
func TestPushoverAlertProvider_IsValid(t *testing.T) {
|
||||
invalidProvider := AlertProvider{}
|
||||
if invalidProvider.IsValid() {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{
|
||||
ApplicationToken: "aTokenWithLengthOf30characters",
|
||||
UserKey: "aTokenWithLengthOf30characters",
|
||||
Title: "Gatus Notification",
|
||||
Priority: 1,
|
||||
}
|
||||
if !validProvider.IsValid() {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushoverAlertProvider_IsInvalid(t *testing.T) {
|
||||
invalidProvider := AlertProvider{
|
||||
ApplicationToken: "aTokenWithLengthOfMoreThan30characters",
|
||||
UserKey: "aTokenWithLengthOfMoreThan30characters",
|
||||
Priority: 5,
|
||||
}
|
||||
if invalidProvider.IsValid() {
|
||||
t.Error("provider should've been invalid")
|
||||
}
|
||||
t.Run("empty-invalid-provider", func(t *testing.T) {
|
||||
invalidProvider := AlertProvider{}
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
})
|
||||
t.Run("valid-provider", func(t *testing.T) {
|
||||
validProvider := AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
ApplicationToken: "aTokenWithLengthOf30characters",
|
||||
UserKey: "aTokenWithLengthOf30characters",
|
||||
Title: "Gatus Notification",
|
||||
Priority: 1,
|
||||
ResolvedPriority: 1,
|
||||
},
|
||||
}
|
||||
if err := validProvider.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
})
|
||||
t.Run("invalid-provider", func(t *testing.T) {
|
||||
invalidProvider := AlertProvider{
|
||||
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) {
|
||||
@@ -52,7 +60,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -62,7 +70,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -72,7 +80,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -82,7 +90,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved-error",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -119,37 +127,67 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
ExpectedBody string
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
ResolvedPriority bool
|
||||
ExpectedBody string
|
||||
}{
|
||||
{
|
||||
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},
|
||||
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",
|
||||
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},
|
||||
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",
|
||||
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},
|
||||
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 {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
&scenario.Provider.DefaultConfig,
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
@@ -179,3 +217,50 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
InputGroup string
|
||||
InputAlert alert.Alert
|
||||
ExpectedOutput Config
|
||||
}{
|
||||
{
|
||||
Name: "provider-no-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"},
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-alert-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"},
|
||||
},
|
||||
InputGroup: "group",
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{"application-token": "TokenWithLengthOf30Characters2", "user-key": "TokenWithLengthOf30Characters3"}},
|
||||
ExpectedOutput: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters3"},
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
if got.ApplicationToken != scenario.ExpectedOutput.ApplicationToken {
|
||||
t.Errorf("expected application token to be %s, got %s", scenario.ExpectedOutput.ApplicationToken, got.ApplicationToken)
|
||||
}
|
||||
if got.UserKey != scenario.ExpectedOutput.UserKey {
|
||||
t.Errorf("expected user key to be %s, got %s", scenario.ExpectedOutput.UserKey, got.UserKey)
|
||||
}
|
||||
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||
t.Errorf("unexpected error: %s", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package slack
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -10,41 +11,70 @@ import (
|
||||
"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"` // Slack webhook URL
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.WebhookURL) == 0 {
|
||||
return ErrWebhookURLNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.WebhookURL) > 0 {
|
||||
cfg.WebhookURL = override.WebhookURL
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Slack
|
||||
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 *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"`
|
||||
WebhookURL string `yaml:"webhook-url"`
|
||||
Group string `yaml:"group"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
|
||||
return false
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return len(provider.WebhookURL) > 0
|
||||
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, provider.getWebhookURLForGroup(ep.Group), buffer)
|
||||
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -126,19 +156,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
||||
return bodyAsJSON
|
||||
}
|
||||
|
||||
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
|
||||
func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
return override.WebhookURL
|
||||
}
|
||||
}
|
||||
}
|
||||
return provider.WebhookURL
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// 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"
|
||||
)
|
||||
|
||||
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
||||
invalidProvider := AlertProvider{WebhookURL: ""}
|
||||
if invalidProvider.IsValid() {
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{WebhookURL: "https://example.com"}
|
||||
if !validProvider.IsValid() {
|
||||
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "https://example.com"}}
|
||||
if err := validProvider.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
||||
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||
providerWithInvalidOverrideGroup := AlertProvider{
|
||||
Overrides: []Override{
|
||||
{
|
||||
WebhookURL: "http://example.com",
|
||||
Group: "",
|
||||
Config: Config{WebhookURL: "http://example.com"},
|
||||
Group: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
if providerWithInvalidOverrideGroup.IsValid() {
|
||||
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||
t.Error("provider Group shouldn't have been valid")
|
||||
}
|
||||
providerWithInvalidOverrideTo := AlertProvider{
|
||||
Overrides: []Override{
|
||||
{
|
||||
WebhookURL: "",
|
||||
Group: "group",
|
||||
Config: Config{WebhookURL: ""},
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if providerWithInvalidOverrideTo.IsValid() {
|
||||
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
|
||||
t.Error("provider integration key shouldn't have been valid")
|
||||
}
|
||||
providerWithValidOverride := AlertProvider{
|
||||
WebhookURL: "http://example.com",
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
WebhookURL: "http://example.com",
|
||||
Group: "group",
|
||||
Config: Config{WebhookURL: "http://example.com"},
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if !providerWithValidOverride.IsValid() {
|
||||
if err := providerWithValidOverride.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -83,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -93,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -103,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved-error",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -227,64 +227,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
|
||||
tests := []struct {
|
||||
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
InputGroup string
|
||||
ExpectedOutput string
|
||||
InputAlert alert.Alert
|
||||
ExpectedOutput Config
|
||||
}{
|
||||
{
|
||||
Name: "provider-no-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
WebhookURL: "http://example.com",
|
||||
Overrides: nil,
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "",
|
||||
ExpectedOutput: "http://example.com",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-no-override-specify-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
WebhookURL: "http://example.com",
|
||||
Overrides: nil,
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "group",
|
||||
ExpectedOutput: "http://example.com",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
WebhookURL: "http://example.com",
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
WebhookURL: "http://example01.com",
|
||||
Group: "group",
|
||||
Config: Config{WebhookURL: "http://example01.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "",
|
||||
ExpectedOutput: "http://example.com",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-group-should-override",
|
||||
Provider: AlertProvider{
|
||||
WebhookURL: "http://example.com",
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
WebhookURL: "http://example01.com",
|
||||
Group: "group",
|
||||
Config: Config{WebhookURL: "http://group-example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
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 {
|
||||
t.Run(tt.Name, func(t *testing.T) {
|
||||
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
|
||||
t.Errorf("AlertProvider.getWebhookURLForGroup() = %v, want %v", got, tt.ExpectedOutput)
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
|
||||
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
|
||||
}
|
||||
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||
t.Errorf("unexpected error: %s", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package teams
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -10,54 +11,85 @@ import (
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrWebhookURLNotSet = errors.New("webhook-url not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
WebhookURL string `yaml:"webhook-url"`
|
||||
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
|
||||
|
||||
// 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
|
||||
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 *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
|
||||
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
||||
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||
|
||||
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||
Overrides []Override `yaml:"overrides,omitempty"`
|
||||
|
||||
// Title is the title of the message that will be sent
|
||||
Title string `yaml:"title,omitempty"`
|
||||
}
|
||||
|
||||
// Override is a case under which the default integration is overridden
|
||||
type Override struct {
|
||||
Group string `yaml:"group"`
|
||||
WebhookURL string `yaml:"webhook-url"`
|
||||
Group string `yaml:"group"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
|
||||
return false
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return len(provider.WebhookURL) > 0
|
||||
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 {
|
||||
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
||||
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer)
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request)
|
||||
response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -84,7 +116,7 @@ type Section struct {
|
||||
}
|
||||
|
||||
// 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
|
||||
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)
|
||||
@@ -111,7 +143,7 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
||||
Type: "MessageCard",
|
||||
Context: "http://schema.org/extensions",
|
||||
ThemeColor: color,
|
||||
Title: provider.Title,
|
||||
Title: cfg.Title,
|
||||
Text: message + description,
|
||||
}
|
||||
if len(body.Title) == 0 {
|
||||
@@ -127,19 +159,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
||||
return bodyAsJSON
|
||||
}
|
||||
|
||||
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
|
||||
func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
return override.WebhookURL
|
||||
}
|
||||
}
|
||||
}
|
||||
return provider.WebhookURL
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// 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"
|
||||
)
|
||||
|
||||
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
||||
invalidProvider := AlertProvider{WebhookURL: ""}
|
||||
if invalidProvider.IsValid() {
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{WebhookURL: "http://example.com"}
|
||||
if !validProvider.IsValid() {
|
||||
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
|
||||
if err := validProvider.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
||||
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||
providerWithInvalidOverrideGroup := AlertProvider{
|
||||
Overrides: []Override{
|
||||
{
|
||||
WebhookURL: "http://example.com",
|
||||
Group: "",
|
||||
Config: Config{WebhookURL: "http://example.com"},
|
||||
Group: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
if providerWithInvalidOverrideGroup.IsValid() {
|
||||
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||
t.Error("provider Group shouldn't have been valid")
|
||||
}
|
||||
providerWithInvalidOverrideTo := AlertProvider{
|
||||
Overrides: []Override{
|
||||
{
|
||||
WebhookURL: "",
|
||||
Group: "group",
|
||||
Config: Config{WebhookURL: ""},
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if providerWithInvalidOverrideTo.IsValid() {
|
||||
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
|
||||
t.Error("provider integration key shouldn't have been valid")
|
||||
}
|
||||
providerWithValidOverride := AlertProvider{
|
||||
WebhookURL: "http://example.com",
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
WebhookURL: "http://example.com",
|
||||
Group: "group",
|
||||
Config: Config{WebhookURL: "http://example.com"},
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if !providerWithValidOverride.IsValid() {
|
||||
if err := providerWithValidOverride.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -83,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -93,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -103,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved-error",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -180,6 +180,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
&scenario.Provider.DefaultConfig,
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{ConditionResults: conditionResults},
|
||||
@@ -205,64 +206,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
|
||||
tests := []struct {
|
||||
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
InputGroup string
|
||||
ExpectedOutput string
|
||||
InputAlert alert.Alert
|
||||
ExpectedOutput Config
|
||||
}{
|
||||
{
|
||||
Name: "provider-no-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
WebhookURL: "http://example.com",
|
||||
Overrides: nil,
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "",
|
||||
ExpectedOutput: "http://example.com",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-no-override-specify-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
WebhookURL: "http://example.com",
|
||||
Overrides: nil,
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "group",
|
||||
ExpectedOutput: "http://example.com",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
WebhookURL: "http://example.com",
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
WebhookURL: "http://example01.com",
|
||||
Group: "group",
|
||||
Config: Config{WebhookURL: "http://example01.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "",
|
||||
ExpectedOutput: "http://example.com",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-group-should-override",
|
||||
Provider: AlertProvider{
|
||||
WebhookURL: "http://example.com",
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
WebhookURL: "http://example01.com",
|
||||
Group: "group",
|
||||
Config: Config{WebhookURL: "http://group-example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
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 {
|
||||
t.Run(tt.Name, func(t *testing.T) {
|
||||
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
|
||||
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
|
||||
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
|
||||
}
|
||||
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||
t.Errorf("unexpected error: %s", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package teamsworkflows
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -10,46 +11,74 @@ import (
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrWebhookURLNotSet = errors.New("webhook-url not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
WebhookURL string `yaml:"webhook-url"`
|
||||
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.WebhookURL) == 0 {
|
||||
return ErrWebhookURLNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.WebhookURL) > 0 {
|
||||
cfg.WebhookURL = override.WebhookURL
|
||||
}
|
||||
if len(override.Title) > 0 {
|
||||
cfg.Title = override.Title
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Teams
|
||||
type AlertProvider struct {
|
||||
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 *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
|
||||
// Overrides is a list of Override that may be prioritized over the default configuration
|
||||
Overrides []Override `yaml:"overrides,omitempty"`
|
||||
|
||||
// Title is the title of the message that will be sent
|
||||
Title string `yaml:"title,omitempty"`
|
||||
}
|
||||
|
||||
// Override is a case under which the default integration is overridden
|
||||
type Override struct {
|
||||
Group string `yaml:"group"`
|
||||
WebhookURL string `yaml:"webhook-url"`
|
||||
Group string `yaml:"group"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
|
||||
return false
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return len(provider.WebhookURL) > 0
|
||||
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 {
|
||||
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
||||
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer)
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -68,9 +97,10 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
||||
|
||||
// AdaptiveCardBody represents the structure of an Adaptive Card
|
||||
type AdaptiveCardBody struct {
|
||||
Type string `json:"type"`
|
||||
Version string `json:"version"`
|
||||
Body []CardBody `json:"body"`
|
||||
Type string `json:"type"`
|
||||
Version string `json:"version"`
|
||||
Body []CardBody `json:"body"`
|
||||
MSTeams MSTeamsBody `json:"msteams"`
|
||||
}
|
||||
|
||||
// CardBody represents the body of the Adaptive Card
|
||||
@@ -84,6 +114,12 @@ type CardBody struct {
|
||||
Items []CardBody `json:"items,omitempty"`
|
||||
Facts []Fact `json:"facts,omitempty"`
|
||||
FactSet *FactSetBody `json:"factSet,omitempty"`
|
||||
Style string `json:"style,omitempty"`
|
||||
}
|
||||
|
||||
// MSTeamsBody represents the msteams options
|
||||
type MSTeamsBody struct {
|
||||
Width string `json:"width"`
|
||||
}
|
||||
|
||||
// FactSetBody represents the FactSet in the Adaptive Card
|
||||
@@ -99,18 +135,21 @@ type Fact struct {
|
||||
}
|
||||
|
||||
// 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 themeColor string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row.", ep.DisplayName(), alert.SuccessThreshold)
|
||||
themeColor = "Good" // green
|
||||
} else {
|
||||
message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row.", ep.DisplayName(), alert.FailureThreshold)
|
||||
themeColor = "Attention" // red
|
||||
}
|
||||
|
||||
// Configure default title if it's not provided
|
||||
title := "⛑ Gatus"
|
||||
if provider.Title != "" {
|
||||
title = provider.Title
|
||||
title := "⛑️ Gatus"
|
||||
if cfg.Title != "" {
|
||||
title = cfg.Title
|
||||
}
|
||||
|
||||
// Build the facts from the condition results
|
||||
@@ -118,9 +157,9 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var key string
|
||||
if conditionResult.Success {
|
||||
key = "✅"
|
||||
key = "✅"
|
||||
} else {
|
||||
key = "❌"
|
||||
key = "❌"
|
||||
}
|
||||
facts = append(facts, Fact{
|
||||
Title: key,
|
||||
@@ -133,21 +172,36 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
||||
Version: "1.4", // Version 1.5 and 1.6 doesn't seem to be supported by Teams as of 27/08/2024
|
||||
Body: []CardBody{
|
||||
{
|
||||
Type: "TextBlock",
|
||||
Text: title,
|
||||
Size: "Medium",
|
||||
Weight: "Bolder",
|
||||
},
|
||||
{
|
||||
Type: "TextBlock",
|
||||
Text: message,
|
||||
Wrap: true,
|
||||
},
|
||||
{
|
||||
Type: "FactSet",
|
||||
Facts: facts,
|
||||
Type: "Container",
|
||||
Style: themeColor,
|
||||
Items: []CardBody{
|
||||
{
|
||||
Type: "Container",
|
||||
Style: "Default",
|
||||
Items: []CardBody{
|
||||
{
|
||||
Type: "TextBlock",
|
||||
Text: title,
|
||||
Size: "Medium",
|
||||
Weight: "Bolder",
|
||||
},
|
||||
{
|
||||
Type: "TextBlock",
|
||||
Text: message,
|
||||
Wrap: true,
|
||||
},
|
||||
{
|
||||
Type: "FactSet",
|
||||
Facts: facts,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MSTeams: MSTeamsBody{
|
||||
Width: "Full",
|
||||
},
|
||||
}
|
||||
|
||||
attachment := map[string]interface{}{
|
||||
@@ -164,19 +218,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
||||
return bodyAsJSON
|
||||
}
|
||||
|
||||
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
|
||||
func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
return override.WebhookURL
|
||||
}
|
||||
}
|
||||
}
|
||||
return provider.WebhookURL
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// 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"
|
||||
)
|
||||
|
||||
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
||||
invalidProvider := AlertProvider{WebhookURL: ""}
|
||||
if invalidProvider.IsValid() {
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{WebhookURL: "http://example.com"}
|
||||
if !validProvider.IsValid() {
|
||||
t.Error("provider should've been valid")
|
||||
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
|
||||
if err := validProvider.Validate(); err != nil {
|
||||
t.Error("provider should've been valid, got", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
||||
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||
providerWithInvalidOverrideGroup := AlertProvider{
|
||||
Overrides: []Override{
|
||||
{
|
||||
WebhookURL: "http://example.com",
|
||||
Group: "",
|
||||
Config: Config{WebhookURL: "http://example.com"},
|
||||
Group: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
if providerWithInvalidOverrideGroup.IsValid() {
|
||||
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||
t.Error("provider Group shouldn't have been valid")
|
||||
}
|
||||
providerWithInvalidOverrideTo := AlertProvider{
|
||||
Overrides: []Override{
|
||||
{
|
||||
WebhookURL: "",
|
||||
Group: "group",
|
||||
Config: Config{WebhookURL: ""},
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if providerWithInvalidOverrideTo.IsValid() {
|
||||
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
|
||||
t.Error("provider integration key shouldn't have been valid")
|
||||
}
|
||||
providerWithValidOverride := AlertProvider{
|
||||
WebhookURL: "http://example.com",
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
WebhookURL: "http://example.com",
|
||||
Group: "group",
|
||||
Config: Config{WebhookURL: "http://example.com"},
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if !providerWithValidOverride.IsValid() {
|
||||
if err := providerWithValidOverride.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -83,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -93,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -103,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved-error",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -152,14 +152,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"TextBlock\",\"text\":\"\\u0026#x26D1; Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"\\u0026#x274C;\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"\\u0026#x274C;\",\"value\":\"[STATUS] == 200\"}]}]},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
|
||||
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"❌\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"❌\",\"value\":\"[STATUS] == 200\"}]}],\"style\":\"Default\"}],\"style\":\"Attention\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"TextBlock\",\"text\":\"\\u0026#x26D1; Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"\\u0026#x2705;\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"\\u0026#x2705;\",\"value\":\"[STATUS] == 200\"}]}]},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
|
||||
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"✅\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"✅\",\"value\":\"[STATUS] == 200\"}]}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
|
||||
},
|
||||
{
|
||||
Name: "resolved-with-no-conditions",
|
||||
@@ -167,7 +167,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"TextBlock\",\"text\":\"\\u0026#x26D1; Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false}]},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
|
||||
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
@@ -180,6 +180,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
&scenario.Provider.DefaultConfig,
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{ConditionResults: conditionResults},
|
||||
@@ -205,64 +206,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) {
|
||||
tests := []struct {
|
||||
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
InputGroup string
|
||||
ExpectedOutput string
|
||||
InputAlert alert.Alert
|
||||
ExpectedOutput Config
|
||||
}{
|
||||
{
|
||||
Name: "provider-no-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
WebhookURL: "http://example.com",
|
||||
Overrides: nil,
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "",
|
||||
ExpectedOutput: "http://example.com",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-no-override-specify-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
WebhookURL: "http://example.com",
|
||||
Overrides: nil,
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "group",
|
||||
ExpectedOutput: "http://example.com",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
WebhookURL: "http://example.com",
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
WebhookURL: "http://example01.com",
|
||||
Group: "group",
|
||||
Config: Config{WebhookURL: "http://example01.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "",
|
||||
ExpectedOutput: "http://example.com",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{WebhookURL: "http://example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-group-should-override",
|
||||
Provider: AlertProvider{
|
||||
WebhookURL: "http://example.com",
|
||||
DefaultConfig: Config{WebhookURL: "http://example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
WebhookURL: "http://example01.com",
|
||||
Group: "group",
|
||||
Config: Config{WebhookURL: "http://group-example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
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 {
|
||||
t.Run(tt.Name, func(t *testing.T) {
|
||||
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput {
|
||||
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput)
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
|
||||
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
|
||||
}
|
||||
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||
t.Errorf("unexpected error: %s", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package telegram
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -10,66 +11,97 @@ import (
|
||||
"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 defaultAPIURL = "https://api.telegram.org"
|
||||
const defaultApiUrl = "https://api.telegram.org"
|
||||
|
||||
var (
|
||||
ErrTokenNotSet = errors.New("token not set")
|
||||
ErrIDNotSet = errors.New("id not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Token string `yaml:"token"`
|
||||
ID string `yaml:"id"`
|
||||
ApiUrl string `yaml:"api-url"`
|
||||
|
||||
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.ApiUrl) == 0 {
|
||||
cfg.ApiUrl = defaultApiUrl
|
||||
}
|
||||
if len(cfg.Token) == 0 {
|
||||
return ErrTokenNotSet
|
||||
}
|
||||
if len(cfg.ID) == 0 {
|
||||
return ErrIDNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if override.ClientConfig != nil {
|
||||
cfg.ClientConfig = override.ClientConfig
|
||||
}
|
||||
if len(override.Token) > 0 {
|
||||
cfg.Token = override.Token
|
||||
}
|
||||
if len(override.ID) > 0 {
|
||||
cfg.ID = override.ID
|
||||
}
|
||||
if len(override.ApiUrl) > 0 {
|
||||
cfg.ApiUrl = override.ApiUrl
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Telegram
|
||||
type AlertProvider struct {
|
||||
Token string `yaml:"token"`
|
||||
ID string `yaml:"id"`
|
||||
APIURL string `yaml:"api-url"`
|
||||
|
||||
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
||||
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||
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 Overrid that may be prioritized over the default configuration
|
||||
// Overrides is a list of overrides that may be prioritized over the default configuration
|
||||
Overrides []*Override `yaml:"overrides,omitempty"`
|
||||
}
|
||||
|
||||
// Override is a configuration that may be prioritized over the default configuration
|
||||
type Override struct {
|
||||
group string `yaml:"group"`
|
||||
token string `yaml:"token"`
|
||||
id string `yaml:"id"`
|
||||
Group string `yaml:"group"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
if provider.ClientConfig == nil {
|
||||
provider.ClientConfig = client.GetDefaultConfig()
|
||||
}
|
||||
|
||||
registerGroups := make(map[string]bool)
|
||||
for _, override := range provider.Overrides {
|
||||
if len(override.group) == 0 {
|
||||
return false
|
||||
// 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
|
||||
}
|
||||
if _, ok := registerGroups[override.group]; ok {
|
||||
return false
|
||||
}
|
||||
registerGroups[override.group] = true
|
||||
}
|
||||
|
||||
return len(provider.Token) > 0 && len(provider.ID) > 0
|
||||
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 {
|
||||
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
||||
apiURL := provider.APIURL
|
||||
if apiURL == "" {
|
||||
apiURL = defaultAPIURL
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/bot%s/sendMessage", apiURL, provider.getTokenForGroup(ep.Group)), buffer)
|
||||
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/bot%s/sendMessage", cfg.ApiUrl, cfg.Token), buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -81,15 +113,6 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
||||
return err
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) getTokenForGroup(group string) string {
|
||||
for _, override := range provider.Overrides {
|
||||
if override.group == group && len(override.token) > 0 {
|
||||
return override.token
|
||||
}
|
||||
}
|
||||
return provider.Token
|
||||
}
|
||||
|
||||
type Body struct {
|
||||
ChatID string `json:"chat_id"`
|
||||
Text string `json:"text"`
|
||||
@@ -97,7 +120,7 @@ type Body struct {
|
||||
}
|
||||
|
||||
// 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
|
||||
if resolved {
|
||||
message = fmt.Sprintf("An alert for *%s* has been resolved:\n—\n _healthcheck passing successfully %d time(s) in a row_\n— ", ep.DisplayName(), alert.SuccessThreshold)
|
||||
@@ -124,23 +147,45 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
||||
text = fmt.Sprintf("⛑ *Gatus* \n%s%s", message, formattedConditionResults)
|
||||
}
|
||||
bodyAsJSON, _ := json.Marshal(Body{
|
||||
ChatID: provider.getIDForGroup(ep.Group),
|
||||
ChatID: cfg.ID,
|
||||
Text: text,
|
||||
ParseMode: "MARKDOWN",
|
||||
})
|
||||
return bodyAsJSON
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) getIDForGroup(group string) string {
|
||||
for _, override := range provider.Overrides {
|
||||
if override.group == group && len(override.id) > 0 {
|
||||
return override.id
|
||||
}
|
||||
}
|
||||
return provider.ID
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// 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,87 +11,36 @@ import (
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
t.Run("invalid-provider", func(t *testing.T) {
|
||||
invalidProvider := AlertProvider{Token: "", ID: ""}
|
||||
if invalidProvider.IsValid() {
|
||||
invalidProvider := AlertProvider{DefaultConfig: Config{Token: "", ID: ""}}
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
})
|
||||
t.Run("valid-provider", func(t *testing.T) {
|
||||
validProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}
|
||||
if validProvider.ClientConfig != nil {
|
||||
t.Error("provider client config should have been nil prior to IsValid() being executed")
|
||||
}
|
||||
if !validProvider.IsValid() {
|
||||
validProvider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}}
|
||||
if err := validProvider.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
if validProvider.ClientConfig == nil {
|
||||
t.Error("provider client config should have been set after IsValid() was executed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAlertProvider_IsValidWithOverrides(t *testing.T) {
|
||||
t.Run("invalid-provider-override-nonexist-group", func(t *testing.T) {
|
||||
invalidProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{token: "token", id: "id"}}}
|
||||
if invalidProvider.IsValid() {
|
||||
invalidProvider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Config: Config{Token: "token", ID: "id"}}}}
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
})
|
||||
t.Run("invalid-provider-override-duplicate-group", func(t *testing.T) {
|
||||
invalidProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{group: "group1", token: "token", id: "id"}, {group: "group1", id: "id2"}}}
|
||||
if invalidProvider.IsValid() {
|
||||
invalidProvider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group1", Config: Config{Token: "token", ID: "id"}}, {Group: "group1", Config: Config{ID: "id2"}}}}
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
})
|
||||
t.Run("valid-provider", func(t *testing.T) {
|
||||
validProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{group: "group", token: "token", id: "id"}}}
|
||||
if validProvider.ClientConfig != nil {
|
||||
t.Error("provider client config should have been nil prior to IsValid() being executed")
|
||||
}
|
||||
if !validProvider.IsValid() {
|
||||
t.Run("valid-provider-with-overrides", func(t *testing.T) {
|
||||
validProvider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group", Config: Config{Token: "token", ID: "id"}}}}
|
||||
if err := validProvider.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
if validProvider.ClientConfig == nil {
|
||||
t.Error("provider client config should have been set after IsValid() was executed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAlertProvider_getTokenAndIDForGroup(t *testing.T) {
|
||||
t.Run("get-token-with-override", func(t *testing.T) {
|
||||
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{group: "group", token: "overrideToken", id: "overrideID"}}}
|
||||
token := provider.getTokenForGroup("group")
|
||||
if token != "overrideToken" {
|
||||
t.Error("token should have been 'overrideToken'")
|
||||
}
|
||||
id := provider.getIDForGroup("group")
|
||||
if id != "overrideID" {
|
||||
t.Error("id should have been 'overrideID'")
|
||||
}
|
||||
})
|
||||
t.Run("get-default-token-with-overridden-id", func(t *testing.T) {
|
||||
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{group: "group", id: "overrideID"}}}
|
||||
token := provider.getTokenForGroup("group")
|
||||
if token != provider.Token {
|
||||
t.Error("token should have been the default token")
|
||||
}
|
||||
id := provider.getIDForGroup("group")
|
||||
if id != "overrideID" {
|
||||
t.Error("id should have been 'overrideID'")
|
||||
}
|
||||
})
|
||||
t.Run("get-default-token-with-overridden-token", func(t *testing.T) {
|
||||
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{group: "group", token: "overrideToken"}}}
|
||||
token := provider.getTokenForGroup("group")
|
||||
if token != "overrideToken" {
|
||||
t.Error("token should have been 'overrideToken'")
|
||||
}
|
||||
id := provider.getIDForGroup("group")
|
||||
if id != provider.ID {
|
||||
t.Error("id should have been the default id")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -109,7 +58,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{ID: "123", Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -119,7 +68,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{ID: "123", Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -129,7 +78,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{ID: "123", Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -139,7 +88,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved-error",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{ID: "123", Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
@@ -185,14 +134,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{ID: "123"},
|
||||
Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\n_description-1_ \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{ID: "123"},
|
||||
Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 5 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\\n*Condition results*\\n✅ - `[CONNECTED] == true`\\n✅ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
|
||||
@@ -200,7 +149,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
{
|
||||
Name: "resolved-with-no-conditions",
|
||||
NoConditions: true,
|
||||
Provider: AlertProvider{ID: "123"},
|
||||
Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 5 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\",\"parse_mode\":\"MARKDOWN\"}",
|
||||
@@ -216,6 +165,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
&scenario.Provider.DefaultConfig,
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{ConditionResults: conditionResults},
|
||||
@@ -240,3 +190,63 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||
t.Run("get-token-with-override", func(t *testing.T) {
|
||||
provider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group", Config: Config{Token: "groupToken", ID: "overrideID"}}}}
|
||||
cfg, err := provider.GetConfig("group", &alert.Alert{})
|
||||
if err != nil {
|
||||
t.Error("expected no error, got", err)
|
||||
}
|
||||
if cfg.Token != "groupToken" {
|
||||
t.Error("token should have been 'groupToken'")
|
||||
}
|
||||
if cfg.ID != "overrideID" {
|
||||
t.Error("id should have been 'overrideID'")
|
||||
}
|
||||
})
|
||||
t.Run("get-default-token-with-overridden-id", func(t *testing.T) {
|
||||
provider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group", Config: Config{ID: "overrideID"}}}}
|
||||
cfg, err := provider.GetConfig("group", &alert.Alert{})
|
||||
if err != nil {
|
||||
t.Error("expected no error, got", err)
|
||||
}
|
||||
if cfg.Token != provider.DefaultConfig.Token {
|
||||
t.Error("token should have been the default token")
|
||||
}
|
||||
if cfg.ID != "overrideID" {
|
||||
t.Error("id should have been 'overrideID'")
|
||||
}
|
||||
})
|
||||
t.Run("get-default-token-with-overridden-token", func(t *testing.T) {
|
||||
provider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group", Config: Config{Token: "groupToken"}}}}
|
||||
cfg, err := provider.GetConfig("group", &alert.Alert{})
|
||||
if err != nil {
|
||||
t.Error("expected no error, got", err)
|
||||
}
|
||||
if cfg.Token != "groupToken" {
|
||||
t.Error("token should have been 'groupToken'")
|
||||
}
|
||||
if cfg.ID != provider.DefaultConfig.ID {
|
||||
t.Error("id should have been the default id")
|
||||
}
|
||||
})
|
||||
t.Run("get-default-token-with-overridden-token-and-alert-token-override", func(t *testing.T) {
|
||||
provider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group", Config: Config{Token: "groupToken"}}}}
|
||||
alert := &alert.Alert{ProviderOverride: map[string]any{"token": "alertToken"}}
|
||||
cfg, err := provider.GetConfig("group", alert)
|
||||
if err != nil {
|
||||
t.Error("expected no error, got", err)
|
||||
}
|
||||
if cfg.Token != "alertToken" {
|
||||
t.Error("token should have been 'alertToken'")
|
||||
}
|
||||
if cfg.ID != provider.DefaultConfig.ID {
|
||||
t.Error("id should have been the default id")
|
||||
}
|
||||
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||
if err = provider.ValidateOverrides("group", alert); err != nil {
|
||||
t.Errorf("unexpected error: %s", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,41 +3,99 @@ package twilio
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"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"
|
||||
)
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Twilio
|
||||
type AlertProvider struct {
|
||||
var (
|
||||
ErrSIDNotSet = errors.New("sid not set")
|
||||
ErrTokenNotSet = errors.New("token not set")
|
||||
ErrFromNotSet = errors.New("from not set")
|
||||
ErrToNotSet = errors.New("to not set")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
SID string `yaml:"sid"`
|
||||
Token string `yaml:"token"`
|
||||
From string `yaml:"from"`
|
||||
To string `yaml:"to"`
|
||||
|
||||
TextTwilioTriggered string `yaml:"text-twilio-triggered,omitempty"` // String used in the SMS body and subject (optional)
|
||||
TextTwilioResolved string `yaml:"text-twilio-resolved,omitempty"` // String used in the SMS body and subject (optional)
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.SID) == 0 {
|
||||
return ErrSIDNotSet
|
||||
}
|
||||
if len(cfg.Token) == 0 {
|
||||
return ErrTokenNotSet
|
||||
}
|
||||
if len(cfg.From) == 0 {
|
||||
return ErrFromNotSet
|
||||
}
|
||||
if len(cfg.To) == 0 {
|
||||
return ErrToNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.SID) > 0 {
|
||||
cfg.SID = override.SID
|
||||
}
|
||||
if len(override.Token) > 0 {
|
||||
cfg.Token = override.Token
|
||||
}
|
||||
if len(override.From) > 0 {
|
||||
cfg.From = override.From
|
||||
}
|
||||
if len(override.To) > 0 {
|
||||
cfg.To = override.To
|
||||
}
|
||||
if len(override.TextTwilioTriggered) > 0 {
|
||||
cfg.TextTwilioTriggered = override.TextTwilioTriggered
|
||||
}
|
||||
if len(override.TextTwilioResolved) > 0 {
|
||||
cfg.TextTwilioResolved = override.TextTwilioResolved
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Twilio
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
return len(provider.Token) > 0 && len(provider.SID) > 0 && len(provider.From) > 0 && len(provider.To) > 0
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(ep, alert, result, resolved)))
|
||||
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", provider.SID), buffer)
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(cfg, ep, alert, result, resolved)))
|
||||
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", cfg.SID), buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(provider.SID+":"+provider.Token))))
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(cfg.SID+":"+cfg.Token))))
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -51,16 +109,24 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
|
||||
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
|
||||
var message string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||
if len(cfg.TextTwilioResolved) > 0 {
|
||||
message = strings.Replace(strings.Replace(cfg.TextTwilioResolved, "{endpoint}", ep.DisplayName(), 1), "{description}", alert.GetDescription(), 1)
|
||||
} else {
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||
}
|
||||
} else {
|
||||
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||
if len(cfg.TextTwilioTriggered) > 0 {
|
||||
message = strings.Replace(strings.Replace(cfg.TextTwilioTriggered, "{endpoint}", ep.DisplayName(), 1), "{description}", alert.GetDescription(), 1)
|
||||
} else {
|
||||
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||
}
|
||||
}
|
||||
return url.Values{
|
||||
"To": {provider.To},
|
||||
"From": {provider.From},
|
||||
"To": {cfg.To},
|
||||
"From": {cfg.From},
|
||||
"Body": {message},
|
||||
}.Encode()
|
||||
}
|
||||
@@ -69,3 +135,25 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,28 +1,110 @@
|
||||
package twilio
|
||||
|
||||
import (
|
||||
"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 TestTwilioAlertProvider_IsValid(t *testing.T) {
|
||||
invalidProvider := AlertProvider{}
|
||||
if invalidProvider.IsValid() {
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{
|
||||
SID: "1",
|
||||
Token: "1",
|
||||
From: "1",
|
||||
To: "1",
|
||||
DefaultConfig: Config{
|
||||
SID: "1",
|
||||
Token: "1",
|
||||
From: "1",
|
||||
To: "1",
|
||||
},
|
||||
}
|
||||
if !validProvider.IsValid() {
|
||||
if err := validProvider.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
MockRoundTripper test.MockRoundTripper
|
||||
ExpectedError bool
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "resolved-error",
|
||||
Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
err := scenario.Provider.Send(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
@@ -35,14 +117,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{SID: "1", Token: "2", From: "3", To: "4"},
|
||||
Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "Body=TRIGGERED%3A+endpoint-name+-+description-1&From=3&To=4",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{SID: "1", Token: "2", From: "3", To: "4"},
|
||||
Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "Body=RESOLVED%3A+endpoint-name+-+description-2&From=3&To=4",
|
||||
@@ -51,6 +133,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
&scenario.Provider.DefaultConfig,
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
@@ -76,3 +159,53 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
InputAlert alert.Alert
|
||||
ExpectedOutput Config
|
||||
}{
|
||||
{
|
||||
Name: "provider-no-override-should-default",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"},
|
||||
},
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{SID: "1", Token: "2", From: "3", To: "4"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-alert-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"},
|
||||
},
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{"sid": "5", "token": "6", "from": "7", "to": "8"}},
|
||||
ExpectedOutput: Config{SID: "5", Token: "6", From: "7", To: "8"},
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
|
||||
if err != nil {
|
||||
t.Error("expected no error, got:", err.Error())
|
||||
}
|
||||
if got.SID != scenario.ExpectedOutput.SID {
|
||||
t.Errorf("expected SID to be %s, got %s", scenario.ExpectedOutput.SID, got.SID)
|
||||
}
|
||||
if got.Token != scenario.ExpectedOutput.Token {
|
||||
t.Errorf("expected token to be %s, got %s", scenario.ExpectedOutput.Token, got.Token)
|
||||
}
|
||||
if got.From != scenario.ExpectedOutput.From {
|
||||
t.Errorf("expected from to be %s, got %s", scenario.ExpectedOutput.From, got.From)
|
||||
}
|
||||
if got.To != scenario.ExpectedOutput.To {
|
||||
t.Errorf("expected to to be %s, got %s", scenario.ExpectedOutput.To, got.To)
|
||||
}
|
||||
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil {
|
||||
t.Errorf("unexpected error: %s", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package zulip
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -10,108 +11,99 @@ import (
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrBotEmailNotSet = errors.New("bot-email not set")
|
||||
ErrBotAPIKeyNotSet = errors.New("bot-api-key not set")
|
||||
ErrDomainNotSet = errors.New("domain not set")
|
||||
ErrChannelIDNotSet = errors.New("channel-id not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
// BotEmail is the email of the bot user
|
||||
BotEmail string `yaml:"bot-email"`
|
||||
// BotAPIKey is the API key of the bot user
|
||||
BotAPIKey string `yaml:"bot-api-key"`
|
||||
// Domain is the domain of the Zulip server
|
||||
Domain string `yaml:"domain"`
|
||||
// ChannelID is the ID of the channel to send the message to
|
||||
ChannelID string `yaml:"channel-id"`
|
||||
BotEmail string `yaml:"bot-email"` // Email of the bot user
|
||||
BotAPIKey string `yaml:"bot-api-key"` // API key of the bot user
|
||||
Domain string `yaml:"domain"` // Domain of the Zulip server
|
||||
ChannelID string `yaml:"channel-id"` // ID of the channel to send the message to
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.BotEmail) == 0 {
|
||||
return ErrBotEmailNotSet
|
||||
}
|
||||
if len(cfg.BotAPIKey) == 0 {
|
||||
return ErrBotAPIKeyNotSet
|
||||
}
|
||||
if len(cfg.Domain) == 0 {
|
||||
return ErrDomainNotSet
|
||||
}
|
||||
if len(cfg.ChannelID) == 0 {
|
||||
return ErrChannelIDNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.BotEmail) > 0 {
|
||||
cfg.BotEmail = override.BotEmail
|
||||
}
|
||||
if len(override.BotAPIKey) > 0 {
|
||||
cfg.BotAPIKey = override.BotAPIKey
|
||||
}
|
||||
if len(override.Domain) > 0 {
|
||||
cfg.Domain = override.Domain
|
||||
}
|
||||
if len(override.ChannelID) > 0 {
|
||||
cfg.ChannelID = override.ChannelID
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Zulip
|
||||
type AlertProvider struct {
|
||||
Config `yaml:",inline"`
|
||||
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 {
|
||||
Config
|
||||
Group string `yaml:"group"`
|
||||
Group string `yaml:"group"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) validateConfig(conf *Config) bool {
|
||||
return len(conf.BotEmail) > 0 && len(conf.BotAPIKey) > 0 && len(conf.Domain) > 0 && len(conf.ChannelID) > 0
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
isAlreadyRegistered := registeredGroups[override.Group]
|
||||
if isAlreadyRegistered || override.Group == "" || !provider.validateConfig(&override.Config) {
|
||||
return false
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return provider.validateConfig(&provider.Config)
|
||||
}
|
||||
|
||||
// getChannelIdForGroup returns the channel ID for the provided group
|
||||
func (provider *AlertProvider) getChannelIdForGroup(group string) string {
|
||||
for _, override := range provider.Overrides {
|
||||
if override.Group == group {
|
||||
return override.ChannelID
|
||||
}
|
||||
}
|
||||
return provider.ChannelID
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
|
||||
var message string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||
} else {
|
||||
message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||
}
|
||||
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
message += "\n> " + alertDescription + "\n"
|
||||
}
|
||||
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
prefix = ":check:"
|
||||
} else {
|
||||
prefix = ":cross_mark:"
|
||||
}
|
||||
message += fmt.Sprintf("\n%s - `%s`", prefix, conditionResult.Condition)
|
||||
}
|
||||
|
||||
postData := map[string]string{
|
||||
"type": "channel",
|
||||
"to": provider.getChannelIdForGroup(ep.Group),
|
||||
"topic": "Gatus",
|
||||
"content": message,
|
||||
}
|
||||
bodyParams := url.Values{}
|
||||
for field, value := range postData {
|
||||
bodyParams.Add(field, value)
|
||||
}
|
||||
return bodyParams.Encode()
|
||||
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 {
|
||||
buffer := bytes.NewBufferString(provider.buildRequestBody(ep, alert, result, resolved))
|
||||
zulipEndpoint := fmt.Sprintf("https://%s/api/v1/messages", provider.Domain)
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBufferString(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||
zulipEndpoint := fmt.Sprintf("https://%s/api/v1/messages", cfg.Domain)
|
||||
request, err := http.NewRequest(http.MethodPost, zulipEndpoint, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.SetBasicAuth(provider.BotEmail, provider.BotAPIKey)
|
||||
request.SetBasicAuth(cfg.BotEmail, cfg.BotAPIKey)
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("User-Agent", "Gatus")
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
@@ -126,7 +118,66 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
|
||||
var message string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||
} else {
|
||||
message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||
}
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
message += "\n> " + alertDescription + "\n"
|
||||
}
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
prefix = ":check:"
|
||||
} else {
|
||||
prefix = ":cross_mark:"
|
||||
}
|
||||
message += fmt.Sprintf("\n%s - `%s`", prefix, conditionResult.Condition)
|
||||
}
|
||||
return url.Values{
|
||||
"type": {"channel"},
|
||||
"to": {cfg.ChannelID},
|
||||
"topic": {"Gatus"},
|
||||
"content": {message},
|
||||
}.Encode()
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle group overrides
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
cfg.Merge(&override.Config)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package zulip
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -12,237 +13,84 @@ import (
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
testCase := []struct {
|
||||
name string
|
||||
alertProvider AlertProvider
|
||||
expected bool
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
AlertProvider AlertProvider
|
||||
ExpectedError error
|
||||
}{
|
||||
{
|
||||
name: "Empty provider",
|
||||
alertProvider: AlertProvider{},
|
||||
expected: false,
|
||||
Name: "Empty provider",
|
||||
AlertProvider: AlertProvider{},
|
||||
ExpectedError: ErrBotEmailNotSet,
|
||||
},
|
||||
{
|
||||
name: "Empty channel id",
|
||||
alertProvider: AlertProvider{
|
||||
Config: Config{
|
||||
Name: "Empty channel id",
|
||||
AlertProvider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
BotEmail: "something",
|
||||
BotAPIKey: "something",
|
||||
Domain: "something",
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
ExpectedError: ErrChannelIDNotSet,
|
||||
},
|
||||
{
|
||||
name: "Empty domain",
|
||||
alertProvider: AlertProvider{
|
||||
Config: Config{
|
||||
Name: "Empty domain",
|
||||
AlertProvider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
BotEmail: "something",
|
||||
BotAPIKey: "something",
|
||||
ChannelID: "something",
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
ExpectedError: ErrDomainNotSet,
|
||||
},
|
||||
{
|
||||
name: "Empty bot api key",
|
||||
alertProvider: AlertProvider{
|
||||
Config: Config{
|
||||
Name: "Empty bot api key",
|
||||
AlertProvider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
BotEmail: "something",
|
||||
Domain: "something",
|
||||
ChannelID: "something",
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
ExpectedError: ErrBotAPIKeyNotSet,
|
||||
},
|
||||
{
|
||||
name: "Empty bot email",
|
||||
alertProvider: AlertProvider{
|
||||
Config: Config{
|
||||
Name: "Empty bot email",
|
||||
AlertProvider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
BotAPIKey: "something",
|
||||
Domain: "something",
|
||||
ChannelID: "something",
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
ExpectedError: ErrBotEmailNotSet,
|
||||
},
|
||||
{
|
||||
name: "Valid provider",
|
||||
alertProvider: AlertProvider{
|
||||
Config: Config{
|
||||
Name: "Valid provider",
|
||||
AlertProvider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
BotEmail: "something",
|
||||
BotAPIKey: "something",
|
||||
Domain: "something",
|
||||
ChannelID: "something",
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
ExpectedError: nil,
|
||||
},
|
||||
}
|
||||
for _, tc := range testCase {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.alertProvider.IsValid() != tc.expected {
|
||||
t.Errorf("IsValid assertion failed (expected %v, got %v)", tc.expected, !tc.expected)
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
if err := scenario.AlertProvider.Validate(); !errors.Is(err, scenario.ExpectedError) {
|
||||
t.Errorf("ExpectedError error %v, got %v", scenario.ExpectedError, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
||||
validConfig := Config{
|
||||
BotEmail: "something",
|
||||
BotAPIKey: "something",
|
||||
Domain: "something",
|
||||
ChannelID: "something",
|
||||
}
|
||||
|
||||
testCase := []struct {
|
||||
name string
|
||||
alertProvider AlertProvider
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Empty group",
|
||||
alertProvider: AlertProvider{
|
||||
Config: validConfig,
|
||||
Overrides: []Override{
|
||||
{
|
||||
Config: validConfig,
|
||||
Group: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Empty override config",
|
||||
alertProvider: AlertProvider{
|
||||
Config: validConfig,
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "something",
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Empty channel id",
|
||||
alertProvider: AlertProvider{
|
||||
Config: validConfig,
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "something",
|
||||
Config: Config{
|
||||
BotEmail: "something",
|
||||
BotAPIKey: "something",
|
||||
Domain: "something",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Empty domain",
|
||||
alertProvider: AlertProvider{
|
||||
Config: validConfig,
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "something",
|
||||
Config: Config{
|
||||
BotEmail: "something",
|
||||
BotAPIKey: "something",
|
||||
ChannelID: "something",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Empty bot api key",
|
||||
alertProvider: AlertProvider{
|
||||
Config: validConfig,
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "something",
|
||||
Config: Config{
|
||||
BotEmail: "something",
|
||||
Domain: "something",
|
||||
ChannelID: "something",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Empty bot email",
|
||||
alertProvider: AlertProvider{
|
||||
Config: validConfig,
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "something",
|
||||
Config: Config{
|
||||
BotAPIKey: "something",
|
||||
Domain: "something",
|
||||
ChannelID: "something",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Valid provider",
|
||||
alertProvider: AlertProvider{
|
||||
Config: validConfig,
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "something",
|
||||
Config: validConfig,
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range testCase {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.alertProvider.IsValid() != tc.expected {
|
||||
t.Errorf("IsValid assertion failed (expected %v, got %v)", tc.expected, !tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetChannelIdForGroup(t *testing.T) {
|
||||
provider := AlertProvider{
|
||||
Config: Config{
|
||||
ChannelID: "default",
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group1",
|
||||
Config: Config{ChannelID: "group1"},
|
||||
},
|
||||
{
|
||||
Group: "group2",
|
||||
Config: Config{ChannelID: "group2"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if provider.getChannelIdForGroup("") != "default" {
|
||||
t.Error("Expected default channel ID")
|
||||
}
|
||||
if provider.getChannelIdForGroup("group2") != "group2" {
|
||||
t.Error("Expected group2 channel ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_BuildRequestBody(t *testing.T) {
|
||||
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
basicConfig := Config{
|
||||
BotEmail: "bot-email",
|
||||
BotAPIKey: "bot-api-key",
|
||||
@@ -266,13 +114,13 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
|
||||
{
|
||||
name: "Resolved alert with no conditions",
|
||||
provider: AlertProvider{
|
||||
Config: basicConfig,
|
||||
DefaultConfig: basicConfig,
|
||||
},
|
||||
alert: basicAlert,
|
||||
resolved: true,
|
||||
hasConditions: false,
|
||||
expectedBody: url.Values{
|
||||
"content": {`An alert for **endpoint-name** has been resolved after passing successfully 2 time(s) in a row
|
||||
"content": {`An alert for **endpoint-Name** has been resolved after passing successfully 2 time(s) in a row
|
||||
> Description
|
||||
`},
|
||||
"to": {"channel-id"},
|
||||
@@ -283,13 +131,13 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
|
||||
{
|
||||
name: "Resolved alert with conditions",
|
||||
provider: AlertProvider{
|
||||
Config: basicConfig,
|
||||
DefaultConfig: basicConfig,
|
||||
},
|
||||
alert: basicAlert,
|
||||
resolved: true,
|
||||
hasConditions: true,
|
||||
expectedBody: url.Values{
|
||||
"content": {`An alert for **endpoint-name** has been resolved after passing successfully 2 time(s) in a row
|
||||
"content": {`An alert for **endpoint-Name** has been resolved after passing successfully 2 time(s) in a row
|
||||
> Description
|
||||
|
||||
:check: - ` + "`[CONNECTED] == true`" + `
|
||||
@@ -303,13 +151,13 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
|
||||
{
|
||||
name: "Failed alert with no conditions",
|
||||
provider: AlertProvider{
|
||||
Config: basicConfig,
|
||||
DefaultConfig: basicConfig,
|
||||
},
|
||||
alert: basicAlert,
|
||||
resolved: false,
|
||||
hasConditions: false,
|
||||
expectedBody: url.Values{
|
||||
"content": {`An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row
|
||||
"content": {`An alert for **endpoint-Name** has been triggered due to having failed 3 time(s) in a row
|
||||
> Description
|
||||
`},
|
||||
"to": {"channel-id"},
|
||||
@@ -320,13 +168,13 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
|
||||
{
|
||||
name: "Failed alert with conditions",
|
||||
provider: AlertProvider{
|
||||
Config: basicConfig,
|
||||
DefaultConfig: basicConfig,
|
||||
},
|
||||
alert: basicAlert,
|
||||
resolved: false,
|
||||
hasConditions: true,
|
||||
expectedBody: url.Values{
|
||||
"content": {`An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row
|
||||
"content": {`An alert for **endpoint-Name** has been triggered due to having failed 3 time(s) in a row
|
||||
> Description
|
||||
|
||||
:cross_mark: - ` + "`[CONNECTED] == true`" + `
|
||||
@@ -349,7 +197,8 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
body := tc.provider.buildRequestBody(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&tc.provider.DefaultConfig,
|
||||
&endpoint.Endpoint{Name: "endpoint-Name"},
|
||||
&tc.alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: conditionResults,
|
||||
@@ -369,10 +218,10 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
t.Error("ExpectedError default alert to be not nil")
|
||||
}
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
t.Error("ExpectedError default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,16 +229,16 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
validateRequest := func(req *http.Request) {
|
||||
if req.URL.String() != "https://custom-domain/api/v1/messages" {
|
||||
t.Errorf("expected url https://custom-domain.zulipchat.com/api/v1/messages, got %s", req.URL.String())
|
||||
t.Errorf("ExpectedError url https://custom-domain.zulipchat.com/api/v1/messages, got %s", req.URL.String())
|
||||
}
|
||||
if req.Method != http.MethodPost {
|
||||
t.Errorf("expected POST request, got %s", req.Method)
|
||||
t.Errorf("ExpectedError POST request, got %s", req.Method)
|
||||
}
|
||||
if req.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
|
||||
t.Errorf("expected Content-Type header to be application/x-www-form-urlencoded, got %s", req.Header.Get("Content-Type"))
|
||||
t.Errorf("ExpectedError Content-Type header to be application/x-www-form-urlencoded, got %s", req.Header.Get("Content-Type"))
|
||||
}
|
||||
if req.Header.Get("User-Agent") != "Gatus" {
|
||||
t.Errorf("expected User-Agent header to be Gatus, got %s", req.Header.Get("User-Agent"))
|
||||
t.Errorf("ExpectedError User-Agent header to be Gatus, got %s", req.Header.Get("User-Agent"))
|
||||
}
|
||||
}
|
||||
basicConfig := Config{
|
||||
@@ -413,7 +262,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
{
|
||||
name: "resolved",
|
||||
provider: AlertProvider{
|
||||
Config: basicConfig,
|
||||
DefaultConfig: basicConfig,
|
||||
},
|
||||
alert: basicAlert,
|
||||
resolved: true,
|
||||
@@ -426,7 +275,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
{
|
||||
name: "resolved error",
|
||||
provider: AlertProvider{
|
||||
Config: basicConfig,
|
||||
DefaultConfig: basicConfig,
|
||||
},
|
||||
alert: basicAlert,
|
||||
resolved: true,
|
||||
@@ -439,7 +288,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
{
|
||||
name: "triggered",
|
||||
provider: AlertProvider{
|
||||
Config: basicConfig,
|
||||
DefaultConfig: basicConfig,
|
||||
},
|
||||
alert: basicAlert,
|
||||
resolved: false,
|
||||
@@ -452,7 +301,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
{
|
||||
name: "triggered error",
|
||||
provider: AlertProvider{
|
||||
Config: basicConfig,
|
||||
DefaultConfig: basicConfig,
|
||||
},
|
||||
alert: basicAlert,
|
||||
resolved: false,
|
||||
@@ -467,7 +316,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: tc.mockRoundTripper})
|
||||
err := tc.provider.Send(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&endpoint.Endpoint{Name: "endpoint-Name"},
|
||||
&tc.alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
@@ -478,10 +327,155 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
tc.resolved,
|
||||
)
|
||||
if tc.expectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
t.Error("ExpectedError error, got none")
|
||||
}
|
||||
if !tc.expectedError && err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
t.Errorf("ExpectedError no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
InputGroup string
|
||||
InputAlert alert.Alert
|
||||
ExpectedOutput Config
|
||||
}{
|
||||
{
|
||||
Name: "provider-no-overrides",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
BotEmail: "default-bot-email",
|
||||
BotAPIKey: "default-bot-api-key",
|
||||
Domain: "default-domain",
|
||||
ChannelID: "default-channel-id",
|
||||
},
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "group",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{
|
||||
BotEmail: "default-bot-email",
|
||||
BotAPIKey: "default-bot-api-key",
|
||||
Domain: "default-domain",
|
||||
ChannelID: "default-channel-id",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
BotEmail: "default-bot-email",
|
||||
BotAPIKey: "default-bot-api-key",
|
||||
Domain: "default-domain",
|
||||
ChannelID: "default-channel-id",
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
Config: Config{ChannelID: "group-channel-id"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{
|
||||
BotEmail: "default-bot-email",
|
||||
BotAPIKey: "default-bot-api-key",
|
||||
Domain: "default-domain",
|
||||
ChannelID: "default-channel-id",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-group-should-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
BotEmail: "default-bot-email",
|
||||
BotAPIKey: "default-bot-api-key",
|
||||
Domain: "default-domain",
|
||||
ChannelID: "default-channel-id",
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
Config: Config{
|
||||
BotEmail: "group-bot-email",
|
||||
BotAPIKey: "group-bot-api-key",
|
||||
Domain: "group-domain",
|
||||
ChannelID: "group-channel-id",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "group",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{
|
||||
BotEmail: "group-bot-email",
|
||||
BotAPIKey: "group-bot-api-key",
|
||||
Domain: "group-domain",
|
||||
ChannelID: "group-channel-id",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
BotEmail: "default-bot-email",
|
||||
BotAPIKey: "default-bot-api-key",
|
||||
Domain: "default-domain",
|
||||
ChannelID: "default-channel-id",
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
Config: Config{
|
||||
BotEmail: "group-bot-email",
|
||||
BotAPIKey: "group-bot-api-key",
|
||||
Domain: "group-domain",
|
||||
ChannelID: "group-channel-id",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "group",
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{
|
||||
"bot-email": "alert-bot-email",
|
||||
"bot-api-key": "alert-bot-api-key",
|
||||
"domain": "alert-domain",
|
||||
"channel-id": "alert-channel-id",
|
||||
}},
|
||||
ExpectedOutput: Config{
|
||||
BotEmail: "alert-bot-email",
|
||||
BotAPIKey: "alert-bot-api-key",
|
||||
Domain: "alert-domain",
|
||||
ChannelID: "alert-channel-id",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
if got.BotEmail != scenario.ExpectedOutput.BotEmail {
|
||||
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.BotEmail, got.BotEmail)
|
||||
}
|
||||
if got.BotAPIKey != scenario.ExpectedOutput.BotAPIKey {
|
||||
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.BotAPIKey, got.BotAPIKey)
|
||||
}
|
||||
if got.Domain != scenario.ExpectedOutput.Domain {
|
||||
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.Domain, got.Domain)
|
||||
}
|
||||
if got.ChannelID != scenario.ExpectedOutput.ChannelID {
|
||||
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.ChannelID, got.ChannelID)
|
||||
}
|
||||
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||
t.Errorf("unexpected error: %s", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
17
api/api.go
17
api/api.go
@@ -2,14 +2,15 @@ package api
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
"github.com/TwiN/gatus/v5/config/ui"
|
||||
"github.com/TwiN/gatus/v5/config/web"
|
||||
static "github.com/TwiN/gatus/v5/web"
|
||||
"github.com/TwiN/health"
|
||||
"github.com/TwiN/logr"
|
||||
fiber "github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/adaptor"
|
||||
"github.com/gofiber/fiber/v2/middleware/compress"
|
||||
@@ -28,9 +29,13 @@ type API struct {
|
||||
func New(cfg *config.Config) *API {
|
||||
api := &API{}
|
||||
if cfg.Web == nil {
|
||||
log.Println("[api.New] nil web config passed as parameter. This should only happen in tests. Using default web configuration")
|
||||
logr.Warnf("[api.New] nil web config passed as parameter. This should only happen in tests. Using default web configuration")
|
||||
cfg.Web = web.GetDefaultConfig()
|
||||
}
|
||||
if cfg.UI == nil {
|
||||
logr.Warnf("[api.New] nil ui config passed as parameter. This should only happen in tests. Using default ui configuration")
|
||||
cfg.UI = ui.GetDefaultConfig()
|
||||
}
|
||||
api.router = api.createRouter(cfg)
|
||||
return api
|
||||
}
|
||||
@@ -42,7 +47,7 @@ func (a *API) Router() *fiber.App {
|
||||
func (a *API) createRouter(cfg *config.Config) *fiber.App {
|
||||
app := fiber.New(fiber.Config{
|
||||
ErrorHandler: func(c *fiber.Ctx, err error) error {
|
||||
log.Printf("[api.ErrorHandler] %s", err.Error())
|
||||
logr.Errorf("[api.ErrorHandler] %s", err.Error())
|
||||
return fiber.DefaultErrorHandler(c, err)
|
||||
},
|
||||
ReadBufferSize: cfg.Web.ReadBufferSize,
|
||||
@@ -73,7 +78,9 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
|
||||
unprotectedAPIRouter.Get("/v1/config", ConfigHandler{securityConfig: cfg.Security}.GetConfig)
|
||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.svg", HealthBadge)
|
||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.shields", HealthBadgeShields)
|
||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/uptimes/:duration", UptimeRaw)
|
||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/uptimes/:duration/badge.svg", UptimeBadge)
|
||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration", ResponseTimeRaw)
|
||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/badge.svg", ResponseTimeBadge(cfg))
|
||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/chart.svg", ResponseTimeChart)
|
||||
// This endpoint requires authz with bearer token, so technically it is protected
|
||||
@@ -87,6 +94,8 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
|
||||
statusCode, body := healthHandler.GetResponseStatusCodeAndBody()
|
||||
return c.Status(statusCode).Send(body)
|
||||
})
|
||||
// Custom CSS
|
||||
app.Get("/css/custom.css", CustomCSSHandler{customCSS: cfg.UI.CustomCSS}.GetCustomCSS)
|
||||
// Everything else falls back on static content
|
||||
app.Use(redirect.New(redirect.Config{
|
||||
Rules: map[string]string{
|
||||
@@ -117,6 +126,6 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
|
||||
}
|
||||
}
|
||||
protectedAPIRouter.Get("/v1/endpoints/statuses", EndpointStatuses(cfg))
|
||||
protectedAPIRouter.Get("/v1/endpoints/:key/statuses", EndpointStatus)
|
||||
protectedAPIRouter.Get("/v1/endpoints/:key/statuses", EndpointStatus(cfg))
|
||||
return app
|
||||
}
|
||||
|
||||
@@ -25,6 +25,17 @@ func TestNew(t *testing.T) {
|
||||
Path: "/health",
|
||||
ExpectedCode: fiber.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "custom.css",
|
||||
Path: "/css/custom.css",
|
||||
ExpectedCode: fiber.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "custom.css-gzipped",
|
||||
Path: "/css/custom.css",
|
||||
ExpectedCode: fiber.StatusOK,
|
||||
Gzip: true,
|
||||
},
|
||||
{
|
||||
Name: "metrics",
|
||||
Path: "/metrics",
|
||||
|
||||
21
api/badge.go
21
api/badge.go
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -53,7 +54,10 @@ func UptimeBadge(c *fiber.Ctx) error {
|
||||
default:
|
||||
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
|
||||
}
|
||||
key := c.Params("key")
|
||||
key, err := url.QueryUnescape(c.Params("key"))
|
||||
if err != nil {
|
||||
return c.Status(400).SendString("invalid key encoding")
|
||||
}
|
||||
uptime, err := store.Get().GetUptimeByKey(key, from, time.Now())
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||
@@ -88,7 +92,10 @@ func ResponseTimeBadge(cfg *config.Config) fiber.Handler {
|
||||
default:
|
||||
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
|
||||
}
|
||||
key := c.Params("key")
|
||||
key, err := url.QueryUnescape(c.Params("key"))
|
||||
if err != nil {
|
||||
return c.Status(400).SendString("invalid key encoding")
|
||||
}
|
||||
averageResponseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||
@@ -107,7 +114,10 @@ func ResponseTimeBadge(cfg *config.Config) fiber.Handler {
|
||||
|
||||
// HealthBadge handles the automatic generation of badge based on the group name and endpoint name passed.
|
||||
func HealthBadge(c *fiber.Ctx) error {
|
||||
key := c.Params("key")
|
||||
key, err := url.QueryUnescape(c.Params("key"))
|
||||
if err != nil {
|
||||
return c.Status(400).SendString("invalid key encoding")
|
||||
}
|
||||
pagingConfig := paging.NewEndpointStatusParams()
|
||||
status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1))
|
||||
if err != nil {
|
||||
@@ -133,7 +143,10 @@ func HealthBadge(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
func HealthBadgeShields(c *fiber.Ctx) error {
|
||||
key := c.Params("key")
|
||||
key, err := url.QueryUnescape(c.Params("key"))
|
||||
if err != nil {
|
||||
return c.Status(400).SendString("invalid key encoding")
|
||||
}
|
||||
pagingConfig := paging.NewEndpointStatusParams()
|
||||
status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1))
|
||||
if err != nil {
|
||||
|
||||
11
api/chart.go
11
api/chart.go
@@ -2,14 +2,15 @@ package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/storage/store"
|
||||
"github.com/TwiN/gatus/v5/storage/store/common"
|
||||
"github.com/TwiN/logr"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/wcharczuk/go-chart/v2"
|
||||
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||
@@ -45,7 +46,11 @@ func ResponseTimeChart(c *fiber.Ctx) error {
|
||||
default:
|
||||
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h")
|
||||
}
|
||||
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(c.Params("key"), from, time.Now())
|
||||
key, err := url.QueryUnescape(c.Params("key"))
|
||||
if err != nil {
|
||||
return c.Status(400).SendString("invalid key encoding")
|
||||
}
|
||||
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(key, from, time.Now())
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||
return c.Status(404).SendString(err.Error())
|
||||
@@ -116,7 +121,7 @@ func ResponseTimeChart(c *fiber.Ctx) error {
|
||||
c.Set("Expires", "0")
|
||||
c.Status(http.StatusOK)
|
||||
if err := graph.Render(chart.SVG, c); err != nil {
|
||||
log.Println("[api.ResponseTimeChart] Failed to render response time chart:", err.Error())
|
||||
logr.Errorf("[api.ResponseTimeChart] Failed to render response time chart: %s", err.Error())
|
||||
return c.Status(500).SendString(err.Error())
|
||||
}
|
||||
return nil
|
||||
|
||||
14
api/custom_css.go
Normal file
14
api/custom_css.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
type CustomCSSHandler struct {
|
||||
customCSS string
|
||||
}
|
||||
|
||||
func (handler CustomCSSHandler) GetCustomCSS(c *fiber.Ctx) error {
|
||||
c.Set("Content-Type", "text/css")
|
||||
return c.Status(200).SendString(handler.customCSS)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/TwiN/gatus/v5/storage/store"
|
||||
"github.com/TwiN/gatus/v5/storage/store/common"
|
||||
"github.com/TwiN/gatus/v5/storage/store/common/paging"
|
||||
"github.com/TwiN/logr"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
@@ -20,25 +21,25 @@ import (
|
||||
// Due to how intensive this operation can be on the storage, this function leverages a cache.
|
||||
func EndpointStatuses(cfg *config.Config) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
page, pageSize := extractPageAndPageSizeFromRequest(c)
|
||||
page, pageSize := extractPageAndPageSizeFromRequest(c, cfg.Storage.MaximumNumberOfResults)
|
||||
value, exists := cache.Get(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize))
|
||||
var data []byte
|
||||
if !exists {
|
||||
endpointStatuses, err := store.Get().GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(page, pageSize))
|
||||
if err != nil {
|
||||
log.Printf("[api.EndpointStatuses] Failed to retrieve endpoint statuses: %s", err.Error())
|
||||
logr.Errorf("[api.EndpointStatuses] Failed to retrieve endpoint statuses: %s", err.Error())
|
||||
return c.Status(500).SendString(err.Error())
|
||||
}
|
||||
// ALPHA: Retrieve endpoint statuses from remote instances
|
||||
if endpointStatusesFromRemote, err := getEndpointStatusesFromRemoteInstances(cfg.Remote); err != nil {
|
||||
log.Printf("[handler.EndpointStatuses] Silently failed to retrieve endpoint statuses from remote: %s", err.Error())
|
||||
logr.Errorf("[handler.EndpointStatuses] Silently failed to retrieve endpoint statuses from remote: %s", err.Error())
|
||||
} else if endpointStatusesFromRemote != nil {
|
||||
endpointStatuses = append(endpointStatuses, endpointStatusesFromRemote...)
|
||||
}
|
||||
// Marshal endpoint statuses to JSON
|
||||
data, err = json.Marshal(endpointStatuses)
|
||||
if err != nil {
|
||||
log.Printf("[api.EndpointStatuses] Unable to marshal object to JSON: %s", err.Error())
|
||||
logr.Errorf("[api.EndpointStatuses] Unable to marshal object to JSON: %s", err.Error())
|
||||
return c.Status(500).SendString("unable to marshal object to JSON")
|
||||
}
|
||||
cache.SetWithTTL(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize), data, cacheTTL)
|
||||
@@ -59,12 +60,14 @@ func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*end
|
||||
for _, instance := range remoteConfig.Instances {
|
||||
response, err := httpClient.Get(instance.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// Log the error but continue with other instances
|
||||
logr.Errorf("[api.getEndpointStatusesFromRemoteInstances] Failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error())
|
||||
continue
|
||||
}
|
||||
var endpointStatuses []*endpoint.Status
|
||||
if err = json.NewDecoder(response.Body).Decode(&endpointStatuses); err != nil {
|
||||
_ = response.Body.Close()
|
||||
log.Printf("[api.getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error())
|
||||
logr.Errorf("[api.getEndpointStatusesFromRemoteInstances] Failed to decode endpoint statuses from %s: %s", instance.URL, err.Error())
|
||||
continue
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
@@ -73,29 +76,40 @@ func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*end
|
||||
}
|
||||
endpointStatusesFromAllRemotes = append(endpointStatusesFromAllRemotes, endpointStatuses...)
|
||||
}
|
||||
// Only return nil, error if no remote instances were successfully processed
|
||||
if len(endpointStatusesFromAllRemotes) == 0 && remoteConfig.Instances != nil {
|
||||
return nil, fmt.Errorf("failed to retrieve endpoint statuses from all remote instances")
|
||||
}
|
||||
return endpointStatusesFromAllRemotes, nil
|
||||
}
|
||||
|
||||
// EndpointStatus retrieves a single endpoint.Status by group and endpoint name
|
||||
func EndpointStatus(c *fiber.Ctx) error {
|
||||
page, pageSize := extractPageAndPageSizeFromRequest(c)
|
||||
endpointStatus, err := store.Get().GetEndpointStatusByKey(c.Params("key"), paging.NewEndpointStatusParams().WithResults(page, pageSize).WithEvents(1, common.MaximumNumberOfEvents))
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||
return c.Status(404).SendString(err.Error())
|
||||
func EndpointStatus(cfg *config.Config) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
page, pageSize := extractPageAndPageSizeFromRequest(c, cfg.Storage.MaximumNumberOfResults)
|
||||
key, err := url.QueryUnescape(c.Params("key"))
|
||||
if err != nil {
|
||||
logr.Errorf("[api.EndpointStatus] Failed to decode key: %s", err.Error())
|
||||
return c.Status(400).SendString("invalid key encoding")
|
||||
}
|
||||
log.Printf("[api.EndpointStatus] Failed to retrieve endpoint status: %s", err.Error())
|
||||
return c.Status(500).SendString(err.Error())
|
||||
endpointStatus, err := store.Get().GetEndpointStatusByKey(key, paging.NewEndpointStatusParams().WithResults(page, pageSize).WithEvents(1, cfg.Storage.MaximumNumberOfEvents))
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||
return c.Status(404).SendString(err.Error())
|
||||
}
|
||||
logr.Errorf("[api.EndpointStatus] Failed to retrieve endpoint status: %s", err.Error())
|
||||
return c.Status(500).SendString(err.Error())
|
||||
}
|
||||
if endpointStatus == nil { // XXX: is this check necessary?
|
||||
logr.Errorf("[api.EndpointStatus] Endpoint with key=%s not found", key)
|
||||
return c.Status(404).SendString("not found")
|
||||
}
|
||||
output, err := json.Marshal(endpointStatus)
|
||||
if err != nil {
|
||||
logr.Errorf("[api.EndpointStatus] Unable to marshal object to JSON: %s", err.Error())
|
||||
return c.Status(500).SendString("unable to marshal object to JSON")
|
||||
}
|
||||
c.Set("Content-Type", "application/json")
|
||||
return c.Status(200).Send(output)
|
||||
}
|
||||
if endpointStatus == nil { // XXX: is this check necessary?
|
||||
log.Printf("[api.EndpointStatus] Endpoint with key=%s not found", c.Params("key"))
|
||||
return c.Status(404).SendString("not found")
|
||||
}
|
||||
output, err := json.Marshal(endpointStatus)
|
||||
if err != nil {
|
||||
log.Printf("[api.EndpointStatus] Unable to marshal object to JSON: %s", err.Error())
|
||||
return c.Status(500).SendString("unable to marshal object to JSON")
|
||||
}
|
||||
c.Set("Content-Type", "application/json")
|
||||
return c.Status(200).Send(output)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/storage"
|
||||
"github.com/TwiN/gatus/v5/storage/store"
|
||||
"github.com/TwiN/gatus/v5/watchdog"
|
||||
)
|
||||
@@ -95,6 +96,10 @@ func TestEndpointStatus(t *testing.T) {
|
||||
Group: "core",
|
||||
},
|
||||
},
|
||||
Storage: &storage.Config{
|
||||
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
|
||||
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
|
||||
},
|
||||
}
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
@@ -156,7 +161,13 @@ func TestEndpointStatuses(t *testing.T) {
|
||||
// Can't be bothered dealing with timezone issues on the worker that runs the automated tests
|
||||
firstResult.Timestamp = time.Time{}
|
||||
secondResult.Timestamp = time.Time{}
|
||||
api := New(&config.Config{Metrics: true})
|
||||
api := New(&config.Config{
|
||||
Metrics: true,
|
||||
Storage: &storage.Config{
|
||||
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
|
||||
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
|
||||
},
|
||||
})
|
||||
router := api.Router()
|
||||
type Scenario struct {
|
||||
Name string
|
||||
|
||||
@@ -2,7 +2,6 @@ package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -11,6 +10,7 @@ import (
|
||||
"github.com/TwiN/gatus/v5/storage/store"
|
||||
"github.com/TwiN/gatus/v5/storage/store/common"
|
||||
"github.com/TwiN/gatus/v5/watchdog"
|
||||
"github.com/TwiN/logr"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
@@ -33,11 +33,11 @@ func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
|
||||
key := c.Params("key")
|
||||
externalEndpoint := cfg.GetExternalEndpointByKey(key)
|
||||
if externalEndpoint == nil {
|
||||
log.Printf("[api.CreateExternalEndpointResult] External endpoint with key=%s not found", key)
|
||||
logr.Errorf("[api.CreateExternalEndpointResult] External endpoint with key=%s not found", key)
|
||||
return c.Status(404).SendString("not found")
|
||||
}
|
||||
if externalEndpoint.Token != token {
|
||||
log.Printf("[api.CreateExternalEndpointResult] Invalid token for external endpoint with key=%s", key)
|
||||
logr.Errorf("[api.CreateExternalEndpointResult] Invalid token for external endpoint with key=%s", key)
|
||||
return c.Status(401).SendString("invalid token")
|
||||
}
|
||||
// Persist the result in the storage
|
||||
@@ -54,13 +54,13 @@ func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
|
||||
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||
return c.Status(404).SendString(err.Error())
|
||||
}
|
||||
log.Printf("[api.CreateExternalEndpointResult] Failed to insert result in storage: %s", err.Error())
|
||||
logr.Errorf("[api.CreateExternalEndpointResult] Failed to insert result in storage: %s", err.Error())
|
||||
return c.Status(500).SendString(err.Error())
|
||||
}
|
||||
log.Printf("[api.CreateExternalEndpointResult] Successfully inserted result for external endpoint with key=%s and success=%s", c.Params("key"), success)
|
||||
logr.Infof("[api.CreateExternalEndpointResult] Successfully inserted result for external endpoint with key=%s and success=%s", c.Params("key"), success)
|
||||
// Check if an alert should be triggered or resolved
|
||||
if !cfg.Maintenance.IsUnderMaintenance() {
|
||||
watchdog.HandleAlerting(convertedEndpoint, result, cfg.Alerting, cfg.Debug)
|
||||
watchdog.HandleAlerting(convertedEndpoint, result, cfg.Alerting)
|
||||
externalEndpoint.NumberOfSuccessesInARow = convertedEndpoint.NumberOfSuccessesInARow
|
||||
externalEndpoint.NumberOfFailuresInARow = convertedEndpoint.NumberOfFailuresInARow
|
||||
}
|
||||
|
||||
75
api/raw.go
Normal file
75
api/raw.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/storage/store"
|
||||
"github.com/TwiN/gatus/v5/storage/store/common"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func UptimeRaw(c *fiber.Ctx) error {
|
||||
duration := c.Params("duration")
|
||||
var from time.Time
|
||||
switch duration {
|
||||
case "30d":
|
||||
from = time.Now().Add(-30 * 24 * time.Hour)
|
||||
case "7d":
|
||||
from = time.Now().Add(-7 * 24 * time.Hour)
|
||||
case "24h":
|
||||
from = time.Now().Add(-24 * time.Hour)
|
||||
case "1h":
|
||||
from = time.Now().Add(-2 * time.Hour) // Because uptime metrics are stored by hour, we have to cheat a little
|
||||
default:
|
||||
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
|
||||
}
|
||||
key := c.Params("key")
|
||||
uptime, err := store.Get().GetUptimeByKey(key, from, time.Now())
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||
return c.Status(404).SendString(err.Error())
|
||||
} else if errors.Is(err, common.ErrInvalidTimeRange) {
|
||||
return c.Status(400).SendString(err.Error())
|
||||
}
|
||||
return c.Status(500).SendString(err.Error())
|
||||
}
|
||||
|
||||
c.Set("Content-Type", "text/plain")
|
||||
c.Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
c.Set("Expires", "0")
|
||||
return c.Status(200).Send([]byte(fmt.Sprintf("%f", uptime)))
|
||||
}
|
||||
|
||||
func ResponseTimeRaw(c *fiber.Ctx) error {
|
||||
duration := c.Params("duration")
|
||||
var from time.Time
|
||||
switch duration {
|
||||
case "30d":
|
||||
from = time.Now().Add(-30 * 24 * time.Hour)
|
||||
case "7d":
|
||||
from = time.Now().Add(-7 * 24 * time.Hour)
|
||||
case "24h":
|
||||
from = time.Now().Add(-24 * time.Hour)
|
||||
case "1h":
|
||||
from = time.Now().Add(-2 * time.Hour) // Because uptime metrics are stored by hour, we have to cheat a little
|
||||
default:
|
||||
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
|
||||
}
|
||||
key := c.Params("key")
|
||||
responseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||
return c.Status(404).SendString(err.Error())
|
||||
} else if errors.Is(err, common.ErrInvalidTimeRange) {
|
||||
return c.Status(400).SendString(err.Error())
|
||||
}
|
||||
return c.Status(500).SendString(err.Error())
|
||||
}
|
||||
|
||||
c.Set("Content-Type", "text/plain")
|
||||
c.Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
c.Set("Expires", "0")
|
||||
return c.Status(200).Send([]byte(fmt.Sprintf("%d", responseTime)))
|
||||
}
|
||||
123
api/raw_test.go
Normal file
123
api/raw_test.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint/ui"
|
||||
"github.com/TwiN/gatus/v5/storage/store"
|
||||
"github.com/TwiN/gatus/v5/watchdog"
|
||||
)
|
||||
|
||||
func TestRawDataEndpoint(t *testing.T) {
|
||||
defer store.Get().Clear()
|
||||
defer cache.Clear()
|
||||
cfg := &config.Config{
|
||||
Metrics: true,
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
{
|
||||
Name: "frontend",
|
||||
Group: "core",
|
||||
},
|
||||
{
|
||||
Name: "backend",
|
||||
Group: "core",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg.Endpoints[0].UIConfig = ui.GetDefaultConfig()
|
||||
cfg.Endpoints[1].UIConfig = ui.GetDefaultConfig()
|
||||
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
api := New(cfg)
|
||||
router := api.Router()
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
ExpectedCode int
|
||||
Gzip bool
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "raw-uptime-1h",
|
||||
Path: "/api/v1/endpoints/core_frontend/uptimes/1h",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "raw-uptime-24h",
|
||||
Path: "/api/v1/endpoints/core_backend/uptimes/24h",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "raw-uptime-7d",
|
||||
Path: "/api/v1/endpoints/core_frontend/uptimes/7d",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "raw-uptime-30d",
|
||||
Path: "/api/v1/endpoints/core_frontend/uptimes/30d",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "raw-uptime-with-invalid-duration",
|
||||
Path: "/api/v1/endpoints/core_backend/uptimes/3d",
|
||||
ExpectedCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
Name: "raw-uptime-for-invalid-key",
|
||||
Path: "/api/v1/endpoints/invalid_key/uptimes/7d",
|
||||
ExpectedCode: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
Name: "raw-response-times-1h",
|
||||
Path: "/api/v1/endpoints/core_frontend/response-times/1h",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "raw-response-times-24h",
|
||||
Path: "/api/v1/endpoints/core_backend/response-times/24h",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "raw-response-times-7d",
|
||||
Path: "/api/v1/endpoints/core_frontend/response-times/7d",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "raw-response-times-30d",
|
||||
Path: "/api/v1/endpoints/core_frontend/response-times/30d",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "raw-response-times-with-invalid-duration",
|
||||
Path: "/api/v1/endpoints/core_backend/response-times/3d",
|
||||
ExpectedCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
Name: "raw-response-times-for-invalid-key",
|
||||
Path: "/api/v1/endpoints/invalid_key/response-times/7d",
|
||||
ExpectedCode: http.StatusNotFound,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
|
||||
if scenario.Gzip {
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
}
|
||||
response, err := router.Test(request)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if response.StatusCode != scenario.ExpectedCode {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
21
api/spa.go
21
api/spa.go
@@ -3,26 +3,37 @@ package api
|
||||
import (
|
||||
_ "embed"
|
||||
"html/template"
|
||||
"log"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config/ui"
|
||||
static "github.com/TwiN/gatus/v5/web"
|
||||
"github.com/TwiN/logr"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func SinglePageApplication(ui *ui.Config) fiber.Handler {
|
||||
func SinglePageApplication(uiConfig *ui.Config) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
vd := ui.ViewData{UI: uiConfig}
|
||||
{
|
||||
themeFromCookie := string(c.Request().Header.Cookie("theme"))
|
||||
if len(themeFromCookie) > 0 {
|
||||
if themeFromCookie == "dark" {
|
||||
vd.Theme = "dark"
|
||||
}
|
||||
} else if uiConfig.IsDarkMode() { // Since there's no theme cookie, we'll rely on ui.DarkMode
|
||||
vd.Theme = "dark"
|
||||
}
|
||||
}
|
||||
t, err := template.ParseFS(static.FileSystem, static.IndexPath)
|
||||
if err != nil {
|
||||
// This should never happen, because ui.ValidateAndSetDefaults validates that the template works.
|
||||
log.Println("[api.SinglePageApplication] Failed to parse template. This should never happen, because the template is validated on start. Error:", err.Error())
|
||||
logr.Errorf("[api.SinglePageApplication] Failed to parse template. This should never happen, because the template is validated on start. Error: %s", err.Error())
|
||||
return c.Status(500).SendString("Failed to parse template. This should never happen, because the template is validated on start.")
|
||||
}
|
||||
c.Set("Content-Type", "text/html")
|
||||
err = t.Execute(c, ui)
|
||||
err = t.Execute(c, vd)
|
||||
if err != nil {
|
||||
// This should never happen, because ui.ValidateAndSetDefaults validates that the template works.
|
||||
log.Println("[api.SinglePageApplication] Failed to execute template. This should never happen, because the template is validated on start. Error:", err.Error())
|
||||
logr.Errorf("[api.SinglePageApplication] Failed to execute template. This should never happen, because the template is validated on start. Error: %s", err.Error())
|
||||
return c.Status(500).SendString("Failed to parse template. This should never happen, because the template is validated on start.")
|
||||
}
|
||||
return c.SendStatus(200)
|
||||
|
||||
@@ -39,29 +39,50 @@ func TestSinglePageApplication(t *testing.T) {
|
||||
api := New(cfg)
|
||||
router := api.Router()
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
ExpectedCode int
|
||||
Gzip bool
|
||||
Name string
|
||||
Path string
|
||||
Gzip bool
|
||||
CookieDarkMode bool
|
||||
UIDarkMode bool
|
||||
ExpectedCode int
|
||||
ExpectedDarkTheme bool
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "frontend-home",
|
||||
Path: "/",
|
||||
ExpectedCode: 200,
|
||||
Name: "frontend-home",
|
||||
Path: "/",
|
||||
CookieDarkMode: true,
|
||||
UIDarkMode: false,
|
||||
ExpectedDarkTheme: true,
|
||||
ExpectedCode: 200,
|
||||
},
|
||||
{
|
||||
Name: "frontend-endpoint",
|
||||
Path: "/endpoints/core_frontend",
|
||||
ExpectedCode: 200,
|
||||
Name: "frontend-endpoint-light",
|
||||
Path: "/endpoints/core_frontend",
|
||||
CookieDarkMode: false,
|
||||
UIDarkMode: false,
|
||||
ExpectedDarkTheme: false,
|
||||
ExpectedCode: 200,
|
||||
},
|
||||
{
|
||||
Name: "frontend-endpoint-dark",
|
||||
Path: "/endpoints/core_frontend",
|
||||
CookieDarkMode: false,
|
||||
UIDarkMode: true,
|
||||
ExpectedDarkTheme: true,
|
||||
ExpectedCode: 200,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
cfg.UI.DarkMode = &scenario.UIDarkMode
|
||||
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
|
||||
if scenario.Gzip {
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
}
|
||||
if scenario.CookieDarkMode {
|
||||
request.Header.Set("Cookie", "theme=dark")
|
||||
}
|
||||
response, err := router.Test(request)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -71,9 +92,16 @@ func TestSinglePageApplication(t *testing.T) {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
if !strings.Contains(string(body), cfg.UI.Title) {
|
||||
strBody := string(body)
|
||||
if !strings.Contains(strBody, cfg.UI.Title) {
|
||||
t.Errorf("%s %s should have contained the title", request.Method, request.URL)
|
||||
}
|
||||
if scenario.ExpectedDarkTheme && !strings.Contains(strBody, "class=\"dark\"") {
|
||||
t.Errorf("%s %s should have responded with dark mode headers", request.Method, request.URL)
|
||||
}
|
||||
if !scenario.ExpectedDarkTheme && strings.Contains(strBody, "class=\"dark\"") {
|
||||
t.Errorf("%s %s should not have responded with dark mode headers", request.Method, request.URL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
10
api/util.go
10
api/util.go
@@ -3,7 +3,6 @@ package api
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/TwiN/gatus/v5/storage/store/common"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
@@ -13,12 +12,9 @@ const (
|
||||
|
||||
// DefaultPageSize is the default page siZE to use if none is specified or an invalid value is provided
|
||||
DefaultPageSize = 20
|
||||
|
||||
// MaximumPageSize is the maximum page size allowed
|
||||
MaximumPageSize = common.MaximumNumberOfResults
|
||||
)
|
||||
|
||||
func extractPageAndPageSizeFromRequest(c *fiber.Ctx) (page, pageSize int) {
|
||||
func extractPageAndPageSizeFromRequest(c *fiber.Ctx, maximumNumberOfResults int) (page, pageSize int) {
|
||||
var err error
|
||||
if pageParameter := c.Query("page"); len(pageParameter) == 0 {
|
||||
page = DefaultPage
|
||||
@@ -38,8 +34,8 @@ func extractPageAndPageSizeFromRequest(c *fiber.Ctx) (page, pageSize int) {
|
||||
if err != nil {
|
||||
pageSize = DefaultPageSize
|
||||
}
|
||||
if pageSize > MaximumPageSize {
|
||||
pageSize = MaximumPageSize
|
||||
if pageSize > maximumNumberOfResults {
|
||||
pageSize = maximumNumberOfResults
|
||||
} else if pageSize < 1 {
|
||||
pageSize = DefaultPageSize
|
||||
}
|
||||
|
||||
@@ -4,54 +4,62 @@ import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/storage"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
func TestExtractPageAndPageSizeFromRequest(t *testing.T) {
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Page string
|
||||
PageSize string
|
||||
ExpectedPage int
|
||||
ExpectedPageSize int
|
||||
Name string
|
||||
Page string
|
||||
PageSize string
|
||||
ExpectedPage int
|
||||
ExpectedPageSize int
|
||||
MaximumNumberOfResults int
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Page: "1",
|
||||
PageSize: "20",
|
||||
ExpectedPage: 1,
|
||||
ExpectedPageSize: 20,
|
||||
Page: "1",
|
||||
PageSize: "20",
|
||||
ExpectedPage: 1,
|
||||
ExpectedPageSize: 20,
|
||||
MaximumNumberOfResults: 20,
|
||||
},
|
||||
{
|
||||
Page: "2",
|
||||
PageSize: "10",
|
||||
ExpectedPage: 2,
|
||||
ExpectedPageSize: 10,
|
||||
Page: "2",
|
||||
PageSize: "10",
|
||||
ExpectedPage: 2,
|
||||
ExpectedPageSize: 10,
|
||||
MaximumNumberOfResults: 40,
|
||||
},
|
||||
{
|
||||
Page: "2",
|
||||
PageSize: "10",
|
||||
ExpectedPage: 2,
|
||||
ExpectedPageSize: 10,
|
||||
Page: "2",
|
||||
PageSize: "10",
|
||||
ExpectedPage: 2,
|
||||
ExpectedPageSize: 10,
|
||||
MaximumNumberOfResults: 200,
|
||||
},
|
||||
{
|
||||
Page: "1",
|
||||
PageSize: "999999",
|
||||
ExpectedPage: 1,
|
||||
ExpectedPageSize: MaximumPageSize,
|
||||
Page: "1",
|
||||
PageSize: "999999",
|
||||
ExpectedPage: 1,
|
||||
ExpectedPageSize: storage.DefaultMaximumNumberOfResults,
|
||||
MaximumNumberOfResults: 100,
|
||||
},
|
||||
{
|
||||
Page: "-1",
|
||||
PageSize: "-1",
|
||||
ExpectedPage: DefaultPage,
|
||||
ExpectedPageSize: DefaultPageSize,
|
||||
Page: "-1",
|
||||
PageSize: "-1",
|
||||
ExpectedPage: DefaultPage,
|
||||
ExpectedPageSize: DefaultPageSize,
|
||||
MaximumNumberOfResults: 20,
|
||||
},
|
||||
{
|
||||
Page: "invalid",
|
||||
PageSize: "invalid",
|
||||
ExpectedPage: DefaultPage,
|
||||
ExpectedPageSize: DefaultPageSize,
|
||||
Page: "invalid",
|
||||
PageSize: "invalid",
|
||||
ExpectedPage: DefaultPage,
|
||||
ExpectedPageSize: DefaultPageSize,
|
||||
MaximumNumberOfResults: 100,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
@@ -61,7 +69,7 @@ func TestExtractPageAndPageSizeFromRequest(t *testing.T) {
|
||||
c := app.AcquireCtx(&fasthttp.RequestCtx{})
|
||||
defer app.ReleaseCtx(c)
|
||||
c.Request().SetRequestURI(fmt.Sprintf("/api/v1/statuses?page=%s&pageSize=%s", scenario.Page, scenario.PageSize))
|
||||
actualPage, actualPageSize := extractPageAndPageSizeFromRequest(c)
|
||||
actualPage, actualPageSize := extractPageAndPageSizeFromRequest(c, scenario.MaximumNumberOfResults)
|
||||
if actualPage != scenario.ExpectedPage {
|
||||
t.Errorf("expected %d, got %d", scenario.ExpectedPage, actualPage)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/smtp"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gocache/v2"
|
||||
"github.com/TwiN/logr"
|
||||
"github.com/TwiN/whois"
|
||||
"github.com/ishidawataru/sctp"
|
||||
"github.com/miekg/dns"
|
||||
@@ -96,21 +98,22 @@ func CanCreateUDPConnection(address string, config *Config) bool {
|
||||
|
||||
// CanCreateSCTPConnection checks whether a connection can be established with a SCTP endpoint
|
||||
func CanCreateSCTPConnection(address string, config *Config) bool {
|
||||
ch := make(chan bool)
|
||||
ch := make(chan bool, 1)
|
||||
go (func(res chan bool) {
|
||||
addr, err := sctp.ResolveSCTPAddr("sctp", address)
|
||||
if err != nil {
|
||||
res <- false
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := sctp.DialSCTP("sctp", nil, addr)
|
||||
if err != nil {
|
||||
res <- false
|
||||
return
|
||||
}
|
||||
_ = conn.Close()
|
||||
res <- true
|
||||
})(ch)
|
||||
|
||||
select {
|
||||
case result := <-ch:
|
||||
return result
|
||||
@@ -182,7 +185,6 @@ func CanCreateSSHConnection(address, username, password string, config *Config)
|
||||
} else {
|
||||
port = "22"
|
||||
}
|
||||
|
||||
cli, err := ssh.Dial("tcp", strings.Join([]string{address, port}, ":"), &ssh.ClientConfig{
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
User: username,
|
||||
@@ -194,46 +196,65 @@ func CanCreateSSHConnection(address, username, password string, config *Config)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
|
||||
return true, cli, nil
|
||||
}
|
||||
|
||||
func CheckSSHBanner(address string, cfg *Config) (bool, int, error) {
|
||||
var port string
|
||||
if strings.Contains(address, ":") {
|
||||
addressAndPort := strings.Split(address, ":")
|
||||
if len(addressAndPort) != 2 {
|
||||
return false, 1, errors.New("invalid address for ssh, format must be ssh://host:port")
|
||||
}
|
||||
address = addressAndPort[0]
|
||||
port = addressAndPort[1]
|
||||
} else {
|
||||
port = "22"
|
||||
}
|
||||
dialer := net.Dialer{}
|
||||
connStr := net.JoinHostPort(address, port)
|
||||
conn, err := dialer.Dial("tcp", connStr)
|
||||
if err != nil {
|
||||
return false, 1, err
|
||||
}
|
||||
defer conn.Close()
|
||||
conn.SetReadDeadline(time.Now().Add(time.Second))
|
||||
buf := make([]byte, 256)
|
||||
_, err = io.ReadAtLeast(conn, buf, 1)
|
||||
if err != nil {
|
||||
return false, 1, err
|
||||
}
|
||||
return true, 0, err
|
||||
}
|
||||
|
||||
// ExecuteSSHCommand executes a command to an address using the SSH protocol.
|
||||
func ExecuteSSHCommand(sshClient *ssh.Client, body string, config *Config) (bool, int, error) {
|
||||
type Body struct {
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
defer sshClient.Close()
|
||||
|
||||
var b Body
|
||||
if err := json.Unmarshal([]byte(body), &b); err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
|
||||
sess, err := sshClient.NewSession()
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
|
||||
err = sess.Start(b.Command)
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
|
||||
defer sess.Close()
|
||||
|
||||
err = sess.Wait()
|
||||
if err == nil {
|
||||
return true, 0, nil
|
||||
}
|
||||
|
||||
e, ok := err.(*ssh.ExitError)
|
||||
if !ok {
|
||||
var exitErr *ssh.ExitError
|
||||
if ok := errors.As(err, &exitErr); !ok {
|
||||
return false, 0, err
|
||||
}
|
||||
|
||||
return true, e.ExitStatus(), nil
|
||||
return true, exitErr.ExitStatus(), nil
|
||||
}
|
||||
|
||||
// Ping checks if an address can be pinged and returns the round-trip time if the address can be pinged
|
||||
@@ -306,6 +327,7 @@ func QueryDNS(queryType, queryName, url string) (connected bool, dnsRcode string
|
||||
m.SetQuestion(queryName, queryTypeAsUint16)
|
||||
r, _, err := c.Exchange(m, url)
|
||||
if err != nil {
|
||||
logr.Infof("[client.QueryDNS] Error exchanging DNS message: %v", err)
|
||||
return false, "", nil, err
|
||||
}
|
||||
connected = true
|
||||
@@ -332,6 +354,14 @@ func QueryDNS(queryType, queryName, url string) (connected bool, dnsRcode string
|
||||
if ns, ok := rr.(*dns.NS); ok {
|
||||
body = []byte(ns.Ns)
|
||||
}
|
||||
case dns.TypePTR:
|
||||
if ptr, ok := rr.(*dns.PTR); ok {
|
||||
body = []byte(ptr.Ptr)
|
||||
}
|
||||
case dns.TypeSRV:
|
||||
if srv, ok := rr.(*dns.SRV); ok {
|
||||
body = []byte(fmt.Sprintf("%s:%d", srv.Target, srv.Port))
|
||||
}
|
||||
default:
|
||||
body = []byte("query type is not supported yet")
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -200,6 +201,24 @@ func TestCanPerformTLS(t *testing.T) {
|
||||
wantConnected: true,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "bad cert with insecure true",
|
||||
args: args{
|
||||
address: "expired.badssl.com:443",
|
||||
insecure: true,
|
||||
},
|
||||
wantConnected: true,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "bad cert with insecure false",
|
||||
args: args{
|
||||
address: "expired.badssl.com:443",
|
||||
insecure: false,
|
||||
},
|
||||
wantConnected: false,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -338,7 +357,7 @@ func TestTlsRenegotiation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestQueryDNS(t *testing.T) {
|
||||
tests := []struct {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
inputDNS dns.Config
|
||||
inputURL string
|
||||
@@ -354,7 +373,7 @@ func TestQueryDNS(t *testing.T) {
|
||||
},
|
||||
inputURL: "8.8.8.8",
|
||||
expectedDNSCode: "NOERROR",
|
||||
expectedBody: "93.184.215.14",
|
||||
expectedBody: "__IPV4__",
|
||||
},
|
||||
{
|
||||
name: "test Config with type AAAA",
|
||||
@@ -364,7 +383,7 @@ func TestQueryDNS(t *testing.T) {
|
||||
},
|
||||
inputURL: "8.8.8.8",
|
||||
expectedDNSCode: "NOERROR",
|
||||
expectedBody: "2606:2800:21f:cb07:6820:80da:af6b:8b2c",
|
||||
expectedBody: "__IPV6__",
|
||||
},
|
||||
{
|
||||
name: "test Config with type CNAME",
|
||||
@@ -396,6 +415,16 @@ func TestQueryDNS(t *testing.T) {
|
||||
expectedDNSCode: "NOERROR",
|
||||
expectedBody: "*.iana-servers.net.",
|
||||
},
|
||||
{
|
||||
name: "test Config with type PTR",
|
||||
inputDNS: dns.Config{
|
||||
QueryType: "PTR",
|
||||
QueryName: "8.8.8.8.in-addr.arpa.",
|
||||
},
|
||||
inputURL: "8.8.8.8",
|
||||
expectedDNSCode: "NOERROR",
|
||||
expectedBody: "dns.google.",
|
||||
},
|
||||
{
|
||||
name: "test Config with fake type and retrieve error",
|
||||
inputDNS: dns.Config{
|
||||
@@ -406,27 +435,77 @@ func TestQueryDNS(t *testing.T) {
|
||||
isErrExpected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, dnsRCode, body, err := QueryDNS(test.inputDNS.QueryType, test.inputDNS.QueryName, test.inputURL)
|
||||
if test.isErrExpected && err == nil {
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
_, dnsRCode, body, err := QueryDNS(scenario.inputDNS.QueryType, scenario.inputDNS.QueryName, scenario.inputURL)
|
||||
if scenario.isErrExpected && err == nil {
|
||||
t.Errorf("there should be an error")
|
||||
}
|
||||
if dnsRCode != test.expectedDNSCode {
|
||||
t.Errorf("expected DNSRCode to be %s, got %s", test.expectedDNSCode, dnsRCode)
|
||||
if dnsRCode != scenario.expectedDNSCode {
|
||||
t.Errorf("expected DNSRCode to be %s, got %s", scenario.expectedDNSCode, dnsRCode)
|
||||
}
|
||||
if test.inputDNS.QueryType == "NS" {
|
||||
if scenario.inputDNS.QueryType == "NS" {
|
||||
// Because there are often multiple nameservers backing a single domain, we'll only look at the suffix
|
||||
if !pattern.Match(test.expectedBody, string(body)) {
|
||||
t.Errorf("got %s, expected result %s,", string(body), test.expectedBody)
|
||||
if !pattern.Match(scenario.expectedBody, string(body)) {
|
||||
t.Errorf("got %s, expected result %s,", string(body), scenario.expectedBody)
|
||||
}
|
||||
} else {
|
||||
if string(body) != test.expectedBody {
|
||||
t.Errorf("got %s, expected result %s,", string(body), test.expectedBody)
|
||||
if string(body) != scenario.expectedBody {
|
||||
// little hack to validate arbitrary ipv4/ipv6
|
||||
switch scenario.expectedBody {
|
||||
case "__IPV4__":
|
||||
if addr, err := netip.ParseAddr(string(body)); err != nil {
|
||||
t.Errorf("got %s, expected result %s", string(body), scenario.expectedBody)
|
||||
} else if !addr.Is4() {
|
||||
t.Errorf("got %s, expected valid IPv4", string(body))
|
||||
}
|
||||
case "__IPV6__":
|
||||
if addr, err := netip.ParseAddr(string(body)); err != nil {
|
||||
t.Errorf("got %s, expected result %s", string(body), scenario.expectedBody)
|
||||
} else if !addr.Is6() {
|
||||
t.Errorf("got %s, expected valid IPv6", string(body))
|
||||
}
|
||||
default:
|
||||
t.Errorf("got %s, expected result %s", string(body), scenario.expectedBody)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSSHBanner(t *testing.T) {
|
||||
cfg := &Config{Timeout: 3}
|
||||
|
||||
t.Run("no-auth-ssh", func(t *testing.T) {
|
||||
connected, status, err := CheckSSHBanner("tty.sdf.org", cfg)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected: error != nil, got: %v ", err)
|
||||
}
|
||||
|
||||
if connected == false {
|
||||
t.Errorf("Expected: connected == true, got: %v", connected)
|
||||
}
|
||||
if status != 0 {
|
||||
t.Errorf("Expected: 0, got: %v", status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid-address", func(t *testing.T) {
|
||||
connected, status, err := CheckSSHBanner("idontplaytheodds.com", cfg)
|
||||
|
||||
if err == nil {
|
||||
t.Errorf("Expected: error, got: %v ", err)
|
||||
}
|
||||
|
||||
if connected != false {
|
||||
t.Errorf("Expected: connected == false, got: %v", connected)
|
||||
}
|
||||
if status != 1 {
|
||||
t.Errorf("Expected: 1, got: %v", status)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -12,6 +11,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/logr"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/clientcredentials"
|
||||
"google.golang.org/api/idtoken"
|
||||
@@ -126,7 +126,7 @@ func (c *Config) ValidateAndSetDefaults() error {
|
||||
if c.HasIAPConfig() && !c.IAPConfig.isValid() {
|
||||
return ErrInvalidClientIAPConfig
|
||||
}
|
||||
if c.HasTlsConfig() {
|
||||
if c.HasTLSConfig() {
|
||||
if err := c.TLS.isValid(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -176,8 +176,8 @@ func (c *Config) HasIAPConfig() bool {
|
||||
return c.IAPConfig != nil
|
||||
}
|
||||
|
||||
// HasTlsConfig returns true if the client has client certificate parameters
|
||||
func (c *Config) HasTlsConfig() bool {
|
||||
// HasTLSConfig returns true if the client has client certificate parameters
|
||||
func (c *Config) HasTLSConfig() bool {
|
||||
return c.TLS != nil && len(c.TLS.CertificateFile) > 0 && len(c.TLS.PrivateKeyFile) > 0
|
||||
}
|
||||
|
||||
@@ -203,12 +203,12 @@ func (t *TLSConfig) isValid() error {
|
||||
return ErrInvalidClientTLSConfig
|
||||
}
|
||||
|
||||
// GetHTTPClient return an HTTP client matching the Config's parameters.
|
||||
// getHTTPClient return an HTTP client matching the Config's parameters.
|
||||
func (c *Config) getHTTPClient() *http.Client {
|
||||
tlsConfig := &tls.Config{
|
||||
InsecureSkipVerify: c.Insecure,
|
||||
}
|
||||
if c.HasTlsConfig() && c.TLS.isValid() == nil {
|
||||
if c.HasTLSConfig() && c.TLS.isValid() == nil {
|
||||
tlsConfig = configureTLS(tlsConfig, *c.TLS)
|
||||
}
|
||||
if c.httpClient == nil {
|
||||
@@ -232,7 +232,7 @@ func (c *Config) getHTTPClient() *http.Client {
|
||||
if c.ProxyURL != "" {
|
||||
proxyURL, err := url.Parse(c.ProxyURL)
|
||||
if err != nil {
|
||||
log.Println("[client.getHTTPClient] THIS SHOULD NOT HAPPEN. Silently ignoring custom proxy due to error:", err.Error())
|
||||
logr.Errorf("[client.getHTTPClient] THIS SHOULD NOT HAPPEN. Silently ignoring custom proxy due to error: %s", err.Error())
|
||||
} else {
|
||||
c.httpClient.Transport.(*http.Transport).Proxy = http.ProxyURL(proxyURL)
|
||||
}
|
||||
@@ -242,7 +242,7 @@ func (c *Config) getHTTPClient() *http.Client {
|
||||
if err != nil {
|
||||
// We're ignoring the error, because it should have been validated on startup ValidateAndSetDefaults.
|
||||
// It shouldn't happen, but if it does, we'll log it... Better safe than sorry ;)
|
||||
log.Println("[client.getHTTPClient] THIS SHOULD NOT HAPPEN. Silently ignoring invalid DNS resolver due to error:", err.Error())
|
||||
logr.Errorf("[client.getHTTPClient] THIS SHOULD NOT HAPPEN. Silently ignoring invalid DNS resolver due to error: %s", err.Error())
|
||||
} else {
|
||||
dialer := &net.Dialer{
|
||||
Resolver: &net.Resolver{
|
||||
@@ -259,7 +259,7 @@ func (c *Config) getHTTPClient() *http.Client {
|
||||
}
|
||||
}
|
||||
if c.HasOAuth2Config() && c.HasIAPConfig() {
|
||||
log.Println("[client.getHTTPClient] Error: Both Identity-Aware-Proxy and Oauth2 configuration are present.")
|
||||
logr.Errorf("[client.getHTTPClient] Error: Both Identity-Aware-Proxy and Oauth2 configuration are present.")
|
||||
} else if c.HasOAuth2Config() {
|
||||
c.httpClient = configureOAuth2(c.httpClient, *c.OAuth2Config)
|
||||
} else if c.HasIAPConfig() {
|
||||
@@ -269,23 +269,22 @@ func (c *Config) getHTTPClient() *http.Client {
|
||||
return c.httpClient
|
||||
}
|
||||
|
||||
// validateIAPToken returns a boolean that will define if the google identity-aware-proxy token can be fetch
|
||||
// validateIAPToken returns a boolean that will define if the Google identity-aware-proxy token can be fetched
|
||||
// and if is it valid.
|
||||
func validateIAPToken(ctx context.Context, c IAPConfig) bool {
|
||||
ts, err := idtoken.NewTokenSource(ctx, c.Audience)
|
||||
if err != nil {
|
||||
log.Println("[client.ValidateIAPToken] Claiming Identity token failed. error:", err.Error())
|
||||
logr.Errorf("[client.ValidateIAPToken] Claiming Identity token failed: %s", err.Error())
|
||||
return false
|
||||
}
|
||||
tok, err := ts.Token()
|
||||
if err != nil {
|
||||
log.Println("[client.ValidateIAPToken] Get Identity-Aware-Proxy token failed. error:", err.Error())
|
||||
logr.Errorf("[client.ValidateIAPToken] Get Identity-Aware-Proxy token failed: %s", err.Error())
|
||||
return false
|
||||
}
|
||||
payload, err := idtoken.Validate(ctx, tok.AccessToken, c.Audience)
|
||||
_ = payload
|
||||
_, err = idtoken.Validate(ctx, tok.AccessToken, c.Audience)
|
||||
if err != nil {
|
||||
log.Println("[client.ValidateIAPToken] Token Validation failed. error:", err.Error())
|
||||
logr.Errorf("[client.ValidateIAPToken] Token Validation failed: %s", err.Error())
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -298,7 +297,7 @@ func configureIAP(httpClient *http.Client, c IAPConfig) *http.Client {
|
||||
if validateIAPToken(ctx, c) {
|
||||
ts, err := idtoken.NewTokenSource(ctx, c.Audience)
|
||||
if err != nil {
|
||||
log.Println("[client.ConfigureIAP] Claiming Token Source failed. error:", err.Error())
|
||||
logr.Errorf("[client.configureIAP] Claiming Token Source failed: %s", err.Error())
|
||||
return httpClient
|
||||
}
|
||||
client := oauth2.NewClient(ctx, ts)
|
||||
@@ -327,17 +326,17 @@ func configureOAuth2(httpClient *http.Client, c OAuth2Config) *http.Client {
|
||||
func configureTLS(tlsConfig *tls.Config, c TLSConfig) *tls.Config {
|
||||
clientTLSCert, err := tls.LoadX509KeyPair(c.CertificateFile, c.PrivateKeyFile)
|
||||
if err != nil {
|
||||
logr.Errorf("[client.configureTLS] Failed to load certificate: %s", err.Error())
|
||||
return nil
|
||||
}
|
||||
tlsConfig.Certificates = []tls.Certificate{clientTLSCert}
|
||||
tlsConfig.Renegotiation = tls.RenegotiateNever
|
||||
|
||||
renegotionSupport := map[string]tls.RenegotiationSupport{
|
||||
renegotiationSupport := map[string]tls.RenegotiationSupport{
|
||||
"once": tls.RenegotiateOnceAsClient,
|
||||
"freely": tls.RenegotiateFreelyAsClient,
|
||||
"never": tls.RenegotiateNever,
|
||||
}
|
||||
if val, ok := renegotionSupport[c.RenegotiationSupport]; ok {
|
||||
if val, ok := renegotiationSupport[c.RenegotiationSupport]; ok {
|
||||
tlsConfig.Renegotiation = val
|
||||
}
|
||||
return tlsConfig
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -22,6 +21,8 @@ import (
|
||||
"github.com/TwiN/gatus/v5/config/web"
|
||||
"github.com/TwiN/gatus/v5/security"
|
||||
"github.com/TwiN/gatus/v5/storage"
|
||||
"github.com/TwiN/logr"
|
||||
"github.com/gofiber/fiber/v2/log"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -52,6 +53,7 @@ var (
|
||||
// Config is the main configuration structure
|
||||
type Config struct {
|
||||
// Debug Whether to enable debug logs
|
||||
// Deprecated: Use the GATUS_LOG_LEVEL environment variable instead
|
||||
Debug bool `yaml:"debug,omitempty"`
|
||||
|
||||
// Metrics Whether to expose metrics at /metrics
|
||||
@@ -172,13 +174,15 @@ func LoadConfiguration(configPath string) (*Config, error) {
|
||||
if fileInfo.IsDir() {
|
||||
err := walkConfigDir(configPath, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
log.Printf("[config.LoadConfiguration] Error walking path=%s: %s", path, err)
|
||||
return err
|
||||
return fmt.Errorf("error walking path %s: %w", path, err)
|
||||
}
|
||||
log.Printf("[config.LoadConfiguration] Reading configuration from %s", path)
|
||||
if strings.Contains(path, "..") {
|
||||
logr.Warnf("[config.LoadConfiguration] Ignoring configuration from %s", path)
|
||||
return nil
|
||||
}
|
||||
logr.Infof("[config.LoadConfiguration] Reading configuration from %s", path)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
log.Printf("[config.LoadConfiguration] Error reading configuration from %s: %s", path, err)
|
||||
return fmt.Errorf("error reading configuration from file %s: %w", path, err)
|
||||
}
|
||||
configBytes, err = deepmerge.YAML(configBytes, data)
|
||||
@@ -188,9 +192,9 @@ func LoadConfiguration(configPath string) (*Config, error) {
|
||||
return nil, fmt.Errorf("error reading configuration from directory %s: %w", usedConfigPath, err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[config.LoadConfiguration] Reading configuration from configFile=%s", usedConfigPath)
|
||||
logr.Infof("[config.LoadConfiguration] Reading configuration from configFile=%s", usedConfigPath)
|
||||
if data, err := os.ReadFile(usedConfigPath); err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("error reading configuration from directory %s: %w", usedConfigPath, err)
|
||||
} else {
|
||||
configBytes = data
|
||||
}
|
||||
@@ -200,11 +204,11 @@ func LoadConfiguration(configPath string) (*Config, error) {
|
||||
}
|
||||
config, err := parseAndValidateConfigBytes(configBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("error parsing config: %w", err)
|
||||
}
|
||||
config.configPath = usedConfigPath
|
||||
config.UpdateLastFileModTime()
|
||||
return config, err
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// walkConfigDir is a wrapper for filepath.WalkDir that strips directories and non-config files
|
||||
@@ -245,7 +249,13 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
|
||||
if config == nil || config.Endpoints == nil || len(config.Endpoints) == 0 {
|
||||
err = ErrNoEndpointInConfig
|
||||
} else {
|
||||
validateAlertingConfig(config.Alerting, config.Endpoints, config.ExternalEndpoints, config.Debug)
|
||||
// XXX: Remove this in v6.0.0
|
||||
if config.Debug {
|
||||
logr.Warn("WARNING: The 'debug' configuration has been deprecated and will be removed in v6.0.0")
|
||||
logr.Warn("WARNING: Please use the GATUS_LOG_LEVEL environment variable instead")
|
||||
}
|
||||
// XXX: End of v6.0.0 removals
|
||||
validateAlertingConfig(config.Alerting, config.Endpoints, config.ExternalEndpoints)
|
||||
if err := validateSecurityConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -270,6 +280,8 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
|
||||
if err := validateConnectivityConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Cross-config changes
|
||||
config.UI.MaximumNumberOfResults = config.Storage.MaximumNumberOfResults
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -293,7 +305,9 @@ func validateRemoteConfig(config *Config) error {
|
||||
func validateStorageConfig(config *Config) error {
|
||||
if config.Storage == nil {
|
||||
config.Storage = &storage.Config{
|
||||
Type: storage.TypeMemory,
|
||||
Type: storage.TypeMemory,
|
||||
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
|
||||
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
|
||||
}
|
||||
} else {
|
||||
if err := config.Storage.ValidateAndSetDefaults(); err != nil {
|
||||
@@ -338,9 +352,7 @@ func validateEndpointsConfig(config *Config) error {
|
||||
duplicateValidationMap := make(map[string]bool)
|
||||
// Validate endpoints
|
||||
for _, ep := range config.Endpoints {
|
||||
if config.Debug {
|
||||
log.Printf("[config.validateEndpointsConfig] Validating endpoint '%s'", ep.Name)
|
||||
}
|
||||
logr.Debugf("[config.validateEndpointsConfig] Validating endpoint with key %s", ep.Key())
|
||||
if endpointKey := ep.Key(); duplicateValidationMap[endpointKey] {
|
||||
return fmt.Errorf("invalid endpoint %s: name and group combination must be unique", ep.Key())
|
||||
} else {
|
||||
@@ -350,12 +362,10 @@ func validateEndpointsConfig(config *Config) error {
|
||||
return fmt.Errorf("invalid endpoint %s: %w", ep.Key(), err)
|
||||
}
|
||||
}
|
||||
log.Printf("[config.validateEndpointsConfig] Validated %d endpoints", len(config.Endpoints))
|
||||
logr.Infof("[config.validateEndpointsConfig] Validated %d endpoints", len(config.Endpoints))
|
||||
// Validate external endpoints
|
||||
for _, ee := range config.ExternalEndpoints {
|
||||
if config.Debug {
|
||||
log.Printf("[config.validateEndpointsConfig] Validating external endpoint '%s'", ee.Name)
|
||||
}
|
||||
logr.Debugf("[config.validateEndpointsConfig] Validating external endpoint '%s'", ee.Name)
|
||||
if endpointKey := ee.Key(); duplicateValidationMap[endpointKey] {
|
||||
return fmt.Errorf("invalid external endpoint %s: name and group combination must be unique", ee.Key())
|
||||
} else {
|
||||
@@ -365,16 +375,14 @@ func validateEndpointsConfig(config *Config) error {
|
||||
return fmt.Errorf("invalid external endpoint %s: %w", ee.Key(), err)
|
||||
}
|
||||
}
|
||||
log.Printf("[config.validateEndpointsConfig] Validated %d external endpoints", len(config.ExternalEndpoints))
|
||||
logr.Infof("[config.validateEndpointsConfig] Validated %d external endpoints", len(config.ExternalEndpoints))
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSecurityConfig(config *Config) error {
|
||||
if config.Security != nil {
|
||||
if config.Security.IsValid() {
|
||||
if config.Debug {
|
||||
log.Printf("[config.validateSecurityConfig] Basic security configuration has been validated")
|
||||
}
|
||||
logr.Debug("[config.validateSecurityConfig] Basic security configuration has been validated")
|
||||
} else {
|
||||
// If there was an attempt to configure security, then it must mean that some confidential or private
|
||||
// data are exposed. As a result, we'll force a panic because it's better to be safe than sorry.
|
||||
@@ -388,9 +396,9 @@ func validateSecurityConfig(config *Config) error {
|
||||
// Note that the alerting configuration has to be validated before the endpoint configuration, because the default alert
|
||||
// returned by provider.AlertProvider.GetDefaultAlert() must be parsed before endpoint.Endpoint.ValidateAndSetDefaults()
|
||||
// sets the default alert values when none are set.
|
||||
func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoint.Endpoint, externalEndpoints []*endpoint.ExternalEndpoint, debug bool) {
|
||||
func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoint.Endpoint, externalEndpoints []*endpoint.ExternalEndpoint) {
|
||||
if alertingConfig == nil {
|
||||
log.Printf("[config.validateAlertingConfig] Alerting is not configured")
|
||||
logr.Info("[config.validateAlertingConfig] Alerting is not configured")
|
||||
return
|
||||
}
|
||||
alertTypes := []alert.Type{
|
||||
@@ -413,41 +421,51 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
|
||||
alert.TypePushover,
|
||||
alert.TypeSlack,
|
||||
alert.TypeTeams,
|
||||
alert.TypeTeamsWorkflows,
|
||||
alert.TypeTelegram,
|
||||
alert.TypeTwilio,
|
||||
alert.TypeZulip,
|
||||
alert.TypeIncidentIO,
|
||||
}
|
||||
var validProviders, invalidProviders []alert.Type
|
||||
for _, alertType := range alertTypes {
|
||||
alertProvider := alertingConfig.GetAlertingProviderByAlertType(alertType)
|
||||
if alertProvider != nil {
|
||||
if alertProvider.IsValid() {
|
||||
if err := alertProvider.Validate(); err == nil {
|
||||
// Parse alerts with the provider's default alert
|
||||
if alertProvider.GetDefaultAlert() != nil {
|
||||
for _, ep := range endpoints {
|
||||
for alertIndex, endpointAlert := range ep.Alerts {
|
||||
if alertType == endpointAlert.Type {
|
||||
if debug {
|
||||
log.Printf("[config.validateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ep.Key())
|
||||
logr.Debugf("[config.validateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ep.Key())
|
||||
provider.MergeProviderDefaultAlertIntoEndpointAlert(alertProvider.GetDefaultAlert(), endpointAlert)
|
||||
// Validate the endpoint alert's overrides, if applicable
|
||||
if len(endpointAlert.ProviderOverride) > 0 {
|
||||
if err = alertProvider.ValidateOverrides(ep.Group, endpointAlert); err != nil {
|
||||
log.Warnf("[config.validateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ep.Key(), alertType, err.Error())
|
||||
}
|
||||
}
|
||||
provider.ParseWithDefaultAlert(alertProvider.GetDefaultAlert(), endpointAlert)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, ee := range externalEndpoints {
|
||||
for alertIndex, endpointAlert := range ee.Alerts {
|
||||
if alertType == endpointAlert.Type {
|
||||
if debug {
|
||||
log.Printf("[config.validateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ee.Key())
|
||||
logr.Debugf("[config.validateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ee.Key())
|
||||
provider.MergeProviderDefaultAlertIntoEndpointAlert(alertProvider.GetDefaultAlert(), endpointAlert)
|
||||
// Validate the endpoint alert's overrides, if applicable
|
||||
if len(endpointAlert.ProviderOverride) > 0 {
|
||||
if err = alertProvider.ValidateOverrides(ee.Group, endpointAlert); err != nil {
|
||||
log.Warnf("[config.validateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ee.Key(), alertType, err.Error())
|
||||
}
|
||||
}
|
||||
provider.ParseWithDefaultAlert(alertProvider.GetDefaultAlert(), endpointAlert)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
validProviders = append(validProviders, alertType)
|
||||
} else {
|
||||
log.Printf("[config.validateAlertingConfig] Ignoring provider=%s because configuration is invalid", alertType)
|
||||
logr.Warnf("[config.validateAlertingConfig] Ignoring provider=%s due to error=%s", alertType, err.Error())
|
||||
invalidProviders = append(invalidProviders, alertType)
|
||||
alertingConfig.SetAlertingProviderToNil(alertProvider)
|
||||
}
|
||||
@@ -455,5 +473,5 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
|
||||
invalidProviders = append(invalidProviders, alertType)
|
||||
}
|
||||
}
|
||||
log.Printf("[config.validateAlertingConfig] configuredProviders=%s; ignoredProviders=%s", validProviders, invalidProviders)
|
||||
logr.Infof("[config.validateAlertingConfig] configuredProviders=%s; ignoredProviders=%s", validProviders, invalidProviders)
|
||||
}
|
||||
|
||||
@@ -11,10 +11,13 @@ import (
|
||||
"github.com/TwiN/gatus/v5/alerting"
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/awsses"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/custom"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/discord"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/email"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/gitea"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/github"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/gotify"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
|
||||
@@ -27,8 +30,10 @@ import (
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/slack"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/teams"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/zulip"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/config/web"
|
||||
@@ -177,7 +182,6 @@ endpoints:
|
||||
conditions:
|
||||
- "[STATUS] == 200"`,
|
||||
"b.yaml": `
|
||||
debug: true
|
||||
|
||||
alerting:
|
||||
discord:
|
||||
@@ -196,11 +200,10 @@ endpoints:
|
||||
- "[STATUS] == 200"`,
|
||||
},
|
||||
expectedConfig: &Config{
|
||||
Debug: true,
|
||||
Metrics: true,
|
||||
Alerting: &alerting.Config{
|
||||
Discord: &discord.AlertProvider{WebhookURL: "https://discord.com/api/webhooks/xxx/yyy"},
|
||||
Slack: &slack.AlertProvider{WebhookURL: "https://hooks.slack.com/services/xxx/yyy/zzz", DefaultAlert: &alert.Alert{Enabled: &yes}},
|
||||
Discord: &discord.AlertProvider{DefaultConfig: discord.Config{WebhookURL: "https://discord.com/api/webhooks/xxx/yyy"}},
|
||||
Slack: &slack.AlertProvider{DefaultConfig: slack.Config{WebhookURL: "https://hooks.slack.com/services/xxx/yyy/zzz"}, DefaultAlert: &alert.Alert{Enabled: &yes}},
|
||||
},
|
||||
ExternalEndpoints: []*endpoint.ExternalEndpoint{
|
||||
{
|
||||
@@ -327,6 +330,8 @@ func TestParseAndValidateConfigBytes(t *testing.T) {
|
||||
storage:
|
||||
type: sqlite
|
||||
path: %s
|
||||
maximum-number-of-results: 10
|
||||
maximum-number-of-events: 5
|
||||
|
||||
maintenance:
|
||||
enabled: true
|
||||
@@ -383,6 +388,9 @@ endpoints:
|
||||
if config.Storage == nil || config.Storage.Path != file || config.Storage.Type != storage.TypeSQLite {
|
||||
t.Error("expected storage to be set to sqlite, got", config.Storage)
|
||||
}
|
||||
if config.Storage == nil || config.Storage.MaximumNumberOfResults != 10 || config.Storage.MaximumNumberOfEvents != 5 {
|
||||
t.Error("expected MaximumNumberOfResults and MaximumNumberOfEvents to be set to 10 and 5, got", config.Storage.MaximumNumberOfResults, config.Storage.MaximumNumberOfEvents)
|
||||
}
|
||||
if config.UI == nil || config.UI.Title != "T" || config.UI.Header != "H" || config.UI.Link != "https://example.org" || len(config.UI.Buttons) != 2 || config.UI.Buttons[0].Name != "Home" || config.UI.Buttons[0].Link != "https://example.org" || config.UI.Buttons[1].Name != "Status page" || config.UI.Buttons[1].Link != "https://status.example.org" {
|
||||
t.Error("expected ui to be set to T, H, https://example.org, 2 buttons, Home and Status page, got", config.UI)
|
||||
}
|
||||
@@ -482,7 +490,7 @@ endpoints:
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
if config == nil {
|
||||
t.Fatal("Config shouldn't have been nil")
|
||||
t.Fatal("DefaultConfig shouldn't have been nil")
|
||||
}
|
||||
if config.Metrics {
|
||||
t.Error("Metrics should've been false by default")
|
||||
@@ -719,7 +727,6 @@ badconfig:
|
||||
|
||||
func TestParseAndValidateConfigBytesWithAlerting(t *testing.T) {
|
||||
config, err := parseAndValidateConfigBytes([]byte(`
|
||||
debug: true
|
||||
alerting:
|
||||
slack:
|
||||
webhook-url: "http://example.com"
|
||||
@@ -788,7 +795,7 @@ endpoints:
|
||||
if config.Alerting == nil {
|
||||
t.Fatal("config.Alerting shouldn't have been nil")
|
||||
}
|
||||
if config.Alerting.Slack == nil || !config.Alerting.Slack.IsValid() {
|
||||
if config.Alerting.Slack == nil || config.Alerting.Slack.Validate() != nil {
|
||||
t.Fatal("Slack alerting config should've been valid")
|
||||
}
|
||||
// Endpoints
|
||||
@@ -919,8 +926,6 @@ endpoints:
|
||||
|
||||
func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlert(t *testing.T) {
|
||||
config, err := parseAndValidateConfigBytes([]byte(`
|
||||
debug: true
|
||||
|
||||
alerting:
|
||||
slack:
|
||||
webhook-url: "http://example.com"
|
||||
@@ -1041,63 +1046,64 @@ endpoints:
|
||||
if config.Alerting == nil {
|
||||
t.Fatal("config.Alerting shouldn't have been nil")
|
||||
}
|
||||
if config.Alerting.Slack == nil || !config.Alerting.Slack.IsValid() {
|
||||
|
||||
if config.Alerting.Slack == nil || config.Alerting.Slack.Validate() != nil {
|
||||
t.Fatal("Slack alerting config should've been valid")
|
||||
}
|
||||
if config.Alerting.Slack.GetDefaultAlert() == nil {
|
||||
t.Fatal("Slack.GetDefaultAlert() shouldn't have returned nil")
|
||||
}
|
||||
if config.Alerting.Slack.WebhookURL != "http://example.com" {
|
||||
t.Errorf("Slack webhook should've been %s, but was %s", "http://example.com", config.Alerting.Slack.WebhookURL)
|
||||
if config.Alerting.Slack.DefaultConfig.WebhookURL != "http://example.com" {
|
||||
t.Errorf("Slack webhook should've been %s, but was %s", "http://example.com", config.Alerting.Slack.DefaultConfig.WebhookURL)
|
||||
}
|
||||
|
||||
if config.Alerting.PagerDuty == nil || !config.Alerting.PagerDuty.IsValid() {
|
||||
if config.Alerting.PagerDuty == nil || config.Alerting.PagerDuty.Validate() != nil {
|
||||
t.Fatal("PagerDuty alerting config should've been valid")
|
||||
}
|
||||
if config.Alerting.PagerDuty.GetDefaultAlert() == nil {
|
||||
t.Fatal("PagerDuty.GetDefaultAlert() shouldn't have returned nil")
|
||||
}
|
||||
if config.Alerting.PagerDuty.IntegrationKey != "00000000000000000000000000000000" {
|
||||
t.Errorf("PagerDuty integration key should've been %s, but was %s", "00000000000000000000000000000000", config.Alerting.PagerDuty.IntegrationKey)
|
||||
if config.Alerting.PagerDuty.DefaultConfig.IntegrationKey != "00000000000000000000000000000000" {
|
||||
t.Errorf("PagerDuty integration key should've been %s, but was %s", "00000000000000000000000000000000", config.Alerting.PagerDuty.DefaultConfig.IntegrationKey)
|
||||
}
|
||||
|
||||
if config.Alerting.Pushover == nil || !config.Alerting.Pushover.IsValid() {
|
||||
if config.Alerting.Pushover == nil || config.Alerting.Pushover.Validate() != nil {
|
||||
t.Fatal("Pushover alerting config should've been valid")
|
||||
}
|
||||
if config.Alerting.Pushover.GetDefaultAlert() == nil {
|
||||
t.Fatal("Pushover.GetDefaultAlert() shouldn't have returned nil")
|
||||
}
|
||||
if config.Alerting.Pushover.ApplicationToken != "000000000000000000000000000000" {
|
||||
t.Errorf("Pushover application token should've been %s, but was %s", "000000000000000000000000000000", config.Alerting.Pushover.ApplicationToken)
|
||||
if config.Alerting.Pushover.DefaultConfig.ApplicationToken != "000000000000000000000000000000" {
|
||||
t.Errorf("Pushover application token should've been %s, but was %s", "000000000000000000000000000000", config.Alerting.Pushover.DefaultConfig.ApplicationToken)
|
||||
}
|
||||
if config.Alerting.Pushover.UserKey != "000000000000000000000000000000" {
|
||||
t.Errorf("Pushover user key should've been %s, but was %s", "000000000000000000000000000000", config.Alerting.Pushover.UserKey)
|
||||
if config.Alerting.Pushover.DefaultConfig.UserKey != "000000000000000000000000000000" {
|
||||
t.Errorf("Pushover user key should've been %s, but was %s", "000000000000000000000000000000", config.Alerting.Pushover.DefaultConfig.UserKey)
|
||||
}
|
||||
|
||||
if config.Alerting.Mattermost == nil || !config.Alerting.Mattermost.IsValid() {
|
||||
if config.Alerting.Mattermost == nil || config.Alerting.Mattermost.Validate() != nil {
|
||||
t.Fatal("Mattermost alerting config should've been valid")
|
||||
}
|
||||
if config.Alerting.Mattermost.GetDefaultAlert() == nil {
|
||||
t.Fatal("Mattermost.GetDefaultAlert() shouldn't have returned nil")
|
||||
}
|
||||
|
||||
if config.Alerting.Messagebird == nil || !config.Alerting.Messagebird.IsValid() {
|
||||
if config.Alerting.Messagebird == nil || config.Alerting.Messagebird.Validate() != nil {
|
||||
t.Fatal("Messagebird alerting config should've been valid")
|
||||
}
|
||||
if config.Alerting.Messagebird.GetDefaultAlert() == nil {
|
||||
t.Fatal("Messagebird.GetDefaultAlert() shouldn't have returned nil")
|
||||
}
|
||||
if config.Alerting.Messagebird.AccessKey != "1" {
|
||||
t.Errorf("Messagebird access key should've been %s, but was %s", "1", config.Alerting.Messagebird.AccessKey)
|
||||
if config.Alerting.Messagebird.DefaultConfig.AccessKey != "1" {
|
||||
t.Errorf("Messagebird access key should've been %s, but was %s", "1", config.Alerting.Messagebird.DefaultConfig.AccessKey)
|
||||
}
|
||||
if config.Alerting.Messagebird.Originator != "31619191918" {
|
||||
t.Errorf("Messagebird originator field should've been %s, but was %s", "31619191918", config.Alerting.Messagebird.Originator)
|
||||
if config.Alerting.Messagebird.DefaultConfig.Originator != "31619191918" {
|
||||
t.Errorf("Messagebird originator field should've been %s, but was %s", "31619191918", config.Alerting.Messagebird.DefaultConfig.Originator)
|
||||
}
|
||||
if config.Alerting.Messagebird.Recipients != "31619191919" {
|
||||
t.Errorf("Messagebird to recipients should've been %s, but was %s", "31619191919", config.Alerting.Messagebird.Recipients)
|
||||
if config.Alerting.Messagebird.DefaultConfig.Recipients != "31619191919" {
|
||||
t.Errorf("Messagebird to recipients should've been %s, but was %s", "31619191919", config.Alerting.Messagebird.DefaultConfig.Recipients)
|
||||
}
|
||||
|
||||
if config.Alerting.Discord == nil || !config.Alerting.Discord.IsValid() {
|
||||
if config.Alerting.Discord == nil || config.Alerting.Discord.Validate() != nil {
|
||||
t.Fatal("Discord alerting config should've been valid")
|
||||
}
|
||||
if config.Alerting.Discord.GetDefaultAlert() == nil {
|
||||
@@ -1109,98 +1115,98 @@ endpoints:
|
||||
if config.Alerting.Discord.GetDefaultAlert().SuccessThreshold != 15 {
|
||||
t.Errorf("Discord default alert success threshold should've been %d, but was %d", 15, config.Alerting.Discord.GetDefaultAlert().SuccessThreshold)
|
||||
}
|
||||
if config.Alerting.Discord.WebhookURL != "http://example.org" {
|
||||
t.Errorf("Discord webhook should've been %s, but was %s", "http://example.org", config.Alerting.Discord.WebhookURL)
|
||||
if config.Alerting.Discord.DefaultConfig.WebhookURL != "http://example.org" {
|
||||
t.Errorf("Discord webhook should've been %s, but was %s", "http://example.org", config.Alerting.Discord.DefaultConfig.WebhookURL)
|
||||
}
|
||||
if config.Alerting.GetAlertingProviderByAlertType(alert.TypeDiscord) != config.Alerting.Discord {
|
||||
t.Error("expected discord configuration")
|
||||
}
|
||||
|
||||
if config.Alerting.Telegram == nil || !config.Alerting.Telegram.IsValid() {
|
||||
if config.Alerting.Telegram == nil || config.Alerting.Telegram.Validate() != nil {
|
||||
t.Fatal("Telegram alerting config should've been valid")
|
||||
}
|
||||
if config.Alerting.Telegram.GetDefaultAlert() == nil {
|
||||
t.Fatal("Telegram.GetDefaultAlert() shouldn't have returned nil")
|
||||
}
|
||||
if config.Alerting.Telegram.Token != "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" {
|
||||
t.Errorf("Telegram token should've been %s, but was %s", "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", config.Alerting.Telegram.Token)
|
||||
if config.Alerting.Telegram.DefaultConfig.Token != "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" {
|
||||
t.Errorf("Telegram token should've been %s, but was %s", "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", config.Alerting.Telegram.DefaultConfig.Token)
|
||||
}
|
||||
if config.Alerting.Telegram.ID != "0123456789" {
|
||||
t.Errorf("Telegram ID should've been %s, but was %s", "012345689", config.Alerting.Telegram.ID)
|
||||
if config.Alerting.Telegram.DefaultConfig.ID != "0123456789" {
|
||||
t.Errorf("Telegram ID should've been %s, but was %s", "012345689", config.Alerting.Telegram.DefaultConfig.ID)
|
||||
}
|
||||
|
||||
if config.Alerting.Twilio == nil || !config.Alerting.Twilio.IsValid() {
|
||||
if config.Alerting.Twilio == nil || config.Alerting.Twilio.Validate() != nil {
|
||||
t.Fatal("Twilio alerting config should've been valid")
|
||||
}
|
||||
if config.Alerting.Twilio.GetDefaultAlert() == nil {
|
||||
t.Fatal("Twilio.GetDefaultAlert() shouldn't have returned nil")
|
||||
}
|
||||
|
||||
if config.Alerting.Teams == nil || !config.Alerting.Teams.IsValid() {
|
||||
if config.Alerting.Teams == nil || config.Alerting.Teams.Validate() != nil {
|
||||
t.Fatal("Teams alerting config should've been valid")
|
||||
}
|
||||
if config.Alerting.Teams.GetDefaultAlert() == nil {
|
||||
t.Fatal("Teams.GetDefaultAlert() shouldn't have returned nil")
|
||||
}
|
||||
if config.Alerting.JetBrainsSpace == nil || !config.Alerting.JetBrainsSpace.IsValid() {
|
||||
|
||||
if config.Alerting.JetBrainsSpace == nil || config.Alerting.JetBrainsSpace.Validate() != nil {
|
||||
t.Fatal("JetBrainsSpace alerting config should've been valid")
|
||||
}
|
||||
|
||||
if config.Alerting.JetBrainsSpace.GetDefaultAlert() == nil {
|
||||
t.Fatal("JetBrainsSpace.GetDefaultAlert() shouldn't have returned nil")
|
||||
}
|
||||
if config.Alerting.JetBrainsSpace.Project != "foo" {
|
||||
t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "foo", config.Alerting.JetBrainsSpace.Project)
|
||||
if config.Alerting.JetBrainsSpace.DefaultConfig.Project != "foo" {
|
||||
t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "foo", config.Alerting.JetBrainsSpace.DefaultConfig.Project)
|
||||
}
|
||||
if config.Alerting.JetBrainsSpace.ChannelID != "bar" {
|
||||
t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "bar", config.Alerting.JetBrainsSpace.ChannelID)
|
||||
if config.Alerting.JetBrainsSpace.DefaultConfig.ChannelID != "bar" {
|
||||
t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "bar", config.Alerting.JetBrainsSpace.DefaultConfig.ChannelID)
|
||||
}
|
||||
if config.Alerting.JetBrainsSpace.Token != "baz" {
|
||||
t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "baz", config.Alerting.JetBrainsSpace.Token)
|
||||
if config.Alerting.JetBrainsSpace.DefaultConfig.Token != "baz" {
|
||||
t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "baz", config.Alerting.JetBrainsSpace.DefaultConfig.Token)
|
||||
}
|
||||
|
||||
if config.Alerting.Email == nil || !config.Alerting.Email.IsValid() {
|
||||
if config.Alerting.Email == nil || config.Alerting.Email.Validate() != nil {
|
||||
t.Fatal("Email alerting config should've been valid")
|
||||
}
|
||||
if config.Alerting.Email.GetDefaultAlert() == nil {
|
||||
t.Fatal("Email.GetDefaultAlert() shouldn't have returned nil")
|
||||
}
|
||||
if config.Alerting.Email.From != "from@example.com" {
|
||||
t.Errorf("Email from should've been %s, but was %s", "from@example.com", config.Alerting.Email.From)
|
||||
if config.Alerting.Email.DefaultConfig.From != "from@example.com" {
|
||||
t.Errorf("Email from should've been %s, but was %s", "from@example.com", config.Alerting.Email.DefaultConfig.From)
|
||||
}
|
||||
if config.Alerting.Email.Username != "from@example.com" {
|
||||
t.Errorf("Email username should've been %s, but was %s", "from@example.com", config.Alerting.Email.Username)
|
||||
if config.Alerting.Email.DefaultConfig.Username != "from@example.com" {
|
||||
t.Errorf("Email username should've been %s, but was %s", "from@example.com", config.Alerting.Email.DefaultConfig.Username)
|
||||
}
|
||||
if config.Alerting.Email.Password != "hunter2" {
|
||||
t.Errorf("Email password should've been %s, but was %s", "hunter2", config.Alerting.Email.Password)
|
||||
if config.Alerting.Email.DefaultConfig.Password != "hunter2" {
|
||||
t.Errorf("Email password should've been %s, but was %s", "hunter2", config.Alerting.Email.DefaultConfig.Password)
|
||||
}
|
||||
if config.Alerting.Email.Host != "mail.example.com" {
|
||||
t.Errorf("Email host should've been %s, but was %s", "mail.example.com", config.Alerting.Email.Host)
|
||||
if config.Alerting.Email.DefaultConfig.Host != "mail.example.com" {
|
||||
t.Errorf("Email host should've been %s, but was %s", "mail.example.com", config.Alerting.Email.DefaultConfig.Host)
|
||||
}
|
||||
if config.Alerting.Email.Port != 587 {
|
||||
t.Errorf("Email port should've been %d, but was %d", 587, config.Alerting.Email.Port)
|
||||
if config.Alerting.Email.DefaultConfig.Port != 587 {
|
||||
t.Errorf("Email port should've been %d, but was %d", 587, config.Alerting.Email.DefaultConfig.Port)
|
||||
}
|
||||
if config.Alerting.Email.To != "recipient1@example.com,recipient2@example.com" {
|
||||
t.Errorf("Email to should've been %s, but was %s", "recipient1@example.com,recipient2@example.com", config.Alerting.Email.To)
|
||||
if config.Alerting.Email.DefaultConfig.To != "recipient1@example.com,recipient2@example.com" {
|
||||
t.Errorf("Email to should've been %s, but was %s", "recipient1@example.com,recipient2@example.com", config.Alerting.Email.DefaultConfig.To)
|
||||
}
|
||||
if config.Alerting.Email.ClientConfig == nil {
|
||||
if config.Alerting.Email.DefaultConfig.ClientConfig == nil {
|
||||
t.Fatal("Email client config should've been set")
|
||||
}
|
||||
if config.Alerting.Email.ClientConfig.Insecure {
|
||||
if config.Alerting.Email.DefaultConfig.ClientConfig.Insecure {
|
||||
t.Error("Email client config should've been secure")
|
||||
}
|
||||
|
||||
if config.Alerting.Gotify == nil || !config.Alerting.Gotify.IsValid() {
|
||||
if config.Alerting.Gotify == nil || config.Alerting.Gotify.Validate() != nil {
|
||||
t.Fatal("Gotify alerting config should've been valid")
|
||||
}
|
||||
if config.Alerting.Gotify.GetDefaultAlert() == nil {
|
||||
t.Fatal("Gotify.GetDefaultAlert() shouldn't have returned nil")
|
||||
}
|
||||
if config.Alerting.Gotify.ServerURL != "https://gotify.example" {
|
||||
t.Errorf("Gotify server URL should've been %s, but was %s", "https://gotify.example", config.Alerting.Gotify.ServerURL)
|
||||
if config.Alerting.Gotify.DefaultConfig.ServerURL != "https://gotify.example" {
|
||||
t.Errorf("Gotify server URL should've been %s, but was %s", "https://gotify.example", config.Alerting.Gotify.DefaultConfig.ServerURL)
|
||||
}
|
||||
if config.Alerting.Gotify.Token != "**************" {
|
||||
t.Errorf("Gotify token should've been %s, but was %s", "**************", config.Alerting.Gotify.Token)
|
||||
if config.Alerting.Gotify.DefaultConfig.Token != "**************" {
|
||||
t.Errorf("Gotify token should've been %s, but was %s", "**************", config.Alerting.Gotify.DefaultConfig.Token)
|
||||
}
|
||||
|
||||
// External endpoints
|
||||
@@ -1409,6 +1415,8 @@ endpoints:
|
||||
- type: slack
|
||||
enabled: false
|
||||
failure-threshold: 30
|
||||
provider-override:
|
||||
webhook-url: https://example.com
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
`))
|
||||
@@ -1422,7 +1430,7 @@ endpoints:
|
||||
if config.Alerting == nil {
|
||||
t.Fatal("config.Alerting shouldn't have been nil")
|
||||
}
|
||||
if config.Alerting.Slack == nil || !config.Alerting.Slack.IsValid() {
|
||||
if config.Alerting.Slack == nil || config.Alerting.Slack.Validate() != nil {
|
||||
t.Fatal("Slack alerting config should've been valid")
|
||||
}
|
||||
// Endpoints
|
||||
@@ -1493,6 +1501,7 @@ endpoints:
|
||||
t.Fatal("PagerDuty alerting config should've been set to nil, because its IsValid() method returned false and therefore alerting.Config.SetAlertingProviderToNil() should've been called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateConfigBytesWithInvalidPushoverAlertingConfig(t *testing.T) {
|
||||
config, err := parseAndValidateConfigBytes([]byte(`
|
||||
alerting:
|
||||
@@ -1549,17 +1558,18 @@ endpoints:
|
||||
if config.Alerting.Custom == nil {
|
||||
t.Fatal("Custom alerting config shouldn't have been nil")
|
||||
}
|
||||
if !config.Alerting.Custom.IsValid() {
|
||||
if err = config.Alerting.Custom.Validate(); err != nil {
|
||||
t.Fatal("Custom alerting config should've been valid")
|
||||
}
|
||||
if config.Alerting.Custom.GetAlertStatePlaceholderValue(true) != "RESOLVED" {
|
||||
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for RESOLVED should've been 'RESOLVED', got", config.Alerting.Custom.GetAlertStatePlaceholderValue(true))
|
||||
cfg, _ := config.Alerting.Custom.GetConfig("", &alert.Alert{ProviderOverride: map[string]any{"client": map[string]any{"insecure": true}}})
|
||||
if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, true) != "RESOLVED" {
|
||||
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for RESOLVED should've been 'RESOLVED', got", config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, true))
|
||||
}
|
||||
if config.Alerting.Custom.GetAlertStatePlaceholderValue(false) != "TRIGGERED" {
|
||||
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'TRIGGERED', got", config.Alerting.Custom.GetAlertStatePlaceholderValue(false))
|
||||
if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, false) != "TRIGGERED" {
|
||||
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'TRIGGERED', got", config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, false))
|
||||
}
|
||||
if config.Alerting.Custom.ClientConfig.Insecure {
|
||||
t.Errorf("ClientConfig.Insecure should have been %v, got %v", false, config.Alerting.Custom.ClientConfig.Insecure)
|
||||
if !cfg.ClientConfig.Insecure {
|
||||
t.Errorf("ClientConfig.Insecure should have been %v, got %v", true, cfg.ClientConfig.Insecure)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1586,7 +1596,7 @@ endpoints:
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
if config == nil {
|
||||
t.Fatal("Config shouldn't have been nil")
|
||||
t.Fatal("DefaultConfig shouldn't have been nil")
|
||||
}
|
||||
if config.Alerting == nil {
|
||||
t.Fatal("config.Alerting shouldn't have been nil")
|
||||
@@ -1594,13 +1604,14 @@ endpoints:
|
||||
if config.Alerting.Custom == nil {
|
||||
t.Fatal("Custom alerting config shouldn't have been nil")
|
||||
}
|
||||
if !config.Alerting.Custom.IsValid() {
|
||||
if err = config.Alerting.Custom.Validate(); err != nil {
|
||||
t.Fatal("Custom alerting config should've been valid")
|
||||
}
|
||||
if config.Alerting.Custom.GetAlertStatePlaceholderValue(true) != "operational" {
|
||||
cfg, _ := config.Alerting.Custom.GetConfig("", &alert.Alert{})
|
||||
if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, true) != "operational" {
|
||||
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for RESOLVED should've been 'operational'")
|
||||
}
|
||||
if config.Alerting.Custom.GetAlertStatePlaceholderValue(false) != "partial_outage" {
|
||||
if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, false) != "partial_outage" {
|
||||
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'partial_outage'")
|
||||
}
|
||||
}
|
||||
@@ -1626,7 +1637,7 @@ endpoints:
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
if config == nil {
|
||||
t.Fatal("Config shouldn't have been nil")
|
||||
t.Fatal("DefaultConfig shouldn't have been nil")
|
||||
}
|
||||
if config.Alerting == nil {
|
||||
t.Fatal("config.Alerting shouldn't have been nil")
|
||||
@@ -1634,13 +1645,14 @@ endpoints:
|
||||
if config.Alerting.Custom == nil {
|
||||
t.Fatal("Custom alerting config shouldn't have been nil")
|
||||
}
|
||||
if !config.Alerting.Custom.IsValid() {
|
||||
if err := config.Alerting.Custom.Validate(); err != nil {
|
||||
t.Fatal("Custom alerting config should've been valid")
|
||||
}
|
||||
if config.Alerting.Custom.GetAlertStatePlaceholderValue(true) != "RESOLVED" {
|
||||
cfg, _ := config.Alerting.Custom.GetConfig("", &alert.Alert{})
|
||||
if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, true) != "RESOLVED" {
|
||||
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for RESOLVED should've been 'RESOLVED'")
|
||||
}
|
||||
if config.Alerting.Custom.GetAlertStatePlaceholderValue(false) != "partial_outage" {
|
||||
if config.Alerting.Custom.GetAlertStatePlaceholderValue(cfg, false) != "partial_outage" {
|
||||
t.Fatal("ALERT_TRIGGERED_OR_RESOLVED placeholder value for TRIGGERED should've been 'partial_outage'")
|
||||
}
|
||||
}
|
||||
@@ -1801,7 +1813,7 @@ endpoints:
|
||||
func TestParseAndValidateConfigBytesWithValidSecurityConfig(t *testing.T) {
|
||||
const expectedUsername = "admin"
|
||||
const expectedPasswordHash = "JDJhJDEwJHRiMnRFakxWazZLdXBzRERQazB1TE8vckRLY05Yb1hSdnoxWU0yQ1FaYXZRSW1McmladDYu"
|
||||
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`debug: true
|
||||
config, err := parseAndValidateConfigBytes([]byte(fmt.Sprintf(`
|
||||
security:
|
||||
basic:
|
||||
username: "%s"
|
||||
@@ -1816,7 +1828,7 @@ endpoints:
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
if config == nil {
|
||||
t.Fatal("Config shouldn't have been nil")
|
||||
t.Fatal("DefaultConfig shouldn't have been nil")
|
||||
}
|
||||
if config.Security == nil {
|
||||
t.Fatal("config.Security shouldn't have been nil")
|
||||
@@ -1849,7 +1861,7 @@ endpoints:
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
if config == nil {
|
||||
t.Fatal("Config shouldn't have been nil")
|
||||
t.Fatal("DefaultConfig shouldn't have been nil")
|
||||
}
|
||||
if config.Endpoints[0].URL != "https://twin.sh/health" {
|
||||
t.Errorf("URL should have been %s", "https://twin.sh/health")
|
||||
@@ -1871,33 +1883,41 @@ func TestParseAndValidateConfigBytesWithNoEndpoints(t *testing.T) {
|
||||
|
||||
func TestGetAlertingProviderByAlertType(t *testing.T) {
|
||||
alertingConfig := &alerting.Config{
|
||||
Custom: &custom.AlertProvider{},
|
||||
Discord: &discord.AlertProvider{},
|
||||
Email: &email.AlertProvider{},
|
||||
GitHub: &github.AlertProvider{},
|
||||
GoogleChat: &googlechat.AlertProvider{},
|
||||
Gotify: &gotify.AlertProvider{},
|
||||
JetBrainsSpace: &jetbrainsspace.AlertProvider{},
|
||||
Matrix: &matrix.AlertProvider{},
|
||||
Mattermost: &mattermost.AlertProvider{},
|
||||
Messagebird: &messagebird.AlertProvider{},
|
||||
Ntfy: &ntfy.AlertProvider{},
|
||||
Opsgenie: &opsgenie.AlertProvider{},
|
||||
PagerDuty: &pagerduty.AlertProvider{},
|
||||
Pushover: &pushover.AlertProvider{},
|
||||
Slack: &slack.AlertProvider{},
|
||||
Telegram: &telegram.AlertProvider{},
|
||||
Twilio: &twilio.AlertProvider{},
|
||||
Teams: &teams.AlertProvider{},
|
||||
AWSSimpleEmailService: &awsses.AlertProvider{},
|
||||
Custom: &custom.AlertProvider{},
|
||||
Discord: &discord.AlertProvider{},
|
||||
Email: &email.AlertProvider{},
|
||||
Gitea: &gitea.AlertProvider{},
|
||||
GitHub: &github.AlertProvider{},
|
||||
GitLab: &gitlab.AlertProvider{},
|
||||
GoogleChat: &googlechat.AlertProvider{},
|
||||
Gotify: &gotify.AlertProvider{},
|
||||
JetBrainsSpace: &jetbrainsspace.AlertProvider{},
|
||||
Matrix: &matrix.AlertProvider{},
|
||||
Mattermost: &mattermost.AlertProvider{},
|
||||
Messagebird: &messagebird.AlertProvider{},
|
||||
Ntfy: &ntfy.AlertProvider{},
|
||||
Opsgenie: &opsgenie.AlertProvider{},
|
||||
PagerDuty: &pagerduty.AlertProvider{},
|
||||
Pushover: &pushover.AlertProvider{},
|
||||
Slack: &slack.AlertProvider{},
|
||||
Telegram: &telegram.AlertProvider{},
|
||||
Teams: &teams.AlertProvider{},
|
||||
TeamsWorkflows: &teamsworkflows.AlertProvider{},
|
||||
Twilio: &twilio.AlertProvider{},
|
||||
Zulip: &zulip.AlertProvider{},
|
||||
}
|
||||
scenarios := []struct {
|
||||
alertType alert.Type
|
||||
expected provider.AlertProvider
|
||||
}{
|
||||
{alertType: alert.TypeAWSSES, expected: alertingConfig.AWSSimpleEmailService},
|
||||
{alertType: alert.TypeCustom, expected: alertingConfig.Custom},
|
||||
{alertType: alert.TypeDiscord, expected: alertingConfig.Discord},
|
||||
{alertType: alert.TypeEmail, expected: alertingConfig.Email},
|
||||
{alertType: alert.TypeGitea, expected: alertingConfig.Gitea},
|
||||
{alertType: alert.TypeGitHub, expected: alertingConfig.GitHub},
|
||||
{alertType: alert.TypeGitLab, expected: alertingConfig.GitLab},
|
||||
{alertType: alert.TypeGoogleChat, expected: alertingConfig.GoogleChat},
|
||||
{alertType: alert.TypeGotify, expected: alertingConfig.Gotify},
|
||||
{alertType: alert.TypeJetBrainsSpace, expected: alertingConfig.JetBrainsSpace},
|
||||
@@ -1910,8 +1930,10 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
|
||||
{alertType: alert.TypePushover, expected: alertingConfig.Pushover},
|
||||
{alertType: alert.TypeSlack, expected: alertingConfig.Slack},
|
||||
{alertType: alert.TypeTelegram, expected: alertingConfig.Telegram},
|
||||
{alertType: alert.TypeTwilio, expected: alertingConfig.Twilio},
|
||||
{alertType: alert.TypeTeams, expected: alertingConfig.Teams},
|
||||
{alertType: alert.TypeTeamsWorkflows, expected: alertingConfig.TeamsWorkflows},
|
||||
{alertType: alert.TypeTwilio, expected: alertingConfig.Twilio},
|
||||
{alertType: alert.TypeZulip, expected: alertingConfig.Zulip},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(string(scenario.alertType), func(t *testing.T) {
|
||||
|
||||
@@ -150,7 +150,7 @@ func (c Condition) evaluate(result *Result, dontResolveFailedConditions bool) bo
|
||||
return false
|
||||
}
|
||||
if !success {
|
||||
//log.Printf("[Condition.evaluate] Condition '%s' did not succeed because '%s' is false", condition, condition)
|
||||
//logr.Debugf("[Condition.evaluate] Condition '%s' did not succeed because '%s' is false", condition, condition)
|
||||
}
|
||||
result.ConditionResults = append(result.ConditionResults, &ConditionResult{Condition: conditionToDisplay, Success: success})
|
||||
return success
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/TwiN/gatus/v5/config/endpoint/dns"
|
||||
sshconfig "github.com/TwiN/gatus/v5/config/endpoint/ssh"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint/ui"
|
||||
"github.com/TwiN/gatus/v5/config/maintenance"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
@@ -104,6 +105,9 @@ type Endpoint struct {
|
||||
// Alerts is the alerting configuration for the endpoint in case of failure
|
||||
Alerts []*alert.Alert `yaml:"alerts,omitempty"`
|
||||
|
||||
// MaintenanceWindow is the configuration for per-endpoint maintenance windows
|
||||
MaintenanceWindows []*maintenance.Config `yaml:"maintenance-windows,omitempty"`
|
||||
|
||||
// DNSConfig is the configuration for DNS monitoring
|
||||
DNSConfig *dns.Config `yaml:"dns,omitempty"`
|
||||
|
||||
@@ -219,6 +223,11 @@ func (e *Endpoint) ValidateAndSetDefaults() error {
|
||||
if e.Type() == TypeUNKNOWN {
|
||||
return ErrUnknownEndpointType
|
||||
}
|
||||
for _, maintenanceWindow := range e.MaintenanceWindows {
|
||||
if err := maintenanceWindow.ValidateAndSetDefaults(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Make sure that the request can be created
|
||||
_, err := http.NewRequest(e.Method, e.URL, bytes.NewBuffer([]byte(e.Body)))
|
||||
if err != nil {
|
||||
@@ -255,12 +264,17 @@ func (e *Endpoint) EvaluateHealth() *Result {
|
||||
// Parse or extract hostname from URL
|
||||
if e.DNSConfig != nil {
|
||||
result.Hostname = strings.TrimSuffix(e.URL, ":53")
|
||||
} else if e.Type() == TypeICMP {
|
||||
// To handle IPv6 addresses, we need to handle the hostname differently here. This is to avoid, for instance,
|
||||
// "1111:2222:3333::4444" being displayed as "1111:2222:3333:" because :4444 would be interpreted as a port.
|
||||
result.Hostname = strings.TrimPrefix(e.URL, "icmp://")
|
||||
} else {
|
||||
urlObject, err := url.Parse(e.URL)
|
||||
if err != nil {
|
||||
result.AddError(err.Error())
|
||||
} else {
|
||||
result.Hostname = urlObject.Hostname()
|
||||
result.port = urlObject.Port()
|
||||
}
|
||||
}
|
||||
// Retrieve IP if necessary
|
||||
@@ -298,7 +312,13 @@ func (e *Endpoint) EvaluateHealth() *Result {
|
||||
for errIdx, errorString := range result.Errors {
|
||||
result.Errors[errIdx] = strings.ReplaceAll(errorString, result.Hostname, "<redacted>")
|
||||
}
|
||||
result.Hostname = ""
|
||||
result.Hostname = "" // remove it from the result so it doesn't get exposed
|
||||
}
|
||||
if e.UIConfig.HidePort && len(result.port) > 0 {
|
||||
for errIdx, errorString := range result.Errors {
|
||||
result.Errors[errIdx] = strings.ReplaceAll(errorString, result.port, "<redacted>")
|
||||
}
|
||||
result.port = ""
|
||||
}
|
||||
if e.UIConfig.HideConditions {
|
||||
result.ConditionResults = nil
|
||||
@@ -363,6 +383,18 @@ func (e *Endpoint) call(result *Result) {
|
||||
}
|
||||
result.Duration = time.Since(startTime)
|
||||
} else if endpointType == TypeSSH {
|
||||
// If there's no username/password specified, attempt to validate just the SSH banner
|
||||
if len(e.SSHConfig.Username) == 0 && len(e.SSHConfig.Password) == 0 {
|
||||
result.Connected, result.HTTPStatus, err =
|
||||
client.CheckSSHBanner(strings.TrimPrefix(e.URL, "ssh://"), e.ClientConfig)
|
||||
if err != nil {
|
||||
result.AddError(err.Error())
|
||||
return
|
||||
}
|
||||
result.Success = result.Connected
|
||||
result.Duration = time.Since(startTime)
|
||||
return
|
||||
}
|
||||
var cli *ssh.Client
|
||||
result.Connected, cli, err = client.CanCreateSSHConnection(strings.TrimPrefix(e.URL, "ssh://"), e.SSHConfig.Username, e.SSHConfig.Password, e.ClientConfig)
|
||||
if err != nil {
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/TwiN/gatus/v5/config/endpoint/dns"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint/ssh"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint/ui"
|
||||
"github.com/TwiN/gatus/v5/config/maintenance"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
@@ -164,9 +165,9 @@ func TestEndpoint(t *testing.T) {
|
||||
Name: "endpoint-that-will-time-out-and-hidden-hostname",
|
||||
Endpoint: Endpoint{
|
||||
Name: "endpoint-that-will-time-out",
|
||||
URL: "https://twin.sh/health",
|
||||
URL: "https://twin.sh:9999/health",
|
||||
Conditions: []Condition{"[CONNECTED] == true"},
|
||||
UIConfig: &ui.Config{HideHostname: true},
|
||||
UIConfig: &ui.Config{HideHostname: true, HidePort: true},
|
||||
ClientConfig: &client.Config{Timeout: time.Millisecond},
|
||||
},
|
||||
ExpectedResult: &Result{
|
||||
@@ -179,7 +180,7 @@ func TestEndpoint(t *testing.T) {
|
||||
// Because there's no [DOMAIN_EXPIRATION] condition, this is not resolved, so it should be 0.
|
||||
DomainExpiration: 0,
|
||||
// Because Endpoint.UIConfig.HideHostname is true, the hostname should be replaced by <redacted>.
|
||||
Errors: []string{`Get "https://<redacted>/health": context deadline exceeded (Client.Timeout exceeded while awaiting headers)`},
|
||||
Errors: []string{`Get "https://<redacted>:<redacted>/health": context deadline exceeded (Client.Timeout exceeded while awaiting headers)`},
|
||||
},
|
||||
MockRoundTripper: nil,
|
||||
},
|
||||
@@ -390,10 +391,11 @@ func TestEndpoint_Type(t *testing.T) {
|
||||
|
||||
func TestEndpoint_ValidateAndSetDefaults(t *testing.T) {
|
||||
endpoint := Endpoint{
|
||||
Name: "website-health",
|
||||
URL: "https://twin.sh/health",
|
||||
Conditions: []Condition{Condition("[STATUS] == 200")},
|
||||
Alerts: []*alert.Alert{{Type: alert.TypePagerDuty}},
|
||||
Name: "website-health",
|
||||
URL: "https://twin.sh/health",
|
||||
Conditions: []Condition{Condition("[STATUS] == 200")},
|
||||
Alerts: []*alert.Alert{{Type: alert.TypePagerDuty}},
|
||||
MaintenanceWindows: []*maintenance.Config{{Start: "03:50", Duration: 4 * time.Hour}},
|
||||
}
|
||||
if err := endpoint.ValidateAndSetDefaults(); err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
@@ -432,6 +434,15 @@ func TestEndpoint_ValidateAndSetDefaults(t *testing.T) {
|
||||
if endpoint.Alerts[0].FailureThreshold != 3 {
|
||||
t.Error("Endpoint alert should've defaulted to a failure threshold of 3")
|
||||
}
|
||||
if len(endpoint.MaintenanceWindows) != 1 {
|
||||
t.Error("Endpoint should've had 1 maintenance window")
|
||||
}
|
||||
if !endpoint.MaintenanceWindows[0].IsEnabled() {
|
||||
t.Error("Endpoint maintenance should've defaulted to true")
|
||||
}
|
||||
if endpoint.MaintenanceWindows[0].Timezone != "UTC" {
|
||||
t.Error("Endpoint maintenance should've defaulted to UTC")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpoint_ValidateAndSetDefaultsWithInvalidCondition(t *testing.T) {
|
||||
@@ -761,7 +772,7 @@ func TestIntegrationEvaluateHealthWithErrorAndHideURL(t *testing.T) {
|
||||
|
||||
func TestIntegrationEvaluateHealthForDNS(t *testing.T) {
|
||||
conditionSuccess := Condition("[DNS_RCODE] == NOERROR")
|
||||
conditionBody := Condition("[BODY] == 93.184.215.14")
|
||||
conditionBody := Condition("[BODY] == pat(*.*.*.*)")
|
||||
endpoint := Endpoint{
|
||||
Name: "example",
|
||||
URL: "8.8.8.8",
|
||||
|
||||
@@ -14,5 +14,6 @@ func sanitize(s string) string {
|
||||
s = strings.ReplaceAll(s, ".", "-")
|
||||
s = strings.ReplaceAll(s, ",", "-")
|
||||
s = strings.ReplaceAll(s, " ", "-")
|
||||
s = strings.ReplaceAll(s, "#", "-")
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -49,6 +49,11 @@ type Result struct {
|
||||
// Note that this field is not persisted in the storage.
|
||||
// It is used for health evaluation as well as debugging purposes.
|
||||
Body []byte `json:"-"`
|
||||
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// Below is used only for the UI and is not persisted in the storage //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
port string `yaml:"-"` // used for endpoints[].ui.hide-port
|
||||
}
|
||||
|
||||
// AddError adds an error to the result's list of errors.
|
||||
|
||||
@@ -19,6 +19,10 @@ type Config struct {
|
||||
|
||||
// Validate the SSH configuration
|
||||
func (cfg *Config) Validate() error {
|
||||
// If there's no username and password, this endpoint can still check the SSH banner, so the endpoint is still valid
|
||||
if len(cfg.Username) == 0 && len(cfg.Password) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(cfg.Username) == 0 {
|
||||
return ErrEndpointWithoutSSHUsername
|
||||
}
|
||||
|
||||
@@ -7,10 +7,8 @@ import (
|
||||
|
||||
func TestSSH_validate(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Error("expected an error")
|
||||
} else if !errors.Is(err, ErrEndpointWithoutSSHUsername) {
|
||||
t.Errorf("expected error to be '%v', got '%v'", ErrEndpointWithoutSSHUsername, err)
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Error("didn't expect an error")
|
||||
}
|
||||
cfg.Username = "username"
|
||||
if err := cfg.Validate(); err == nil {
|
||||
|
||||
@@ -13,6 +13,9 @@ type Config struct {
|
||||
// HideURL whether to ensure the URL is not displayed in the results. Useful if the URL contains a token.
|
||||
HideURL bool `yaml:"hide-url"`
|
||||
|
||||
// HidePort whether to hide the port in the Result
|
||||
HidePort bool `yaml:"hide-port"`
|
||||
|
||||
// DontResolveFailedConditions whether to resolve failed conditions in the Result for display in the UI
|
||||
DontResolveFailedConditions bool `yaml:"dont-resolve-failed-conditions"`
|
||||
|
||||
@@ -54,6 +57,7 @@ func GetDefaultConfig() *Config {
|
||||
return &Config{
|
||||
HideHostname: false,
|
||||
HideURL: false,
|
||||
HidePort: false,
|
||||
DontResolveFailedConditions: false,
|
||||
HideConditions: false,
|
||||
Badge: &Badge{
|
||||
|
||||
@@ -110,12 +110,13 @@ func (c *Config) IsUnderMaintenance() bool {
|
||||
if c.TimezoneLocation != nil {
|
||||
now = now.In(c.TimezoneLocation)
|
||||
}
|
||||
var dayWhereMaintenancePeriodWouldStart time.Time
|
||||
if now.Hour() >= int(c.durationToStartFromMidnight.Hours()) {
|
||||
dayWhereMaintenancePeriodWouldStart = now.Truncate(24 * time.Hour)
|
||||
} else {
|
||||
dayWhereMaintenancePeriodWouldStart = now.Add(-c.Duration).Truncate(24 * time.Hour)
|
||||
adjustedDate := now.Day()
|
||||
if now.Hour() < int(c.durationToStartFromMidnight.Hours()) {
|
||||
// if time in maintenance window is later than now, treat it as yesterday
|
||||
adjustedDate--
|
||||
}
|
||||
// Set to midnight prior to adding duration
|
||||
dayWhereMaintenancePeriodWouldStart := time.Date(now.Year(), now.Month(), adjustedDate, 0, 0, 0, 0, now.Location())
|
||||
hasMaintenanceEveryDay := len(c.Every) == 0
|
||||
hasMaintenancePeriodScheduledToStartOnThatWeekday := c.hasDay(dayWhereMaintenancePeriodWouldStart.Weekday().String())
|
||||
if !hasMaintenanceEveryDay && !hasMaintenancePeriodScheduledToStartOnThatWeekday {
|
||||
|
||||
@@ -177,16 +177,16 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
|
||||
yes, no := true, false
|
||||
now := time.Now().UTC()
|
||||
scenarios := []struct {
|
||||
name string
|
||||
cfg *Config
|
||||
expected bool
|
||||
name string
|
||||
cfg *Config
|
||||
expectedUnderMaintenance bool
|
||||
}{
|
||||
{
|
||||
name: "disabled",
|
||||
cfg: &Config{
|
||||
Enabled: &no,
|
||||
},
|
||||
expected: false,
|
||||
expectedUnderMaintenance: false,
|
||||
},
|
||||
{
|
||||
name: "under-maintenance-explicitly-enabled",
|
||||
@@ -195,7 +195,7 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
|
||||
Start: fmt.Sprintf("%02d:00", now.Hour()),
|
||||
Duration: 2 * time.Hour,
|
||||
},
|
||||
expected: true,
|
||||
expectedUnderMaintenance: true,
|
||||
},
|
||||
{
|
||||
name: "under-maintenance-starting-now-for-2h",
|
||||
@@ -203,7 +203,7 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
|
||||
Start: fmt.Sprintf("%02d:00", now.Hour()),
|
||||
Duration: 2 * time.Hour,
|
||||
},
|
||||
expected: true,
|
||||
expectedUnderMaintenance: true,
|
||||
},
|
||||
{
|
||||
name: "under-maintenance-starting-now-for-8h",
|
||||
@@ -211,7 +211,7 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
|
||||
Start: fmt.Sprintf("%02d:00", now.Hour()),
|
||||
Duration: 8 * time.Hour,
|
||||
},
|
||||
expected: true,
|
||||
expectedUnderMaintenance: true,
|
||||
},
|
||||
{
|
||||
name: "under-maintenance-starting-now-for-8h-explicit-days",
|
||||
@@ -220,7 +220,7 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
|
||||
Duration: 8 * time.Hour,
|
||||
Every: []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"},
|
||||
},
|
||||
expected: true,
|
||||
expectedUnderMaintenance: true,
|
||||
},
|
||||
{
|
||||
name: "under-maintenance-starting-now-for-23h-explicit-days",
|
||||
@@ -229,7 +229,7 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
|
||||
Duration: 23 * time.Hour,
|
||||
Every: []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"},
|
||||
},
|
||||
expected: true,
|
||||
expectedUnderMaintenance: true,
|
||||
},
|
||||
{
|
||||
name: "under-maintenance-starting-4h-ago-for-8h",
|
||||
@@ -237,7 +237,7 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
|
||||
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-4)),
|
||||
Duration: 8 * time.Hour,
|
||||
},
|
||||
expected: true,
|
||||
expectedUnderMaintenance: true,
|
||||
},
|
||||
{
|
||||
name: "under-maintenance-starting-22h-ago-for-23h",
|
||||
@@ -245,7 +245,7 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
|
||||
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-22)),
|
||||
Duration: 23 * time.Hour,
|
||||
},
|
||||
expected: true,
|
||||
expectedUnderMaintenance: true,
|
||||
},
|
||||
{
|
||||
name: "under-maintenance-starting-22h-ago-for-24h",
|
||||
@@ -253,59 +253,25 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
|
||||
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-22)),
|
||||
Duration: 24 * time.Hour,
|
||||
},
|
||||
expected: true,
|
||||
expectedUnderMaintenance: true,
|
||||
},
|
||||
{
|
||||
name: "under-maintenance-amsterdam-timezone-starting-now-for-2h",
|
||||
cfg: &Config{
|
||||
Start: fmt.Sprintf("%02d:00", now.Hour()),
|
||||
Start: fmt.Sprintf("%02d:00", inTimezone(now, "Europe/Amsterdam", t).Hour()),
|
||||
Duration: 2 * time.Hour,
|
||||
Timezone: "Europe/Amsterdam",
|
||||
},
|
||||
expected: true,
|
||||
expectedUnderMaintenance: true,
|
||||
},
|
||||
{
|
||||
name: "under-maintenance-utc-timezone-starting-now-for-2h",
|
||||
name: "under-maintenance-perth-timezone-starting-now-for-2h",
|
||||
cfg: &Config{
|
||||
Start: fmt.Sprintf("%02d:00", now.Hour()),
|
||||
Start: fmt.Sprintf("%02d:00", inTimezone(now, "Australia/Perth", t).Hour()),
|
||||
Duration: 2 * time.Hour,
|
||||
Timezone: "UTC",
|
||||
Timezone: "Australia/Perth",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "not-under-maintenance-starting-4h-ago-for-3h",
|
||||
cfg: &Config{
|
||||
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-4)),
|
||||
Duration: 3 * time.Hour,
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "not-under-maintenance-starting-5h-ago-for-1h",
|
||||
cfg: &Config{
|
||||
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-5)),
|
||||
Duration: time.Hour,
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "not-under-maintenance-today",
|
||||
cfg: &Config{
|
||||
Start: fmt.Sprintf("%02d:00", now.Hour()),
|
||||
Duration: time.Hour,
|
||||
Every: []string{now.Add(48 * time.Hour).Weekday().String()},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "not-under-maintenance-today-with-24h-duration",
|
||||
cfg: &Config{
|
||||
Start: fmt.Sprintf("%02d:00", now.Hour()),
|
||||
Duration: 24 * time.Hour,
|
||||
Every: []string{now.Add(48 * time.Hour).Weekday().String()},
|
||||
},
|
||||
expected: false,
|
||||
expectedUnderMaintenance: true,
|
||||
},
|
||||
{
|
||||
name: "not-under-maintenance-los-angeles-timezone-starting-now-for-2h-today",
|
||||
@@ -315,7 +281,50 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
|
||||
Timezone: "America/Los_Angeles",
|
||||
Every: []string{now.Weekday().String()},
|
||||
},
|
||||
expected: false,
|
||||
expectedUnderMaintenance: false,
|
||||
},
|
||||
{
|
||||
name: "under-maintenance-utc-timezone-starting-now-for-2h",
|
||||
cfg: &Config{
|
||||
Start: fmt.Sprintf("%02d:00", now.Hour()),
|
||||
Duration: 2 * time.Hour,
|
||||
Timezone: "UTC",
|
||||
},
|
||||
expectedUnderMaintenance: true,
|
||||
},
|
||||
{
|
||||
name: "not-under-maintenance-starting-4h-ago-for-3h",
|
||||
cfg: &Config{
|
||||
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-4)),
|
||||
Duration: 3 * time.Hour,
|
||||
},
|
||||
expectedUnderMaintenance: false,
|
||||
},
|
||||
{
|
||||
name: "not-under-maintenance-starting-5h-ago-for-1h",
|
||||
cfg: &Config{
|
||||
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-5)),
|
||||
Duration: time.Hour,
|
||||
},
|
||||
expectedUnderMaintenance: false,
|
||||
},
|
||||
{
|
||||
name: "not-under-maintenance-today",
|
||||
cfg: &Config{
|
||||
Start: fmt.Sprintf("%02d:00", now.Hour()),
|
||||
Duration: time.Hour,
|
||||
Every: []string{now.Add(48 * time.Hour).Weekday().String()},
|
||||
},
|
||||
expectedUnderMaintenance: false,
|
||||
},
|
||||
{
|
||||
name: "not-under-maintenance-today-with-24h-duration",
|
||||
cfg: &Config{
|
||||
Start: fmt.Sprintf("%02d:00", now.Hour()),
|
||||
Duration: 24 * time.Hour,
|
||||
Every: []string{now.Add(48 * time.Hour).Weekday().String()},
|
||||
},
|
||||
expectedUnderMaintenance: false,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
@@ -326,8 +335,8 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
|
||||
t.Fatal("validation shouldn't have returned an error, got", err)
|
||||
}
|
||||
isUnderMaintenance := scenario.cfg.IsUnderMaintenance()
|
||||
if isUnderMaintenance != scenario.expected {
|
||||
t.Errorf("expected %v, got %v", scenario.expected, isUnderMaintenance)
|
||||
if isUnderMaintenance != scenario.expectedUnderMaintenance {
|
||||
t.Errorf("expectedUnderMaintenance %v, got %v", scenario.expectedUnderMaintenance, isUnderMaintenance)
|
||||
t.Logf("start=%v; duration=%v; now=%v", scenario.cfg.Start, scenario.cfg.Duration, time.Now().UTC())
|
||||
}
|
||||
})
|
||||
@@ -340,3 +349,11 @@ func normalizeHour(hour int) int {
|
||||
}
|
||||
return hour
|
||||
}
|
||||
|
||||
func inTimezone(passedTime time.Time, timezone string, t *testing.T) time.Time {
|
||||
timezoneLocation, err := time.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
t.Fatalf("timezone %s did not load", timezone)
|
||||
}
|
||||
return passedTime.In(timezoneLocation)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
package remote
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/logr"
|
||||
)
|
||||
|
||||
// NOTICE: This is an experimental alpha feature and may be updated/removed in future versions.
|
||||
@@ -31,9 +30,8 @@ func (c *Config) ValidateAndSetDefaults() error {
|
||||
}
|
||||
}
|
||||
if len(c.Instances) > 0 {
|
||||
log.Println("WARNING: Your configuration is using 'remote', which is in alpha and may be updated/removed in future versions.")
|
||||
log.Println("WARNING: See https://github.com/TwiN/gatus/issues/64 for more information")
|
||||
log.Println("WARNING: This feature is a candidate for removal in future versions. Please comment on the issue above if you need this feature.")
|
||||
logr.Warn("WARNING: Your configuration is using 'remote', which is in alpha and may be updated/removed in future versions.")
|
||||
logr.Warn("WARNING: See https://github.com/TwiN/gatus/issues/64 for more information")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user