Compare commits
144 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7efe7429dd | ||
|
|
4393a49900 | ||
|
|
241956b28c | ||
|
|
a4bc3c4dfe | ||
|
|
7cf2b427c9 | ||
|
|
dc9cddfd77 | ||
|
|
f93cebe715 | ||
|
|
f54c45e20e | ||
|
|
cacfbc0185 | ||
|
|
922638e071 | ||
|
|
979d467e36 | ||
|
|
ceb2c7884f | ||
|
|
ae750aa367 | ||
|
|
5aa83ee274 | ||
|
|
1e431c797a | ||
|
|
143a093e20 | ||
|
|
1eba430797 | ||
|
|
6299b630ce | ||
|
|
1fcc6c0cc0 | ||
|
|
408a46f2af | ||
|
|
3269e96f49 | ||
|
|
08742e4af3 | ||
|
|
2a623a59d3 | ||
|
|
3d1b4e566d | ||
|
|
6cbc59b0e8 | ||
|
|
1a7aeb5b35 | ||
|
|
228cd4d1fb | ||
|
|
bdad56e205 | ||
|
|
911902353f | ||
|
|
f76be6df92 | ||
|
|
dd6c4142fb | ||
|
|
1e82d2f07d | ||
|
|
3c246f0c69 | ||
|
|
76111ee133 | ||
|
|
d3a82244a1 | ||
|
|
807599c05e | ||
|
|
de7256e671 | ||
|
|
c6515c4b1c | ||
|
|
522b958d0f | ||
|
|
16366e169e | ||
|
|
ea3ae52f1e | ||
|
|
5a16151bba | ||
|
|
802ad7ff8f | ||
|
|
619b69f480 | ||
|
|
87e029f555 | ||
|
|
71c4d3ade1 | ||
|
|
315f9b7792 | ||
|
|
bde30b2efb | ||
|
|
88cb92745b | ||
|
|
744d63abac | ||
|
|
c1cdf50851 | ||
|
|
2bd268670f | ||
|
|
e88bfa8518 | ||
|
|
0fa3c5d114 | ||
|
|
0903d28b56 | ||
|
|
b50f3f3646 | ||
|
|
1b0dfdd09d | ||
|
|
6d3468d81a | ||
|
|
200d007eca | ||
|
|
24c3a84db9 | ||
|
|
0402bdb774 | ||
|
|
c7af44bcb0 | ||
|
|
494a8594cc | ||
|
|
81dd84e5f2 | ||
|
|
aa7f8131cd | ||
|
|
4dea597726 | ||
|
|
8df77b09ed | ||
|
|
d2b274e609 | ||
|
|
15c81f93d2 | ||
|
|
05565e3d0a | ||
|
|
8fbfba2163 | ||
|
|
0e9df7f00f | ||
|
|
7d570ce148 | ||
|
|
67941865db | ||
|
|
5c5a954b68 | ||
|
|
5f69351b6b | ||
|
|
34313bec7e | ||
|
|
640e455d33 | ||
|
|
2be6a1e5f3 | ||
|
|
225e6ac7ae | ||
|
|
87d9722621 | ||
|
|
fd17dcd204 | ||
|
|
2f6b8f23f7 | ||
|
|
0a37a61619 | ||
|
|
70f9f8738c | ||
|
|
a725e7e770 | ||
|
|
fc29c45e5f | ||
|
|
6a8c308af7 | ||
|
|
0e33556775 | ||
|
|
6bb65f4eec | ||
|
|
9142ff837c | ||
|
|
11eb7fb02e | ||
|
|
271b836160 | ||
|
|
c42f3ef787 | ||
|
|
c7a774b213 | ||
|
|
36207490b2 | ||
|
|
5eebe6d9cc | ||
|
|
ecda4a9987 | ||
|
|
162dd9bc7c | ||
|
|
60b0f3fa29 | ||
|
|
0cf7621162 | ||
|
|
8d27864aaa | ||
|
|
3bf880a199 | ||
|
|
719f684982 | ||
|
|
a3df822df3 | ||
|
|
d62a6c5054 | ||
|
|
fcf8b5d86f | ||
|
|
5bbd240dd9 | ||
|
|
7e163c3fcf | ||
|
|
1dd0ecf10b | ||
|
|
3350e81443 | ||
|
|
c3e1835dd6 | ||
|
|
2a45a151da | ||
|
|
74cde8ae8d | ||
|
|
669877baf4 | ||
|
|
447e140479 | ||
|
|
6908199716 | ||
|
|
b12f652553 | ||
|
|
83edca6e80 | ||
|
|
636688b43e | ||
|
|
4fdb55d632 | ||
|
|
a05daeda2e | ||
|
|
0bd0c1fd15 | ||
|
|
eb3ca71c72 | ||
|
|
37325cd78a | ||
|
|
f6e7e346b6 | ||
|
|
b5e742acde | ||
|
|
685351a025 | ||
|
|
ee8e0c4b40 | ||
|
|
fb94eea914 | ||
|
|
a69ccfdb08 | ||
|
|
018f723e78 | ||
|
|
038c8c8d8e | ||
|
|
f8f61deb2c | ||
|
|
32a15decfd | ||
|
|
0dba6e8674 | ||
|
|
0c92534432 | ||
|
|
6ab8899dc6 | ||
|
|
819abf4263 | ||
|
|
6950a080df | ||
|
|
7d6923730e | ||
|
|
542da61215 | ||
|
|
45fe7beb6d | ||
|
|
26611b7793 |
@@ -4,4 +4,5 @@ Dockerfile
|
||||
.idea
|
||||
.git
|
||||
web/app
|
||||
*.db
|
||||
*.db
|
||||
testdata
|
||||
@@ -1,6 +1,6 @@
|
||||
storage:
|
||||
type: postgres
|
||||
path: "postgres://username:password@postgres:5432/gatus?sslmode=disable"
|
||||
path: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable"
|
||||
|
||||
endpoints:
|
||||
- name: back-end
|
||||
|
||||
@@ -18,6 +18,10 @@ services:
|
||||
restart: always
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
- POSTGRES_USER=username
|
||||
- POSTGRES_PASSWORD=password
|
||||
- POSTGRES_DB=gatus
|
||||
volumes:
|
||||
- ./config:/config
|
||||
networks:
|
||||
|
||||
BIN
.github/assets/gitlab-alerts.png
vendored
Normal file
BIN
.github/assets/gitlab-alerts.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 252 KiB |
BIN
.github/assets/gotify-alerts.png
vendored
Normal file
BIN
.github/assets/gotify-alerts.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
BIN
.github/assets/jetbrains-space-alerts.png
vendored
Normal file
BIN
.github/assets/jetbrains-space-alerts.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
6
.github/dependabot.yml
vendored
6
.github/dependabot.yml
vendored
@@ -4,12 +4,10 @@ updates:
|
||||
directory: "/"
|
||||
labels: ["dependencies"]
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "wednesday"
|
||||
interval: "daily"
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
open-pull-requests-limit: 1
|
||||
labels: ["dependencies"]
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "wednesday"
|
||||
interval: "daily"
|
||||
|
||||
12
.github/workflows/benchmark.yml
vendored
12
.github/workflows/benchmark.yml
vendored
@@ -1,5 +1,9 @@
|
||||
name: benchmark
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [publish-latest]
|
||||
branches: [master]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
repository:
|
||||
@@ -16,11 +20,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/setup-go@v3
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 1.19
|
||||
repository: "${{ github.event.inputs.repository }}"
|
||||
ref: "${{ github.event.inputs.ref }}"
|
||||
- uses: actions/checkout@v3
|
||||
repository: "${{ github.event.inputs.repository || 'TwiN/gatus' }}"
|
||||
ref: "${{ github.event.inputs.ref || 'master' }}"
|
||||
- uses: actions/checkout@v4
|
||||
- name: Benchmark
|
||||
run: go test -bench=. ./storage/store
|
||||
|
||||
10
.github/workflows/publish-experimental.yml
vendored
10
.github/workflows/publish-experimental.yml
vendored
@@ -5,20 +5,20 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Get image repository
|
||||
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
- name: Login to Docker Registry
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
platforms: linux/amd64
|
||||
pull: true
|
||||
|
||||
14
.github/workflows/publish-latest-to-ghcr.yml
vendored
14
.github/workflows/publish-latest-to-ghcr.yml
vendored
@@ -5,32 +5,32 @@ on:
|
||||
branches: [master]
|
||||
types: [completed]
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
group: ${{ github.event.workflow_run.head_repository.full_name }}::${{ github.event.workflow_run.head_branch }}::${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
publish-latest-to-ghcr:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
if: ${{ (github.event.workflow_run.conclusion == 'success') && (github.event.workflow_run.head_repository.full_name == github.repository) }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
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@v2
|
||||
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@v4
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
|
||||
pull: true
|
||||
|
||||
14
.github/workflows/publish-latest.yml
vendored
14
.github/workflows/publish-latest.yml
vendored
@@ -5,28 +5,28 @@ on:
|
||||
branches: [master]
|
||||
types: [completed]
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
group: ${{ github.event.workflow_run.head_repository.full_name }}::${{ github.event.workflow_run.head_branch }}::${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
publish-latest:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
if: ${{ (github.event.workflow_run.conclusion == 'success') && (github.event.workflow_run.head_repository.full_name == github.repository) }}
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Get image repository
|
||||
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
- name: Login to Docker Registry
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
|
||||
pull: true
|
||||
|
||||
10
.github/workflows/publish-release-to-ghcr.yml
vendored
10
.github/workflows/publish-release-to-ghcr.yml
vendored
@@ -10,23 +10,23 @@ jobs:
|
||||
packages: write
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
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@v2
|
||||
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@v4
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
|
||||
pull: true
|
||||
|
||||
10
.github/workflows/publish-release.yml
vendored
10
.github/workflows/publish-release.yml
vendored
@@ -8,22 +8,22 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Get image repository
|
||||
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
- name: Get the release
|
||||
run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV
|
||||
- name: Login to Docker Registry
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
|
||||
pull: true
|
||||
|
||||
17
.github/workflows/test.yml
vendored
17
.github/workflows/test.yml
vendored
@@ -3,23 +3,23 @@ on:
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- '*.md'
|
||||
- '.examples/*'
|
||||
- '.examples/**'
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths-ignore:
|
||||
- '*.md'
|
||||
- '.github/*'
|
||||
- '.examples/*'
|
||||
- '.github/**'
|
||||
- '.examples/**'
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/setup-go@v3
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 1.19
|
||||
- uses: actions/checkout@v3
|
||||
go-version: 1.21
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build binary to make sure it works
|
||||
run: go build
|
||||
- name: Test
|
||||
@@ -28,6 +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@v3.1.1
|
||||
uses: codecov/codecov-action@v4.3.0
|
||||
with:
|
||||
files: ./coverage.txt
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
@@ -3,6 +3,7 @@ FROM golang:alpine as builder
|
||||
RUN apk --update add ca-certificates
|
||||
WORKDIR /app
|
||||
COPY . ./
|
||||
RUN go mod tidy
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o gatus .
|
||||
|
||||
# Run Tests inside docker image if you don't have a configured go environment
|
||||
|
||||
9
Makefile
9
Makefile
@@ -1,17 +1,18 @@
|
||||
BINARY=gatus
|
||||
|
||||
# Because there's a folder called "test", we need to make the target "test" phony
|
||||
.PHONY: test
|
||||
|
||||
.PHONY: install
|
||||
install:
|
||||
go build -mod vendor -o $(BINARY) .
|
||||
go build -v -o $(BINARY) .
|
||||
|
||||
.PHONY: run
|
||||
run:
|
||||
GATUS_CONFIG_PATH=./config.yaml ./$(BINARY)
|
||||
|
||||
.PHONY: clean
|
||||
clean:
|
||||
rm $(BINARY)
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
go test ./... -cover
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ func (alert *Alert) ValidateAndSetDefaults() error {
|
||||
}
|
||||
|
||||
// GetDescription retrieves the description of the alert
|
||||
func (alert Alert) GetDescription() string {
|
||||
func (alert *Alert) GetDescription() string {
|
||||
if alert.Description == nil {
|
||||
return ""
|
||||
}
|
||||
@@ -80,7 +80,7 @@ func (alert Alert) GetDescription() string {
|
||||
|
||||
// IsEnabled returns whether an alert is enabled or not
|
||||
// Returns true if not set
|
||||
func (alert Alert) IsEnabled() bool {
|
||||
func (alert *Alert) IsEnabled() bool {
|
||||
if alert.Enabled == nil {
|
||||
return true
|
||||
}
|
||||
@@ -88,7 +88,7 @@ func (alert Alert) IsEnabled() bool {
|
||||
}
|
||||
|
||||
// IsSendingOnResolved returns whether an alert is sending on resolve or not
|
||||
func (alert Alert) IsSendingOnResolved() bool {
|
||||
func (alert *Alert) IsSendingOnResolved() bool {
|
||||
if alert.SendOnResolved == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package alert
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -38,7 +39,7 @@ func TestAlert_ValidateAndSetDefaults(t *testing.T) {
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
if err := scenario.alert.ValidateAndSetDefaults(); err != scenario.expectedError {
|
||||
if err := scenario.alert.ValidateAndSetDefaults(); !errors.Is(err, scenario.expectedError) {
|
||||
t.Errorf("expected error %v, got %v", scenario.expectedError, err)
|
||||
}
|
||||
if scenario.alert.SuccessThreshold != scenario.expectedSuccessThreshold {
|
||||
@@ -52,34 +53,34 @@ func TestAlert_ValidateAndSetDefaults(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlert_IsEnabled(t *testing.T) {
|
||||
if !(Alert{Enabled: nil}).IsEnabled() {
|
||||
if !(&Alert{Enabled: nil}).IsEnabled() {
|
||||
t.Error("alert.IsEnabled() should've returned true, because Enabled was set to nil")
|
||||
}
|
||||
if value := false; (Alert{Enabled: &value}).IsEnabled() {
|
||||
if value := false; (&Alert{Enabled: &value}).IsEnabled() {
|
||||
t.Error("alert.IsEnabled() should've returned false, because Enabled was set to false")
|
||||
}
|
||||
if value := true; !(Alert{Enabled: &value}).IsEnabled() {
|
||||
if value := true; !(&Alert{Enabled: &value}).IsEnabled() {
|
||||
t.Error("alert.IsEnabled() should've returned true, because Enabled was set to true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlert_GetDescription(t *testing.T) {
|
||||
if (Alert{Description: nil}).GetDescription() != "" {
|
||||
if (&Alert{Description: nil}).GetDescription() != "" {
|
||||
t.Error("alert.GetDescription() should've returned an empty string, because Description was set to nil")
|
||||
}
|
||||
if value := "description"; (Alert{Description: &value}).GetDescription() != value {
|
||||
if value := "description"; (&Alert{Description: &value}).GetDescription() != value {
|
||||
t.Error("alert.GetDescription() should've returned false, because Description was set to 'description'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlert_IsSendingOnResolved(t *testing.T) {
|
||||
if (Alert{SendOnResolved: nil}).IsSendingOnResolved() {
|
||||
if (&Alert{SendOnResolved: nil}).IsSendingOnResolved() {
|
||||
t.Error("alert.IsSendingOnResolved() should've returned false, because SendOnResolved was set to nil")
|
||||
}
|
||||
if value := false; (Alert{SendOnResolved: &value}).IsSendingOnResolved() {
|
||||
if value := false; (&Alert{SendOnResolved: &value}).IsSendingOnResolved() {
|
||||
t.Error("alert.IsSendingOnResolved() should've returned false, because SendOnResolved was set to false")
|
||||
}
|
||||
if value := true; !(Alert{SendOnResolved: &value}).IsSendingOnResolved() {
|
||||
if value := true; !(&Alert{SendOnResolved: &value}).IsSendingOnResolved() {
|
||||
t.Error("alert.IsSendingOnResolved() should've returned true, because SendOnResolved was set to true")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ package alert
|
||||
type Type string
|
||||
|
||||
const (
|
||||
// TypeAWSSES is the Type for the awsses alerting provider
|
||||
TypeAWSSES Type = "aws-ses"
|
||||
|
||||
// TypeCustom is the Type for the custom alerting provider
|
||||
TypeCustom Type = "custom"
|
||||
|
||||
@@ -17,9 +20,18 @@ const (
|
||||
// TypeGitHub is the Type for the github alerting provider
|
||||
TypeGitHub Type = "github"
|
||||
|
||||
// TypeGitLab is the Type for the gitlab alerting provider
|
||||
TypeGitLab Type = "gitlab"
|
||||
|
||||
// TypeGoogleChat is the Type for the googlechat alerting provider
|
||||
TypeGoogleChat Type = "googlechat"
|
||||
|
||||
// TypeGotify is the Type for the gotify alerting provider
|
||||
TypeGotify Type = "gotify"
|
||||
|
||||
// TypeJetBrainsSpace is the Type for the jetbrains alerting provider
|
||||
TypeJetBrainsSpace Type = "jetbrainsspace"
|
||||
|
||||
// TypeMatrix is the Type for the matrix alerting provider
|
||||
TypeMatrix Type = "matrix"
|
||||
|
||||
|
||||
@@ -7,11 +7,15 @@ import (
|
||||
|
||||
"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/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"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
|
||||
@@ -27,6 +31,9 @@ import (
|
||||
|
||||
// Config is the configuration for alerting providers
|
||||
type Config struct {
|
||||
// AWSSimpleEmailService is the configuration for the aws-ses alerting provider
|
||||
AWSSimpleEmailService *awsses.AlertProvider `yaml:"aws-ses,omitempty"`
|
||||
|
||||
// Custom is the configuration for the custom alerting provider
|
||||
Custom *custom.AlertProvider `yaml:"custom,omitempty"`
|
||||
|
||||
@@ -39,9 +46,18 @@ type Config struct {
|
||||
// GitHub is the configuration for the github alerting provider
|
||||
GitHub *github.AlertProvider `yaml:"github,omitempty"`
|
||||
|
||||
// GitLab is the configuration for the gitlab alerting provider
|
||||
GitLab *gitlab.AlertProvider `yaml:"gitlab,omitempty"`
|
||||
|
||||
// GoogleChat is the configuration for the googlechat alerting provider
|
||||
GoogleChat *googlechat.AlertProvider `yaml:"googlechat,omitempty"`
|
||||
|
||||
// Gotify is the configuration for the gotify alerting provider
|
||||
Gotify *gotify.AlertProvider `yaml:"gotify,omitempty"`
|
||||
|
||||
// JetBrainsSpace is the configuration for the jetbrains space alerting provider
|
||||
JetBrainsSpace *jetbrainsspace.AlertProvider `yaml:"jetbrainsspace,omitempty"`
|
||||
|
||||
// Matrix is the configuration for the matrix alerting provider
|
||||
Matrix *matrix.AlertProvider `yaml:"matrix,omitempty"`
|
||||
|
||||
@@ -81,7 +97,8 @@ func (config *Config) GetAlertingProviderByAlertType(alertType alert.Type) provi
|
||||
entityType := reflect.TypeOf(config).Elem()
|
||||
for i := 0; i < entityType.NumField(); i++ {
|
||||
field := entityType.Field(i)
|
||||
if strings.ToLower(field.Name) == string(alertType) {
|
||||
tag := strings.Split(field.Tag.Get("yaml"), ",")[0]
|
||||
if tag == string(alertType) {
|
||||
fieldValue := reflect.ValueOf(config).Elem().Field(i)
|
||||
if fieldValue.IsNil() {
|
||||
return nil
|
||||
@@ -89,7 +106,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)
|
||||
log.Printf("[alerting.GetAlertingProviderByAlertType] No alerting provider found for alert type %s", alertType)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
167
alerting/provider/awsses/awsses.go
Normal file
167
alerting/provider/awsses/awsses.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package awsses
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/core"
|
||||
"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"
|
||||
)
|
||||
|
||||
const (
|
||||
CharSet = "UTF-8"
|
||||
)
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using AWS Simple Email Service
|
||||
type AlertProvider 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"`
|
||||
|
||||
// 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"`
|
||||
To string `yaml:"to"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.To) == 0 {
|
||||
return false
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||
sess, err := provider.createSession()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
svc := ses.New(sess)
|
||||
subject, body := provider.buildMessageSubjectAndBody(endpoint, alert, result, resolved)
|
||||
emails := strings.Split(provider.getToForGroup(endpoint.Group), ",")
|
||||
|
||||
input := &ses.SendEmailInput{
|
||||
Destination: &ses.Destination{
|
||||
ToAddresses: aws.StringSlice(emails),
|
||||
},
|
||||
Message: &ses.Message{
|
||||
Body: &ses.Body{
|
||||
Text: &ses.Content{
|
||||
Charset: aws.String(CharSet),
|
||||
Data: aws.String(body),
|
||||
},
|
||||
},
|
||||
Subject: &ses.Content{
|
||||
Charset: aws.String(CharSet),
|
||||
Data: aws.String(subject),
|
||||
},
|
||||
},
|
||||
Source: aws.String(provider.From),
|
||||
}
|
||||
_, err = svc.SendEmail(input)
|
||||
|
||||
if err != nil {
|
||||
if aerr, ok := err.(awserr.Error); ok {
|
||||
switch aerr.Code() {
|
||||
case ses.ErrCodeMessageRejected:
|
||||
fmt.Println(ses.ErrCodeMessageRejected, aerr.Error())
|
||||
case ses.ErrCodeMailFromDomainNotVerifiedException:
|
||||
fmt.Println(ses.ErrCodeMailFromDomainNotVerifiedException, aerr.Error())
|
||||
case ses.ErrCodeConfigurationSetDoesNotExistException:
|
||||
fmt.Println(ses.ErrCodeConfigurationSetDoesNotExistException, aerr.Error())
|
||||
default:
|
||||
fmt.Println(aerr.Error())
|
||||
}
|
||||
} else {
|
||||
// Print the error, cast err to awserr.Error to get the Code and
|
||||
// Message from an error.
|
||||
fmt.Println(err.Error())
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildMessageSubjectAndBody builds the message subject and body
|
||||
func (provider *AlertProvider) buildMessageSubjectAndBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) (string, string) {
|
||||
var subject, message string
|
||||
if resolved {
|
||||
subject = fmt.Sprintf("[%s] Alert resolved", endpoint.DisplayName())
|
||||
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
|
||||
} else {
|
||||
subject = fmt.Sprintf("[%s] Alert triggered", endpoint.DisplayName())
|
||||
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
||||
}
|
||||
var formattedConditionResults string
|
||||
if len(result.ConditionResults) > 0 {
|
||||
formattedConditionResults = "\n\nCondition results:\n"
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
prefix = "✅"
|
||||
} else {
|
||||
prefix = "❌"
|
||||
}
|
||||
formattedConditionResults += fmt.Sprintf("%s %s\n", prefix, conditionResult.Condition)
|
||||
}
|
||||
}
|
||||
var description string
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description = "\n\nAlert description: " + alertDescription
|
||||
}
|
||||
return subject, message + description + formattedConditionResults
|
||||
}
|
||||
|
||||
// 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),
|
||||
}
|
||||
if len(provider.AccessKeyID) > 0 && len(provider.SecretAccessKey) > 0 {
|
||||
config.Credentials = credentials.NewStaticCredentials(provider.AccessKeyID, provider.SecretAccessKey, "")
|
||||
}
|
||||
return session.NewSession(config)
|
||||
}
|
||||
188
alerting/provider/awsses/awsses_test.go
Normal file
188
alerting/provider/awsses/awsses_test.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package awsses
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/core"
|
||||
)
|
||||
|
||||
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
||||
invalidProvider := AlertProvider{}
|
||||
if invalidProvider.IsValid() {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
invalidProviderWithOneKey := AlertProvider{From: "from@example.com", To: "to@example.com", AccessKeyID: "1"}
|
||||
if invalidProviderWithOneKey.IsValid() {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{From: "from@example.com", To: "to@example.com"}
|
||||
if !validProvider.IsValid() {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
validProviderWithKeys := AlertProvider{From: "from@example.com", To: "to@example.com", AccessKeyID: "1", SecretAccessKey: "1"}
|
||||
if !validProviderWithKeys.IsValid() {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
||||
providerWithInvalidOverrideGroup := AlertProvider{
|
||||
Overrides: []Override{
|
||||
{
|
||||
To: "to@example.com",
|
||||
Group: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
if providerWithInvalidOverrideGroup.IsValid() {
|
||||
t.Error("provider Group shouldn't have been valid")
|
||||
}
|
||||
providerWithInvalidOverrideTo := AlertProvider{
|
||||
Overrides: []Override{
|
||||
{
|
||||
To: "",
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if providerWithInvalidOverrideTo.IsValid() {
|
||||
t.Error("provider integration key shouldn't have been valid")
|
||||
}
|
||||
providerWithValidOverride := AlertProvider{
|
||||
From: "from@example.com",
|
||||
To: "to@example.com",
|
||||
Overrides: []Override{
|
||||
{
|
||||
To: "to@example.com",
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if !providerWithValidOverride.IsValid() {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
ExpectedSubject string
|
||||
ExpectedBody string
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedSubject: "[endpoint-name] Alert triggered",
|
||||
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedSubject: "[endpoint-name] Alert resolved",
|
||||
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
subject, body := scenario.Provider.buildMessageSubjectAndBody(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if subject != scenario.ExpectedSubject {
|
||||
t.Errorf("expected subject to be %s, got %s", scenario.ExpectedSubject, subject)
|
||||
}
|
||||
if body != scenario.ExpectedBody {
|
||||
t.Errorf("expected body to be %s, got %s", scenario.ExpectedBody, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_getToForGroup(t *testing.T) {
|
||||
tests := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
InputGroup string
|
||||
ExpectedOutput string
|
||||
}{
|
||||
{
|
||||
Name: "provider-no-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
To: "to@example.com",
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "",
|
||||
ExpectedOutput: "to@example.com",
|
||||
},
|
||||
{
|
||||
Name: "provider-no-override-specify-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
To: "to@example.com",
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "group",
|
||||
ExpectedOutput: "to@example.com",
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
To: "to@example.com",
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
To: "to01@example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "",
|
||||
ExpectedOutput: "to@example.com",
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-group-should-override",
|
||||
Provider: AlertProvider{
|
||||
To: "to@example.com",
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
To: "to01@example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "group",
|
||||
ExpectedOutput: "to01@example.com",
|
||||
},
|
||||
}
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,6 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
@@ -217,10 +217,10 @@ func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ type AlertProvider struct {
|
||||
|
||||
// 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
|
||||
@@ -72,7 +75,7 @@ type Embed struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Color int `json:"color"`
|
||||
Fields []Field `json:"fields"`
|
||||
Fields []Field `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
type Field struct {
|
||||
@@ -83,7 +86,7 @@ type Field struct {
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
|
||||
var message, results string
|
||||
var message string
|
||||
var colorCode int
|
||||
if resolved {
|
||||
message = fmt.Sprintf("An alert for **%s** has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
|
||||
@@ -92,6 +95,7 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
|
||||
message = fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
||||
colorCode = 15158332
|
||||
}
|
||||
var formattedConditionResults string
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
@@ -99,30 +103,35 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
|
||||
} else {
|
||||
prefix = ":x:"
|
||||
}
|
||||
results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
||||
formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
||||
}
|
||||
var description string
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description = ":\n> " + alertDescription
|
||||
}
|
||||
body, _ := json.Marshal(Body{
|
||||
title := ":helmet_with_white_cross: Gatus"
|
||||
if provider.Title != "" {
|
||||
title = provider.Title
|
||||
}
|
||||
body := Body{
|
||||
Content: "",
|
||||
Embeds: []Embed{
|
||||
{
|
||||
Title: ":helmet_with_white_cross: Gatus",
|
||||
Title: title,
|
||||
Description: message + description,
|
||||
Color: colorCode,
|
||||
Fields: []Field{
|
||||
{
|
||||
Name: "Condition results",
|
||||
Value: results,
|
||||
Inline: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return body
|
||||
}
|
||||
if len(formattedConditionResults) > 0 {
|
||||
body.Embeds[0].Fields = append(body.Embeds[0].Fields, Field{
|
||||
Name: "Condition results",
|
||||
Value: formattedConditionResults,
|
||||
Inline: false,
|
||||
})
|
||||
}
|
||||
bodyAsJSON, _ := json.Marshal(body)
|
||||
return bodyAsJSON
|
||||
}
|
||||
|
||||
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
|
||||
@@ -138,6 +147,6 @@ func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
title := "provider-title"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
@@ -111,6 +112,16 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "triggered-with-modified-title",
|
||||
Provider: AlertProvider{Title: title},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
@@ -139,10 +150,12 @@ func TestAlertProvider_Send(t *testing.T) {
|
||||
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
title := "provider-title"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
NoConditions bool
|
||||
Resolved bool
|
||||
ExpectedBody string
|
||||
}{
|
||||
@@ -160,18 +173,37 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"description\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"color\":3066993,\"fields\":[{\"name\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n:white_check_mark: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}",
|
||||
},
|
||||
{
|
||||
Name: "triggered-with-modified-title",
|
||||
Provider: AlertProvider{Title: title},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\"provider-title\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332,\"fields\":[{\"name\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n:x: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}",
|
||||
},
|
||||
{
|
||||
Name: "triggered-with-no-conditions",
|
||||
NoConditions: true,
|
||||
Provider: AlertProvider{Title: title},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\"provider-title\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332}]}",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
var conditionResults []*core.ConditionResult
|
||||
if !scenario.NoConditions {
|
||||
conditionResults = []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
{Condition: "[BODY] != \"\"", Success: scenario.Resolved},
|
||||
}
|
||||
}
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
{Condition: "[BODY] != \"\"", Success: scenario.Resolved},
|
||||
},
|
||||
ConditionResults: conditionResults,
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
@@ -187,10 +219,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/core"
|
||||
gomail "gopkg.in/mail.v2"
|
||||
)
|
||||
@@ -19,6 +21,9 @@ type AlertProvider struct {
|
||||
Port int `yaml:"port"`
|
||||
To string `yaml:"to"`
|
||||
|
||||
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
||||
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
|
||||
@@ -44,7 +49,7 @@ func (provider *AlertProvider) IsValid() bool {
|
||||
}
|
||||
}
|
||||
|
||||
return len(provider.From) > 0 && len(provider.Password) > 0 && len(provider.Host) > 0 && len(provider.To) > 0 && provider.Port > 0 && provider.Port < math.MaxUint16
|
||||
return len(provider.From) > 0 && len(provider.Host) > 0 && len(provider.To) > 0 && provider.Port > 0 && provider.Port < math.MaxUint16
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
@@ -61,13 +66,29 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
|
||||
m.SetHeader("To", strings.Split(provider.getToForGroup(endpoint.Group), ",")...)
|
||||
m.SetHeader("Subject", subject)
|
||||
m.SetBody("text/plain", body)
|
||||
d := gomail.NewDialer(provider.Host, provider.Port, username, provider.Password)
|
||||
var d *gomail.Dialer
|
||||
if len(provider.Password) == 0 {
|
||||
// Get the domain in the From address
|
||||
localName := "localhost"
|
||||
fromParts := strings.Split(provider.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}
|
||||
} else {
|
||||
// Create an authenticated dialer
|
||||
d = gomail.NewDialer(provider.Host, provider.Port, username, provider.Password)
|
||||
}
|
||||
if provider.ClientConfig != nil && provider.ClientConfig.Insecure {
|
||||
d.TLSConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
}
|
||||
return d.DialAndSend(m)
|
||||
}
|
||||
|
||||
// buildMessageSubjectAndBody builds the message subject and body
|
||||
func (provider *AlertProvider) buildMessageSubjectAndBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) (string, string) {
|
||||
var subject, message, results string
|
||||
var subject, message string
|
||||
if resolved {
|
||||
subject = fmt.Sprintf("[%s] Alert resolved", endpoint.DisplayName())
|
||||
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
|
||||
@@ -75,20 +96,24 @@ func (provider *AlertProvider) buildMessageSubjectAndBody(endpoint *core.Endpoin
|
||||
subject = fmt.Sprintf("[%s] Alert triggered", endpoint.DisplayName())
|
||||
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
||||
}
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
prefix = "✅"
|
||||
} else {
|
||||
prefix = "❌"
|
||||
var formattedConditionResults string
|
||||
if len(result.ConditionResults) > 0 {
|
||||
formattedConditionResults = "\n\nCondition results:\n"
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
prefix = "✅"
|
||||
} else {
|
||||
prefix = "❌"
|
||||
}
|
||||
formattedConditionResults += fmt.Sprintf("%s %s\n", prefix, conditionResult.Condition)
|
||||
}
|
||||
results += fmt.Sprintf("%s %s\n", prefix, conditionResult.Condition)
|
||||
}
|
||||
var description string
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description = "\n\nAlert description: " + alertDescription
|
||||
}
|
||||
return subject, message + description + "\n\nCondition results:\n" + results
|
||||
return subject, message + description + formattedConditionResults
|
||||
}
|
||||
|
||||
// getToForGroup returns the appropriate email integration to for a given group
|
||||
@@ -104,6 +129,6 @@ func (provider *AlertProvider) getToForGroup(group string) string {
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
@@ -18,6 +18,13 @@ func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
||||
providerWithInvalidOverrideGroup := AlertProvider{
|
||||
Overrides: []Override{
|
||||
@@ -111,10 +118,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,25 +105,28 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
|
||||
|
||||
// buildIssueBody builds the body of the issue
|
||||
func (provider *AlertProvider) buildIssueBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result) string {
|
||||
var results string
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
prefix = ":white_check_mark:"
|
||||
} else {
|
||||
prefix = ":x:"
|
||||
var formattedConditionResults string
|
||||
if len(result.ConditionResults) > 0 {
|
||||
formattedConditionResults = "\n\n## Condition results\n"
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
prefix = ":white_check_mark:"
|
||||
} else {
|
||||
prefix = ":x:"
|
||||
}
|
||||
formattedConditionResults += fmt.Sprintf("- %s - `%s`\n", prefix, conditionResult.Condition)
|
||||
}
|
||||
results += fmt.Sprintf("- %s - `%s`\n", prefix, conditionResult.Condition)
|
||||
}
|
||||
var description string
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description = ":\n> " + alertDescription
|
||||
}
|
||||
message := fmt.Sprintf("An alert for **%s** has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
||||
return message + description + "\n\n## Condition results\n" + results
|
||||
return message + description + formattedConditionResults
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Endpoint core.Endpoint
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
NoConditions bool
|
||||
ExpectedBody string
|
||||
}{
|
||||
{
|
||||
@@ -122,24 +123,34 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\n> description-1\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`",
|
||||
},
|
||||
{
|
||||
Name: "no-description",
|
||||
Name: "triggered-with-no-description",
|
||||
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{FailureThreshold: 10},
|
||||
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row\n\n## Condition results\n- :white_check_mark: - `[CONNECTED] == true`\n- :x: - `[STATUS] == 200`",
|
||||
},
|
||||
{
|
||||
Name: "triggered-with-no-conditions",
|
||||
NoConditions: true,
|
||||
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 10},
|
||||
ExpectedBody: "An alert for **endpoint-name** has been triggered due to having failed 10 time(s) in a row:\n> description-1",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
var conditionResults []*core.ConditionResult
|
||||
if !scenario.NoConditions {
|
||||
conditionResults = []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: true},
|
||||
{Condition: "[STATUS] == 200", Success: false},
|
||||
}
|
||||
}
|
||||
body := scenario.Provider.buildIssueBody(
|
||||
&scenario.Endpoint,
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: true},
|
||||
{Condition: "[STATUS] == 200", Success: false},
|
||||
},
|
||||
},
|
||||
&core.Result{ConditionResults: conditionResults},
|
||||
)
|
||||
if strings.TrimSpace(body) != strings.TrimSpace(scenario.ExpectedBody) {
|
||||
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
||||
@@ -149,10 +160,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
150
alerting/provider/gitlab/gitlab.go
Normal file
150
alerting/provider/gitlab/gitlab.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package gitlab
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/core"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 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
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
|
||||
// or closes the relevant issue(s) if the resolved parameter passed is true.
|
||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||
if len(alert.ResolveKey) == 0 {
|
||||
alert.ResolveKey = uuid.NewString()
|
||||
}
|
||||
buffer := bytes.NewBuffer(provider.buildAlertBody(endpoint, alert, result, resolved))
|
||||
request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", provider.AuthorizationKey))
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode > 399 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type AlertBody struct {
|
||||
Title string `json:"title,omitempty"` // The title of the alert.
|
||||
Description string `json:"description,omitempty"` // A high-level summary of the problem.
|
||||
StartTime string `json:"start_time,omitempty"` // The time of the alert. If none is provided, a current time is used.
|
||||
EndTime string `json:"end_time,omitempty"` // The resolution time of the alert. If provided, the alert is resolved.
|
||||
Service string `json:"service,omitempty"` // The affected service.
|
||||
MonitoringTool string `json:"monitoring_tool,omitempty"` // The name of the associated monitoring tool.
|
||||
Hosts string `json:"hosts,omitempty"` // One or more hosts, as to where this incident occurred.
|
||||
Severity string `json:"severity,omitempty"` // The severity of the alert. Case-insensitive. Can be one of: critical, high, medium, low, info, unknown. Defaults to critical if missing or value is not in this list.
|
||||
Fingerprint string `json:"fingerprint,omitempty"` // The unique identifier of the alert. This can be used to group occurrences of the same alert.
|
||||
GitlabEnvironmentName string `json:"gitlab_environment_name,omitempty"` // The name of the associated GitLab environment. Required to display alerts on a dashboard.
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) monitoringTool() string {
|
||||
if len(provider.MonitoringTool) > 0 {
|
||||
return provider.MonitoringTool
|
||||
}
|
||||
return "gatus"
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) service(endpoint *core.Endpoint) string {
|
||||
if len(provider.Service) > 0 {
|
||||
return provider.Service
|
||||
}
|
||||
return endpoint.DisplayName()
|
||||
}
|
||||
|
||||
// buildAlertBody builds the body of the alert
|
||||
func (provider *AlertProvider) buildAlertBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
|
||||
body := AlertBody{
|
||||
Title: fmt.Sprintf("alert(%s): %s", provider.monitoringTool(), provider.service(endpoint)),
|
||||
StartTime: result.Timestamp.Format(time.RFC3339),
|
||||
Service: provider.service(endpoint),
|
||||
MonitoringTool: provider.monitoringTool(),
|
||||
Hosts: endpoint.URL,
|
||||
GitlabEnvironmentName: provider.EnvironmentName,
|
||||
Severity: provider.Severity,
|
||||
Fingerprint: alert.ResolveKey,
|
||||
}
|
||||
if resolved {
|
||||
body.EndTime = result.Timestamp.Format(time.RFC3339)
|
||||
}
|
||||
var formattedConditionResults string
|
||||
if len(result.ConditionResults) > 0 {
|
||||
formattedConditionResults = "\n\n## Condition results\n"
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
prefix = ":white_check_mark:"
|
||||
} else {
|
||||
prefix = ":x:"
|
||||
}
|
||||
formattedConditionResults += fmt.Sprintf("- %s - `%s`\n", prefix, conditionResult.Condition)
|
||||
}
|
||||
}
|
||||
var description string
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description = ":\n> " + alertDescription
|
||||
}
|
||||
var message string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
|
||||
} else {
|
||||
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
||||
}
|
||||
body.Description = message + description + formattedConditionResults
|
||||
bodyAsJSON, _ := json.Marshal(body)
|
||||
return bodyAsJSON
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
158
alerting/provider/gitlab/gitlab_test.go
Normal file
158
alerting/provider/gitlab/gitlab_test.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package gitlab
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/core"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Expected bool
|
||||
}{
|
||||
{
|
||||
Name: "invalid",
|
||||
Provider: AlertProvider{WebhookURL: "", AuthorizationKey: ""},
|
||||
Expected: false,
|
||||
},
|
||||
{
|
||||
Name: "missing-webhook-url",
|
||||
Provider: AlertProvider{WebhookURL: "", AuthorizationKey: "12345"},
|
||||
Expected: false,
|
||||
},
|
||||
{
|
||||
Name: "missing-authorization-key",
|
||||
Provider: AlertProvider{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: ""},
|
||||
Expected: false,
|
||||
},
|
||||
{
|
||||
Name: "invalid-url",
|
||||
Provider: AlertProvider{WebhookURL: " http://foo.com", AuthorizationKey: "12345"},
|
||||
Expected: false,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
if scenario.Provider.IsValid() != scenario.Expected {
|
||||
t.Errorf("expected %t, got %t", scenario.Expected, scenario.Provider.IsValid())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
MockRoundTripper test.MockRoundTripper
|
||||
ExpectedError bool
|
||||
}{
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedError: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
},
|
||||
{
|
||||
Name: "resolved-error",
|
||||
Provider: AlertProvider{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedError: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
err := scenario.Provider.Send(
|
||||
&core.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildAlertBody(t *testing.T) {
|
||||
firstDescription := "description-1"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Endpoint core.Endpoint
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
ExpectedBody string
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3},
|
||||
ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\"}",
|
||||
},
|
||||
{
|
||||
Name: "no-description",
|
||||
Endpoint: core.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{FailureThreshold: 10},
|
||||
ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 10 time(s) in a row\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\"}",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
body := scenario.Provider.buildAlertBody(
|
||||
&scenario.Endpoint,
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: true},
|
||||
{Condition: "[STATUS] == 200", Success: false},
|
||||
},
|
||||
},
|
||||
false,
|
||||
)
|
||||
if strings.TrimSpace(string(body)) != strings.TrimSpace(scenario.ExpectedBody) {
|
||||
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
@@ -121,7 +121,7 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
|
||||
color = "#DD0000"
|
||||
message = fmt.Sprintf("<font color='%s'>An alert has been triggered due to having failed %d time(s) in a row</font>", color, alert.FailureThreshold)
|
||||
}
|
||||
var results string
|
||||
var formattedConditionResults string
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
@@ -129,7 +129,7 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
|
||||
} else {
|
||||
prefix = "❌"
|
||||
}
|
||||
results += fmt.Sprintf("%s %s<br>", prefix, conditionResult.Condition)
|
||||
formattedConditionResults += fmt.Sprintf("%s %s<br>", prefix, conditionResult.Condition)
|
||||
}
|
||||
var description string
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
@@ -150,20 +150,22 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
|
||||
Icon: "BOOKMARK",
|
||||
},
|
||||
},
|
||||
{
|
||||
KeyValue: &KeyValue{
|
||||
TopLabel: "Condition results",
|
||||
Content: results,
|
||||
ContentMultiline: "true",
|
||||
Icon: "DESCRIPTION",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if len(formattedConditionResults) > 0 {
|
||||
payload.Cards[0].Sections[0].Widgets = append(payload.Cards[0].Sections[0].Widgets, Widgets{
|
||||
KeyValue: &KeyValue{
|
||||
TopLabel: "Condition results",
|
||||
Content: formattedConditionResults,
|
||||
ContentMultiline: "true",
|
||||
Icon: "DESCRIPTION",
|
||||
},
|
||||
})
|
||||
}
|
||||
if endpoint.Type() == core.EndpointTypeHTTP {
|
||||
// We only include a button targeting the URL if the endpoint is an HTTP endpoint
|
||||
// If the URL isn't prefixed with https://, Google Chat will just display a blank message aynways.
|
||||
@@ -179,8 +181,8 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
|
||||
},
|
||||
})
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
return body
|
||||
bodyAsJSON, _ := json.Marshal(payload)
|
||||
return bodyAsJSON
|
||||
}
|
||||
|
||||
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
|
||||
@@ -196,6 +198,6 @@ func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
@@ -205,10 +205,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
106
alerting/provider/gotify/gotify.go
Normal file
106
alerting/provider/gotify/gotify.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package gotify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/core"
|
||||
)
|
||||
|
||||
const DefaultPriority = 5
|
||||
|
||||
// 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
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
|
||||
request, err := http.NewRequest(http.MethodPost, provider.ServerURL+"/message?token="+provider.Token, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode > 399 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("failed to send alert to Gotify: %s", string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Body struct {
|
||||
Message string `json:"message"`
|
||||
Title string `json:"title"`
|
||||
Priority int `json:"priority"`
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
|
||||
var message string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("An alert for `%s` has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
|
||||
} else {
|
||||
message = fmt.Sprintf("An alert for `%s` has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
||||
}
|
||||
var formattedConditionResults string
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
prefix = "✓"
|
||||
} else {
|
||||
prefix = "✕"
|
||||
}
|
||||
formattedConditionResults += fmt.Sprintf("\n%s - %s", prefix, conditionResult.Condition)
|
||||
}
|
||||
if len(alert.GetDescription()) > 0 {
|
||||
message += " with the following description: " + alert.GetDescription()
|
||||
}
|
||||
message += formattedConditionResults
|
||||
title := "Gatus: " + endpoint.DisplayName()
|
||||
if provider.Title != "" {
|
||||
title = provider.Title
|
||||
}
|
||||
bodyAsJSON, _ := json.Marshal(Body{
|
||||
Message: message,
|
||||
Title: title,
|
||||
Priority: provider.Priority,
|
||||
})
|
||||
return bodyAsJSON
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
105
alerting/provider/gotify/gotify_test.go
Normal file
105
alerting/provider/gotify/gotify_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package gotify
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/core"
|
||||
)
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "invalid-server-url",
|
||||
provider: AlertProvider{ServerURL: "", Token: "faketoken"},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "invalid-app-token",
|
||||
provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: ""},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "no-priority-should-use-default-value",
|
||||
provider: AlertProvider{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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
var (
|
||||
description = "custom-description"
|
||||
//title = "custom-title"
|
||||
endpoint = "custom-endpoint"
|
||||
)
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
ExpectedBody string
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"},
|
||||
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been triggered due to having failed 3 time(s) in a row with the following description: %s\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpoint, description),
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"},
|
||||
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been resolved after passing successfully 5 time(s) in a row with the following description: %s\\n✓ - [CONNECTED] == true\\n✓ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpoint, description),
|
||||
},
|
||||
{
|
||||
Name: "custom-title",
|
||||
Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken", Title: "custom-title"},
|
||||
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been triggered due to having failed 3 time(s) in a row with the following description: %s\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"title\":\"custom-title\",\"priority\":0}", endpoint, description),
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
&core.Endpoint{Name: endpoint},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
162
alerting/provider/jetbrainsspace/jetbrainsspace.go
Normal file
162
alerting/provider/jetbrainsspace/jetbrainsspace.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package jetbrainsspace
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/core"
|
||||
)
|
||||
|
||||
// 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
|
||||
|
||||
// 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"`
|
||||
ChannelID string `yaml:"channel-id"`
|
||||
}
|
||||
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
func (provider *AlertProvider) IsValid() bool {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.ChannelID) == 0 {
|
||||
return false
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return len(provider.Project) > 0 && len(provider.ChannelID) > 0 && len(provider.Token) > 0
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
|
||||
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
|
||||
url := fmt.Sprintf("https://%s.jetbrains.space/api/http/chats/messages/send-message", provider.Project)
|
||||
request, err := http.NewRequest(http.MethodPost, url, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", "Bearer "+provider.Token)
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode > 399 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type Body struct {
|
||||
Channel string `json:"channel"`
|
||||
Content Content `json:"content"`
|
||||
}
|
||||
|
||||
type Content struct {
|
||||
ClassName string `json:"className"`
|
||||
Style string `json:"style"`
|
||||
Sections []Section `json:"sections,omitempty"`
|
||||
}
|
||||
|
||||
type Section struct {
|
||||
ClassName string `json:"className"`
|
||||
Elements []Element `json:"elements"`
|
||||
Header string `json:"header"`
|
||||
}
|
||||
|
||||
type Element struct {
|
||||
ClassName string `json:"className"`
|
||||
Accessory Accessory `json:"accessory"`
|
||||
Style string `json:"style"`
|
||||
Size string `json:"size"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type Accessory struct {
|
||||
ClassName string `json:"className"`
|
||||
Icon Icon `json:"icon"`
|
||||
Style string `json:"style"`
|
||||
}
|
||||
|
||||
type Icon struct {
|
||||
Icon string `json:"icon"`
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
|
||||
body := Body{
|
||||
Channel: "id:" + provider.getChannelIDForGroup(endpoint.Group),
|
||||
Content: Content{
|
||||
ClassName: "ChatMessage.Block",
|
||||
Sections: []Section{{
|
||||
ClassName: "MessageSection",
|
||||
Elements: []Element{},
|
||||
}},
|
||||
},
|
||||
}
|
||||
if resolved {
|
||||
body.Content.Style = "SUCCESS"
|
||||
body.Content.Sections[0].Header = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
|
||||
} else {
|
||||
body.Content.Style = "WARNING"
|
||||
body.Content.Sections[0].Header = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
||||
}
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
icon := "warning"
|
||||
style := "WARNING"
|
||||
if conditionResult.Success {
|
||||
icon = "success"
|
||||
style = "SUCCESS"
|
||||
}
|
||||
body.Content.Sections[0].Elements = append(body.Content.Sections[0].Elements, Element{
|
||||
ClassName: "MessageText",
|
||||
Accessory: Accessory{
|
||||
ClassName: "MessageIcon",
|
||||
Icon: Icon{Icon: icon},
|
||||
Style: style,
|
||||
},
|
||||
Style: style,
|
||||
Size: "REGULAR",
|
||||
Content: conditionResult.Condition,
|
||||
})
|
||||
}
|
||||
bodyAsJSON, _ := json.Marshal(body)
|
||||
return bodyAsJSON
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
279
alerting/provider/jetbrainsspace/jetbrainsspace_test.go
Normal file
279
alerting/provider/jetbrainsspace/jetbrainsspace_test.go
Normal file
@@ -0,0 +1,279 @@
|
||||
package jetbrainsspace
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/core"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
||||
invalidProvider := AlertProvider{Project: ""}
|
||||
if invalidProvider.IsValid() {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{Project: "foo", ChannelID: "bar", Token: "baz"}
|
||||
if !validProvider.IsValid() {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
||||
providerWithInvalidOverrideGroup := AlertProvider{
|
||||
Project: "foobar",
|
||||
Overrides: []Override{
|
||||
{
|
||||
ChannelID: "http://example.com",
|
||||
Group: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
if providerWithInvalidOverrideGroup.IsValid() {
|
||||
t.Error("provider Group shouldn't have been valid")
|
||||
}
|
||||
providerWithInvalidOverrideTo := AlertProvider{
|
||||
Project: "foobar",
|
||||
Overrides: []Override{
|
||||
{
|
||||
ChannelID: "",
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if providerWithInvalidOverrideTo.IsValid() {
|
||||
t.Error("provider integration key shouldn't have been valid")
|
||||
}
|
||||
providerWithValidOverride := AlertProvider{
|
||||
Project: "foo",
|
||||
ChannelID: "bar",
|
||||
Token: "baz",
|
||||
Overrides: []Override{
|
||||
{
|
||||
ChannelID: "foobar",
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if !providerWithValidOverride.IsValid() {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
MockRoundTripper test.MockRoundTripper
|
||||
ExpectedError bool
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "resolved-error",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
err := scenario.Provider.Send(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Endpoint core.Endpoint
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
ExpectedBody string
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Endpoint: core.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"}]}}`,
|
||||
},
|
||||
{
|
||||
Name: "triggered-with-group",
|
||||
Provider: AlertProvider{},
|
||||
Endpoint: core.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"}]}}`,
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Endpoint: core.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"}]}}`,
|
||||
},
|
||||
{
|
||||
Name: "resolved-with-group",
|
||||
Provider: AlertProvider{},
|
||||
Endpoint: core.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"}]}}`,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
&scenario.Endpoint,
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.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_getChannelIDForGroup(t *testing.T) {
|
||||
tests := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
InputGroup string
|
||||
ExpectedOutput string
|
||||
}{
|
||||
{
|
||||
Name: "provider-no-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
ChannelID: "bar",
|
||||
},
|
||||
InputGroup: "",
|
||||
ExpectedOutput: "bar",
|
||||
},
|
||||
{
|
||||
Name: "provider-no-override-specify-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
ChannelID: "bar",
|
||||
},
|
||||
InputGroup: "group",
|
||||
ExpectedOutput: "bar",
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
ChannelID: "bar",
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
ChannelID: "foobar",
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "",
|
||||
ExpectedOutput: "bar",
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-group-should-override",
|
||||
Provider: AlertProvider{
|
||||
ChannelID: "bar",
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
ChannelID: "foobar",
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "group",
|
||||
ExpectedOutput: "foobar",
|
||||
},
|
||||
}
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Matrix
|
||||
type AlertProvider struct {
|
||||
MatrixProviderConfig `yaml:",inline"`
|
||||
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"`
|
||||
@@ -30,12 +30,12 @@ type AlertProvider struct {
|
||||
type Override struct {
|
||||
Group string `yaml:"group"`
|
||||
|
||||
MatrixProviderConfig `yaml:",inline"`
|
||||
ProviderConfig `yaml:",inline"`
|
||||
}
|
||||
|
||||
const defaultHomeserverURL = "https://matrix-client.matrix.org"
|
||||
const defaultServerURL = "https://matrix-client.matrix.org"
|
||||
|
||||
type MatrixProviderConfig struct {
|
||||
type ProviderConfig struct {
|
||||
// ServerURL is the custom homeserver to use (optional)
|
||||
ServerURL string `yaml:"server-url"`
|
||||
|
||||
@@ -65,7 +65,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
|
||||
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
|
||||
config := provider.getConfigForGroup(endpoint.Group)
|
||||
if config.ServerURL == "" {
|
||||
config.ServerURL = defaultHomeserverURL
|
||||
config.ServerURL = defaultServerURL
|
||||
}
|
||||
// The Matrix endpoint requires a unique transaction ID for each event sent
|
||||
txnId := randStringBytes(24)
|
||||
@@ -115,12 +115,13 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
|
||||
|
||||
// buildPlaintextMessageBody builds the message body in plaintext to include in request
|
||||
func buildPlaintextMessageBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
||||
var message, results string
|
||||
var message string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("An alert for `%s` has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
|
||||
} else {
|
||||
message = fmt.Sprintf("An alert for `%s` has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
||||
}
|
||||
var formattedConditionResults string
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
@@ -128,49 +129,54 @@ func buildPlaintextMessageBody(endpoint *core.Endpoint, alert *alert.Alert, resu
|
||||
} else {
|
||||
prefix = "✕"
|
||||
}
|
||||
results += fmt.Sprintf("\n%s - %s", prefix, conditionResult.Condition)
|
||||
formattedConditionResults += fmt.Sprintf("\n%s - %s", prefix, conditionResult.Condition)
|
||||
}
|
||||
var description string
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description = "\n" + alertDescription
|
||||
}
|
||||
return fmt.Sprintf("%s%s\n%s", message, description, results)
|
||||
return fmt.Sprintf("%s%s\n%s", message, description, formattedConditionResults)
|
||||
}
|
||||
|
||||
// buildHTMLMessageBody builds the message body in HTML to include in request
|
||||
func buildHTMLMessageBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) string {
|
||||
var message, results string
|
||||
var message string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("An alert for <code>%s</code> has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
|
||||
} else {
|
||||
message = fmt.Sprintf("An alert for <code>%s</code> has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
||||
}
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
prefix = "✅"
|
||||
} else {
|
||||
prefix = "❌"
|
||||
var formattedConditionResults string
|
||||
if len(result.ConditionResults) > 0 {
|
||||
formattedConditionResults = "\n<h5>Condition results</h5><ul>"
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
prefix = "✅"
|
||||
} else {
|
||||
prefix = "❌"
|
||||
}
|
||||
formattedConditionResults += fmt.Sprintf("<li>%s - <code>%s</code></li>", prefix, conditionResult.Condition)
|
||||
}
|
||||
results += fmt.Sprintf("<li>%s - <code>%s</code></li>", prefix, conditionResult.Condition)
|
||||
formattedConditionResults += "</ul>"
|
||||
}
|
||||
var description string
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description = fmt.Sprintf("\n<blockquote>%s</blockquote>", alertDescription)
|
||||
}
|
||||
return fmt.Sprintf("<h3>%s</h3>%s\n<h5>Condition results</h5><ul>%s</ul>", message, description, results)
|
||||
return fmt.Sprintf("<h3>%s</h3>%s%s", message, description, formattedConditionResults)
|
||||
}
|
||||
|
||||
// getConfigForGroup returns the appropriate configuration for a given group
|
||||
func (provider *AlertProvider) getConfigForGroup(group string) MatrixProviderConfig {
|
||||
func (provider *AlertProvider) getConfigForGroup(group string) ProviderConfig {
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
return override.MatrixProviderConfig
|
||||
return override.ProviderConfig
|
||||
}
|
||||
}
|
||||
}
|
||||
return provider.MatrixProviderConfig
|
||||
return provider.ProviderConfig
|
||||
}
|
||||
|
||||
func randStringBytes(n int) string {
|
||||
@@ -185,6 +191,6 @@ func randStringBytes(n int) string {
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
|
||||
func TestAlertProvider_IsValid(t *testing.T) {
|
||||
invalidProvider := AlertProvider{
|
||||
MatrixProviderConfig: MatrixProviderConfig{
|
||||
ProviderConfig: ProviderConfig{
|
||||
AccessToken: "",
|
||||
InternalRoomID: "",
|
||||
},
|
||||
@@ -22,7 +22,7 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{
|
||||
MatrixProviderConfig: MatrixProviderConfig{
|
||||
ProviderConfig: ProviderConfig{
|
||||
AccessToken: "1",
|
||||
InternalRoomID: "!a:example.com",
|
||||
},
|
||||
@@ -31,7 +31,7 @@ func TestAlertProvider_IsValid(t *testing.T) {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
validProviderWithHomeserver := AlertProvider{
|
||||
MatrixProviderConfig: MatrixProviderConfig{
|
||||
ProviderConfig: ProviderConfig{
|
||||
ServerURL: "https://example.com",
|
||||
AccessToken: "1",
|
||||
InternalRoomID: "!a:example.com",
|
||||
@@ -47,7 +47,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "",
|
||||
MatrixProviderConfig: MatrixProviderConfig{
|
||||
ProviderConfig: ProviderConfig{
|
||||
AccessToken: "",
|
||||
InternalRoomID: "",
|
||||
},
|
||||
@@ -61,7 +61,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
MatrixProviderConfig: MatrixProviderConfig{
|
||||
ProviderConfig: ProviderConfig{
|
||||
AccessToken: "",
|
||||
InternalRoomID: "",
|
||||
},
|
||||
@@ -72,14 +72,14 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
||||
t.Error("provider integration key shouldn't have been valid")
|
||||
}
|
||||
providerWithValidOverride := AlertProvider{
|
||||
MatrixProviderConfig: MatrixProviderConfig{
|
||||
ProviderConfig: ProviderConfig{
|
||||
AccessToken: "1",
|
||||
InternalRoomID: "!a:example.com",
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
MatrixProviderConfig: MatrixProviderConfig{
|
||||
ProviderConfig: ProviderConfig{
|
||||
ServerURL: "https://example.com",
|
||||
AccessToken: "1",
|
||||
InternalRoomID: "!a:example.com",
|
||||
@@ -219,10 +219,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
@@ -232,12 +232,12 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
InputGroup string
|
||||
ExpectedOutput MatrixProviderConfig
|
||||
ExpectedOutput ProviderConfig
|
||||
}{
|
||||
{
|
||||
Name: "provider-no-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
MatrixProviderConfig: MatrixProviderConfig{
|
||||
ProviderConfig: ProviderConfig{
|
||||
ServerURL: "https://example.com",
|
||||
AccessToken: "1",
|
||||
InternalRoomID: "!a:example.com",
|
||||
@@ -245,7 +245,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "",
|
||||
ExpectedOutput: MatrixProviderConfig{
|
||||
ExpectedOutput: ProviderConfig{
|
||||
ServerURL: "https://example.com",
|
||||
AccessToken: "1",
|
||||
InternalRoomID: "!a:example.com",
|
||||
@@ -254,7 +254,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
||||
{
|
||||
Name: "provider-no-override-specify-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
MatrixProviderConfig: MatrixProviderConfig{
|
||||
ProviderConfig: ProviderConfig{
|
||||
ServerURL: "https://example.com",
|
||||
AccessToken: "1",
|
||||
InternalRoomID: "!a:example.com",
|
||||
@@ -262,7 +262,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "group",
|
||||
ExpectedOutput: MatrixProviderConfig{
|
||||
ExpectedOutput: ProviderConfig{
|
||||
ServerURL: "https://example.com",
|
||||
AccessToken: "1",
|
||||
InternalRoomID: "!a:example.com",
|
||||
@@ -271,7 +271,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
||||
{
|
||||
Name: "provider-with-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
MatrixProviderConfig: MatrixProviderConfig{
|
||||
ProviderConfig: ProviderConfig{
|
||||
ServerURL: "https://example.com",
|
||||
AccessToken: "1",
|
||||
InternalRoomID: "!a:example.com",
|
||||
@@ -279,7 +279,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
MatrixProviderConfig: MatrixProviderConfig{
|
||||
ProviderConfig: ProviderConfig{
|
||||
ServerURL: "https://example01.com",
|
||||
AccessToken: "12",
|
||||
InternalRoomID: "!a:example01.com",
|
||||
@@ -288,7 +288,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
||||
},
|
||||
},
|
||||
InputGroup: "",
|
||||
ExpectedOutput: MatrixProviderConfig{
|
||||
ExpectedOutput: ProviderConfig{
|
||||
ServerURL: "https://example.com",
|
||||
AccessToken: "1",
|
||||
InternalRoomID: "!a:example.com",
|
||||
@@ -297,7 +297,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
||||
{
|
||||
Name: "provider-with-override-specify-group-should-override",
|
||||
Provider: AlertProvider{
|
||||
MatrixProviderConfig: MatrixProviderConfig{
|
||||
ProviderConfig: ProviderConfig{
|
||||
ServerURL: "https://example.com",
|
||||
AccessToken: "1",
|
||||
InternalRoomID: "!a:example.com",
|
||||
@@ -305,7 +305,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
MatrixProviderConfig: MatrixProviderConfig{
|
||||
ProviderConfig: ProviderConfig{
|
||||
ServerURL: "https://example01.com",
|
||||
AccessToken: "12",
|
||||
InternalRoomID: "!a:example01.com",
|
||||
@@ -314,7 +314,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
|
||||
},
|
||||
},
|
||||
InputGroup: "group",
|
||||
ExpectedOutput: MatrixProviderConfig{
|
||||
ExpectedOutput: ProviderConfig{
|
||||
ServerURL: "https://example01.com",
|
||||
AccessToken: "12",
|
||||
InternalRoomID: "!a:example01.com",
|
||||
|
||||
@@ -93,7 +93,7 @@ type Field struct {
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
|
||||
var message, color, results string
|
||||
var message, color string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
|
||||
color = "#36A64F"
|
||||
@@ -101,20 +101,23 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
|
||||
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
||||
color = "#DD0000"
|
||||
}
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
prefix = ":white_check_mark:"
|
||||
} else {
|
||||
prefix = ":x:"
|
||||
var formattedConditionResults string
|
||||
if len(result.ConditionResults) > 0 {
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
prefix = ":white_check_mark:"
|
||||
} else {
|
||||
prefix = ":x:"
|
||||
}
|
||||
formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
||||
}
|
||||
results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
||||
}
|
||||
var description string
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description = ":\n> " + alertDescription
|
||||
}
|
||||
body, _ := json.Marshal(Body{
|
||||
body := Body{
|
||||
Text: "",
|
||||
Username: "gatus",
|
||||
IconURL: "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png",
|
||||
@@ -125,17 +128,18 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
|
||||
Text: message + description,
|
||||
Short: false,
|
||||
Color: color,
|
||||
Fields: []Field{
|
||||
{
|
||||
Title: "Condition results",
|
||||
Value: results,
|
||||
Short: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return body
|
||||
}
|
||||
if len(formattedConditionResults) > 0 {
|
||||
body.Attachments[0].Fields = append(body.Attachments[0].Fields, Field{
|
||||
Title: "Condition results",
|
||||
Value: formattedConditionResults,
|
||||
Short: false,
|
||||
})
|
||||
}
|
||||
bodyAsJSON, _ := json.Marshal(body)
|
||||
return bodyAsJSON
|
||||
}
|
||||
|
||||
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
|
||||
@@ -151,6 +155,6 @@ func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) {
|
||||
},
|
||||
}
|
||||
if providerWithInvalidOverrideWebHookUrl.IsValid() {
|
||||
t.Error("provider WebHookURL shoudn't have been valid")
|
||||
t.Error("provider WebHookURL shouldn't have been valid")
|
||||
}
|
||||
|
||||
providerWithValidOverride := AlertProvider{
|
||||
@@ -190,10 +190,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,6 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
@@ -153,10 +153,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
@@ -23,6 +24,7 @@ type AlertProvider struct {
|
||||
Topic string `yaml:"topic"`
|
||||
URL string `yaml:"url,omitempty"` // Defaults to DefaultURL
|
||||
Priority int `yaml:"priority,omitempty"` // Defaults to DefaultPriority
|
||||
Token string `yaml:"token,omitempty"` // Defaults to ""
|
||||
|
||||
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
|
||||
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
|
||||
@@ -36,7 +38,11 @@ func (provider *AlertProvider) IsValid() bool {
|
||||
if provider.Priority == 0 {
|
||||
provider.Priority = DefaultPriority
|
||||
}
|
||||
return len(provider.URL) > 0 && len(provider.Topic) > 0 && provider.Priority > 0 && provider.Priority < 6
|
||||
isTokenValid := true
|
||||
if len(provider.Token) > 0 {
|
||||
isTokenValid = strings.HasPrefix(provider.Token, "tk_")
|
||||
}
|
||||
return len(provider.URL) > 0 && len(provider.Topic) > 0 && provider.Priority > 0 && provider.Priority < 6 && isTokenValid
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
@@ -47,6 +53,9 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
if len(provider.Token) > 0 {
|
||||
request.Header.Set("Authorization", "Bearer "+provider.Token)
|
||||
}
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -69,17 +78,27 @@ type Body struct {
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
|
||||
var message, tag string
|
||||
var message, formattedConditionResults, tag string
|
||||
if resolved {
|
||||
tag = "white_check_mark"
|
||||
message = "An alert has been resolved after passing successfully " + strconv.Itoa(alert.SuccessThreshold) + " time(s) in a row"
|
||||
} else {
|
||||
tag = "x"
|
||||
tag = "rotating_light"
|
||||
message = "An alert has been triggered due to having failed " + strconv.Itoa(alert.FailureThreshold) + " time(s) in a row"
|
||||
}
|
||||
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
|
||||
body, _ := json.Marshal(Body{
|
||||
Topic: provider.Topic,
|
||||
Title: "Gatus: " + endpoint.DisplayName(),
|
||||
@@ -91,6 +110,6 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
@@ -24,6 +24,16 @@ func TestAlertDefaultProvider_IsValid(t *testing.T) {
|
||||
provider: AlertProvider{Topic: "example", Priority: 1},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "valid-with-token",
|
||||
provider: AlertProvider{Topic: "example", Priority: 1, Token: "tk_faketoken"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "invalid-token",
|
||||
provider: AlertProvider{Topic: "example", Priority: 1, Token: "xx_faketoken"},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "invalid-topic",
|
||||
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "", Priority: 1},
|
||||
@@ -69,14 +79,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Provider: AlertProvider{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","tags":["x"],"priority":1}`,
|
||||
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1}`,
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{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","tags":["white_check_mark"],"priority":2}`,
|
||||
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\n🟢 [CONNECTED] == true\n🟢 [STATUS] == 200","tags":["white_check_mark"],"priority":2}`,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
|
||||
@@ -116,7 +116,7 @@ func (provider *AlertProvider) sendRequest(url, method string, payload interface
|
||||
}
|
||||
|
||||
func (provider *AlertProvider) buildCreateRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) alertCreateRequest {
|
||||
var message, description, results string
|
||||
var message, description string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription())
|
||||
description = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
|
||||
@@ -127,6 +127,7 @@ func (provider *AlertProvider) buildCreateRequestBody(endpoint *core.Endpoint, a
|
||||
if endpoint.Group != "" {
|
||||
message = fmt.Sprintf("[%s] %s", endpoint.Group, message)
|
||||
}
|
||||
var formattedConditionResults string
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
@@ -134,9 +135,9 @@ func (provider *AlertProvider) buildCreateRequestBody(endpoint *core.Endpoint, a
|
||||
} else {
|
||||
prefix = "▢"
|
||||
}
|
||||
results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
||||
formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
||||
}
|
||||
description = description + "\n" + results
|
||||
description = description + "\n" + formattedConditionResults
|
||||
key := buildKey(endpoint)
|
||||
details := map[string]string{
|
||||
"endpoint:url": endpoint.URL,
|
||||
@@ -206,7 +207,7 @@ func (provider *AlertProvider) priority() string {
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert,
|
||||
var payload pagerDutyResponsePayload
|
||||
if err = json.Unmarshal(body, &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())
|
||||
log.Printf("[pagerduty.Send] Ran into error unmarshaling pagerduty response: %s", err.Error())
|
||||
} else {
|
||||
alert.ResolveKey = payload.DedupKey
|
||||
}
|
||||
@@ -138,7 +138,7 @@ func (provider *AlertProvider) getIntegrationKeyForGroup(group string) string {
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
|
||||
@@ -237,10 +237,10 @@ func TestAlertProvider_getIntegrationKeyForGroup(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@ package provider
|
||||
|
||||
import (
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/awsses"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/custom"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/discord"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/email"
|
||||
"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/jetbrainsspace"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
|
||||
@@ -21,7 +24,7 @@ import (
|
||||
"github.com/TwiN/gatus/v5/core"
|
||||
)
|
||||
|
||||
// AlertProvider is the interface that each providers should implement
|
||||
// AlertProvider is the interface that each provider should implement
|
||||
type AlertProvider interface {
|
||||
// IsValid returns whether the provider's configuration is valid
|
||||
IsValid() bool
|
||||
@@ -57,11 +60,14 @@ func ParseWithDefaultAlert(providerDefaultAlert, endpointAlert *alert.Alert) {
|
||||
|
||||
var (
|
||||
// Validate interface implementation on compile
|
||||
_ AlertProvider = (*awsses.AlertProvider)(nil)
|
||||
_ AlertProvider = (*custom.AlertProvider)(nil)
|
||||
_ AlertProvider = (*discord.AlertProvider)(nil)
|
||||
_ AlertProvider = (*email.AlertProvider)(nil)
|
||||
_ AlertProvider = (*github.AlertProvider)(nil)
|
||||
_ AlertProvider = (*gitlab.AlertProvider)(nil)
|
||||
_ AlertProvider = (*googlechat.AlertProvider)(nil)
|
||||
_ AlertProvider = (*jetbrainsspace.AlertProvider)(nil)
|
||||
_ AlertProvider = (*matrix.AlertProvider)(nil)
|
||||
_ AlertProvider = (*mattermost.AlertProvider)(nil)
|
||||
_ AlertProvider = (*messagebird.AlertProvider)(nil)
|
||||
|
||||
@@ -107,6 +107,6 @@ func (provider *AlertProvider) priority() int {
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
@@ -172,10 +172,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ type Attachment struct {
|
||||
Text string `json:"text"`
|
||||
Short bool `json:"short"`
|
||||
Color string `json:"color"`
|
||||
Fields []Field `json:"fields"`
|
||||
Fields []Field `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
type Field struct {
|
||||
@@ -82,7 +82,7 @@ type Field struct {
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
|
||||
var message, color, results string
|
||||
var message, color string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
|
||||
color = "#36A64F"
|
||||
@@ -90,6 +90,7 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
|
||||
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
||||
color = "#DD0000"
|
||||
}
|
||||
var formattedConditionResults string
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
@@ -97,13 +98,13 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
|
||||
} else {
|
||||
prefix = ":x:"
|
||||
}
|
||||
results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
||||
formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
||||
}
|
||||
var description string
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description = ":\n> " + alertDescription
|
||||
}
|
||||
body, _ := json.Marshal(Body{
|
||||
body := Body{
|
||||
Text: "",
|
||||
Attachments: []Attachment{
|
||||
{
|
||||
@@ -111,17 +112,18 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
|
||||
Text: message + description,
|
||||
Short: false,
|
||||
Color: color,
|
||||
Fields: []Field{
|
||||
{
|
||||
Title: "Condition results",
|
||||
Value: results,
|
||||
Short: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return body
|
||||
}
|
||||
if len(formattedConditionResults) > 0 {
|
||||
body.Attachments[0].Fields = append(body.Attachments[0].Fields, Field{
|
||||
Title: "Condition results",
|
||||
Value: formattedConditionResults,
|
||||
Short: false,
|
||||
})
|
||||
}
|
||||
bodyAsJSON, _ := json.Marshal(body)
|
||||
return bodyAsJSON
|
||||
}
|
||||
|
||||
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
|
||||
@@ -137,6 +139,6 @@ func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
@@ -144,6 +144,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Provider AlertProvider
|
||||
Endpoint core.Endpoint
|
||||
Alert alert.Alert
|
||||
NoConditions bool
|
||||
Resolved bool
|
||||
ExpectedBody string
|
||||
}{
|
||||
@@ -163,6 +164,15 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *group/name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
|
||||
},
|
||||
{
|
||||
Name: "triggered-with-no-conditions",
|
||||
NoConditions: true,
|
||||
Provider: AlertProvider{},
|
||||
Endpoint: core.Endpoint{Name: "name"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"short\":false,\"color\":\"#DD0000\"}]}",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
@@ -182,14 +192,18 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
var conditionResults []*core.ConditionResult
|
||||
if !scenario.NoConditions {
|
||||
conditionResults = []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
}
|
||||
}
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
&scenario.Endpoint,
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
ConditionResults: conditionResults,
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
@@ -205,10 +219,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ type Body struct {
|
||||
ThemeColor string `json:"themeColor"`
|
||||
Title string `json:"title"`
|
||||
Text string `json:"text"`
|
||||
Sections []Section `json:"sections"`
|
||||
Sections []Section `json:"sections,omitempty"`
|
||||
}
|
||||
|
||||
type Section struct {
|
||||
@@ -87,7 +87,7 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
|
||||
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
|
||||
color = "#DD0000"
|
||||
}
|
||||
var results string
|
||||
var formattedConditionResults string
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
@@ -95,26 +95,27 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
|
||||
} else {
|
||||
prefix = "❌"
|
||||
}
|
||||
results += fmt.Sprintf("%s - `%s`<br/>", prefix, conditionResult.Condition)
|
||||
formattedConditionResults += fmt.Sprintf("%s - `%s`<br/>", prefix, conditionResult.Condition)
|
||||
}
|
||||
var description string
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description = ": " + alertDescription
|
||||
}
|
||||
body, _ := json.Marshal(Body{
|
||||
body := Body{
|
||||
Type: "MessageCard",
|
||||
Context: "http://schema.org/extensions",
|
||||
ThemeColor: color,
|
||||
Title: "🚨 Gatus",
|
||||
Text: message + description,
|
||||
Sections: []Section{
|
||||
{
|
||||
ActivityTitle: "Condition results",
|
||||
Text: results,
|
||||
},
|
||||
},
|
||||
})
|
||||
return body
|
||||
}
|
||||
if len(formattedConditionResults) > 0 {
|
||||
body.Sections = append(body.Sections, Section{
|
||||
ActivityTitle: "Condition results",
|
||||
Text: formattedConditionResults,
|
||||
})
|
||||
}
|
||||
bodyAsJSON, _ := json.Marshal(body)
|
||||
return bodyAsJSON
|
||||
}
|
||||
|
||||
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
|
||||
@@ -130,6 +131,6 @@ func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
@@ -143,6 +143,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
NoConditions bool
|
||||
Resolved bool
|
||||
ExpectedBody string
|
||||
}{
|
||||
@@ -160,18 +161,28 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"@type\":\"MessageCard\",\"@context\":\"http://schema.org/extensions\",\"themeColor\":\"#36A64F\",\"title\":\"\\u0026#x1F6A8; Gatus\",\"text\":\"An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row: description-2\",\"sections\":[{\"activityTitle\":\"Condition results\",\"text\":\"\\u0026#x2705; - `[CONNECTED] == true`\\u003cbr/\\u003e\\u0026#x2705; - `[STATUS] == 200`\\u003cbr/\\u003e\"}]}",
|
||||
},
|
||||
{
|
||||
Name: "resolved-with-no-conditions",
|
||||
NoConditions: true,
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"@type\":\"MessageCard\",\"@context\":\"http://schema.org/extensions\",\"themeColor\":\"#36A64F\",\"title\":\"\\u0026#x1F6A8; Gatus\",\"text\":\"An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row: description-2\"}",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
var conditionResults []*core.ConditionResult
|
||||
if !scenario.NoConditions {
|
||||
conditionResults = []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
}
|
||||
}
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
&core.Result{ConditionResults: conditionResults},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if string(body) != scenario.ExpectedBody {
|
||||
@@ -186,10 +197,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,36 +67,40 @@ type Body struct {
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
|
||||
var message, results string
|
||||
var message string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("An alert for *%s* has been resolved:\n—\n _healthcheck passing successfully %d time(s) in a row_\n— ", endpoint.DisplayName(), alert.FailureThreshold)
|
||||
message = fmt.Sprintf("An alert for *%s* has been resolved:\n—\n _healthcheck passing successfully %d time(s) in a row_\n— ", endpoint.DisplayName(), alert.SuccessThreshold)
|
||||
} else {
|
||||
message = fmt.Sprintf("An alert for *%s* has been triggered:\n—\n _healthcheck failed %d time(s) in a row_\n— ", endpoint.DisplayName(), alert.FailureThreshold)
|
||||
}
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
prefix = "✅"
|
||||
} else {
|
||||
prefix = "❌"
|
||||
var formattedConditionResults string
|
||||
if len(result.ConditionResults) > 0 {
|
||||
formattedConditionResults = "\n*Condition results*\n"
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
prefix = "✅"
|
||||
} else {
|
||||
prefix = "❌"
|
||||
}
|
||||
formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
||||
}
|
||||
results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
||||
}
|
||||
var text string
|
||||
if len(alert.GetDescription()) > 0 {
|
||||
text = fmt.Sprintf("⛑ *Gatus* \n%s \n*Description* \n_%s_ \n\n*Condition results*\n%s", message, alert.GetDescription(), results)
|
||||
text = fmt.Sprintf("⛑ *Gatus* \n%s \n*Description* \n_%s_ \n%s", message, alert.GetDescription(), formattedConditionResults)
|
||||
} else {
|
||||
text = fmt.Sprintf("⛑ *Gatus* \n%s \n*Condition results*\n%s", message, results)
|
||||
text = fmt.Sprintf("⛑ *Gatus* \n%s%s", message, formattedConditionResults)
|
||||
}
|
||||
body, _ := json.Marshal(Body{
|
||||
bodyAsJSON, _ := json.Marshal(Body{
|
||||
ChatID: provider.ID,
|
||||
Text: text,
|
||||
ParseMode: "MARKDOWN",
|
||||
})
|
||||
return body
|
||||
return bodyAsJSON
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
NoConditions bool
|
||||
Resolved bool
|
||||
ExpectedBody string
|
||||
}{
|
||||
@@ -131,20 +132,30 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Provider: AlertProvider{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 3 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\\n*Condition results*\\n✅ - `[CONNECTED] == true`\\n✅ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
|
||||
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 5 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\\n*Condition results*\\n✅ - `[CONNECTED] == true`\\n✅ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
|
||||
},
|
||||
{
|
||||
Name: "resolved-with-no-conditions",
|
||||
NoConditions: true,
|
||||
Provider: AlertProvider{ID: "123"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 5 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\",\"parse_mode\":\"MARKDOWN\"}",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
var conditionResults []*core.ConditionResult
|
||||
if !scenario.NoConditions {
|
||||
conditionResults = []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
}
|
||||
}
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
&core.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&core.Result{
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
&core.Result{ConditionResults: conditionResults},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if string(body) != scenario.ExpectedBody {
|
||||
@@ -159,10 +170,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,6 @@ func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
@@ -69,10 +69,10 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
122
api/api.go
Normal file
122
api/api.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
"github.com/TwiN/gatus/v5/config/web"
|
||||
static "github.com/TwiN/gatus/v5/web"
|
||||
"github.com/TwiN/health"
|
||||
fiber "github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/adaptor"
|
||||
"github.com/gofiber/fiber/v2/middleware/compress"
|
||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||
fiberfs "github.com/gofiber/fiber/v2/middleware/filesystem"
|
||||
"github.com/gofiber/fiber/v2/middleware/recover"
|
||||
"github.com/gofiber/fiber/v2/middleware/redirect"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
router *fiber.App
|
||||
}
|
||||
|
||||
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")
|
||||
cfg.Web = web.GetDefaultConfig()
|
||||
}
|
||||
api.router = api.createRouter(cfg)
|
||||
return api
|
||||
}
|
||||
|
||||
func (a *API) Router() *fiber.App {
|
||||
return a.router
|
||||
}
|
||||
|
||||
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())
|
||||
return fiber.DefaultErrorHandler(c, err)
|
||||
},
|
||||
ReadBufferSize: cfg.Web.ReadBufferSize,
|
||||
Network: fiber.NetworkTCP,
|
||||
})
|
||||
if os.Getenv("ENVIRONMENT") == "dev" {
|
||||
app.Use(cors.New(cors.Config{
|
||||
AllowOrigins: "http://localhost:8081",
|
||||
AllowCredentials: true,
|
||||
}))
|
||||
}
|
||||
// Middlewares
|
||||
app.Use(recover.New())
|
||||
app.Use(compress.New())
|
||||
// Define metrics handler, if necessary
|
||||
if cfg.Metrics {
|
||||
metricsHandler := promhttp.InstrumentMetricHandler(prometheus.DefaultRegisterer, promhttp.HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{
|
||||
DisableCompression: true,
|
||||
}))
|
||||
app.Get("/metrics", adaptor.HTTPHandler(metricsHandler))
|
||||
}
|
||||
// Define main router
|
||||
apiRouter := app.Group("/api")
|
||||
////////////////////////
|
||||
// UNPROTECTED ROUTES //
|
||||
////////////////////////
|
||||
unprotectedAPIRouter := apiRouter.Group("/")
|
||||
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/badge.svg", UptimeBadge)
|
||||
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
|
||||
unprotectedAPIRouter.Post("/v1/endpoints/:key/external", CreateExternalEndpointResult(cfg))
|
||||
// SPA
|
||||
app.Get("/", SinglePageApplication(cfg.UI))
|
||||
app.Get("/endpoints/:name", SinglePageApplication(cfg.UI))
|
||||
// Health endpoint
|
||||
healthHandler := health.Handler().WithJSON(true)
|
||||
app.Get("/health", func(c *fiber.Ctx) error {
|
||||
statusCode, body := healthHandler.GetResponseStatusCodeAndBody()
|
||||
return c.Status(statusCode).Send(body)
|
||||
})
|
||||
// Everything else falls back on static content
|
||||
app.Use(redirect.New(redirect.Config{
|
||||
Rules: map[string]string{
|
||||
"/index.html": "/",
|
||||
},
|
||||
StatusCode: 301,
|
||||
}))
|
||||
staticFileSystem, err := fs.Sub(static.FileSystem, static.RootPath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
app.Use("/", fiberfs.New(fiberfs.Config{
|
||||
Root: http.FS(staticFileSystem),
|
||||
Index: "index.html",
|
||||
Browse: true,
|
||||
}))
|
||||
//////////////////////
|
||||
// PROTECTED ROUTES //
|
||||
//////////////////////
|
||||
// ORDER IS IMPORTANT: all routes applied AFTER the security middleware will require authn
|
||||
protectedAPIRouter := apiRouter.Group("/")
|
||||
if cfg.Security != nil {
|
||||
if err := cfg.Security.RegisterHandlers(app); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := cfg.Security.ApplySecurityMiddleware(protectedAPIRouter); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
protectedAPIRouter.Get("/v1/endpoints/statuses", EndpointStatuses(cfg))
|
||||
protectedAPIRouter.Get("/v1/endpoints/:key/statuses", EndpointStatus)
|
||||
return app
|
||||
}
|
||||
121
api/api_test.go
Normal file
121
api/api_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
"github.com/TwiN/gatus/v5/config/ui"
|
||||
"github.com/TwiN/gatus/v5/security"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
ExpectedCode int
|
||||
Gzip bool
|
||||
WithSecurity bool
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "health",
|
||||
Path: "/health",
|
||||
ExpectedCode: fiber.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "metrics",
|
||||
Path: "/metrics",
|
||||
ExpectedCode: fiber.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "favicon.ico",
|
||||
Path: "/favicon.ico",
|
||||
ExpectedCode: fiber.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "app.js",
|
||||
Path: "/js/app.js",
|
||||
ExpectedCode: fiber.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "app.js-gzipped",
|
||||
Path: "/js/app.js",
|
||||
ExpectedCode: fiber.StatusOK,
|
||||
Gzip: true,
|
||||
},
|
||||
{
|
||||
Name: "chunk-vendors.js",
|
||||
Path: "/js/chunk-vendors.js",
|
||||
ExpectedCode: fiber.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "chunk-vendors.js-gzipped",
|
||||
Path: "/js/chunk-vendors.js",
|
||||
ExpectedCode: fiber.StatusOK,
|
||||
Gzip: true,
|
||||
},
|
||||
{
|
||||
Name: "index",
|
||||
Path: "/",
|
||||
ExpectedCode: fiber.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "index-html-redirect",
|
||||
Path: "/index.html",
|
||||
ExpectedCode: fiber.StatusMovedPermanently,
|
||||
},
|
||||
{
|
||||
Name: "index-should-return-200-even-if-not-authenticated",
|
||||
Path: "/",
|
||||
ExpectedCode: fiber.StatusOK,
|
||||
WithSecurity: true,
|
||||
},
|
||||
{
|
||||
Name: "endpoints-should-return-401-if-not-authenticated",
|
||||
Path: "/api/v1/endpoints/statuses",
|
||||
ExpectedCode: fiber.StatusUnauthorized,
|
||||
WithSecurity: true,
|
||||
},
|
||||
{
|
||||
Name: "config-should-return-200-even-if-not-authenticated",
|
||||
Path: "/api/v1/config",
|
||||
ExpectedCode: fiber.StatusOK,
|
||||
WithSecurity: true,
|
||||
},
|
||||
{
|
||||
Name: "config-should-always-return-200",
|
||||
Path: "/api/v1/config",
|
||||
ExpectedCode: fiber.StatusOK,
|
||||
WithSecurity: false,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
cfg := &config.Config{Metrics: true, UI: &ui.Config{}}
|
||||
if scenario.WithSecurity {
|
||||
cfg.Security = &security.Config{
|
||||
Basic: &security.BasicConfig{
|
||||
Username: "john.doe",
|
||||
PasswordBcryptHashBase64Encoded: "JDJhJDA4JDFoRnpPY1hnaFl1OC9ISlFsa21VS09wOGlPU1ZOTDlHZG1qeTFvb3dIckRBUnlHUmNIRWlT",
|
||||
},
|
||||
}
|
||||
}
|
||||
api := New(cfg)
|
||||
router := api.Router()
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,19 @@
|
||||
package handler
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
"github.com/TwiN/gatus/v5/core/ui"
|
||||
"github.com/TwiN/gatus/v5/storage/store"
|
||||
"github.com/TwiN/gatus/v5/storage/store/common"
|
||||
"github.com/TwiN/gatus/v5/storage/store/common/paging"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -35,10 +37,9 @@ var (
|
||||
|
||||
// UptimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
|
||||
//
|
||||
// Valid values for {duration}: 7d, 24h, 1h
|
||||
func UptimeBadge(writer http.ResponseWriter, request *http.Request) {
|
||||
variables := mux.Vars(request)
|
||||
duration := variables["duration"]
|
||||
// Valid values for :duration -> 7d, 24h, 1h
|
||||
func UptimeBadge(c *fiber.Ctx) error {
|
||||
duration := c.Params("duration")
|
||||
var from time.Time
|
||||
switch duration {
|
||||
case "7d":
|
||||
@@ -48,35 +49,30 @@ func UptimeBadge(writer http.ResponseWriter, request *http.Request) {
|
||||
case "1h":
|
||||
from = time.Now().Add(-2 * time.Hour) // Because uptime metrics are stored by hour, we have to cheat a little
|
||||
default:
|
||||
http.Error(writer, "Durations supported: 7d, 24h, 1h", http.StatusBadRequest)
|
||||
return
|
||||
return c.Status(400).SendString("Durations supported: 7d, 24h, 1h")
|
||||
}
|
||||
key := variables["key"]
|
||||
key := c.Params("key")
|
||||
uptime, err := store.Get().GetUptimeByKey(key, from, time.Now())
|
||||
if err != nil {
|
||||
if err == common.ErrEndpointNotFound {
|
||||
http.Error(writer, err.Error(), http.StatusNotFound)
|
||||
} else if err == common.ErrInvalidTimeRange {
|
||||
http.Error(writer, err.Error(), http.StatusBadRequest)
|
||||
} else {
|
||||
http.Error(writer, err.Error(), http.StatusInternalServerError)
|
||||
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
|
||||
return c.Status(500).SendString(err.Error())
|
||||
}
|
||||
writer.Header().Set("Content-Type", "image/svg+xml")
|
||||
writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
writer.Header().Set("Expires", "0")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
_, _ = writer.Write(generateUptimeBadgeSVG(duration, uptime))
|
||||
c.Set("Content-Type", "image/svg+xml")
|
||||
c.Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
c.Set("Expires", "0")
|
||||
return c.Status(200).Send(generateUptimeBadgeSVG(duration, uptime))
|
||||
}
|
||||
|
||||
// ResponseTimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
|
||||
//
|
||||
// Valid values for {duration}: 7d, 24h, 1h
|
||||
func ResponseTimeBadge(config *config.Config) http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
variables := mux.Vars(request)
|
||||
duration := variables["duration"]
|
||||
// Valid values for :duration -> 7d, 24h, 1h
|
||||
func ResponseTimeBadge(cfg *config.Config) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
duration := c.Params("duration")
|
||||
var from time.Time
|
||||
switch duration {
|
||||
case "7d":
|
||||
@@ -86,44 +82,37 @@ func ResponseTimeBadge(config *config.Config) http.HandlerFunc {
|
||||
case "1h":
|
||||
from = time.Now().Add(-2 * time.Hour) // Because response time metrics are stored by hour, we have to cheat a little
|
||||
default:
|
||||
http.Error(writer, "Durations supported: 7d, 24h, 1h", http.StatusBadRequest)
|
||||
return
|
||||
return c.Status(400).SendString("Durations supported: 7d, 24h, 1h")
|
||||
}
|
||||
key := variables["key"]
|
||||
key := c.Params("key")
|
||||
averageResponseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
|
||||
if err != nil {
|
||||
if err == common.ErrEndpointNotFound {
|
||||
http.Error(writer, err.Error(), http.StatusNotFound)
|
||||
} else if err == common.ErrInvalidTimeRange {
|
||||
http.Error(writer, err.Error(), http.StatusBadRequest)
|
||||
} else {
|
||||
http.Error(writer, err.Error(), http.StatusInternalServerError)
|
||||
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
|
||||
return c.Status(500).SendString(err.Error())
|
||||
}
|
||||
writer.Header().Set("Content-Type", "image/svg+xml")
|
||||
writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
writer.Header().Set("Expires", "0")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
_, _ = writer.Write(generateResponseTimeBadgeSVG(duration, averageResponseTime, key, config))
|
||||
c.Set("Content-Type", "image/svg+xml")
|
||||
c.Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
c.Set("Expires", "0")
|
||||
return c.Status(200).Send(generateResponseTimeBadgeSVG(duration, averageResponseTime, key, cfg))
|
||||
}
|
||||
}
|
||||
|
||||
// HealthBadge handles the automatic generation of badge based on the group name and endpoint name passed.
|
||||
func HealthBadge(writer http.ResponseWriter, request *http.Request) {
|
||||
variables := mux.Vars(request)
|
||||
key := variables["key"]
|
||||
func HealthBadge(c *fiber.Ctx) error {
|
||||
key := c.Params("key")
|
||||
pagingConfig := paging.NewEndpointStatusParams()
|
||||
status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1))
|
||||
if err != nil {
|
||||
if err == common.ErrEndpointNotFound {
|
||||
http.Error(writer, err.Error(), http.StatusNotFound)
|
||||
} else if err == common.ErrInvalidTimeRange {
|
||||
http.Error(writer, err.Error(), http.StatusBadRequest)
|
||||
} else {
|
||||
http.Error(writer, err.Error(), http.StatusInternalServerError)
|
||||
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
|
||||
return c.Status(500).SendString(err.Error())
|
||||
}
|
||||
healthStatus := HealthStatusUnknown
|
||||
if len(status.Results) > 0 {
|
||||
@@ -133,11 +122,40 @@ func HealthBadge(writer http.ResponseWriter, request *http.Request) {
|
||||
healthStatus = HealthStatusDown
|
||||
}
|
||||
}
|
||||
writer.Header().Set("Content-Type", "image/svg+xml")
|
||||
writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
writer.Header().Set("Expires", "0")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
_, _ = writer.Write(generateHealthBadgeSVG(healthStatus))
|
||||
c.Set("Content-Type", "image/svg+xml")
|
||||
c.Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
c.Set("Expires", "0")
|
||||
return c.Status(200).Send(generateHealthBadgeSVG(healthStatus))
|
||||
}
|
||||
|
||||
func HealthBadgeShields(c *fiber.Ctx) error {
|
||||
key := c.Params("key")
|
||||
pagingConfig := paging.NewEndpointStatusParams()
|
||||
status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1))
|
||||
if err != nil {
|
||||
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||
return c.Status(404).SendString(err.Error())
|
||||
} else if errors.Is(err, common.ErrInvalidTimeRange) {
|
||||
return c.Status(400).SendString(err.Error())
|
||||
}
|
||||
return c.Status(500).SendString(err.Error())
|
||||
}
|
||||
healthStatus := HealthStatusUnknown
|
||||
if len(status.Results) > 0 {
|
||||
if status.Results[0].Success {
|
||||
healthStatus = HealthStatusUp
|
||||
} else {
|
||||
healthStatus = HealthStatusDown
|
||||
}
|
||||
}
|
||||
c.Set("Content-Type", "application/json")
|
||||
c.Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
c.Set("Expires", "0")
|
||||
jsonData, err := generateHealthBadgeShields(healthStatus)
|
||||
if err != nil {
|
||||
return c.Status(500).SendString(err.Error())
|
||||
}
|
||||
return c.Status(200).Send(jsonData)
|
||||
}
|
||||
|
||||
func generateUptimeBadgeSVG(duration string, uptime float64) []byte {
|
||||
@@ -255,10 +273,13 @@ func generateResponseTimeBadgeSVG(duration string, averageResponseTime int, key
|
||||
}
|
||||
|
||||
func getBadgeColorFromResponseTime(responseTime int, key string, cfg *config.Config) string {
|
||||
endpoint := cfg.GetEndpointByKey(key)
|
||||
thresholds := ui.GetDefaultConfig().Badge.ResponseTime.Thresholds
|
||||
if endpoint := cfg.GetEndpointByKey(key); endpoint != nil {
|
||||
thresholds = endpoint.UIConfig.Badge.ResponseTime.Thresholds
|
||||
}
|
||||
// the threshold config requires 5 values, so we can be sure it's set here
|
||||
for i := 0; i < 5; i++ {
|
||||
if responseTime <= endpoint.UIConfig.Badge.ResponseTime.Thresholds[i] {
|
||||
if responseTime <= thresholds[i] {
|
||||
return badgeColors[i]
|
||||
}
|
||||
}
|
||||
@@ -314,6 +335,17 @@ func generateHealthBadgeSVG(healthStatus string) []byte {
|
||||
return svg
|
||||
}
|
||||
|
||||
func generateHealthBadgeShields(healthStatus string) ([]byte, error) {
|
||||
color := getBadgeShieldsColorFromHealth(healthStatus)
|
||||
data := map[string]interface{}{
|
||||
"schemaVersion": 1,
|
||||
"label": "gatus",
|
||||
"message": healthStatus,
|
||||
"color": color,
|
||||
}
|
||||
return json.Marshal(data)
|
||||
}
|
||||
|
||||
func getBadgeColorFromHealth(healthStatus string) string {
|
||||
if healthStatus == HealthStatusUp {
|
||||
return badgeColorHexAwesome
|
||||
@@ -322,3 +354,12 @@ func getBadgeColorFromHealth(healthStatus string) string {
|
||||
}
|
||||
return badgeColorHexPassable
|
||||
}
|
||||
|
||||
func getBadgeShieldsColorFromHealth(healthStatus string) string {
|
||||
if healthStatus == HealthStatusUp {
|
||||
return "brightgreen"
|
||||
} else if healthStatus == HealthStatusDown {
|
||||
return "red"
|
||||
}
|
||||
return "yellow"
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package handler
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -31,38 +31,13 @@ func TestBadge(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
testSuccessfulResult = core.Result{
|
||||
Hostname: "example.org",
|
||||
IP: "127.0.0.1",
|
||||
HTTPStatus: 200,
|
||||
Errors: nil,
|
||||
Connected: true,
|
||||
Success: true,
|
||||
Timestamp: timestamp,
|
||||
Duration: 150 * time.Millisecond,
|
||||
CertificateExpiration: 10 * time.Hour,
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{
|
||||
Condition: "[STATUS] == 200",
|
||||
Success: true,
|
||||
},
|
||||
{
|
||||
Condition: "[RESPONSE_TIME] < 500",
|
||||
Success: true,
|
||||
},
|
||||
{
|
||||
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
|
||||
Success: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg.Endpoints[0].UIConfig = ui.GetDefaultConfig()
|
||||
cfg.Endpoints[1].UIConfig = ui.GetDefaultConfig()
|
||||
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
router := CreateRouter(cfg)
|
||||
api := New(cfg)
|
||||
router := api.Router()
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
@@ -135,6 +110,21 @@ func TestBadge(t *testing.T) {
|
||||
Path: "/api/v1/endpoints/invalid_key/health/badge.svg",
|
||||
ExpectedCode: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
Name: "badge-shields-health-up",
|
||||
Path: "/api/v1/endpoints/core_frontend/health/badge.shields",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "badge-shields-health-down",
|
||||
Path: "/api/v1/endpoints/core_backend/health/badge.shields",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "badge-shields-health-for-invalid-key",
|
||||
Path: "/api/v1/endpoints/invalid_key/health/badge.shields",
|
||||
ExpectedCode: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
Name: "chart-response-time-24h",
|
||||
Path: "/api/v1/endpoints/core_backend/response-times/24h/chart.svg",
|
||||
@@ -153,14 +143,16 @@ func TestBadge(t *testing.T) {
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
request, _ := http.NewRequest("GET", scenario.Path, http.NoBody)
|
||||
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
|
||||
if scenario.Gzip {
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
}
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(responseRecorder, request)
|
||||
if responseRecorder.Code != scenario.ExpectedCode {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -266,6 +258,32 @@ func TestGetBadgeColorFromResponseTime(t *testing.T) {
|
||||
Endpoints: []*core.Endpoint{&firstTestEndpoint, &secondTestEndpoint},
|
||||
}
|
||||
|
||||
testSuccessfulResult := core.Result{
|
||||
Hostname: "example.org",
|
||||
IP: "127.0.0.1",
|
||||
HTTPStatus: 200,
|
||||
Errors: nil,
|
||||
Connected: true,
|
||||
Success: true,
|
||||
Timestamp: time.Now(),
|
||||
Duration: 150 * time.Millisecond,
|
||||
CertificateExpiration: 10 * time.Hour,
|
||||
ConditionResults: []*core.ConditionResult{
|
||||
{
|
||||
Condition: "[STATUS] == 200",
|
||||
Success: true,
|
||||
},
|
||||
{
|
||||
Condition: "[RESPONSE_TIME] < 500",
|
||||
Success: true,
|
||||
},
|
||||
{
|
||||
Condition: "[CERTIFICATE_EXPIRATION] < 72h",
|
||||
Success: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
store.Get().Insert(&firstTestEndpoint, &testSuccessfulResult)
|
||||
store.Get().Insert(&secondTestEndpoint, &testSuccessfulResult)
|
||||
|
||||
15
api/cache.go
Normal file
15
api/cache.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gocache/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
cacheTTL = 10 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
cache = gocache.NewCache().WithMaxSize(100).WithEvictionPolicy(gocache.FirstInFirstOut)
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
@@ -9,7 +10,7 @@ import (
|
||||
|
||||
"github.com/TwiN/gatus/v5/storage/store"
|
||||
"github.com/TwiN/gatus/v5/storage/store/common"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/wcharczuk/go-chart/v2"
|
||||
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||
)
|
||||
@@ -29,9 +30,8 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
func ResponseTimeChart(writer http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
duration := vars["duration"]
|
||||
func ResponseTimeChart(c *fiber.Ctx) error {
|
||||
duration := c.Params("duration")
|
||||
var from time.Time
|
||||
switch duration {
|
||||
case "7d":
|
||||
@@ -39,23 +39,19 @@ func ResponseTimeChart(writer http.ResponseWriter, r *http.Request) {
|
||||
case "24h":
|
||||
from = time.Now().Truncate(time.Hour).Add(-24 * time.Hour)
|
||||
default:
|
||||
http.Error(writer, "Durations supported: 7d, 24h", http.StatusBadRequest)
|
||||
return
|
||||
return c.Status(400).SendString("Durations supported: 7d, 24h")
|
||||
}
|
||||
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(vars["key"], from, time.Now())
|
||||
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(c.Params("key"), from, time.Now())
|
||||
if err != nil {
|
||||
if err == common.ErrEndpointNotFound {
|
||||
http.Error(writer, err.Error(), http.StatusNotFound)
|
||||
} else if err == common.ErrInvalidTimeRange {
|
||||
http.Error(writer, err.Error(), http.StatusBadRequest)
|
||||
} else {
|
||||
http.Error(writer, err.Error(), http.StatusInternalServerError)
|
||||
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
|
||||
return c.Status(500).SendString(err.Error())
|
||||
}
|
||||
if len(hourlyAverageResponseTime) == 0 {
|
||||
http.Error(writer, "", http.StatusNoContent)
|
||||
return
|
||||
return c.Status(204).SendString("")
|
||||
}
|
||||
series := chart.TimeSeries{
|
||||
Name: "Average response time per hour",
|
||||
@@ -111,12 +107,13 @@ func ResponseTimeChart(writer http.ResponseWriter, r *http.Request) {
|
||||
},
|
||||
Series: []chart.Series{series},
|
||||
}
|
||||
writer.Header().Set("Content-Type", "image/svg+xml")
|
||||
writer.Header().Set("Cache-Control", "no-cache, no-store")
|
||||
writer.Header().Set("Expires", "0")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
if err := graph.Render(chart.SVG, writer); err != nil {
|
||||
log.Println("[handler][ResponseTimeChart] Failed to render response time chart:", err.Error())
|
||||
return
|
||||
c.Set("Content-Type", "image/svg+xml")
|
||||
c.Set("Cache-Control", "no-cache, no-store")
|
||||
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())
|
||||
return c.Status(500).SendString(err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package handler
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -30,7 +30,8 @@ func TestResponseTimeChart(t *testing.T) {
|
||||
}
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
router := CreateRouter(cfg)
|
||||
api := New(cfg)
|
||||
router := api.Router()
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
@@ -61,14 +62,16 @@ func TestResponseTimeChart(t *testing.T) {
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
request, _ := http.NewRequest("GET", scenario.Path, http.NoBody)
|
||||
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
|
||||
if scenario.Gzip {
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
}
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(responseRecorder, request)
|
||||
if responseRecorder.Code != scenario.ExpectedCode {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
25
api/config.go
Normal file
25
api/config.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/TwiN/gatus/v5/security"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
type ConfigHandler struct {
|
||||
securityConfig *security.Config
|
||||
}
|
||||
|
||||
func (handler ConfigHandler) GetConfig(c *fiber.Ctx) error {
|
||||
hasOIDC := false
|
||||
isAuthenticated := true // Default to true if no security config is set
|
||||
if handler.securityConfig != nil {
|
||||
hasOIDC = handler.securityConfig.OIDC != nil
|
||||
isAuthenticated = handler.securityConfig.IsAuthenticated(c)
|
||||
}
|
||||
// Return the config
|
||||
c.Set("Content-Type", "application/json")
|
||||
return c.Status(200).
|
||||
SendString(fmt.Sprintf(`{"oidc":%v,"authenticated":%v}`, hasOIDC, isAuthenticated))
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
package handler
|
||||
package api
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/security"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func TestConfigHandler_ServeHTTP(t *testing.T) {
|
||||
@@ -20,15 +20,27 @@ func TestConfigHandler_ServeHTTP(t *testing.T) {
|
||||
}
|
||||
handler := ConfigHandler{securityConfig: securityConfig}
|
||||
// Create a fake router. We're doing this because I need the gate to be initialized.
|
||||
securityConfig.ApplySecurityMiddleware(mux.NewRouter())
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/config", handler.GetConfig)
|
||||
err := securityConfig.ApplySecurityMiddleware(app)
|
||||
if err != nil {
|
||||
t.Error("expected err to be nil, but was", err)
|
||||
}
|
||||
// Test the config handler
|
||||
request, _ := http.NewRequest("GET", "/api/v1/config", http.NoBody)
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(responseRecorder, request)
|
||||
if responseRecorder.Code != http.StatusOK {
|
||||
t.Error("expected code to be 200, but was", responseRecorder.Code)
|
||||
response, err := app.Test(request)
|
||||
if err != nil {
|
||||
t.Error("expected err to be nil, but was", err)
|
||||
}
|
||||
if responseRecorder.Body.String() != `{"oidc":true,"authenticated":false}` {
|
||||
t.Error("expected body to be `{\"oidc\":true,\"authenticated\":false}`, but was", responseRecorder.Body.String())
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Error("expected code to be 200, but was", response.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Error("expected err to be nil, but was", err)
|
||||
}
|
||||
if string(body) != `{"oidc":true,"authenticated":false}` {
|
||||
t.Error("expected body to be `{\"oidc\":true,\"authenticated\":false}`, but was", string(body))
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
package handler
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
@@ -15,53 +14,40 @@ 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/gocache/v2"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
const (
|
||||
cacheTTL = 10 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
cache = gocache.NewCache().WithMaxSize(100).WithEvictionPolicy(gocache.FirstInFirstOut)
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// EndpointStatuses handles requests to retrieve all EndpointStatus
|
||||
// Due to how intensive this operation can be on the storage, this function leverages a cache.
|
||||
func EndpointStatuses(cfg *config.Config) http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, r *http.Request) {
|
||||
page, pageSize := extractPageAndPageSizeFromRequest(r)
|
||||
func EndpointStatuses(cfg *config.Config) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
page, pageSize := extractPageAndPageSizeFromRequest(c)
|
||||
value, exists := cache.Get(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize))
|
||||
var data []byte
|
||||
if !exists {
|
||||
var err error
|
||||
endpointStatuses, err := store.Get().GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(page, pageSize))
|
||||
if err != nil {
|
||||
log.Printf("[handler][EndpointStatuses] Failed to retrieve endpoint statuses: %s", err.Error())
|
||||
http.Error(writer, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
log.Printf("[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())
|
||||
log.Printf("[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("[handler][EndpointStatuses] Unable to marshal object to JSON: %s", err.Error())
|
||||
http.Error(writer, "unable to marshal object to JSON", http.StatusInternalServerError)
|
||||
return
|
||||
log.Printf("[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)
|
||||
} else {
|
||||
data = value.([]byte)
|
||||
}
|
||||
writer.Header().Add("Content-Type", "application/json")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
_, _ = writer.Write(data)
|
||||
c.Set("Content-Type", "application/json")
|
||||
return c.Status(200).Send(data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,13 +65,13 @@ func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*cor
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
_ = response.Body.Close()
|
||||
log.Printf("[handler][getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error())
|
||||
log.Printf("[api.getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error())
|
||||
continue
|
||||
}
|
||||
var endpointStatuses []*core.EndpointStatus
|
||||
if err = json.Unmarshal(body, &endpointStatuses); err != nil {
|
||||
_ = response.Body.Close()
|
||||
log.Printf("[handler][getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error())
|
||||
log.Printf("[api.getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error())
|
||||
continue
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
@@ -98,31 +84,25 @@ func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*cor
|
||||
}
|
||||
|
||||
// EndpointStatus retrieves a single core.EndpointStatus by group and endpoint name
|
||||
func EndpointStatus(writer http.ResponseWriter, r *http.Request) {
|
||||
page, pageSize := extractPageAndPageSizeFromRequest(r)
|
||||
vars := mux.Vars(r)
|
||||
endpointStatus, err := store.Get().GetEndpointStatusByKey(vars["key"], paging.NewEndpointStatusParams().WithResults(page, pageSize).WithEvents(1, common.MaximumNumberOfEvents))
|
||||
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 err == common.ErrEndpointNotFound {
|
||||
http.Error(writer, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||
return c.Status(404).SendString(err.Error())
|
||||
}
|
||||
log.Printf("[handler][EndpointStatus] Failed to retrieve endpoint status: %s", err.Error())
|
||||
http.Error(writer, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
log.Printf("[api.EndpointStatus] Failed to retrieve endpoint status: %s", err.Error())
|
||||
return c.Status(500).SendString(err.Error())
|
||||
}
|
||||
if endpointStatus == nil {
|
||||
log.Printf("[handler][EndpointStatus] Endpoint with key=%s not found", vars["key"])
|
||||
http.Error(writer, "not found", http.StatusNotFound)
|
||||
return
|
||||
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("[handler][EndpointStatus] Unable to marshal object to JSON: %s", err.Error())
|
||||
http.Error(writer, "unable to marshal object to JSON", http.StatusInternalServerError)
|
||||
return
|
||||
log.Printf("[api.EndpointStatus] Unable to marshal object to JSON: %s", err.Error())
|
||||
return c.Status(500).SendString("unable to marshal object to JSON")
|
||||
}
|
||||
writer.Header().Add("Content-Type", "application/json")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
_, _ = writer.Write(output)
|
||||
c.Set("Content-Type", "application/json")
|
||||
return c.Status(200).Send(output)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
package api
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -97,8 +98,8 @@ func TestEndpointStatus(t *testing.T) {
|
||||
}
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
router := CreateRouter(cfg)
|
||||
|
||||
api := New(cfg)
|
||||
router := api.Router()
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
@@ -130,14 +131,16 @@ func TestEndpointStatus(t *testing.T) {
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
request, _ := http.NewRequest("GET", scenario.Path, http.NoBody)
|
||||
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
|
||||
if scenario.Gzip {
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
}
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(responseRecorder, request)
|
||||
if responseRecorder.Code != scenario.ExpectedCode {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -153,8 +156,8 @@ 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{}
|
||||
router := CreateRouter(&config.Config{Metrics: true})
|
||||
|
||||
api := New(&config.Config{Metrics: true})
|
||||
router := api.Router()
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
@@ -196,15 +199,21 @@ func TestEndpointStatuses(t *testing.T) {
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
request, _ := http.NewRequest("GET", scenario.Path, http.NoBody)
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(responseRecorder, request)
|
||||
if responseRecorder.Code != scenario.ExpectedCode {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
|
||||
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
|
||||
response, err := router.Test(request)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
output := responseRecorder.Body.String()
|
||||
if output != scenario.ExpectedBody {
|
||||
t.Errorf("expected:\n %s\n\ngot:\n %s", scenario.ExpectedBody, output)
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != scenario.ExpectedCode {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Error("expected err to be nil, but was", err)
|
||||
}
|
||||
if string(body) != scenario.ExpectedBody {
|
||||
t.Errorf("expected:\n %s\n\ngot:\n %s", scenario.ExpectedBody, string(body))
|
||||
}
|
||||
})
|
||||
}
|
||||
67
api/external_endpoint.go
Normal file
67
api/external_endpoint.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
"github.com/TwiN/gatus/v5/core"
|
||||
"github.com/TwiN/gatus/v5/storage/store"
|
||||
"github.com/TwiN/gatus/v5/storage/store/common"
|
||||
"github.com/TwiN/gatus/v5/watchdog"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
// Check if the success query parameter is present
|
||||
success, exists := c.Queries()["success"]
|
||||
if !exists || (success != "true" && success != "false") {
|
||||
return c.Status(400).SendString("missing or invalid success query parameter")
|
||||
}
|
||||
// Check if the authorization bearer token header is correct
|
||||
authorizationHeader := string(c.Request().Header.Peek("Authorization"))
|
||||
if !strings.HasPrefix(authorizationHeader, "Bearer ") {
|
||||
return c.Status(401).SendString("invalid Authorization header")
|
||||
}
|
||||
token := strings.TrimSpace(strings.TrimPrefix(authorizationHeader, "Bearer "))
|
||||
if len(token) == 0 {
|
||||
return c.Status(401).SendString("bearer token must not be empty")
|
||||
}
|
||||
key := c.Params("key")
|
||||
externalEndpoint := cfg.GetExternalEndpointByKey(key)
|
||||
if externalEndpoint == nil {
|
||||
log.Printf("[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)
|
||||
return c.Status(401).SendString("invalid token")
|
||||
}
|
||||
// Persist the result in the storage
|
||||
result := &core.Result{
|
||||
Timestamp: time.Now(),
|
||||
Success: c.QueryBool("success"),
|
||||
Errors: []string{},
|
||||
}
|
||||
convertedEndpoint := externalEndpoint.ToEndpoint()
|
||||
if err := store.Get().Insert(convertedEndpoint, result); err != nil {
|
||||
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||
return c.Status(404).SendString(err.Error())
|
||||
}
|
||||
log.Printf("[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)
|
||||
// Check if an alert should be triggered or resolved
|
||||
if !cfg.Maintenance.IsUnderMaintenance() {
|
||||
watchdog.HandleAlerting(convertedEndpoint, result, cfg.Alerting, cfg.Debug)
|
||||
externalEndpoint.NumberOfSuccessesInARow = convertedEndpoint.NumberOfSuccessesInARow
|
||||
externalEndpoint.NumberOfFailuresInARow = convertedEndpoint.NumberOfFailuresInARow
|
||||
}
|
||||
// Return the result
|
||||
return c.Status(200).SendString("")
|
||||
}
|
||||
}
|
||||
131
api/external_endpoint_test.go
Normal file
131
api/external_endpoint_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting"
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/discord"
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
"github.com/TwiN/gatus/v5/config/maintenance"
|
||||
"github.com/TwiN/gatus/v5/core"
|
||||
"github.com/TwiN/gatus/v5/storage/store"
|
||||
"github.com/TwiN/gatus/v5/storage/store/common/paging"
|
||||
)
|
||||
|
||||
func TestCreateExternalEndpointResult(t *testing.T) {
|
||||
defer store.Get().Clear()
|
||||
defer cache.Clear()
|
||||
cfg := &config.Config{
|
||||
Alerting: &alerting.Config{
|
||||
Discord: &discord.AlertProvider{},
|
||||
},
|
||||
ExternalEndpoints: []*core.ExternalEndpoint{
|
||||
{
|
||||
Name: "n",
|
||||
Group: "g",
|
||||
Token: "token",
|
||||
Alerts: []*alert.Alert{
|
||||
{
|
||||
Type: alert.TypeDiscord,
|
||||
FailureThreshold: 2,
|
||||
SuccessThreshold: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Maintenance: &maintenance.Config{},
|
||||
}
|
||||
api := New(cfg)
|
||||
router := api.Router()
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Path string
|
||||
AuthorizationHeaderBearerToken string
|
||||
ExpectedCode int
|
||||
}{
|
||||
{
|
||||
Name: "no-token",
|
||||
Path: "/api/v1/endpoints/g_n/external?success=true",
|
||||
AuthorizationHeaderBearerToken: "",
|
||||
ExpectedCode: 401,
|
||||
},
|
||||
{
|
||||
Name: "bad-token",
|
||||
Path: "/api/v1/endpoints/g_n/external?success=true",
|
||||
AuthorizationHeaderBearerToken: "Bearer bad-token",
|
||||
ExpectedCode: 401,
|
||||
},
|
||||
{
|
||||
Name: "bad-key",
|
||||
Path: "/api/v1/endpoints/bad_key/external?success=true",
|
||||
AuthorizationHeaderBearerToken: "Bearer token",
|
||||
ExpectedCode: 404,
|
||||
},
|
||||
{
|
||||
Name: "good-token-success-true",
|
||||
Path: "/api/v1/endpoints/g_n/external?success=true",
|
||||
AuthorizationHeaderBearerToken: "Bearer token",
|
||||
ExpectedCode: 200,
|
||||
},
|
||||
{
|
||||
Name: "good-token-success-false",
|
||||
Path: "/api/v1/endpoints/g_n/external?success=false",
|
||||
AuthorizationHeaderBearerToken: "Bearer token",
|
||||
ExpectedCode: 200,
|
||||
},
|
||||
{
|
||||
Name: "good-token-success-false-again",
|
||||
Path: "/api/v1/endpoints/g_n/external?success=false",
|
||||
AuthorizationHeaderBearerToken: "Bearer token",
|
||||
ExpectedCode: 200,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
request := httptest.NewRequest("POST", scenario.Path, http.NoBody)
|
||||
if len(scenario.AuthorizationHeaderBearerToken) > 0 {
|
||||
request.Header.Set("Authorization", scenario.AuthorizationHeaderBearerToken)
|
||||
}
|
||||
response, err := router.Test(request)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != scenario.ExpectedCode {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("verify-end-results", func(t *testing.T) {
|
||||
endpointStatus, err := store.Get().GetEndpointStatus("g", "n", paging.NewEndpointStatusParams().WithResults(1, 10))
|
||||
if err != nil {
|
||||
t.Errorf("failed to get endpoint status: %s", err.Error())
|
||||
return
|
||||
}
|
||||
if endpointStatus.Key != "g_n" {
|
||||
t.Errorf("expected key to be g_n but got %s", endpointStatus.Key)
|
||||
}
|
||||
if len(endpointStatus.Results) != 3 {
|
||||
t.Errorf("expected 3 results but got %d", len(endpointStatus.Results))
|
||||
}
|
||||
if !endpointStatus.Results[0].Success {
|
||||
t.Errorf("expected first result to be successful")
|
||||
}
|
||||
if endpointStatus.Results[1].Success {
|
||||
t.Errorf("expected second result to be unsuccessful")
|
||||
}
|
||||
if endpointStatus.Results[2].Success {
|
||||
t.Errorf("expected third result to be unsuccessful")
|
||||
}
|
||||
externalEndpointFromConfig := cfg.GetExternalEndpointByKey("g_n")
|
||||
if externalEndpointFromConfig.NumberOfFailuresInARow != 2 {
|
||||
t.Errorf("expected 2 failures in a row but got %d", externalEndpointFromConfig.NumberOfFailuresInARow)
|
||||
}
|
||||
if externalEndpointFromConfig.NumberOfSuccessesInARow != 0 {
|
||||
t.Errorf("expected 0 successes in a row but got %d", externalEndpointFromConfig.NumberOfSuccessesInARow)
|
||||
}
|
||||
})
|
||||
}
|
||||
30
api/spa.go
Normal file
30
api/spa.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"html/template"
|
||||
"log"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config/ui"
|
||||
static "github.com/TwiN/gatus/v5/web"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func SinglePageApplication(ui *ui.Config) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
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())
|
||||
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)
|
||||
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())
|
||||
return c.Status(500).SendString("Failed to parse template. This should never happen, because the template is validated on start.")
|
||||
}
|
||||
return c.SendStatus(200)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
package handler
|
||||
package api
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
"github.com/TwiN/gatus/v5/config/ui"
|
||||
"github.com/TwiN/gatus/v5/core"
|
||||
"github.com/TwiN/gatus/v5/storage/store"
|
||||
"github.com/TwiN/gatus/v5/watchdog"
|
||||
@@ -27,10 +30,14 @@ func TestSinglePageApplication(t *testing.T) {
|
||||
Group: "core",
|
||||
},
|
||||
},
|
||||
UI: &ui.Config{
|
||||
Title: "example-title",
|
||||
},
|
||||
}
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
router := CreateRouter(cfg)
|
||||
api := New(cfg)
|
||||
router := api.Router()
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
@@ -41,24 +48,31 @@ func TestSinglePageApplication(t *testing.T) {
|
||||
{
|
||||
Name: "frontend-home",
|
||||
Path: "/",
|
||||
ExpectedCode: http.StatusOK,
|
||||
ExpectedCode: 200,
|
||||
},
|
||||
{
|
||||
Name: "frontend-endpoint",
|
||||
Path: "/endpoints/core_frontend",
|
||||
ExpectedCode: http.StatusOK,
|
||||
ExpectedCode: 200,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
request, _ := http.NewRequest("GET", scenario.Path, http.NoBody)
|
||||
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
|
||||
if scenario.Gzip {
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
}
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(responseRecorder, request)
|
||||
if responseRecorder.Code != scenario.ExpectedCode {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
|
||||
response, err := router.Test(request)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != scenario.ExpectedCode {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
if !strings.Contains(string(body), cfg.UI.Title) {
|
||||
t.Errorf("%s %s should have contained the title", request.Method, request.URL)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
package handler
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/TwiN/gatus/v5/storage/store/common"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -18,9 +18,9 @@ const (
|
||||
MaximumPageSize = common.MaximumNumberOfResults
|
||||
)
|
||||
|
||||
func extractPageAndPageSizeFromRequest(r *http.Request) (page int, pageSize int) {
|
||||
func extractPageAndPageSizeFromRequest(c *fiber.Ctx) (page, pageSize int) {
|
||||
var err error
|
||||
if pageParameter := r.URL.Query().Get("page"); len(pageParameter) == 0 {
|
||||
if pageParameter := c.Query("page"); len(pageParameter) == 0 {
|
||||
page = DefaultPage
|
||||
} else {
|
||||
page, err = strconv.Atoi(pageParameter)
|
||||
@@ -31,7 +31,7 @@ func extractPageAndPageSizeFromRequest(r *http.Request) (page int, pageSize int)
|
||||
page = DefaultPage
|
||||
}
|
||||
}
|
||||
if pageSizeParameter := r.URL.Query().Get("pageSize"); len(pageSizeParameter) == 0 {
|
||||
if pageSizeParameter := c.Query("pageSize"); len(pageSizeParameter) == 0 {
|
||||
pageSize = DefaultPageSize
|
||||
} else {
|
||||
pageSize, err = strconv.Atoi(pageSizeParameter)
|
||||
@@ -1,9 +1,11 @@
|
||||
package handler
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
func TestExtractPageAndPageSizeFromRequest(t *testing.T) {
|
||||
@@ -54,8 +56,12 @@ func TestExtractPageAndPageSizeFromRequest(t *testing.T) {
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run("page-"+scenario.Page+"-pageSize-"+scenario.PageSize, func(t *testing.T) {
|
||||
request, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/statuses?page=%s&pageSize=%s", scenario.Page, scenario.PageSize), http.NoBody)
|
||||
actualPage, actualPageSize := extractPageAndPageSizeFromRequest(request)
|
||||
//request := httptest.NewRequest("GET", fmt.Sprintf("/api/v1/statuses?page=%s&pageSize=%s", scenario.Page, scenario.PageSize), http.NoBody)
|
||||
app := fiber.New()
|
||||
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)
|
||||
if actualPage != scenario.ExpectedPage {
|
||||
t.Errorf("expected %d, got %d", scenario.ExpectedPage, actualPage)
|
||||
}
|
||||
125
client/client.go
125
client/client.go
@@ -3,6 +3,7 @@ package client
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
@@ -14,8 +15,10 @@ import (
|
||||
|
||||
"github.com/TwiN/gocache/v2"
|
||||
"github.com/TwiN/whois"
|
||||
"github.com/go-ping/ping"
|
||||
"github.com/ishidawataru/sctp"
|
||||
ping "github.com/prometheus-community/pro-bing"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/net/websocket"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -142,35 +145,107 @@ func CanPerformStartTLS(address string, config *Config) (connected bool, certifi
|
||||
|
||||
// CanPerformTLS checks whether a connection can be established to an address using the TLS protocol
|
||||
func CanPerformTLS(address string, config *Config) (connected bool, certificate *x509.Certificate, err error) {
|
||||
connection, err := tls.DialWithDialer(&net.Dialer{Timeout: config.Timeout}, "tcp", address, nil)
|
||||
connection, err := tls.DialWithDialer(&net.Dialer{Timeout: config.Timeout}, "tcp", address, &tls.Config{
|
||||
InsecureSkipVerify: config.Insecure,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer connection.Close()
|
||||
verifiedChains := connection.ConnectionState().VerifiedChains
|
||||
// If config.Insecure is set to true, verifiedChains will be an empty list []
|
||||
// We should get the parsed certificates from PeerCertificates, it can't be empty on the client side
|
||||
// Reference: https://pkg.go.dev/crypto/tls#PeerCertificates
|
||||
if len(verifiedChains) == 0 || len(verifiedChains[0]) == 0 {
|
||||
return
|
||||
peerCertificates := connection.ConnectionState().PeerCertificates
|
||||
return true, peerCertificates[0], nil
|
||||
}
|
||||
return true, verifiedChains[0][0], nil
|
||||
}
|
||||
|
||||
// CanCreateSSHConnection checks whether a connection can be established and a command can be executed to an address
|
||||
// using the SSH protocol.
|
||||
func CanCreateSSHConnection(address, username, password string, config *Config) (bool, *ssh.Client, error) {
|
||||
var port string
|
||||
if strings.Contains(address, ":") {
|
||||
addressAndPort := strings.Split(address, ":")
|
||||
if len(addressAndPort) != 2 {
|
||||
return false, nil, errors.New("invalid address for ssh, format must be host:port")
|
||||
}
|
||||
address = addressAndPort[0]
|
||||
port = addressAndPort[1]
|
||||
} else {
|
||||
port = "22"
|
||||
}
|
||||
|
||||
cli, err := ssh.Dial("tcp", strings.Join([]string{address, port}, ":"), &ssh.ClientConfig{
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
User: username,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.Password(password),
|
||||
},
|
||||
Timeout: config.Timeout,
|
||||
})
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
|
||||
return true, cli, nil
|
||||
}
|
||||
|
||||
// ExecuteSSHCommand executes a command to an address using the SSH protocol.
|
||||
func ExecuteSSHCommand(sshClient *ssh.Client, body string, config *Config) (bool, int, error) {
|
||||
type Body struct {
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
defer sshClient.Close()
|
||||
|
||||
var b Body
|
||||
if err := json.Unmarshal([]byte(body), &b); err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
|
||||
sess, err := sshClient.NewSession()
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
|
||||
err = sess.Start(b.Command)
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
|
||||
defer sess.Close()
|
||||
|
||||
err = sess.Wait()
|
||||
if err == nil {
|
||||
return true, 0, nil
|
||||
}
|
||||
|
||||
e, ok := err.(*ssh.ExitError)
|
||||
if !ok {
|
||||
return false, 0, err
|
||||
}
|
||||
|
||||
return true, e.ExitStatus(), nil
|
||||
}
|
||||
|
||||
// Ping checks if an address can be pinged and returns the round-trip time if the address can be pinged
|
||||
//
|
||||
// Note that this function takes at least 100ms, even if the address is 127.0.0.1
|
||||
func Ping(address string, config *Config) (bool, time.Duration) {
|
||||
pinger, err := ping.NewPinger(address)
|
||||
if err != nil {
|
||||
return false, 0
|
||||
}
|
||||
pinger := ping.New(address)
|
||||
pinger.Count = 1
|
||||
pinger.Timeout = config.Timeout
|
||||
// Set the pinger's privileged mode to true for every GOOS except darwin
|
||||
// See https://github.com/TwiN/gatus/issues/132
|
||||
//
|
||||
// Note that for this to work on Linux, Gatus must run with sudo privileges.
|
||||
// See https://github.com/go-ping/ping#linux
|
||||
// See https://github.com/prometheus-community/pro-bing#linux
|
||||
pinger.SetPrivileged(runtime.GOOS != "darwin")
|
||||
err = pinger.Run()
|
||||
pinger.SetNetwork(config.Network)
|
||||
err := pinger.Run()
|
||||
if err != nil {
|
||||
return false, 0
|
||||
}
|
||||
@@ -184,6 +259,38 @@ func Ping(address string, config *Config) (bool, time.Duration) {
|
||||
return true, 0
|
||||
}
|
||||
|
||||
// QueryWebSocket opens a websocket connection, write `body` and return a message from the server
|
||||
func QueryWebSocket(address, body string, config *Config) (bool, []byte, error) {
|
||||
const (
|
||||
Origin = "http://localhost/"
|
||||
MaximumMessageSize = 1024 // in bytes
|
||||
)
|
||||
wsConfig, err := websocket.NewConfig(address, Origin)
|
||||
if err != nil {
|
||||
return false, nil, fmt.Errorf("error configuring websocket connection: %w", err)
|
||||
}
|
||||
if config != nil {
|
||||
wsConfig.Dialer = &net.Dialer{Timeout: config.Timeout}
|
||||
}
|
||||
// Dial URL
|
||||
ws, err := websocket.DialConfig(wsConfig)
|
||||
if err != nil {
|
||||
return false, nil, fmt.Errorf("error dialing websocket: %w", err)
|
||||
}
|
||||
defer ws.Close()
|
||||
// Write message
|
||||
if _, err := ws.Write([]byte(body)); err != nil {
|
||||
return false, nil, fmt.Errorf("error writing websocket body: %w", err)
|
||||
}
|
||||
// Read message
|
||||
var n int
|
||||
msg := make([]byte, MaximumMessageSize)
|
||||
if n, err = ws.Read(msg); err != nil {
|
||||
return false, nil, fmt.Errorf("error reading websocket message: %w", err)
|
||||
}
|
||||
return true, msg[:n], nil
|
||||
}
|
||||
|
||||
// InjectHTTPClient is used to inject a custom HTTP client for testing purposes
|
||||
func InjectHTTPClient(httpClient *http.Client) {
|
||||
injectedHTTPClient = httpClient
|
||||
|
||||
@@ -2,6 +2,7 @@ package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
@@ -83,6 +84,32 @@ func TestPing(t *testing.T) {
|
||||
t.Error("Round-trip time returned on failure should've been 0")
|
||||
}
|
||||
}
|
||||
// Can't perform integration tests (e.g. pinging public targets by single-stacked hostname) here,
|
||||
// because ICMP is blocked in the network of GitHub-hosted runners.
|
||||
if success, rtt := Ping("127.0.0.1", &Config{Timeout: 500 * time.Millisecond, Network: "ip"}); !success {
|
||||
t.Error("expected true")
|
||||
if rtt == 0 {
|
||||
t.Error("Round-trip time returned on failure should've been 0")
|
||||
}
|
||||
}
|
||||
if success, rtt := Ping("::1", &Config{Timeout: 500 * time.Millisecond, Network: "ip"}); !success {
|
||||
t.Error("expected true")
|
||||
if rtt == 0 {
|
||||
t.Error("Round-trip time returned on failure should've been 0")
|
||||
}
|
||||
}
|
||||
if success, rtt := Ping("::1", &Config{Timeout: 500 * time.Millisecond, Network: "ip4"}); success {
|
||||
t.Error("expected false, because the IP isn't an IPv4 address")
|
||||
if rtt != 0 {
|
||||
t.Error("Round-trip time returned on failure should've been 0")
|
||||
}
|
||||
}
|
||||
if success, rtt := Ping("127.0.0.1", &Config{Timeout: 500 * time.Millisecond, Network: "ip6"}); success {
|
||||
t.Error("expected false, because the IP isn't an IPv6 address")
|
||||
if rtt != 0 {
|
||||
t.Error("Round-trip time returned on failure should've been 0")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanPerformStartTLS(t *testing.T) {
|
||||
@@ -191,6 +218,9 @@ func TestCanCreateTCPConnection(t *testing.T) {
|
||||
if CanCreateTCPConnection("127.0.0.1", &Config{Timeout: 5 * time.Second}) {
|
||||
t.Error("should've failed, because there's no port in the address")
|
||||
}
|
||||
if !CanCreateTCPConnection("1.1.1.1:53", &Config{Timeout: 5 * time.Second}) {
|
||||
t.Error("should've succeeded, because that IP should always™ be up")
|
||||
}
|
||||
}
|
||||
|
||||
// This test checks if a HTTP client configured with `configureOAuth2()` automatically
|
||||
@@ -247,6 +277,60 @@ func TestHttpClientProvidesOAuth2BearerToken(t *testing.T) {
|
||||
// to us as `X-Org-Authorization` header, we check here if the value matches
|
||||
// our expected token `secret-token`
|
||||
if response.Header.Get("X-Org-Authorization") != "Bearer secret-token" {
|
||||
t.Error("exptected `secret-token` as Bearer token in the mocked response header `X-Org-Authorization`, but got", response.Header.Get("X-Org-Authorization"))
|
||||
t.Error("expected `secret-token` as Bearer token in the mocked response header `X-Org-Authorization`, but got", response.Header.Get("X-Org-Authorization"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryWebSocket(t *testing.T) {
|
||||
_, _, err := QueryWebSocket("", "body", &Config{Timeout: 2 * time.Second})
|
||||
if err == nil {
|
||||
t.Error("expected an error due to the address being invalid")
|
||||
}
|
||||
_, _, err = QueryWebSocket("ws://example.org", "body", &Config{Timeout: 2 * time.Second})
|
||||
if err == nil {
|
||||
t.Error("expected an error due to the target not being websocket-friendly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTlsRenegotiation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg TLSConfig
|
||||
expectedConfig tls.RenegotiationSupport
|
||||
}{
|
||||
{
|
||||
name: "default",
|
||||
cfg: TLSConfig{CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/cert.key"},
|
||||
expectedConfig: tls.RenegotiateNever,
|
||||
},
|
||||
{
|
||||
name: "never",
|
||||
cfg: TLSConfig{RenegotiationSupport: "never", CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/cert.key"},
|
||||
expectedConfig: tls.RenegotiateNever,
|
||||
},
|
||||
{
|
||||
name: "once",
|
||||
cfg: TLSConfig{RenegotiationSupport: "once", CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/cert.key"},
|
||||
expectedConfig: tls.RenegotiateOnceAsClient,
|
||||
},
|
||||
{
|
||||
name: "freely",
|
||||
cfg: TLSConfig{RenegotiationSupport: "freely", CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/cert.key"},
|
||||
expectedConfig: tls.RenegotiateFreelyAsClient,
|
||||
},
|
||||
{
|
||||
name: "not-valid-and-broken",
|
||||
cfg: TLSConfig{RenegotiationSupport: "invalid", CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/cert.key"},
|
||||
expectedConfig: tls.RenegotiateNever,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
tls := &tls.Config{}
|
||||
tlsConfig := configureTLS(tls, test.cfg)
|
||||
if tlsConfig.Renegotiation != test.expectedConfig {
|
||||
t.Errorf("expected tls renegotiation to be %v, but got %v", test.expectedConfig, tls.Renegotiation)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
161
client/config.go
161
client/config.go
@@ -7,27 +7,32 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/clientcredentials"
|
||||
"google.golang.org/api/idtoken"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultHTTPTimeout = 10 * time.Second
|
||||
defaultTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidDNSResolver = errors.New("invalid DNS resolver specified. Required format is {proto}://{ip}:{port}")
|
||||
ErrInvalidDNSResolverPort = errors.New("invalid DNS resolver port")
|
||||
ErrInvalidClientOAuth2Config = errors.New("invalid oauth2 configuration: must define all fields for client credentials flow (token-url, client-id, client-secret, scopes)")
|
||||
ErrInvalidClientIAPConfig = errors.New("invalid Identity-Aware-Proxy configuration: must define all fields for Google Identity-Aware-Proxy programmatic authentication (audience)")
|
||||
ErrInvalidClientTLSConfig = errors.New("invalid TLS configuration: certificate-file and private-key-file must be specified")
|
||||
|
||||
defaultConfig = Config{
|
||||
Insecure: false,
|
||||
IgnoreRedirect: false,
|
||||
Timeout: defaultHTTPTimeout,
|
||||
Timeout: defaultTimeout,
|
||||
Network: "ip",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -39,6 +44,9 @@ func GetDefaultConfig() *Config {
|
||||
|
||||
// Config is the configuration for clients
|
||||
type Config struct {
|
||||
// ProxyURL is the URL of the proxy to use for the client
|
||||
ProxyURL string `yaml:"proxy-url,omitempty"`
|
||||
|
||||
// Insecure determines whether to skip verifying the server's certificate chain and host name
|
||||
Insecure bool `yaml:"insecure,omitempty"`
|
||||
|
||||
@@ -58,7 +66,16 @@ type Config struct {
|
||||
// See configureOAuth2 for more details.
|
||||
OAuth2Config *OAuth2Config `yaml:"oauth2,omitempty"`
|
||||
|
||||
// IAPConfig is the Google Cloud Identity-Aware-Proxy configuration used for the client. (e.g. audience)
|
||||
IAPConfig *IAPConfig `yaml:"identity-aware-proxy,omitempty"`
|
||||
|
||||
httpClient *http.Client
|
||||
|
||||
// Network (ip, ip4 or ip6) for the ICMP client
|
||||
Network string `yaml:"network"`
|
||||
|
||||
// TLS configuration (optional)
|
||||
TLS *TLSConfig `yaml:"tls,omitempty"`
|
||||
}
|
||||
|
||||
// DNSResolverConfig is the parsed configuration from the DNSResolver config string.
|
||||
@@ -76,6 +93,22 @@ type OAuth2Config struct {
|
||||
Scopes []string `yaml:"scopes"` // e.g. ["openid"]
|
||||
}
|
||||
|
||||
// IAPConfig is the configuration for the Google Cloud Identity-Aware-Proxy
|
||||
type IAPConfig struct {
|
||||
Audience string `yaml:"audience"` // e.g. "toto.apps.googleusercontent.com"
|
||||
}
|
||||
|
||||
// TLSConfig is the configuration for mTLS configurations
|
||||
type TLSConfig struct {
|
||||
// CertificateFile is the public certificate for TLS in PEM format.
|
||||
CertificateFile string `yaml:"certificate-file,omitempty"`
|
||||
|
||||
// PrivateKeyFile is the private key file for TLS in PEM format.
|
||||
PrivateKeyFile string `yaml:"private-key-file,omitempty"`
|
||||
|
||||
RenegotiationSupport string `yaml:"renegotiation,omitempty"`
|
||||
}
|
||||
|
||||
// ValidateAndSetDefaults validates the client configuration and sets the default values if necessary
|
||||
func (c *Config) ValidateAndSetDefaults() error {
|
||||
if c.Timeout < time.Millisecond {
|
||||
@@ -90,6 +123,14 @@ func (c *Config) ValidateAndSetDefaults() error {
|
||||
if c.HasOAuth2Config() && !c.OAuth2Config.isValid() {
|
||||
return ErrInvalidClientOAuth2Config
|
||||
}
|
||||
if c.HasIAPConfig() && !c.IAPConfig.isValid() {
|
||||
return ErrInvalidClientIAPConfig
|
||||
}
|
||||
if c.HasTlsConfig() {
|
||||
if err := c.TLS.isValid(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -130,13 +171,46 @@ func (c *Config) HasOAuth2Config() bool {
|
||||
return c.OAuth2Config != nil
|
||||
}
|
||||
|
||||
// HasIAPConfig returns true if the client has IAP configuration parameters
|
||||
func (c *Config) HasIAPConfig() bool {
|
||||
return c.IAPConfig != nil
|
||||
}
|
||||
|
||||
// HasTlsConfig returns true if the client has client certificate parameters
|
||||
func (c *Config) HasTlsConfig() bool {
|
||||
return c.TLS != nil && len(c.TLS.CertificateFile) > 0 && len(c.TLS.PrivateKeyFile) > 0
|
||||
}
|
||||
|
||||
// isValid() returns true if the IAP configuration is valid
|
||||
func (c *IAPConfig) isValid() bool {
|
||||
return len(c.Audience) > 0
|
||||
}
|
||||
|
||||
// isValid() returns true if the OAuth2 configuration is valid
|
||||
func (c *OAuth2Config) isValid() bool {
|
||||
return len(c.TokenURL) > 0 && len(c.ClientID) > 0 && len(c.ClientSecret) > 0 && len(c.Scopes) > 0
|
||||
}
|
||||
|
||||
// isValid() returns nil if the client tls certificates are valid, otherwise returns an error
|
||||
func (t *TLSConfig) isValid() error {
|
||||
if len(t.CertificateFile) > 0 && len(t.PrivateKeyFile) > 0 {
|
||||
_, err := tls.LoadX509KeyPair(t.CertificateFile, t.PrivateKeyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return ErrInvalidClientTLSConfig
|
||||
}
|
||||
|
||||
// GetHTTPClient return an HTTP client matching the Config's parameters.
|
||||
func (c *Config) getHTTPClient() *http.Client {
|
||||
tlsConfig := &tls.Config{
|
||||
InsecureSkipVerify: c.Insecure,
|
||||
}
|
||||
if c.HasTlsConfig() && c.TLS.isValid() == nil {
|
||||
tlsConfig = configureTLS(tlsConfig, *c.TLS)
|
||||
}
|
||||
if c.httpClient == nil {
|
||||
c.httpClient = &http.Client{
|
||||
Timeout: c.Timeout,
|
||||
@@ -144,9 +218,7 @@ func (c *Config) getHTTPClient() *http.Client {
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 20,
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: c.Insecure,
|
||||
},
|
||||
TLSClientConfig: tlsConfig,
|
||||
},
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if c.IgnoreRedirect {
|
||||
@@ -157,12 +229,20 @@ func (c *Config) getHTTPClient() *http.Client {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
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())
|
||||
} else {
|
||||
c.httpClient.Transport.(*http.Transport).Proxy = http.ProxyURL(proxyURL)
|
||||
}
|
||||
}
|
||||
if c.HasCustomDNSResolver() {
|
||||
dnsResolver, err := c.parseDNSResolver()
|
||||
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())
|
||||
log.Println("[client.getHTTPClient] THIS SHOULD NOT HAPPEN. Silently ignoring invalid DNS resolver due to error:", err.Error())
|
||||
} else {
|
||||
dialer := &net.Dialer{
|
||||
Resolver: &net.Resolver{
|
||||
@@ -178,13 +258,56 @@ func (c *Config) getHTTPClient() *http.Client {
|
||||
}
|
||||
}
|
||||
}
|
||||
if c.HasOAuth2Config() {
|
||||
if c.HasOAuth2Config() && c.HasIAPConfig() {
|
||||
log.Println("[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() {
|
||||
c.httpClient = configureIAP(c.httpClient, *c.IAPConfig)
|
||||
}
|
||||
}
|
||||
return c.httpClient
|
||||
}
|
||||
|
||||
// validateIAPToken returns a boolean that will define if the google identity-aware-proxy token can be fetch
|
||||
// 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())
|
||||
return false
|
||||
}
|
||||
tok, err := ts.Token()
|
||||
if err != nil {
|
||||
log.Println("[client.ValidateIAPToken] Get Identity-Aware-Proxy token failed. error:", err.Error())
|
||||
return false
|
||||
}
|
||||
payload, err := idtoken.Validate(ctx, tok.AccessToken, c.Audience)
|
||||
_ = payload
|
||||
if err != nil {
|
||||
log.Println("[client.ValidateIAPToken] Token Validation failed. error:", err.Error())
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// configureIAP returns an HTTP client that will obtain and refresh Identity-Aware-Proxy tokens as necessary.
|
||||
// The returned Client and its Transport should not be modified.
|
||||
func configureIAP(httpClient *http.Client, c IAPConfig) *http.Client {
|
||||
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient)
|
||||
if validateIAPToken(ctx, c) {
|
||||
ts, err := idtoken.NewTokenSource(ctx, c.Audience)
|
||||
if err != nil {
|
||||
log.Println("[client.ConfigureIAP] Claiming Token Source failed. error:", err.Error())
|
||||
return httpClient
|
||||
}
|
||||
client := oauth2.NewClient(ctx, ts)
|
||||
client.Timeout = httpClient.Timeout
|
||||
return client
|
||||
}
|
||||
return httpClient
|
||||
}
|
||||
|
||||
// configureOAuth2 returns an HTTP client that will obtain and refresh tokens as necessary.
|
||||
// The returned Client and its Transport should not be modified.
|
||||
func configureOAuth2(httpClient *http.Client, c OAuth2Config) *http.Client {
|
||||
@@ -195,5 +318,27 @@ func configureOAuth2(httpClient *http.Client, c OAuth2Config) *http.Client {
|
||||
TokenURL: c.TokenURL,
|
||||
}
|
||||
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient)
|
||||
return oauth2cfg.Client(ctx)
|
||||
client := oauth2cfg.Client(ctx)
|
||||
client.Timeout = httpClient.Timeout
|
||||
return client
|
||||
}
|
||||
|
||||
// configureTLS returns a TLS Config that will enable mTLS
|
||||
func configureTLS(tlsConfig *tls.Config, c TLSConfig) *tls.Config {
|
||||
clientTLSCert, err := tls.LoadX509KeyPair(c.CertificateFile, c.PrivateKeyFile)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
tlsConfig.Certificates = []tls.Certificate{clientTLSCert}
|
||||
tlsConfig.Renegotiation = tls.RenegotiateNever
|
||||
|
||||
renegotionSupport := map[string]tls.RenegotiationSupport{
|
||||
"once": tls.RenegotiateOnceAsClient,
|
||||
"freely": tls.RenegotiateFreelyAsClient,
|
||||
"never": tls.RenegotiateNever,
|
||||
}
|
||||
if val, ok := renegotionSupport[c.RenegotiationSupport]; ok {
|
||||
tlsConfig.Renegotiation = val
|
||||
}
|
||||
return tlsConfig
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package client
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -13,7 +14,7 @@ func TestConfig_getHTTPClient(t *testing.T) {
|
||||
if !(insecureClient.Transport).(*http.Transport).TLSClientConfig.InsecureSkipVerify {
|
||||
t.Error("expected Config.Insecure set to true to cause the HTTP client to skip certificate verification")
|
||||
}
|
||||
if insecureClient.Timeout != defaultHTTPTimeout {
|
||||
if insecureClient.Timeout != defaultTimeout {
|
||||
t.Error("expected Config.Timeout to default the HTTP client to a timeout of 10s")
|
||||
}
|
||||
request, _ := http.NewRequest("GET", "", nil)
|
||||
@@ -79,3 +80,92 @@ func TestConfig_ValidateAndSetDefaults_withCustomDNSResolver(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_getHTTPClient_withCustomProxyURL(t *testing.T) {
|
||||
proxyURL := "http://proxy.example.com:8080"
|
||||
cfg := &Config{
|
||||
ProxyURL: proxyURL,
|
||||
}
|
||||
cfg.ValidateAndSetDefaults()
|
||||
client := cfg.getHTTPClient()
|
||||
transport := client.Transport.(*http.Transport)
|
||||
if transport.Proxy == nil {
|
||||
t.Errorf("expected Config.ProxyURL to set the HTTP client's proxy to %s", proxyURL)
|
||||
}
|
||||
req := &http.Request{
|
||||
URL: &url.URL{
|
||||
Scheme: "http",
|
||||
Host: "www.example.com",
|
||||
},
|
||||
}
|
||||
expectProxyURL, err := transport.Proxy(req)
|
||||
if err != nil {
|
||||
t.Errorf("can't proxy the request %s", proxyURL)
|
||||
}
|
||||
if proxyURL != expectProxyURL.String() {
|
||||
t.Errorf("expected Config.ProxyURL to set the HTTP client's proxy to %s", proxyURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_TlsIsValid(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *Config
|
||||
expectedErr bool
|
||||
}{
|
||||
{
|
||||
name: "good-tls-config",
|
||||
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/cert.key"}},
|
||||
expectedErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing-certificate-file",
|
||||
cfg: &Config{TLS: &TLSConfig{CertificateFile: "doesnotexist", PrivateKeyFile: "../testdata/cert.key"}},
|
||||
expectedErr: true,
|
||||
},
|
||||
{
|
||||
name: "bad-certificate-file",
|
||||
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../testdata/badcert.pem", PrivateKeyFile: "../testdata/cert.key"}},
|
||||
expectedErr: true,
|
||||
},
|
||||
{
|
||||
name: "no-certificate-file",
|
||||
cfg: &Config{TLS: &TLSConfig{CertificateFile: "", PrivateKeyFile: "../testdata/cert.key"}},
|
||||
expectedErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing-private-key-file",
|
||||
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "doesnotexist"}},
|
||||
expectedErr: true,
|
||||
},
|
||||
{
|
||||
name: "no-private-key-file",
|
||||
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../testdata/cert.pem", PrivateKeyFile: ""}},
|
||||
expectedErr: true,
|
||||
},
|
||||
{
|
||||
name: "bad-private-key-file",
|
||||
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/badcert.key"}},
|
||||
expectedErr: true,
|
||||
},
|
||||
{
|
||||
name: "bad-certificate-and-private-key-file",
|
||||
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../testdata/badcert.pem", PrivateKeyFile: "../testdata/badcert.key"}},
|
||||
expectedErr: true,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := test.cfg.TLS.isValid()
|
||||
if (err != nil) != test.expectedErr {
|
||||
t.Errorf("expected the existence of an error to be %v, got %v", test.expectedErr, err)
|
||||
return
|
||||
}
|
||||
if !test.expectedErr {
|
||||
if test.cfg.TLS.isValid() != nil {
|
||||
t.Error("cfg.TLS.isValid() returned an error even though no error was expected")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,14 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/deepmerge"
|
||||
"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/config/connectivity"
|
||||
"github.com/TwiN/gatus/v5/config/maintenance"
|
||||
"github.com/TwiN/gatus/v5/config/remote"
|
||||
"github.com/TwiN/gatus/v5/config/ui"
|
||||
@@ -65,15 +67,18 @@ type Config struct {
|
||||
// Disabling this may lead to inaccurate response times
|
||||
DisableMonitoringLock bool `yaml:"disable-monitoring-lock,omitempty"`
|
||||
|
||||
// Security Configuration for securing access to Gatus
|
||||
// Security is the configuration for securing access to Gatus
|
||||
Security *security.Config `yaml:"security,omitempty"`
|
||||
|
||||
// Alerting Configuration for alerting
|
||||
// Alerting is the configuration for alerting providers
|
||||
Alerting *alerting.Config `yaml:"alerting,omitempty"`
|
||||
|
||||
// Endpoints List of endpoints to monitor
|
||||
// Endpoints is the list of endpoints to monitor
|
||||
Endpoints []*core.Endpoint `yaml:"endpoints,omitempty"`
|
||||
|
||||
// ExternalEndpoints is the list of all external endpoints
|
||||
ExternalEndpoints []*core.ExternalEndpoint `yaml:"external-endpoints,omitempty"`
|
||||
|
||||
// Storage is the configuration for how the data is stored
|
||||
Storage *storage.Config `yaml:"storage,omitempty"`
|
||||
|
||||
@@ -90,12 +95,14 @@ type Config struct {
|
||||
// WARNING: This is in ALPHA and may change or be completely removed in the future
|
||||
Remote *remote.Config `yaml:"remote,omitempty"`
|
||||
|
||||
// Connectivity is the configuration for connectivity
|
||||
Connectivity *connectivity.Config `yaml:"connectivity,omitempty"`
|
||||
|
||||
configPath string // path to the file or directory from which config was loaded
|
||||
lastFileModTime time.Time // last modification time
|
||||
}
|
||||
|
||||
func (config *Config) GetEndpointByKey(key string) *core.Endpoint {
|
||||
// TODO: Should probably add a mutex here to prevent concurrent access
|
||||
for i := 0; i < len(config.Endpoints); i++ {
|
||||
ep := config.Endpoints[i]
|
||||
if util.ConvertGroupAndEndpointNameToKey(ep.Group, ep.Name) == key {
|
||||
@@ -105,9 +112,19 @@ func (config *Config) GetEndpointByKey(key string) *core.Endpoint {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (config *Config) GetExternalEndpointByKey(key string) *core.ExternalEndpoint {
|
||||
for i := 0; i < len(config.ExternalEndpoints); i++ {
|
||||
ee := config.ExternalEndpoints[i]
|
||||
if util.ConvertGroupAndEndpointNameToKey(ee.Group, ee.Name) == key {
|
||||
return ee
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasLoadedConfigurationBeenModified returns whether one of the file that the
|
||||
// configuration has been loaded from has been modified since it was last read
|
||||
func (config Config) HasLoadedConfigurationBeenModified() bool {
|
||||
func (config *Config) HasLoadedConfigurationBeenModified() bool {
|
||||
lastMod := config.lastFileModTime.Unix()
|
||||
fileInfo, err := os.Stat(config.configPath)
|
||||
if err != nil {
|
||||
@@ -120,7 +137,7 @@ func (config Config) HasLoadedConfigurationBeenModified() bool {
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return err == errEarlyReturn
|
||||
return errors.Is(err, errEarlyReturn)
|
||||
}
|
||||
return !fileInfo.ModTime().IsZero() && config.lastFileModTime.Unix() < fileInfo.ModTime().Unix()
|
||||
}
|
||||
@@ -130,7 +147,7 @@ func (config *Config) UpdateLastFileModTime() {
|
||||
config.lastFileModTime = time.Now()
|
||||
}
|
||||
|
||||
// LoadConfiguration loads the full configuration composed from the main configuration file
|
||||
// LoadConfiguration loads the full configuration composed of the main configuration file
|
||||
// and all composed configuration files
|
||||
func LoadConfiguration(configPath string) (*Config, error) {
|
||||
var configBytes []byte
|
||||
@@ -156,13 +173,13 @@ 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)
|
||||
log.Printf("[config.LoadConfiguration] Error walking path=%s: %s", path, err)
|
||||
return err
|
||||
}
|
||||
log.Printf("[config][LoadConfiguration] Reading configuration from %s", path)
|
||||
log.Printf("[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)
|
||||
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)
|
||||
@@ -172,7 +189,7 @@ func LoadConfiguration(configPath string) (*Config, error) {
|
||||
return nil, fmt.Errorf("error reading configuration from directory %s: %w", usedConfigPath, err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[config][LoadConfiguration] Reading configuration from configFile=%s", configPath)
|
||||
log.Printf("[config.LoadConfiguration] Reading configuration from configFile=%s", configPath)
|
||||
if data, err := os.ReadFile(usedConfigPath); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
@@ -214,8 +231,13 @@ func walkConfigDir(path string, fn fs.WalkDirFunc) error {
|
||||
|
||||
// parseAndValidateConfigBytes parses a Gatus configuration file into a Config struct and validates its parameters
|
||||
func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
|
||||
// Replace $$ with __GATUS_LITERAL_DOLLAR_SIGN__ to prevent os.ExpandEnv from treating "$$" as if it was an
|
||||
// environment variable. This allows Gatus to support literal "$" in the configuration file.
|
||||
yamlBytes = []byte(strings.ReplaceAll(string(yamlBytes), "$$", "__GATUS_LITERAL_DOLLAR_SIGN__"))
|
||||
// Expand environment variables
|
||||
yamlBytes = []byte(os.ExpandEnv(string(yamlBytes)))
|
||||
// Replace __GATUS_LITERAL_DOLLAR_SIGN__ with "$" to restore the literal "$" in the configuration file
|
||||
yamlBytes = []byte(strings.ReplaceAll(string(yamlBytes), "__GATUS_LITERAL_DOLLAR_SIGN__", "$"))
|
||||
// Parse configuration file
|
||||
if err = yaml.Unmarshal(yamlBytes, &config); err != nil {
|
||||
return
|
||||
@@ -231,6 +253,9 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
|
||||
if err := validateEndpointsConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateExternalEndpointsConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateWebConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -246,10 +271,20 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
|
||||
if err := validateRemoteConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateConnectivityConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func validateConnectivityConfig(config *Config) error {
|
||||
if config.Connectivity != nil {
|
||||
return config.Connectivity.ValidateAndSetDefaults()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRemoteConfig(config *Config) error {
|
||||
if config.Remote != nil {
|
||||
if err := config.Remote.ValidateAndSetDefaults(); err != nil {
|
||||
@@ -306,13 +341,26 @@ func validateWebConfig(config *Config) error {
|
||||
func validateEndpointsConfig(config *Config) error {
|
||||
for _, endpoint := range config.Endpoints {
|
||||
if config.Debug {
|
||||
log.Printf("[config][validateEndpointsConfig] Validating endpoint '%s'", endpoint.Name)
|
||||
log.Printf("[config.validateEndpointsConfig] Validating endpoint '%s'", endpoint.Name)
|
||||
}
|
||||
if err := endpoint.ValidateAndSetDefaults(); err != nil {
|
||||
return fmt.Errorf("invalid endpoint %s: %w", endpoint.DisplayName(), err)
|
||||
}
|
||||
}
|
||||
log.Printf("[config][validateEndpointsConfig] Validated %d endpoints", len(config.Endpoints))
|
||||
log.Printf("[config.validateEndpointsConfig] Validated %d endpoints", len(config.Endpoints))
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateExternalEndpointsConfig(config *Config) error {
|
||||
for _, externalEndpoint := range config.ExternalEndpoints {
|
||||
if config.Debug {
|
||||
log.Printf("[config.validateExternalEndpointsConfig] Validating external endpoint '%s'", externalEndpoint.Name)
|
||||
}
|
||||
if err := externalEndpoint.ValidateAndSetDefaults(); err != nil {
|
||||
return fmt.Errorf("invalid external endpoint %s: %w", externalEndpoint.DisplayName(), err)
|
||||
}
|
||||
}
|
||||
log.Printf("[config.validateExternalEndpointsConfig] Validated %d external endpoints", len(config.ExternalEndpoints))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -320,7 +368,7 @@ 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")
|
||||
log.Printf("[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
|
||||
@@ -337,14 +385,18 @@ func validateSecurityConfig(config *Config) error {
|
||||
// sets the default alert values when none are set.
|
||||
func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.Endpoint, debug bool) {
|
||||
if alertingConfig == nil {
|
||||
log.Printf("[config][validateAlertingConfig] Alerting is not configured")
|
||||
log.Printf("[config.validateAlertingConfig] Alerting is not configured")
|
||||
return
|
||||
}
|
||||
alertTypes := []alert.Type{
|
||||
alert.TypeAWSSES,
|
||||
alert.TypeCustom,
|
||||
alert.TypeDiscord,
|
||||
alert.TypeGitHub,
|
||||
alert.TypeGitLab,
|
||||
alert.TypeGoogleChat,
|
||||
alert.TypeGotify,
|
||||
alert.TypeJetBrainsSpace,
|
||||
alert.TypeEmail,
|
||||
alert.TypeMatrix,
|
||||
alert.TypeMattermost,
|
||||
@@ -369,7 +421,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.E
|
||||
for alertIndex, endpointAlert := range endpoint.Alerts {
|
||||
if alertType == endpointAlert.Type {
|
||||
if debug {
|
||||
log.Printf("[config][validateAlertingConfig] Parsing alert %d with provider's default alert for provider=%s in endpoint=%s", alertIndex, alertType, endpoint.Name)
|
||||
log.Printf("[config.validateAlertingConfig] Parsing alert %d with provider's default alert for provider=%s in endpoint=%s", alertIndex, alertType, endpoint.Name)
|
||||
}
|
||||
provider.ParseWithDefaultAlert(alertProvider.GetDefaultAlert(), endpointAlert)
|
||||
}
|
||||
@@ -378,7 +430,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.E
|
||||
}
|
||||
validProviders = append(validProviders, alertType)
|
||||
} else {
|
||||
log.Printf("[config][validateAlertingConfig] Ignoring provider=%s because configuration is invalid", alertType)
|
||||
log.Printf("[config.validateAlertingConfig] Ignoring provider=%s because configuration is invalid", alertType)
|
||||
invalidProviders = append(invalidProviders, alertType)
|
||||
alertingConfig.SetAlertingProviderToNil(alertProvider)
|
||||
}
|
||||
@@ -386,5 +438,5 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.E
|
||||
invalidProviders = append(invalidProviders, alertType)
|
||||
}
|
||||
}
|
||||
log.Printf("[config][validateAlertingConfig] configuredProviders=%s; ignoredProviders=%s", validProviders, invalidProviders)
|
||||
log.Printf("[config.validateAlertingConfig] configuredProviders=%s; ignoredProviders=%s", validProviders, invalidProviders)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/email"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/github"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
|
||||
@@ -303,11 +304,13 @@ func TestParseAndValidateConfigBytes(t *testing.T) {
|
||||
storage:
|
||||
type: sqlite
|
||||
path: %s
|
||||
|
||||
maintenance:
|
||||
enabled: true
|
||||
start: 00:00
|
||||
duration: 4h
|
||||
every: [Monday, Thursday]
|
||||
|
||||
ui:
|
||||
title: T
|
||||
header: H
|
||||
@@ -317,6 +320,16 @@ ui:
|
||||
link: "https://example.org"
|
||||
- name: "Status page"
|
||||
link: "https://status.example.org"
|
||||
|
||||
external-endpoints:
|
||||
- name: ext-ep-test
|
||||
group: core
|
||||
token: "potato"
|
||||
alerts:
|
||||
- type: discord
|
||||
description: "healthcheck failed"
|
||||
send-on-resolved: true
|
||||
|
||||
endpoints:
|
||||
- name: website
|
||||
url: https://twin.sh/health
|
||||
@@ -357,10 +370,33 @@ endpoints:
|
||||
if mc := config.Maintenance; mc == nil || mc.Start != "00:00" || !mc.IsEnabled() || mc.Duration != 4*time.Hour || len(mc.Every) != 2 {
|
||||
t.Error("Expected Config.Maintenance to be configured properly")
|
||||
}
|
||||
if len(config.ExternalEndpoints) != 1 {
|
||||
t.Error("Should have returned one external endpoint")
|
||||
}
|
||||
if config.ExternalEndpoints[0].Name != "ext-ep-test" {
|
||||
t.Errorf("Name should have been %s", "ext-ep-test")
|
||||
}
|
||||
if config.ExternalEndpoints[0].Group != "core" {
|
||||
t.Errorf("Group should have been %s", "core")
|
||||
}
|
||||
if config.ExternalEndpoints[0].Token != "potato" {
|
||||
t.Errorf("Token should have been %s", "potato")
|
||||
}
|
||||
if len(config.ExternalEndpoints[0].Alerts) != 1 {
|
||||
t.Error("Should have returned one alert")
|
||||
}
|
||||
if config.ExternalEndpoints[0].Alerts[0].Type != alert.TypeDiscord {
|
||||
t.Errorf("Type should have been %s", alert.TypeDiscord)
|
||||
}
|
||||
if config.ExternalEndpoints[0].Alerts[0].FailureThreshold != 3 {
|
||||
t.Errorf("FailureThreshold should have been %d, got %d", 3, config.ExternalEndpoints[0].Alerts[0].FailureThreshold)
|
||||
}
|
||||
if config.ExternalEndpoints[0].Alerts[0].SuccessThreshold != 2 {
|
||||
t.Errorf("SuccessThreshold should have been %d, got %d", 2, config.ExternalEndpoints[0].Alerts[0].SuccessThreshold)
|
||||
}
|
||||
if len(config.Endpoints) != 3 {
|
||||
t.Error("Should have returned two endpoints")
|
||||
}
|
||||
|
||||
if config.Endpoints[0].URL != "https://twin.sh/health" {
|
||||
t.Errorf("URL should have been %s", "https://twin.sh/health")
|
||||
}
|
||||
@@ -382,7 +418,6 @@ endpoints:
|
||||
if len(config.Endpoints[0].Conditions) != 1 {
|
||||
t.Errorf("There should have been %d conditions", 1)
|
||||
}
|
||||
|
||||
if config.Endpoints[1].URL != "https://api.github.com/healthz" {
|
||||
t.Errorf("URL should have been %s", "https://api.github.com/healthz")
|
||||
}
|
||||
@@ -706,6 +741,10 @@ alerting:
|
||||
to: "+1-234-567-8901"
|
||||
teams:
|
||||
webhook-url: "http://example.com"
|
||||
jetbrainsspace:
|
||||
project: "foo"
|
||||
channel-id: "bar"
|
||||
token: "baz"
|
||||
|
||||
endpoints:
|
||||
- name: website
|
||||
@@ -728,6 +767,7 @@ endpoints:
|
||||
success-threshold: 15
|
||||
- type: teams
|
||||
- type: pushover
|
||||
- type: jetbrainsspace
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
`))
|
||||
@@ -754,8 +794,8 @@ endpoints:
|
||||
if config.Endpoints[0].Interval != 60*time.Second {
|
||||
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
|
||||
}
|
||||
if len(config.Endpoints[0].Alerts) != 9 {
|
||||
t.Fatal("There should've been 9 alerts configured")
|
||||
if len(config.Endpoints[0].Alerts) != 10 {
|
||||
t.Fatal("There should've been 10 alerts configured")
|
||||
}
|
||||
|
||||
if config.Endpoints[0].Alerts[0].Type != alert.TypeSlack {
|
||||
@@ -862,6 +902,12 @@ endpoints:
|
||||
if !config.Endpoints[0].Alerts[8].IsEnabled() {
|
||||
t.Error("The alert should've been enabled")
|
||||
}
|
||||
if config.Endpoints[0].Alerts[9].Type != alert.TypeJetBrainsSpace {
|
||||
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeJetBrainsSpace, config.Endpoints[0].Alerts[9].Type)
|
||||
}
|
||||
if !config.Endpoints[0].Alerts[9].IsEnabled() {
|
||||
t.Error("The alert should've been enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlert(t *testing.T) {
|
||||
@@ -923,6 +969,14 @@ alerting:
|
||||
webhook-url: "http://example.com"
|
||||
default-alert:
|
||||
enabled: true
|
||||
jetbrainsspace:
|
||||
project: "foo"
|
||||
channel-id: "bar"
|
||||
token: "baz"
|
||||
default-alert:
|
||||
enabled: true
|
||||
failure-threshold: 5
|
||||
success-threshold: 3
|
||||
|
||||
endpoints:
|
||||
- name: website
|
||||
@@ -938,6 +992,7 @@ endpoints:
|
||||
- type: twilio
|
||||
- type: teams
|
||||
- type: pushover
|
||||
- type: jetbrainsspace
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
`))
|
||||
@@ -1049,6 +1104,21 @@ endpoints:
|
||||
if config.Alerting.Teams.GetDefaultAlert() == nil {
|
||||
t.Fatal("Teams.GetDefaultAlert() shouldn't have returned nil")
|
||||
}
|
||||
if config.Alerting.JetBrainsSpace == nil || !config.Alerting.JetBrainsSpace.IsValid() {
|
||||
t.Fatal("JetBrainsSpace alerting config should've been valid")
|
||||
}
|
||||
if config.Alerting.JetBrainsSpace.GetDefaultAlert() == nil {
|
||||
t.Fatal("JetBrainsSpace.GetDefaultAlert() shouldn't have returned nil")
|
||||
}
|
||||
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.ChannelID != "bar" {
|
||||
t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "bar", config.Alerting.JetBrainsSpace.ChannelID)
|
||||
}
|
||||
if config.Alerting.JetBrainsSpace.Token != "baz" {
|
||||
t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "baz", config.Alerting.JetBrainsSpace.Token)
|
||||
}
|
||||
|
||||
// Endpoints
|
||||
if len(config.Endpoints) != 1 {
|
||||
@@ -1060,8 +1130,8 @@ endpoints:
|
||||
if config.Endpoints[0].Interval != 60*time.Second {
|
||||
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
|
||||
}
|
||||
if len(config.Endpoints[0].Alerts) != 9 {
|
||||
t.Fatal("There should've been 9 alerts configured")
|
||||
if len(config.Endpoints[0].Alerts) != 10 {
|
||||
t.Fatal("There should've been 10 alerts configured")
|
||||
}
|
||||
|
||||
if config.Endpoints[0].Alerts[0].Type != alert.TypeSlack {
|
||||
@@ -1178,6 +1248,18 @@ endpoints:
|
||||
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[8].SuccessThreshold)
|
||||
}
|
||||
|
||||
if config.Endpoints[0].Alerts[9].Type != alert.TypeJetBrainsSpace {
|
||||
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeJetBrainsSpace, config.Endpoints[0].Alerts[9].Type)
|
||||
}
|
||||
if !config.Endpoints[0].Alerts[9].IsEnabled() {
|
||||
t.Error("The alert should've been enabled")
|
||||
}
|
||||
if config.Endpoints[0].Alerts[9].FailureThreshold != 5 {
|
||||
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[9].FailureThreshold)
|
||||
}
|
||||
if config.Endpoints[0].Alerts[9].SuccessThreshold != 3 {
|
||||
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[9].SuccessThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlertAndMultipleAlertsOfSameTypeWithOverriddenParameters(t *testing.T) {
|
||||
@@ -1534,7 +1616,34 @@ endpoints:
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateConfigBytesWithNoEndpointsOrAutoDiscovery(t *testing.T) {
|
||||
func TestParseAndValidateConfigBytesWithLiteralDollarSign(t *testing.T) {
|
||||
os.Setenv("GATUS_TestParseAndValidateConfigBytesWithLiteralDollarSign", "whatever")
|
||||
config, err := parseAndValidateConfigBytes([]byte(`
|
||||
endpoints:
|
||||
- name: website
|
||||
url: https://twin.sh/health
|
||||
conditions:
|
||||
- "[BODY] == $$GATUS_TestParseAndValidateConfigBytesWithLiteralDollarSign"
|
||||
- "[BODY] == $GATUS_TestParseAndValidateConfigBytesWithLiteralDollarSign"
|
||||
`))
|
||||
if err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
if config == nil {
|
||||
t.Fatal("Config 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")
|
||||
}
|
||||
if config.Endpoints[0].Conditions[0] != "[BODY] == $GATUS_TestParseAndValidateConfigBytesWithLiteralDollarSign" {
|
||||
t.Errorf("Condition should have been %s", "[BODY] == $GATUS_TestParseAndValidateConfigBytesWithLiteralDollarSign")
|
||||
}
|
||||
if config.Endpoints[0].Conditions[1] != "[BODY] == whatever" {
|
||||
t.Errorf("Condition should have been %s", "[BODY] == whatever")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateConfigBytesWithNoEndpoints(t *testing.T) {
|
||||
_, err := parseAndValidateConfigBytes([]byte(``))
|
||||
if err != ErrNoEndpointInConfig {
|
||||
t.Error("The error returned should have been of type ErrNoEndpointInConfig")
|
||||
@@ -1543,22 +1652,23 @@ func TestParseAndValidateConfigBytesWithNoEndpointsOrAutoDiscovery(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{},
|
||||
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{},
|
||||
Custom: &custom.AlertProvider{},
|
||||
Discord: &discord.AlertProvider{},
|
||||
Email: &email.AlertProvider{},
|
||||
GitHub: &github.AlertProvider{},
|
||||
GoogleChat: &googlechat.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{},
|
||||
}
|
||||
scenarios := []struct {
|
||||
alertType alert.Type
|
||||
@@ -1569,6 +1679,7 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
|
||||
{alertType: alert.TypeEmail, expected: alertingConfig.Email},
|
||||
{alertType: alert.TypeGitHub, expected: alertingConfig.GitHub},
|
||||
{alertType: alert.TypeGoogleChat, expected: alertingConfig.GoogleChat},
|
||||
{alertType: alert.TypeJetBrainsSpace, expected: alertingConfig.JetBrainsSpace},
|
||||
{alertType: alert.TypeMatrix, expected: alertingConfig.Matrix},
|
||||
{alertType: alert.TypeMattermost, expected: alertingConfig.Mattermost},
|
||||
{alertType: alert.TypeMessagebird, expected: alertingConfig.Messagebird},
|
||||
|
||||
53
config/connectivity/connectivity.go
Normal file
53
config/connectivity/connectivity.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package connectivity
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidInterval = errors.New("connectivity.checker.interval must be 5s or higher")
|
||||
ErrInvalidDNSTarget = errors.New("connectivity.checker.target must be suffixed with :53")
|
||||
)
|
||||
|
||||
// Config is the configuration for the connectivity checker.
|
||||
type Config struct {
|
||||
Checker *Checker `yaml:"checker,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Config) ValidateAndSetDefaults() error {
|
||||
if c.Checker != nil {
|
||||
if c.Checker.Interval == 0 {
|
||||
c.Checker.Interval = 60 * time.Second
|
||||
} else if c.Checker.Interval < 5*time.Second {
|
||||
return ErrInvalidInterval
|
||||
}
|
||||
if !strings.HasSuffix(c.Checker.Target, ":53") {
|
||||
return ErrInvalidDNSTarget
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Checker is the configuration for making sure Gatus has access to the internet.
|
||||
type Checker struct {
|
||||
Target string `yaml:"target"` // e.g. 1.1.1.1:53
|
||||
Interval time.Duration `yaml:"interval,omitempty"`
|
||||
|
||||
isConnected bool
|
||||
lastCheck time.Time
|
||||
}
|
||||
|
||||
func (c Checker) Check() bool {
|
||||
return client.CanCreateTCPConnection(c.Target, &client.Config{Timeout: 5 * time.Second})
|
||||
}
|
||||
|
||||
func (c *Checker) IsConnected() bool {
|
||||
if now := time.Now(); now.After(c.lastCheck.Add(c.Interval)) {
|
||||
c.lastCheck, c.isConnected = now, c.Check()
|
||||
}
|
||||
return c.isConnected
|
||||
}
|
||||
62
config/connectivity/connectivity_test.go
Normal file
62
config/connectivity/connectivity_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package connectivity
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConfig(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
cfg *Config
|
||||
expectedErr error
|
||||
expectedInterval time.Duration
|
||||
}{
|
||||
{
|
||||
name: "good-config",
|
||||
cfg: &Config{Checker: &Checker{Target: "1.1.1.1:53", Interval: 10 * time.Second}},
|
||||
expectedInterval: 10 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "good-config-with-default-interval",
|
||||
cfg: &Config{Checker: &Checker{Target: "8.8.8.8:53", Interval: 0}},
|
||||
expectedInterval: 60 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "config-with-interval-too-low",
|
||||
cfg: &Config{Checker: &Checker{Target: "1.1.1.1:53", Interval: 4 * time.Second}},
|
||||
expectedErr: ErrInvalidInterval,
|
||||
},
|
||||
{
|
||||
name: "config-with-invalid-target-due-to-missing-port",
|
||||
cfg: &Config{Checker: &Checker{Target: "1.1.1.1", Interval: 15 * time.Second}},
|
||||
expectedErr: ErrInvalidDNSTarget,
|
||||
},
|
||||
{
|
||||
name: "config-with-invalid-target-due-to-invalid-dns-port",
|
||||
cfg: &Config{Checker: &Checker{Target: "1.1.1.1:52", Interval: 15 * time.Second}},
|
||||
expectedErr: ErrInvalidDNSTarget,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
err := scenario.cfg.ValidateAndSetDefaults()
|
||||
if fmt.Sprintf("%s", err) != fmt.Sprintf("%s", scenario.expectedErr) {
|
||||
t.Errorf("expected error %v, got %v", scenario.expectedErr, err)
|
||||
}
|
||||
if err == nil && scenario.expectedErr == nil {
|
||||
if scenario.cfg.Checker.Interval != scenario.expectedInterval {
|
||||
t.Errorf("expected interval %v, got %v", scenario.expectedInterval, scenario.cfg.Checker.Interval)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChecker_IsConnected(t *testing.T) {
|
||||
checker := &Checker{Target: "1.1.1.1:53", Interval: 10 * time.Second}
|
||||
if !checker.IsConnected() {
|
||||
t.Error("expected checker.IsConnected() to be true")
|
||||
}
|
||||
}
|
||||
1
config/endpoints/README.md
Normal file
1
config/endpoints/README.md
Normal file
@@ -0,0 +1 @@
|
||||
TODO: move files from core to here.
|
||||
@@ -82,7 +82,7 @@ func (c *Config) ValidateAndSetDefaults() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Duration <= 0 || c.Duration >= 24*time.Hour {
|
||||
if c.Duration <= 0 || c.Duration > 24*time.Hour {
|
||||
return errInvalidMaintenanceDuration
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -211,6 +211,14 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "under-maintenance-starting-22h-ago-for-24h",
|
||||
cfg: &Config{
|
||||
Start: fmt.Sprintf("%02d:00", normalizeHour(now.Hour()-22)),
|
||||
Duration: 24 * time.Hour,
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "under-maintenance-starting-4h-ago-for-3h",
|
||||
cfg: &Config{
|
||||
@@ -236,6 +244,15 @@ func TestConfig_IsUnderMaintenance(t *testing.T) {
|
||||
},
|
||||
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,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
@@ -11,21 +13,50 @@ const (
|
||||
|
||||
// DefaultPort is the default port the application will listen on
|
||||
DefaultPort = 8080
|
||||
|
||||
// DefaultReadBufferSize is the default value for ReadBufferSize
|
||||
DefaultReadBufferSize = 8192
|
||||
|
||||
// MinimumReadBufferSize is the minimum value for ReadBufferSize, and also the default value set
|
||||
// for fiber.Config.ReadBufferSize
|
||||
MinimumReadBufferSize = 4096
|
||||
)
|
||||
|
||||
// Config is the structure which supports the configuration of the endpoint
|
||||
// which provides access to the web frontend
|
||||
// Config is the structure which supports the configuration of the server listening to requests
|
||||
type Config struct {
|
||||
// Address to listen on (defaults to 0.0.0.0 specified by DefaultAddress)
|
||||
Address string `yaml:"address"`
|
||||
|
||||
// Port to listen on (default to 8080 specified by DefaultPort)
|
||||
Port int `yaml:"port"`
|
||||
|
||||
// ReadBufferSize sets fiber.Config.ReadBufferSize, which is the buffer size for reading requests coming from a
|
||||
// single connection and also acts as a limit for the maximum header size.
|
||||
//
|
||||
// If you're getting occasional "Request Header Fields Too Large", you may want to try increasing this value.
|
||||
//
|
||||
// Defaults to DefaultReadBufferSize
|
||||
ReadBufferSize int `yaml:"read-buffer-size,omitempty"`
|
||||
|
||||
// TLS configuration (optional)
|
||||
TLS *TLSConfig `yaml:"tls,omitempty"`
|
||||
}
|
||||
|
||||
type TLSConfig struct {
|
||||
// CertificateFile is the public certificate for TLS in PEM format.
|
||||
CertificateFile string `yaml:"certificate-file,omitempty"`
|
||||
|
||||
// PrivateKeyFile is the private key file for TLS in PEM format.
|
||||
PrivateKeyFile string `yaml:"private-key-file,omitempty"`
|
||||
}
|
||||
|
||||
// GetDefaultConfig returns a Config struct with the default values
|
||||
func GetDefaultConfig() *Config {
|
||||
return &Config{Address: DefaultAddress, Port: DefaultPort}
|
||||
return &Config{
|
||||
Address: DefaultAddress,
|
||||
Port: DefaultPort,
|
||||
ReadBufferSize: DefaultReadBufferSize,
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateAndSetDefaults validates the web configuration and sets the default values if necessary.
|
||||
@@ -40,10 +71,37 @@ func (web *Config) ValidateAndSetDefaults() error {
|
||||
} else if web.Port < 0 || web.Port > math.MaxUint16 {
|
||||
return fmt.Errorf("invalid port: value should be between %d and %d", 0, math.MaxUint16)
|
||||
}
|
||||
// Validate ReadBufferSize
|
||||
if web.ReadBufferSize == 0 {
|
||||
web.ReadBufferSize = DefaultReadBufferSize // Not set? Use the default value.
|
||||
} else if web.ReadBufferSize < MinimumReadBufferSize {
|
||||
web.ReadBufferSize = MinimumReadBufferSize // Below the minimum? Use the minimum value.
|
||||
}
|
||||
// Try to load the TLS certificates
|
||||
if web.TLS != nil {
|
||||
if err := web.TLS.isValid(); err != nil {
|
||||
return fmt.Errorf("invalid tls config: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (web *Config) HasTLS() bool {
|
||||
return web.TLS != nil && len(web.TLS.CertificateFile) > 0 && len(web.TLS.PrivateKeyFile) > 0
|
||||
}
|
||||
|
||||
// SocketAddress returns the combination of the Address and the Port
|
||||
func (web *Config) SocketAddress() string {
|
||||
return fmt.Sprintf("%s:%d", web.Address, web.Port)
|
||||
}
|
||||
|
||||
func (t *TLSConfig) isValid() error {
|
||||
if len(t.CertificateFile) > 0 && len(t.PrivateKeyFile) > 0 {
|
||||
_, err := tls.LoadX509KeyPair(t.CertificateFile, t.PrivateKeyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return errors.New("certificate-file and private-key-file must be specified")
|
||||
}
|
||||
|
||||
@@ -12,28 +12,84 @@ func TestGetDefaultConfig(t *testing.T) {
|
||||
if defaultConfig.Address != DefaultAddress {
|
||||
t.Error("expected default config to have the default address")
|
||||
}
|
||||
if defaultConfig.ReadBufferSize != DefaultReadBufferSize {
|
||||
t.Error("expected default config to have the default read buffer size")
|
||||
}
|
||||
if defaultConfig.TLS != nil {
|
||||
t.Error("expected default config to have TLS disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
cfg *Config
|
||||
expectedAddress string
|
||||
expectedPort int
|
||||
expectedErr bool
|
||||
name string
|
||||
cfg *Config
|
||||
expectedAddress string
|
||||
expectedPort int
|
||||
expectedReadBufferSize int
|
||||
expectedErr bool
|
||||
}{
|
||||
{
|
||||
name: "no-explicit-config",
|
||||
cfg: &Config{},
|
||||
expectedAddress: "0.0.0.0",
|
||||
expectedPort: 8080,
|
||||
expectedErr: false,
|
||||
name: "no-explicit-config",
|
||||
cfg: &Config{},
|
||||
expectedAddress: "0.0.0.0",
|
||||
expectedPort: 8080,
|
||||
expectedReadBufferSize: 8192,
|
||||
expectedErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid-port",
|
||||
cfg: &Config{Port: 100000000},
|
||||
expectedErr: true,
|
||||
},
|
||||
{
|
||||
name: "read-buffer-size-below-minimum",
|
||||
cfg: &Config{ReadBufferSize: 1024},
|
||||
expectedAddress: "0.0.0.0",
|
||||
expectedPort: 8080,
|
||||
expectedReadBufferSize: MinimumReadBufferSize, // minimum is 4096, default is 8192.
|
||||
expectedErr: false,
|
||||
},
|
||||
{
|
||||
name: "read-buffer-size-at-minimum",
|
||||
cfg: &Config{ReadBufferSize: MinimumReadBufferSize},
|
||||
expectedAddress: "0.0.0.0",
|
||||
expectedPort: 8080,
|
||||
expectedReadBufferSize: 4096,
|
||||
expectedErr: false,
|
||||
},
|
||||
{
|
||||
name: "custom-read-buffer-size",
|
||||
cfg: &Config{ReadBufferSize: 65536},
|
||||
expectedAddress: "0.0.0.0",
|
||||
expectedPort: 8080,
|
||||
expectedReadBufferSize: 65536,
|
||||
expectedErr: false,
|
||||
},
|
||||
{
|
||||
name: "with-good-tls-config",
|
||||
cfg: &Config{Port: 443, TLS: &TLSConfig{CertificateFile: "../../testdata/cert.pem", PrivateKeyFile: "../../testdata/cert.key"}},
|
||||
expectedAddress: "0.0.0.0",
|
||||
expectedPort: 443,
|
||||
expectedReadBufferSize: 8192,
|
||||
expectedErr: false,
|
||||
},
|
||||
{
|
||||
name: "with-bad-tls-config",
|
||||
cfg: &Config{Port: 443, TLS: &TLSConfig{CertificateFile: "../../testdata/badcert.pem", PrivateKeyFile: "../../testdata/cert.key"}},
|
||||
expectedAddress: "0.0.0.0",
|
||||
expectedPort: 443,
|
||||
expectedReadBufferSize: 8192,
|
||||
expectedErr: true,
|
||||
},
|
||||
{
|
||||
name: "with-partial-tls-config",
|
||||
cfg: &Config{Port: 443, TLS: &TLSConfig{CertificateFile: "", PrivateKeyFile: "../../testdata/cert.key"}},
|
||||
expectedAddress: "0.0.0.0",
|
||||
expectedPort: 443,
|
||||
expectedReadBufferSize: 8192,
|
||||
expectedErr: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
@@ -44,10 +100,13 @@ func TestConfig_ValidateAndSetDefaults(t *testing.T) {
|
||||
}
|
||||
if !scenario.expectedErr {
|
||||
if scenario.cfg.Port != scenario.expectedPort {
|
||||
t.Errorf("expected port to be %d, got %d", scenario.expectedPort, scenario.cfg.Port)
|
||||
t.Errorf("expected Port to be %d, got %d", scenario.expectedPort, scenario.cfg.Port)
|
||||
}
|
||||
if scenario.cfg.ReadBufferSize != scenario.expectedReadBufferSize {
|
||||
t.Errorf("expected ReadBufferSize to be %d, got %d", scenario.expectedReadBufferSize, scenario.cfg.ReadBufferSize)
|
||||
}
|
||||
if scenario.cfg.Address != scenario.expectedAddress {
|
||||
t.Errorf("expected address to be %s, got %s", scenario.expectedAddress, scenario.cfg.Address)
|
||||
t.Errorf("expected Address to be %s, got %s", scenario.expectedAddress, scenario.cfg.Address)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -63,3 +122,66 @@ func TestConfig_SocketAddress(t *testing.T) {
|
||||
t.Errorf("expected %s, got %s", "0.0.0.0:8081", web.SocketAddress())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_isValid(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
cfg *Config
|
||||
expectedErr bool
|
||||
}{
|
||||
{
|
||||
name: "good-tls-config",
|
||||
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../../testdata/cert.pem", PrivateKeyFile: "../../testdata/cert.key"}},
|
||||
expectedErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing-certificate-file",
|
||||
cfg: &Config{TLS: &TLSConfig{CertificateFile: "doesnotexist", PrivateKeyFile: "../../testdata/cert.key"}},
|
||||
expectedErr: true,
|
||||
},
|
||||
{
|
||||
name: "bad-certificate-file",
|
||||
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../../testdata/badcert.pem", PrivateKeyFile: "../../testdata/cert.key"}},
|
||||
expectedErr: true,
|
||||
},
|
||||
{
|
||||
name: "no-certificate-file",
|
||||
cfg: &Config{TLS: &TLSConfig{CertificateFile: "", PrivateKeyFile: "../../testdata/cert.key"}},
|
||||
expectedErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing-private-key-file",
|
||||
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../../testdata/cert.pem", PrivateKeyFile: "doesnotexist"}},
|
||||
expectedErr: true,
|
||||
},
|
||||
{
|
||||
name: "no-private-key-file",
|
||||
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../../testdata/cert.pem", PrivateKeyFile: ""}},
|
||||
expectedErr: true,
|
||||
},
|
||||
{
|
||||
name: "bad-private-key-file",
|
||||
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../../testdata/cert.pem", PrivateKeyFile: "../../testdata/badcert.key"}},
|
||||
expectedErr: true,
|
||||
},
|
||||
{
|
||||
name: "bad-certificate-and-private-key-file",
|
||||
cfg: &Config{TLS: &TLSConfig{CertificateFile: "../../testdata/badcert.pem", PrivateKeyFile: "../../testdata/badcert.key"}},
|
||||
expectedErr: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
err := scenario.cfg.ValidateAndSetDefaults()
|
||||
if (err != nil) != scenario.expectedErr {
|
||||
t.Errorf("expected the existence of an error to be %v, got %v", scenario.expectedErr, err)
|
||||
return
|
||||
}
|
||||
if !scenario.expectedErr {
|
||||
if scenario.cfg.TLS.isValid() != nil {
|
||||
t.Error("cfg.TLS.isValid() returned an error even though no error was expected")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +1,49 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/api"
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
"github.com/TwiN/gatus/v5/controller/handler"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
// server is the http.Server created by Handle.
|
||||
// The only reason it exists is for testing purposes.
|
||||
server *http.Server
|
||||
app *fiber.App
|
||||
)
|
||||
|
||||
// Handle creates the router and starts the server
|
||||
func Handle(cfg *config.Config) {
|
||||
var router http.Handler = handler.CreateRouter(cfg)
|
||||
if os.Getenv("ENVIRONMENT") == "dev" {
|
||||
router = handler.DevelopmentCORS(router)
|
||||
}
|
||||
server = &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", cfg.Web.Address, cfg.Web.Port),
|
||||
Handler: router,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 15 * time.Second,
|
||||
}
|
||||
log.Println("[controller][Handle] Listening on " + cfg.Web.SocketAddress())
|
||||
api := api.New(cfg)
|
||||
app = api.Router()
|
||||
server := app.Server()
|
||||
server.ReadTimeout = 15 * time.Second
|
||||
server.WriteTimeout = 15 * time.Second
|
||||
server.IdleTimeout = 15 * time.Second
|
||||
if os.Getenv("ROUTER_TEST") == "true" {
|
||||
return
|
||||
}
|
||||
log.Println("[controller][Handle]", server.ListenAndServe())
|
||||
log.Println("[controller.Handle] Listening on " + cfg.Web.SocketAddress())
|
||||
if cfg.Web.HasTLS() {
|
||||
err := app.ListenTLS(cfg.Web.SocketAddress(), cfg.Web.TLS.CertificateFile, cfg.Web.TLS.PrivateKeyFile)
|
||||
if err != nil {
|
||||
log.Fatal("[controller.Handle]", err)
|
||||
}
|
||||
} else {
|
||||
err := app.Listen(cfg.Web.SocketAddress())
|
||||
if err != nil {
|
||||
log.Fatal("[controller.Handle]", err)
|
||||
}
|
||||
}
|
||||
log.Println("[controller.Handle] Server has shut down successfully")
|
||||
}
|
||||
|
||||
// Shutdown stops the server
|
||||
func Shutdown() {
|
||||
if server != nil {
|
||||
_ = server.Shutdown(context.TODO())
|
||||
server = nil
|
||||
if app != nil {
|
||||
_ = app.Shutdown()
|
||||
app = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
"github.com/TwiN/gatus/v5/config/web"
|
||||
"github.com/TwiN/gatus/v5/core"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func TestHandle(t *testing.T) {
|
||||
@@ -34,22 +35,68 @@ func TestHandle(t *testing.T) {
|
||||
defer os.Clearenv()
|
||||
Handle(cfg)
|
||||
defer Shutdown()
|
||||
request, _ := http.NewRequest("GET", "/health", http.NoBody)
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
server.Handler.ServeHTTP(responseRecorder, request)
|
||||
if responseRecorder.Code != http.StatusOK {
|
||||
request := httptest.NewRequest("GET", "/health", http.NoBody)
|
||||
response, err := app.Test(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.StatusCode != 200 {
|
||||
t.Error("expected GET /health to return status code 200")
|
||||
}
|
||||
if server == nil {
|
||||
if app == nil {
|
||||
t.Fatal("server should've been set (but because we set ROUTER_TEST, it shouldn't have been started)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTLS(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
tls *web.TLSConfig
|
||||
expectedStatusCode int
|
||||
}{
|
||||
{
|
||||
name: "good-tls-config",
|
||||
tls: &web.TLSConfig{CertificateFile: "../testdata/cert.pem", PrivateKeyFile: "../testdata/cert.key"},
|
||||
expectedStatusCode: 200,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Web: &web.Config{Address: "0.0.0.0", Port: rand.Intn(65534), TLS: scenario.tls},
|
||||
Endpoints: []*core.Endpoint{
|
||||
{Name: "frontend", Group: "core"},
|
||||
{Name: "backend", Group: "core"},
|
||||
},
|
||||
}
|
||||
if err := cfg.Web.ValidateAndSetDefaults(); err != nil {
|
||||
t.Error("expected no error from web (TLS) validation, got", err)
|
||||
}
|
||||
_ = os.Setenv("ROUTER_TEST", "true")
|
||||
_ = os.Setenv("ENVIRONMENT", "dev")
|
||||
defer os.Clearenv()
|
||||
Handle(cfg)
|
||||
defer Shutdown()
|
||||
request := httptest.NewRequest("GET", "/health", http.NoBody)
|
||||
response, err := app.Test(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.StatusCode != scenario.expectedStatusCode {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.expectedStatusCode, response.StatusCode)
|
||||
}
|
||||
if app == nil {
|
||||
t.Fatal("server should've been set (but because we set ROUTER_TEST, it shouldn't have been started)")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShutdown(t *testing.T) {
|
||||
// Pretend that we called controller.Handle(), which initializes the server variable
|
||||
server = &http.Server{}
|
||||
app = fiber.New()
|
||||
Shutdown()
|
||||
if server != nil {
|
||||
if app != nil {
|
||||
t.Error("server should've been shut down")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwiN/gatus/v5/security"
|
||||
)
|
||||
|
||||
// ConfigHandler is a handler that returns information for the front end of the application.
|
||||
type ConfigHandler struct {
|
||||
securityConfig *security.Config
|
||||
}
|
||||
|
||||
func (handler ConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
hasOIDC := false
|
||||
isAuthenticated := true // Default to true if no security config is set
|
||||
if handler.securityConfig != nil {
|
||||
hasOIDC = handler.securityConfig.OIDC != nil
|
||||
isAuthenticated = handler.securityConfig.IsAuthenticated(r)
|
||||
}
|
||||
// Return the config
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(fmt.Sprintf(`{"oidc":%v,"authenticated":%v}`, hasOIDC, isAuthenticated)))
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package handler
|
||||
|
||||
import "net/http"
|
||||
|
||||
func DevelopmentCORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:8081")
|
||||
if r.Method == "OPTIONS" {
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var gzPool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
return gzip.NewWriter(io.Discard)
|
||||
},
|
||||
}
|
||||
|
||||
type gzipResponseWriter struct {
|
||||
io.Writer
|
||||
http.ResponseWriter
|
||||
}
|
||||
|
||||
// WriteHeader sends an HTTP response header with the provided status code.
|
||||
// It also deletes the Content-Length header, since the GZIP compression may modify the size of the payload
|
||||
func (w *gzipResponseWriter) WriteHeader(status int) {
|
||||
w.Header().Del("Content-Length")
|
||||
w.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
// Write writes len(b) bytes from b to the underlying data stream.
|
||||
func (w *gzipResponseWriter) Write(b []byte) (int, error) {
|
||||
return w.Writer.Write(b)
|
||||
}
|
||||
|
||||
// GzipHandler compresses the response of a given http.Handler if the request's headers specify that the client
|
||||
// supports gzip encoding
|
||||
func GzipHandler(next http.Handler) http.Handler {
|
||||
return GzipHandlerFunc(func(writer http.ResponseWriter, r *http.Request) {
|
||||
next.ServeHTTP(writer, r)
|
||||
})
|
||||
}
|
||||
|
||||
// GzipHandlerFunc compresses the response of a given http.HandlerFunc if the request's headers specify that the client
|
||||
// supports gzip encoding
|
||||
func GzipHandlerFunc(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, r *http.Request) {
|
||||
// If the request doesn't specify that it supports gzip, then don't compress it
|
||||
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
next.ServeHTTP(writer, r)
|
||||
return
|
||||
}
|
||||
writer.Header().Set("Content-Encoding", "gzip")
|
||||
gz := gzPool.Get().(*gzip.Writer)
|
||||
defer gzPool.Put(gz)
|
||||
gz.Reset(writer)
|
||||
defer gz.Close()
|
||||
next.ServeHTTP(&gzipResponseWriter{ResponseWriter: writer, Writer: gz}, r)
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
static "github.com/TwiN/gatus/v5/web"
|
||||
"github.com/TwiN/health"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
func CreateRouter(cfg *config.Config) *mux.Router {
|
||||
router := mux.NewRouter()
|
||||
if cfg.Metrics {
|
||||
router.Handle("/metrics", promhttp.InstrumentMetricHandler(prometheus.DefaultRegisterer, promhttp.HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{
|
||||
DisableCompression: true,
|
||||
}))).Methods("GET")
|
||||
}
|
||||
router.Use(GzipHandler)
|
||||
api := router.PathPrefix("/api").Subrouter()
|
||||
protected := api.PathPrefix("/").Subrouter()
|
||||
unprotected := api.PathPrefix("/").Subrouter()
|
||||
if cfg.Security != nil {
|
||||
if err := cfg.Security.RegisterHandlers(router); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := cfg.Security.ApplySecurityMiddleware(protected); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
// Endpoints
|
||||
unprotected.Handle("/v1/config", ConfigHandler{securityConfig: cfg.Security}).Methods("GET")
|
||||
protected.HandleFunc("/v1/endpoints/statuses", EndpointStatuses(cfg)).Methods("GET")
|
||||
protected.HandleFunc("/v1/endpoints/{key}/statuses", EndpointStatus).Methods("GET")
|
||||
unprotected.HandleFunc("/v1/endpoints/{key}/health/badge.svg", HealthBadge).Methods("GET")
|
||||
unprotected.HandleFunc("/v1/endpoints/{key}/uptimes/{duration}/badge.svg", UptimeBadge).Methods("GET")
|
||||
unprotected.HandleFunc("/v1/endpoints/{key}/response-times/{duration}/badge.svg", ResponseTimeBadge(cfg)).Methods("GET")
|
||||
unprotected.HandleFunc("/v1/endpoints/{key}/response-times/{duration}/chart.svg", ResponseTimeChart).Methods("GET")
|
||||
// Misc
|
||||
router.Handle("/health", health.Handler().WithJSON(true)).Methods("GET")
|
||||
// SPA
|
||||
router.HandleFunc("/endpoints/{name}", SinglePageApplication(cfg.UI)).Methods("GET")
|
||||
router.HandleFunc("/", SinglePageApplication(cfg.UI)).Methods("GET")
|
||||
// Everything else falls back on static content
|
||||
staticFileSystem, err := fs.Sub(static.FileSystem, static.RootPath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
router.PathPrefix("/").Handler(http.FileServer(http.FS(staticFileSystem)))
|
||||
return router
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
)
|
||||
|
||||
func TestCreateRouter(t *testing.T) {
|
||||
router := CreateRouter(&config.Config{Metrics: true})
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
ExpectedCode int
|
||||
Gzip bool
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "health",
|
||||
Path: "/health",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "metrics",
|
||||
Path: "/metrics",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "favicon.ico",
|
||||
Path: "/favicon.ico",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "app.js",
|
||||
Path: "/js/app.js",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "app.js-gzipped",
|
||||
Path: "/js/app.js",
|
||||
ExpectedCode: http.StatusOK,
|
||||
Gzip: true,
|
||||
},
|
||||
{
|
||||
Name: "chunk-vendors.js",
|
||||
Path: "/js/chunk-vendors.js",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "chunk-vendors.js-gzipped",
|
||||
Path: "/js/chunk-vendors.js",
|
||||
ExpectedCode: http.StatusOK,
|
||||
Gzip: true,
|
||||
},
|
||||
{
|
||||
Name: "index-redirect",
|
||||
Path: "/index.html",
|
||||
ExpectedCode: http.StatusMovedPermanently,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
request, _ := http.NewRequest("GET", scenario.Path, http.NoBody)
|
||||
if scenario.Gzip {
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
}
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(responseRecorder, request)
|
||||
if responseRecorder.Code != scenario.ExpectedCode {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, responseRecorder.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config/ui"
|
||||
static "github.com/TwiN/gatus/v5/web"
|
||||
)
|
||||
|
||||
func SinglePageApplication(ui *ui.Config) http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
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("[handler][SinglePageApplication] Failed to parse template. This should never happen, because the template is validated on start. Error:", err.Error())
|
||||
http.Error(writer, "Failed to parse template. This should never happen, because the template is validated on start.", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writer.Header().Set("Content-Type", "text/html")
|
||||
err = t.Execute(writer, ui)
|
||||
if err != nil {
|
||||
// This should never happen, because ui.ValidateAndSetDefaults validates that the template works.
|
||||
log.Println("[handler][SinglePageApplication] Failed to execute template. This should never happen, because the template is validated on start. Error:", err.Error())
|
||||
http.Error(writer, "Failed to execute template. This should never happen, because the template is validated on start.", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ const (
|
||||
// Values that could replace the placeholder: 1, 500, 1000, ...
|
||||
ResponseTimePlaceholder = "[RESPONSE_TIME]"
|
||||
|
||||
// BodyPlaceholder is a placeholder for the body of the response
|
||||
// BodyPlaceholder is a placeholder for the Body of the response
|
||||
//
|
||||
// Values that could replace the placeholder: {}, {"data":{"name":"john"}}, ...
|
||||
BodyPlaceholder = "[BODY]"
|
||||
@@ -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)
|
||||
//log.Printf("[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
|
||||
@@ -224,6 +224,14 @@ func isEqual(first, second string) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// test if inputs are integers
|
||||
firstInt, err1 := strconv.ParseInt(first, 0, 64)
|
||||
secondInt, err2 := strconv.ParseInt(second, 0, 64)
|
||||
if err1 == nil && err2 == nil {
|
||||
return firstInt == secondInt
|
||||
}
|
||||
|
||||
return first == second
|
||||
}
|
||||
|
||||
@@ -232,7 +240,7 @@ func isEqual(first, second string) bool {
|
||||
func sanitizeAndResolve(elements []string, result *Result) ([]string, []string) {
|
||||
parameters := make([]string, len(elements))
|
||||
resolvedParameters := make([]string, len(elements))
|
||||
body := strings.TrimSpace(string(result.body))
|
||||
body := strings.TrimSpace(string(result.Body))
|
||||
for i, element := range elements {
|
||||
element = strings.TrimSpace(element)
|
||||
parameters[i] = element
|
||||
@@ -266,7 +274,7 @@ func sanitizeAndResolve(elements []string, result *Result) ([]string, []string)
|
||||
checkingForExistence = true
|
||||
element = strings.TrimSuffix(strings.TrimPrefix(element, HasFunctionPrefix), FunctionSuffix)
|
||||
}
|
||||
resolvedElement, resolvedElementLength, err := jsonpath.Eval(strings.TrimPrefix(strings.TrimPrefix(element, BodyPlaceholder), "."), result.body)
|
||||
resolvedElement, resolvedElementLength, err := jsonpath.Eval(strings.TrimPrefix(strings.TrimPrefix(element, BodyPlaceholder), "."), result.Body)
|
||||
if checkingForExistence {
|
||||
if err != nil {
|
||||
element = "false"
|
||||
@@ -302,10 +310,18 @@ func sanitizeAndResolveNumerical(list []string, result *Result) (parameters []st
|
||||
parameters, resolvedParameters := sanitizeAndResolve(list, result)
|
||||
for _, element := range resolvedParameters {
|
||||
if duration, err := time.ParseDuration(element); duration != 0 && err == nil {
|
||||
// If the string is a duration, convert it to milliseconds
|
||||
resolvedNumericalParameters = append(resolvedNumericalParameters, duration.Milliseconds())
|
||||
} else if number, err := strconv.ParseInt(element, 10, 64); err != nil {
|
||||
// Default to 0 if the string couldn't be converted to an integer
|
||||
resolvedNumericalParameters = append(resolvedNumericalParameters, 0)
|
||||
} else if number, err := strconv.ParseInt(element, 0, 64); err != nil {
|
||||
// It's not an int, so we'll check if it's a float
|
||||
if f, err := strconv.ParseFloat(element, 64); err == nil {
|
||||
// It's a float, but we'll convert it to an int. We're losing precision here, but it's better than
|
||||
// just returning 0.
|
||||
resolvedNumericalParameters = append(resolvedNumericalParameters, int64(f))
|
||||
} else {
|
||||
// Default to 0 if the string couldn't be converted to an integer or a float
|
||||
resolvedNumericalParameters = append(resolvedNumericalParameters, 0)
|
||||
}
|
||||
} else {
|
||||
resolvedNumericalParameters = append(resolvedNumericalParameters, number)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user